diff --git a/Ultima/AnimationsUopLoader.cs b/Ultima/AnimationsUopLoader.cs index e7f4a78e..c2a2dac7 100644 --- a/Ultima/AnimationsUopLoader.cs +++ b/Ultima/AnimationsUopLoader.cs @@ -22,6 +22,15 @@ internal static class AnimationsUopLoader internal const int _maxAnimActions = 80; private const int _maxDirections = 5; + /// + /// Size of the fixed part of one AnimationSequence group record: the four leading fields, + /// the per frame bytes and the reserved block. Two variable length lists follow it. + /// + private const int _sequenceGroupFixedSize = 64; + + /// Size of one property record hanging off a group: six ints, two shorts and a float. + private const int _sequenceGroupPropSize = 32; + private static FileStream[] _uopFiles = new FileStream[6]; private static readonly Dictionary _hashTable = new(); private static readonly Dictionary _sequenceReplacements = new(); @@ -258,6 +267,21 @@ private static void LoadAnimationSequence() } } + /// + /// Builds the action replacement table for one body from its AnimationSequence entry. + /// + /// + /// Group records are variable length. The 72 byte stride this used to assume is only the + /// degenerate case where both trailing lists are empty: the fixed part is 64 bytes and the + /// two counted lists add 8 more when both counts are zero. Three bodies of a shipped client + /// - 400, 666 and 1253 in 7.0.114.4 - carry property records and are longer than that. + /// + /// The walk also used to be skipped outright when the group count was 48 or 68. Those are + /// legitimate counts, not sentinels - they are simply the counts of the bodies that carry + /// property records, which is what a fixed stride cannot walk. Reading them properly means + /// body 666 resolves 67 to 66, and body 1253 resolves 2 to 0, 3 to 1 and 42 to 38. + /// + /// private static void ParseSequenceEntry(int animId, byte[] data) { if (data.Length < 56) @@ -271,7 +295,7 @@ private static void ParseSequenceEntry(int animId, byte[] data) binaryReader.ReadUInt32(); // animId stored in file binaryReader.BaseStream.Seek(48, SeekOrigin.Current); // skip 12 × u32 - int replaces = binaryReader.ReadInt32(); + int groupCount = binaryReader.ReadInt32(); var replacements = new int[_maxAnimActions]; for (int i = 0; i < _maxAnimActions; i++) @@ -279,29 +303,73 @@ private static void ParseSequenceEntry(int animId, byte[] data) replacements[i] = i; } - if (replaces != 48 && replaces != 68) + for (int i = 0; i < groupCount; i++) { - for (int k = 0; k < replaces; k++) + if (!ReadSequenceGroup(binaryReader, replacements)) { - if (binaryReader.BaseStream.Position + 72 > binaryReader.BaseStream.Length) - { - break; - } + break; + } + } - int oldGroup = binaryReader.ReadInt32(); - uint frameCount = binaryReader.ReadUInt32(); - int newGroup = binaryReader.ReadInt32(); + _sequenceReplacements[animId] = replacements; + } - if (frameCount == 0 && oldGroup >= 0 && oldGroup < _maxAnimActions && newGroup >= 0) - { - replacements[oldGroup] = newGroup; - } + /// + /// Reads one group record, recording the alias it declares. + /// + /// + /// A replacement group of -1 means the body has its own frames for that group; any other + /// value borrows another group's, and then the frame count is zero. + /// + /// False when the record runs past the payload, which stops the walk. + private static bool ReadSequenceGroup(BinaryReader reader, int[] replacements) + { + Stream stream = reader.BaseStream; - binaryReader.BaseStream.Seek(60, SeekOrigin.Current); // skip remaining per-replacement fields - } + if (stream.Position + _sequenceGroupFixedSize > stream.Length) + { + return false; } - _sequenceReplacements[animId] = replacements; + int group = reader.ReadInt32(); + int frameCount = reader.ReadInt32(); + int replacementGroup = reader.ReadInt32(); + + // The frame rate, the per frame bytes and the reserved ints that make up the rest of the + // fixed part carry nothing the replacement table needs. + stream.Seek(_sequenceGroupFixedSize - (3 * sizeof(int)), SeekOrigin.Current); + + if (frameCount == 0 && group >= 0 && group < _maxAnimActions && replacementGroup >= 0) + { + replacements[group] = replacementGroup; + } + + return TrySkipList(reader, _sequenceGroupPropSize) && TrySkipList(reader, sizeof(int)); + } + + /// + /// Skips one counted list, checking the count against the bytes left before seeking past it, + /// so a malformed entry cannot walk the reader off the payload. + /// + private static bool TrySkipList(BinaryReader reader, int itemSize) + { + Stream stream = reader.BaseStream; + + if (stream.Position + sizeof(int) > stream.Length) + { + return false; + } + + int count = reader.ReadInt32(); + + if (count < 0 || (long)count * itemSize > stream.Length - stream.Position) + { + return false; + } + + stream.Seek((long)count * itemSize, SeekOrigin.Current); + + return true; } public static bool IsUopBody(int body) diff --git a/Ultima/Art.cs b/Ultima/Art.cs index 8fe235f3..88f51675 100644 --- a/Ultima/Art.cs +++ b/Ultima/Art.cs @@ -23,6 +23,9 @@ public static class Art private static bool[] _removed; private static readonly Dictionary _patched = new Dictionary(); public static bool Modified; + // Indexes edited since load or since the last save, in the same combined index space the + // Replace/Remove methods use (land = index & 0x3FFF, static = legal item id + 0x4000). + private static readonly ModifiedIndexTracker _modified = new ModifiedIndexTracker(); private static readonly byte[] _validBuffer = new byte[4]; @@ -180,6 +183,7 @@ public static void Reload() _replaced.Clear(); _removed = new bool[0x14000]; _patched.Clear(); + _modified.Clear(); Modified = false; } @@ -208,6 +212,7 @@ public static void ReplaceStatic(int index, Bitmap bmp) _patched.Remove(index); + _modified.Mark(index); Modified = true; } @@ -225,6 +230,7 @@ public static void ReplaceLand(int index, Bitmap bmp) _patched.Remove(index); + _modified.Mark(index); Modified = true; } @@ -237,6 +243,7 @@ public static void RemoveStatic(int index) index = GetLegalItemId(index); index += 0x4000; _removed[index] = true; + _modified.Mark(index); Modified = true; } @@ -248,9 +255,41 @@ public static void RemoveLand(int index) { index &= 0x3FFF; _removed[index] = true; + _modified.Mark(index); Modified = true; } + /// + /// Tests if the Static at was replaced or removed since the art was + /// loaded or last saved. + /// + public static bool IsStaticModified(int index) + { + return _modified.IsMarked(GetLegalItemId(index) + 0x4000); + } + + /// + /// Tests if the Land tile at was replaced or removed since the art was + /// loaded or last saved. + /// + public static bool IsLandModified(int index) + { + return _modified.IsMarked(index & 0x3FFF); + } + + /// + /// Number of Land tiles and Statics edited since the art was loaded or last saved. + /// + public static int ModifiedCount => _modified.Count; + + /// + /// Drops every modified mark without touching the edits themselves. + /// + public static void ClearModified() + { + _modified.Clear(); + } + /// /// Tests if Static is defined (width and height check) /// @@ -1094,6 +1133,8 @@ public static unsafe void Save(string path) memmul.WriteTo(fsmul); } } + + _modified.Clear(); } } diff --git a/Ultima/FileIndex.cs b/Ultima/FileIndex.cs index c5c43ea4..fafca347 100644 --- a/Ultima/FileIndex.cs +++ b/Ultima/FileIndex.cs @@ -249,6 +249,22 @@ public FileIndex(string idxFile, string mulFile, int file) } } + /// + /// True when the entry carries the high bit that sets + /// to mark it as coming from verdata. + /// + /// + /// A length of -1 is the "unused entry" filler that real .idx files are padded with, and it + /// has that same high bit set. Without the -1 test every unused entry looks like a verdata + /// patch of length 0x7FFFFFFF, and the reader then tries to pull that many bytes out of + /// - which, with no verdata.mul present, is Stream.Null. + /// A real patch never reaches 0x7FFFFFFF bytes, so the two cases cannot be confused. + /// + private static bool IsVerdataPatched(IEntry e) + { + return e.Length != -1 && (e.Length & (1 << 31)) != 0; + } + public Stream Seek(int index, out int length, out int extra, out bool patched) { if (FileAccessor is null) @@ -277,7 +293,7 @@ public Stream Seek(int index, out int length, out int extra, out bool patched) length = e.Length & 0x7FFFFFFF; extra = e.Extra; - if ((e.Length & (1 << 31)) != 0) + if (IsVerdataPatched(e)) { patched = true; Verdata.Seek(e.Lookup); @@ -343,7 +359,7 @@ public Stream Seek(int index, ref IEntry entry, out bool patched) entry = e; - if ((e.Length & (1 << 31)) != 0) + if (IsVerdataPatched(e)) { patched = true; Verdata.Seek(e.Lookup); @@ -443,7 +459,7 @@ public bool Valid(int index, out int length, out int extra, out bool patched) length = e.Length & 0x7FFFFFFF; extra = e.Extra; - if ((e.Length & (1 << 31)) != 0) + if (IsVerdataPatched(e)) { patched = true; return true; diff --git a/Ultima/Files.cs b/Ultima/Files.cs index 5e9a0e3f..23f32855 100644 --- a/Ultima/Files.cs +++ b/Ultima/Files.cs @@ -126,11 +126,13 @@ public static void FireFileSaveEvent() "mapdif2.mul", "mapdif3.mul", "mapdif4.mul", + "mapdif5.mul", "mapdifl0.mul", "mapdifl1.mul", "mapdifl2.mul", "mapdifl3.mul", "mapdifl4.mul", + "mapdifl5.mul", "mobtypes.txt", "multi.idx", "multi.mul", @@ -150,16 +152,19 @@ public static void FireFileSaveEvent() "stadif2.mul", "stadif3.mul", "stadif4.mul", + "stadif5.mul", "stadifi0.mul", "stadifi1.mul", "stadifi2.mul", "stadifi3.mul", "stadifi4.mul", + "stadifi5.mul", "stadifl0.mul", "stadifl1.mul", "stadifl2.mul", "stadifl3.mul", "stadifl4.mul", + "stadifl5.mul", "staidx0.mul", "staidx1.mul", "staidx2.mul", diff --git a/Ultima/Gumps.cs b/Ultima/Gumps.cs index 6f7442f3..51c0df3c 100644 --- a/Ultima/Gumps.cs +++ b/Ultima/Gumps.cs @@ -35,10 +35,9 @@ public sealed class Gumps private static readonly Dictionary _replaced = new Dictionary(); private static bool[] _removed; private static readonly Dictionary _patched = new Dictionary(); + // Indexes edited since load or since the last save. + private static readonly ModifiedIndexTracker _modified = new ModifiedIndexTracker(); - private static byte[] _pixelBuffer; - private static byte[] _streamBuffer; - private static byte[] _colorTable; // Authoritative id range — what _cache.Length used to be before the // LRU swap. Sourced from the FileIndex when available, falls back to @@ -110,10 +109,8 @@ public static void Reload() _contentState = new byte[_indexLength]; } - //_pixelBuffer = null; - _streamBuffer = null; - //_colorTable = null; _patched.Clear(); + _modified.Clear(); } public static int GetCount() @@ -133,6 +130,7 @@ public static void ReplaceGump(int index, Bitmap bmp) _removed[index] = false; _patched.Remove(index); _contentState[index] = _contentPresent; + _modified.Mark(index); } /// @@ -142,6 +140,29 @@ public static void ReplaceGump(int index, Bitmap bmp) public static void RemoveGump(int index) { _removed[index] = true; + _modified.Mark(index); + } + + /// + /// Tests if the Gump at was replaced or removed since the gumps were + /// loaded or last saved. + /// + public static bool IsModified(int index) + { + return _modified.IsMarked(index); + } + + /// + /// Number of Gumps edited since the gumps were loaded or last saved. + /// + public static int ModifiedCount => _modified.Count; + + /// + /// Drops every modified mark without touching the edits themselves. + /// + public static void ClearModified() + { + _modified.Clear(); } /// @@ -156,7 +177,7 @@ public static bool IsValidIndex(int index) return false; } - if (index > _indexLength - 1) + if (index < 0 || index > _indexLength - 1) { return false; } @@ -206,7 +227,8 @@ private static bool ProbeContent(int index, int packedExtra) // The index can answer for stored and verdata patched entries. For zlib it still can: the // payload is the eight byte width/height header plus pixels, so a declared length of eight or // less is a 0x0 gump. Mythic cannot - there DecompressedLength is the inner stream length. - bool verdataPatched = (entry.Length & (1 << 31)) != 0; + // -1 is the unused entry filler, not a patch - it carries the same high bit. + bool verdataPatched = entry.Length != -1 && (entry.Length & (1 << 31)) != 0; if (verdataPatched || entry.Flag == CompressionFlag.None) { @@ -437,162 +459,30 @@ public static byte[] GetRawGump(int index, out int width, out int height) /// /// /// - // TODO: Currently unused and may be broken because of recent UOP changes. Needs verdata `patched` checks and compression handling - public static unsafe Bitmap GetGump(int index, Hue hue, bool onlyHueGrayPixels, out bool patched) + public static Bitmap GetGump(int index, Hue hue, bool onlyHueGrayPixels, out bool patched) { - Stream stream = _fileIndex.Seek(index, out int length, out int extra, out patched); + // Decode through the regular path so this overload inherits the index/removed/ + // replaced checks and the UOP (zlib / Mythic) handling. The previous hand-rolled + // RLE decoder here still used the legacy Seek overload, so it walked compressed + // UOP bytes as if they were raw RLE and ran the pixel pointer off the buffer. + Bitmap gump = GetGump(index, out patched); - if (stream == null) + if (gump == null) { return null; } - if (extra == -1) + if (hue == null) { - return null; - } - - int width = (extra >> 16) & 0xFFFF; - int height = extra & 0xFFFF; - - if (width <= 0 || height <= 0) - { - return null; + return gump; } - int bytesPerLine = width << 1; - int bytesPerStride = (bytesPerLine + 3) & ~3; - int bytesForImage = height * bytesPerStride; - - int pixelsPerStride = (width + 1) & ~1; - int pixelsPerStrideDelta = pixelsPerStride - width; - - byte[] pixelBuffer = _pixelBuffer; - - if (pixelBuffer == null || pixelBuffer.Length < bytesForImage) - { - _pixelBuffer = pixelBuffer = new byte[(bytesForImage + 2047) & ~2047]; - } - - byte[] streamBuffer = _streamBuffer; - - if (streamBuffer == null || streamBuffer.Length < length) - { - _streamBuffer = streamBuffer = new byte[(length + 2047) & ~2047]; - } - - byte[] colorTable = _colorTable; - - if (colorTable == null) - { - _colorTable = colorTable = new byte[128]; - } - - stream.ReadExactly(streamBuffer, 0, length); - - fixed (ushort* psHueColors = hue.Colors) - { - fixed (byte* pbStream = streamBuffer) - { - fixed (byte* pbPixels = pixelBuffer) - { - fixed (byte* pbColorTable = colorTable) - { - var pHueColors = psHueColors; - ushort* pHueColorsEnd = pHueColors + 32; - - var pColorTable = (ushort*)pbColorTable; - - ushort* pColorTableOpaque = pColorTable; - - while (pHueColors < pHueColorsEnd) - { - *pColorTableOpaque++ = *pHueColors++; - } - - var pPixelDataStart = (ushort*)pbPixels; - - var pLookup = (int*)pbStream; - int* pLookupEnd = pLookup + height; - int* pPixelRleStart = pLookup; - int* pPixelRle; - - ushort* pPixel = pPixelDataStart; - ushort* pRleEnd; - ushort* pPixelEnd = pPixel + width; - - ushort color, count; + // GetGump can hand back a cached or replaced instance, so hue a private copy. + var hued = gump.Clone(new Rectangle(0, 0, gump.Width, gump.Height), gump.PixelFormat); - if (onlyHueGrayPixels) - { - while (pLookup < pLookupEnd) - { - pPixelRle = pPixelRleStart + *pLookup++; - pRleEnd = pPixel; - - while (pPixel < pPixelEnd) - { - color = *(ushort*)pPixelRle; - count = *(1 + (ushort*)pPixelRle); - ++pPixelRle; - - pRleEnd += count; + hue.ApplyTo(hued, onlyHueGrayPixels); - if (color != 0 && (color & 0x1F) == ((color >> 5) & 0x1F) && (color & 0x1F) == ((color >> 10) & 0x1F)) - { - color = pColorTable[color >> 10]; - } - else if (color != 0) - { - color ^= 0x8000; - } - - while (pPixel < pRleEnd) - { - *pPixel++ = color; - } - } - - pPixel += pixelsPerStrideDelta; - pPixelEnd += pixelsPerStride; - } - } - else - { - while (pLookup < pLookupEnd) - { - pPixelRle = pPixelRleStart + *pLookup++; - pRleEnd = pPixel; - - while (pPixel < pPixelEnd) - { - color = *(ushort*)pPixelRle; - count = *(1 + (ushort*)pPixelRle); - ++pPixelRle; - - pRleEnd += count; - - if (color != 0) - { - color = pColorTable[color >> 10]; - } - - while (pPixel < pRleEnd) - { - *pPixel++ = color; - } - } - - pPixel += pixelsPerStrideDelta; - pPixelEnd += pixelsPerStride; - } - } - - return new Bitmap(width, height, bytesPerStride, PixelFormat.Format16bppArgb1555, (IntPtr)pPixelDataStart); - } - } - } - } + return hued; } /// @@ -822,7 +712,7 @@ public static unsafe Bitmap GetGump(int index, out bool patched) { patched = _patched.ContainsKey(index) && _patched[index]; - if (index > _indexLength - 1) + if (index < 0 || index > _indexLength - 1) { return null; } @@ -1353,6 +1243,8 @@ public static unsafe void Save(string path) binidx.Flush(); fsidx.SetLength(rows * 12); } + + _modified.Clear(); } } } \ No newline at end of file diff --git a/UoFiddler.Controls/Classes/AppLog.cs b/Ultima/Helpers/AppLog.cs similarity index 96% rename from UoFiddler.Controls/Classes/AppLog.cs rename to Ultima/Helpers/AppLog.cs index c2a6570b..31d24d36 100644 --- a/UoFiddler.Controls/Classes/AppLog.cs +++ b/Ultima/Helpers/AppLog.cs @@ -13,7 +13,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -namespace UoFiddler.Controls.Classes +namespace Ultima.Helpers { /// /// Static logger façade for sites that cannot accept ILogger via constructor injection @@ -33,4 +33,4 @@ public static void Initialize(ILoggerFactory factory) public static ILogger For(Type type) => _factory.CreateLogger(type.FullName); } -} +} \ No newline at end of file diff --git a/Ultima/Helpers/UopUtils.cs b/Ultima/Helpers/UopUtils.cs index c2e7a9df..81e09dde 100644 --- a/Ultima/Helpers/UopUtils.cs +++ b/Ultima/Helpers/UopUtils.cs @@ -91,6 +91,23 @@ public static ulong HashFileName(string input) /// returned — matching the client function, which only yields the low output /// word. /// + /// + /// Adler32 of a UOP entry's bytes, as stored in the 32 bit hash field of its table row. + /// + public static uint HashAdler32(ReadOnlySpan data) + { + uint a = 1; + uint b = 0; + + for (int i = 0; i < data.Length; i++) + { + a = (a + data[i]) % 65521; + b = (b + a) % 65521; + } + + return (b << 16) | a; + } + public static uint HashWord2(ReadOnlySpan data, uint initValue = 0) { int length = data.Length, i = 0; diff --git a/Ultima/Map.cs b/Ultima/Map.cs index e96bd6a9..1335cf70 100644 --- a/Ultima/Map.cs +++ b/Ultima/Map.cs @@ -3,6 +3,7 @@ using System.Drawing.Imaging; using System.IO; using System.Text; +using Ultima.Statics; namespace Ultima { @@ -1040,275 +1041,20 @@ public unsafe void GetImageQuarter(int x, int y, int width, int height, Bitmap b bmp.UnlockBits(bd); } + /// + /// Kept so existing plugins keep compiling. New code should drive + /// directly, which exposes the filters, + /// the dry run and the report this overload cannot. + /// + [Obsolete("Use Ultima.Statics.StaticsDefragmenter, which reports what it removed instead of dropping tiles silently.")] public static void DefragStatics(string path, Map map, int width, int height, bool remove) { - string indexPath = Files.GetFilePath($"staidx{map.FileIndex}.mul"); - BinaryReader indexReader; - if (indexPath != null) - { - FileStream index = new FileStream(indexPath, FileMode.Open, FileAccess.Read, FileShare.Read); - indexReader = new BinaryReader(index); - } - else - { - return; - } - - string staticsPath = Files.GetFilePath($"statics{map.FileIndex}.mul"); - - FileStream staticsStream; - BinaryReader staticsReader; - if (staticsPath != null) - { - staticsStream = new FileStream(staticsPath, FileMode.Open, FileAccess.Read, FileShare.Read); - staticsReader = new BinaryReader(staticsStream); - } - else - { - return; - } - - int blockx = width >> 3; - int blocky = height >> 3; + StaticsDefragOptions options = StaticsDefragOptions.Legacy(path, map, remove); - string idx = Path.Combine(path, $"staidx{map.FileIndex}.mul"); - string mul = Path.Combine(path, $"statics{map.FileIndex}.mul"); - - using (var fsidx = new FileStream(idx, FileMode.Create, FileAccess.Write, FileShare.Write)) - using (var fsmul = new FileStream(mul, FileMode.Create, FileAccess.Write, FileShare.Write)) - { - var memidx = new MemoryStream(); - var memmul = new MemoryStream(); - using (var binidx = new BinaryWriter(memidx)) - using (var binmul = new BinaryWriter(memmul)) - { - for (int x = 0; x < blockx; ++x) - { - for (int y = 0; y < blocky; ++y) - { - try - { - indexReader.BaseStream.Seek(((x * blocky) + y) * 12, SeekOrigin.Begin); - int lookup = indexReader.ReadInt32(); - int length = indexReader.ReadInt32(); - int extra = indexReader.ReadInt32(); - - if (((lookup < 0 || length <= 0) && (!map.Tiles.PendingStatic(x, y))) || - (map.Tiles.IsStaticBlockRemoved(x, y))) - { - binidx.Write(-1); // lookup - binidx.Write(-1); // length - binidx.Write(-1); // extra - } - else - { - if ((lookup >= 0) && (length > 0)) - { - staticsStream.Seek(lookup, SeekOrigin.Begin); - } - - var fsmullength = (int)binmul.BaseStream.Position; - int count = length / 7; - if (!remove) // without duplicate remove - { - bool firstitem = true; - for (int i = 0; i < count; ++i) - { - ushort graphic = staticsReader.ReadUInt16(); - byte sx = staticsReader.ReadByte(); - byte sy = staticsReader.ReadByte(); - sbyte sz = staticsReader.ReadSByte(); - short shue = staticsReader.ReadInt16(); - - if (graphic > Art.GetMaxItemId()) - { - continue; - } - - if (shue < 0) - { - shue = 0; - } - - if (firstitem) - { - binidx.Write((int)binmul.BaseStream.Position); // lookup - firstitem = false; - } - - binmul.Write(graphic); - binmul.Write(sx); - binmul.Write(sy); - binmul.Write(sz); - binmul.Write(shue); - } - - StaticTile[] tileList = map.Tiles.GetPendingStatics(x, y); - if (tileList != null) - { - for (int i = 0; i < tileList.Length; ++i) - { - if (tileList[i].Id > Art.GetMaxItemId()) - { - continue; - } - - if (tileList[i].Hue < 0) - { - tileList[i].Hue = 0; - } - - if (firstitem) - { - binidx.Write((int)binmul.BaseStream.Position); // lookup - firstitem = false; - } - - binmul.Write(tileList[i].Id); - binmul.Write(tileList[i].X); - binmul.Write(tileList[i].Y); - binmul.Write(tileList[i].Z); - binmul.Write(tileList[i].Hue); - } - } - } - else // with duplicate remove - { - var tileList = new StaticTile[count]; - int j = 0; - for (int i = 0; i < count; ++i) - { - var tile = new StaticTile - { - Id = staticsReader.ReadUInt16(), - X = staticsReader.ReadByte(), - Y = staticsReader.ReadByte(), - Z = staticsReader.ReadSByte(), - Hue = staticsReader.ReadInt16() - }; - - if (tile.Id > Art.GetMaxItemId()) - { - continue; - } - - if (tile.Hue < 0) - { - tile.Hue = 0; - } - - bool first = true; - for (int k = 0; k < j; ++k) - { - if ((tileList[k].Id == tile.Id) && (tileList[k].X == tile.X) && (tileList[k].Y == tile.Y) && (tileList[k].Z == tile.Z) && (tileList[k].Hue == tile.Hue)) - { - first = false; - break; - } - } - - if (!first) - { - continue; - } - - tileList[j] = tile; - j++; - } - - if (map.Tiles.PendingStatic(x, y)) - { - StaticTile[] pending = map.Tiles.GetPendingStatics(x, y); - StaticTile[] old = tileList; - tileList = new StaticTile[old.Length + pending.Length]; - old.CopyTo(tileList, 0); - for (int i = 0; i < pending.Length; ++i) - { - if (pending[i].Id > Art.GetMaxItemId()) - { - continue; - } - - if (pending[i].Hue < 0) - { - pending[i].Hue = 0; - } - - bool first = true; - for (int k = 0; k < j; ++k) - { - if ((tileList[k].Id == pending[i].Id) && (tileList[k].X == pending[i].X) && (tileList[k].Y == pending[i].Y) && (tileList[k].Z == pending[i].Z) && (tileList[k].Hue == pending[i].Hue)) - { - first = false; - break; - } - } - - if (first) - { - tileList[j++] = pending[i]; - } - } - } - - if (j > 0) - { - binidx.Write((int)binmul.BaseStream.Position); // lookup - for (int i = 0; i < j; ++i) - { - binmul.Write(tileList[i].Id); - binmul.Write(tileList[i].X); - binmul.Write(tileList[i].Y); - binmul.Write(tileList[i].Z); - binmul.Write(tileList[i].Hue); - } - } - } - - fsmullength = (int)binmul.BaseStream.Position - fsmullength; - if (fsmullength > 0) - { - binidx.Write(fsmullength); // length - if (extra == -1) - { - extra = 0; - } - - binidx.Write(extra); // extra - } - else - { - binidx.Write(-1); // lookup - binidx.Write(-1); // length - binidx.Write(-1); // extra - } - } - } - catch // fill the rest - { - binidx.BaseStream.Seek(((x * blocky) + y) * 12, SeekOrigin.Begin); - for (; x < blockx; ++x) - { - for (; y < blocky; ++y) - { - binidx.Write(-1); // lookup - binidx.Write(-1); // length - binidx.Write(-1); // extra - } - - y = 0; - } - } - } - } - - memidx.WriteTo(fsidx); - memmul.WriteTo(fsmul); - } - } + options.BlockWidth = width >> 3; + options.BlockHeight = height >> 3; - indexReader.Close(); - staticsReader.Close(); + StaticsDefragmenter.Defrag(options); } public static void RewriteMap(string path, int mapIndex, int width, int height) diff --git a/Ultima/Maps/MapBlockSink.cs b/Ultima/Maps/MapBlockSink.cs new file mode 100644 index 00000000..b11321c2 --- /dev/null +++ b/Ultima/Maps/MapBlockSink.cs @@ -0,0 +1,200 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.IO; +using Ultima.Uop; + +namespace Ultima.Maps +{ + public enum MapOutputFormat + { + Mul, + Uop + } + + /// + /// Somewhere to put a facet's land blocks, in index order, without the producer caring whether + /// the result is a map{N}.mul or a map{N}LegacyMUL.uop. + /// + public interface IMapBlockSink : IDisposable + { + /// Where the finished file will be, or is. + string OutputPath { get; } + + /// Appends one 196-byte block: a 4-byte header followed by 64 three-byte tiles. + void WriteBlock(ReadOnlySpan block); + + /// Finishes the file and moves it into place. Without this the output is discarded. + void Complete(); + } + + public static class MapBlockSink + { + public const int MapBlockSize = 196; + + /// + /// Opens a sink for a facet. The file is built beside its destination under a temporary name + /// and only moved into place by , so an interrupted run + /// leaves whatever was there before untouched. + /// + public static IMapBlockSink Create(string outputDirectory, int fileIndex, MapOutputFormat format, long blockCount) + { + if (string.IsNullOrWhiteSpace(outputDirectory)) + { + throw new ArgumentException("No output directory was given.", nameof(outputDirectory)); + } + + Directory.CreateDirectory(outputDirectory); + + string name = format == MapOutputFormat.Uop + ? $"map{fileIndex}LegacyMUL.uop" + : $"map{fileIndex}.mul"; + + string path = Path.GetFullPath(Path.Combine(outputDirectory, name)); + + return format == MapOutputFormat.Uop + ? new UopMapBlockSink(path, fileIndex, blockCount) + : (IMapBlockSink)new MulMapBlockSink(path, blockCount); + } + + internal static FileStream CreateTemporary(string path, out string temporaryPath) + { + temporaryPath = path + ".tmp-" + Guid.NewGuid().ToString("N"); + + return new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1 << 20); + } + + internal static void TryDelete(string path) + { + if (path == null || !File.Exists(path)) + { + return; + } + + try + { + File.Delete(path); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + } + + internal sealed class MulMapBlockSink : IMapBlockSink + { + private readonly long _blockCount; + private readonly FileStream _stream; + + private string _temporaryPath; + private long _blocksWritten; + private bool _completed; + + public MulMapBlockSink(string path, long blockCount) + { + OutputPath = path; + _blockCount = blockCount; + _stream = MapBlockSink.CreateTemporary(path, out _temporaryPath); + } + + public string OutputPath { get; } + + public void WriteBlock(ReadOnlySpan block) + { + if (block.Length != MapBlockSink.MapBlockSize) + { + throw new ArgumentException($"A land block is {MapBlockSink.MapBlockSize} bytes.", nameof(block)); + } + + _stream.Write(block); + ++_blocksWritten; + } + + public void Complete() + { + if (_completed) + { + return; + } + + if (_blocksWritten != _blockCount) + { + throw new InvalidOperationException( + $"{_blocksWritten:N0} blocks were written but the facet holds {_blockCount:N0}."); + } + + _stream.Flush(); + _stream.Dispose(); + + File.Move(_temporaryPath, OutputPath, true); + + _temporaryPath = null; + _completed = true; + } + + public void Dispose() + { + _stream.Dispose(); + MapBlockSink.TryDelete(_temporaryPath); + } + } + + internal sealed class UopMapBlockSink : IMapBlockSink + { + private readonly FileStream _stream; + private readonly MapUopWriter _writer; + + private string _temporaryPath; + private bool _completed; + + public UopMapBlockSink(string path, int fileIndex, long blockCount) + { + OutputPath = path; + _stream = MapBlockSink.CreateTemporary(path, out _temporaryPath); + _writer = new MapUopWriter(_stream, fileIndex, blockCount, MapTrailingBlock.Empty, true); + } + + public string OutputPath { get; } + + public void WriteBlock(ReadOnlySpan block) + { + _writer.WriteBlock(block); + } + + public void Complete() + { + if (_completed) + { + return; + } + + _writer.Complete(); + _stream.Flush(); + _stream.Dispose(); + + File.Move(_temporaryPath, OutputPath, true); + + _temporaryPath = null; + _completed = true; + } + + public void Dispose() + { + _writer.Dispose(); + _stream.Dispose(); + MapBlockSink.TryDelete(_temporaryPath); + } + } +} \ No newline at end of file diff --git a/Ultima/Maps/MapDiffApply.cs b/Ultima/Maps/MapDiffApply.cs new file mode 100644 index 00000000..b47c9710 --- /dev/null +++ b/Ultima/Maps/MapDiffApply.cs @@ -0,0 +1,481 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using Ultima.Statics; + +namespace Ultima.Maps +{ + public sealed class MapDiffApplyOptions + { + /// The loaded map whose diff files are being folded in. + public Map Map { get; set; } + + /// Region to apply, in tile coordinates, inclusive. Reversed values are normalised. + public int X1 { get; set; } + + public int Y1 { get; set; } + + public int X2 { get; set; } + + public int Y2 { get; set; } + + public bool ApplyLand { get; set; } = true; + + public bool ApplyStatics { get; set; } = true; + + public MapOutputFormat MapFormat { get; set; } = MapOutputFormat.Mul; + + public StaticsTileFilter StaticsFilter { get; set; } + + public string OutputDirectory { get; set; } + + public IProgress Progress { get; set; } + + public CancellationToken CancellationToken { get; set; } + } + + public sealed class MapDiffApplyResult : IStaticsFilterStats + { + /// The facet that was patched, so a verification can read the right files back. + public int FileIndex { get; set; } + + public BlockRectangle Region { get; set; } + + public int RequestedX1 { get; set; } + + public int RequestedY1 { get; set; } + + public int RequestedX2 { get; set; } + + public int RequestedY2 { get; set; } + + public MapSize MapSize { get; set; } + + public string OutputMapPath { get; set; } + + public string OutputIndexPath { get; set; } + + public string OutputStaticsPath { get; set; } + + /// Blocks the land diff lists, across the whole facet. + public int LandBlocksPatched { get; set; } + + /// Blocks the statics diff lists, across the whole facet. + public int StaticBlocksPatched { get; set; } + + public long LandBlocksApplied { get; set; } + + public long StaticBlocksApplied { get; set; } + + public long StaticsRead { get; set; } + + public long StaticsWritten { get; set; } + + public long DroppedInvalidItemId { get; set; } + + public long DroppedOutOfBlock { get; set; } + + public long MaskedOutOfBlock { get; set; } + + public long DroppedInvalidZ { get; set; } + + public long DuplicatesRemoved { get; set; } + + public long HuesNormalized { get; set; } + + public int HighestItemIdSeen { get; set; } + + public TimeSpan Elapsed { get; set; } + + public List Warnings { get; } = new List(); + + public List RejectSamples { get; } = new List(); + + public int RejectSampleLimit { get; set; } = 200; + + public bool RegionWasSnapped => + Region.TileX1 != RequestedX1 || Region.TileY1 != RequestedY1 || + Region.TileX2 != RequestedX2 || Region.TileY2 != RequestedY2; + + void IStaticsFilterStats.TileRejected(int blockX, int blockY, StaticTile tile, RejectReason reason) + { + switch (reason) + { + case RejectReason.InvalidItemId: + ++DroppedInvalidItemId; + break; + + case RejectReason.OutOfBlockOffset: + ++DroppedOutOfBlock; + break; + + case RejectReason.InvalidZ: + ++DroppedInvalidZ; + break; + + case RejectReason.Duplicate: + ++DuplicatesRemoved; + break; + } + + if (RejectSamples.Count < RejectSampleLimit) + { + RejectSamples.Add(new RejectedStaticTile(blockX, blockY, tile, reason)); + } + } + + void IStaticsFilterStats.HueNormalized() => ++HuesNormalized; + + void IStaticsFilterStats.OutOfBlockMasked() => ++MaskedOutOfBlock; + + public string ToReport() + { + var sb = new StringBuilder(); + + sb.AppendLine(Line("Requested region : {0},{1} - {2},{3}", RequestedX1, RequestedY1, RequestedX2, RequestedY2)); + sb.AppendLine(Line("Applied to : {0}", Region)); + + if (RegionWasSnapped) + { + sb.AppendLine(" the request was widened to whole 8-tile blocks"); + } + + sb.AppendLine(); + sb.AppendLine(Line("Map size : {0}", MapSize)); + sb.AppendLine(); + + if (OutputMapPath != null) + { + sb.AppendLine(Line("Map written : {0}", OutputMapPath)); + sb.AppendLine(Line(" land diff lists: {0:N0} blocks for this facet", LandBlocksPatched)); + sb.AppendLine(Line(" applied : {0:N0} of them fall in the region", LandBlocksApplied)); + } + + if (OutputIndexPath != null) + { + sb.AppendLine(Line("Statics written : {0}", OutputStaticsPath)); + sb.AppendLine(Line(" static diff : {0:N0} blocks for this facet", StaticBlocksPatched)); + sb.AppendLine(Line(" applied : {0:N0} of them fall in the region", StaticBlocksApplied)); + sb.AppendLine(Line(" statics : {0:N0} read, {1:N0} written", StaticsRead, StaticsWritten)); + sb.AppendLine(Line(" highest id : 0x{0:X4}", HighestItemIdSeen)); + + if (DroppedInvalidItemId > 0 || DuplicatesRemoved > 0 || HuesNormalized > 0) + { + sb.AppendLine(Line(" removed : {0:N0} invalid id, {1:N0} duplicates; {2:N0} hues reset", + DroppedInvalidItemId, DuplicatesRemoved, HuesNormalized)); + } + } + + sb.AppendLine(); + sb.AppendLine(Line("Elapsed : {0:hh\\:mm\\:ss\\.fff}", Elapsed)); + + if (Warnings.Count > 0) + { + sb.AppendLine(); + sb.AppendLine(Line("Warnings ({0}):", Warnings.Count)); + + foreach (string warning in Warnings) + { + sb.AppendLine(" " + warning); + } + } + + return sb.ToString(); + } + + private static string Line(string format, params object[] args) + { + return string.Format(CultureInfo.InvariantCulture, format, args); + } + } + + /// + /// Folds a facet's mapdif and stadif data into fresh map and statics files, for a chosen region. + /// + /// + /// The land side goes through rather than seeking into map{N}.mul, so a + /// client whose maps are UOP-only works. The block header the mul carries is not exposed by the + /// patch data, so a patched block is written with a zero header; the renderer ignores it. + /// + public static class MapDiffApplier + { + private const int ProgressInterval = 256; + + public static MapDiffApplyResult Run(MapDiffApplyOptions options) + { + if (options?.Map == null) + { + throw new MapRegionCopyException("No map was given."); + } + + var result = new MapDiffApplyResult(); + var stopwatch = Stopwatch.StartNew(); + + var size = new MapSize(options.Map.Width, options.Map.Height); + result.MapSize = size; + result.FileIndex = options.Map.FileIndex; + + Normalise(options, result, size); + + TileMatrix tiles = options.Map.Tiles; + TileMatrixPatch patch = tiles.Patch; + + result.LandBlocksPatched = patch.LandBlocksCount; + result.StaticBlocksPatched = patch.StaticBlocksCount; + + if (patch.LandBlocksCount == 0 && patch.StaticBlocksCount == 0) + { + result.Warnings.Add( + $"No diff data was loaded for map {options.Map.FileIndex}. Check that mapdif{options.Map.FileIndex}.mul " + + $"and stadif{options.Map.FileIndex}.mul are present and that Use Map Diff is on."); + } + + if (options.ApplyLand) + { + ApplyLand(options, result, size, tiles, patch); + } + + if (options.ApplyStatics) + { + ApplyStatics(options, result, size, tiles, patch); + } + + result.Elapsed = stopwatch.Elapsed; + + return result; + } + + private static void Normalise(MapDiffApplyOptions options, MapDiffApplyResult result, MapSize size) + { + int x1 = options.X1; + int y1 = options.Y1; + int x2 = options.X2; + int y2 = options.Y2; + + if (x1 > x2) + { + (x1, x2) = (x2, x1); + } + + if (y1 > y2) + { + (y1, y2) = (y2, y1); + } + + result.RequestedX1 = x1; + result.RequestedY1 = y1; + result.RequestedX2 = x2; + result.RequestedY2 = y2; + + if (x1 < 0 || x2 >= size.Width) + { + throw new MapRegionCopyException($"The region's X runs {x1}..{x2}, but the map is {size.Width} tiles wide."); + } + + if (y1 < 0 || y2 >= size.Height) + { + throw new MapRegionCopyException($"The region's Y runs {y1}..{y2}, but the map is {size.Height} tiles tall."); + } + + result.Region = new BlockRectangle(x1 >> 3, y1 >> 3, x2 >> 3, y2 >> 3); + } + + private static void ApplyLand(MapDiffApplyOptions options, MapDiffApplyResult result, MapSize size, + TileMatrix tiles, TileMatrixPatch patch) + { + long blockCount = size.BlockCount; + int done = 0; + + using (IMapBlockSink sink = MapBlockSink.Create(options.OutputDirectory, + options.Map.FileIndex, options.MapFormat, blockCount)) + { + var block = new byte[TileMatrix.MapBlockSize]; + + for (int x = 0; x < size.BlockWidth; ++x) + { + for (int y = 0; y < size.BlockHeight; ++y) + { + bool inRegion = InRegion(result.Region, x, y); + + if (inRegion && patch.IsLandBlockPatched(x, y)) + { + Tile[] patched = patch.GetLandBlock(x, y); + + Array.Clear(block); + MemoryMarshal.AsBytes(patched.AsSpan()) + .CopyTo(block.AsSpan(TileMatrix.BlockHeaderSize)); + + ++result.LandBlocksApplied; + } + else + { + tiles.ReadLandBlockBytes(x, y, block); + } + + sink.WriteBlock(block); + + if ((++done & (ProgressInterval - 1)) == 0) + { + options.CancellationToken.ThrowIfCancellationRequested(); + Report(options, "Inserting map", done, (int)blockCount); + } + } + } + + sink.Complete(); + result.OutputMapPath = sink.OutputPath; + } + + Report(options, "Inserting map", (int)blockCount, (int)blockCount); + } + + private static void ApplyStatics(MapDiffApplyOptions options, MapDiffApplyResult result, MapSize size, + TileMatrix tiles, TileMatrixPatch patch) + { + int fileIndex = options.Map.FileIndex; + + string sourceIndex = Resolve($"staidx{fileIndex}.mul"); + string sourceStatics = Resolve($"statics{fileIndex}.mul"); + + string outputIndex = Path.Combine(options.OutputDirectory, $"staidx{fileIndex}.mul"); + string outputStatics = Path.Combine(options.OutputDirectory, $"statics{fileIndex}.mul"); + + RefuseToOverwrite(sourceIndex, outputIndex); + RefuseToOverwrite(sourceStatics, outputStatics); + + Directory.CreateDirectory(options.OutputDirectory); + + string tempIndex = outputIndex + ".tmp-" + Guid.NewGuid().ToString("N"); + string tempStatics = outputStatics + ".tmp-" + Guid.NewGuid().ToString("N"); + + var problems = new StaticsBlockProblems(); + var staticTiles = new List(256); + + try + { + using (StaticsIndexReader reader = StaticsIndexReader.Open(sourceIndex, sourceStatics, + size.BlockWidth, size.BlockHeight, result.Warnings)) + using (var outIndexStream = new FileStream(tempIndex, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1 << 20)) + using (var outStaticsStream = new FileStream(tempStatics, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1 << 20)) + using (var writer = new StaticsBlockWriter(outIndexStream, outStaticsStream, + size.BlockWidth, size.BlockHeight, EmptyBlockStyle.NegativeOne, true)) + { + int done = 0; + int blockCount = size.BlockWidth * size.BlockHeight; + + for (int x = 0; x < size.BlockWidth; ++x) + { + for (int y = 0; y < size.BlockHeight; ++y) + { + staticTiles.Clear(); + + bool patched = InRegion(result.Region, x, y) && patch.IsStaticBlockPatched(x, y); + + if (patched) + { + result.StaticsRead += StaticsBlockConversion.FromHuedBlock(patch.GetStaticBlock(x, y), staticTiles); + ++result.StaticBlocksApplied; + } + else + { + result.StaticsRead += reader.ReadBlock(x, y, staticTiles, result.Warnings, problems); + } + + options.StaticsFilter?.Apply(staticTiles, x, y, result); + + writer.WriteBlock(x, y, staticTiles, patched ? 0 : reader.GetEntry(x, y).Extra); + + if ((++done & (ProgressInterval - 1)) == 0) + { + options.CancellationToken.ThrowIfCancellationRequested(); + Report(options, "Inserting statics", done, blockCount); + } + } + } + + writer.Complete(); + + result.StaticsWritten = writer.TilesWritten; + + Report(options, "Inserting statics", blockCount, blockCount); + } + + File.Move(tempStatics, outputStatics, true); + tempStatics = null; + + File.Move(tempIndex, outputIndex, true); + tempIndex = null; + + result.OutputIndexPath = Path.GetFullPath(outputIndex); + result.OutputStaticsPath = Path.GetFullPath(outputStatics); + + if (options.StaticsFilter != null) + { + result.HighestItemIdSeen = options.StaticsFilter.HighestItemIdSeen; + } + + if (problems.BadLookup > 0 || problems.BadLength > 0 || problems.OutOfRange > 0) + { + result.Warnings.Add(string.Format(CultureInfo.InvariantCulture, + "Damaged index records: {0:N0} bad lookup, {1:N0} bad length, {2:N0} out of range.", + problems.BadLookup, problems.BadLength, problems.OutOfRange)); + } + } + finally + { + MapBlockSink.TryDelete(tempIndex); + MapBlockSink.TryDelete(tempStatics); + } + } + + private static bool InRegion(BlockRectangle region, int x, int y) + { + return x >= region.BlockX1 && x <= region.BlockX2 && y >= region.BlockY1 && y <= region.BlockY2; + } + + private static void Report(MapDiffApplyOptions options, string stage, int done, int total) + { + options.Progress?.Report(new MapCopyProgress { Stage = stage, BlocksDone = done, BlocksTotal = total }); + } + + private static string Resolve(string fileName) + { + string path = Files.GetFilePath(fileName); + + if (path == null) + { + throw new MapRegionCopyException( + $"{fileName} was not found. Check the path settings for the loaded client."); + } + + return Path.GetFullPath(path); + } + + private static void RefuseToOverwrite(string sourcePath, string outputPath) + { + if (!string.Equals(sourcePath, Path.GetFullPath(outputPath), StringComparison.OrdinalIgnoreCase)) + { + return; + } + + throw new MapRegionCopyException( + $"The output directory holds a file being read ({outputPath}). Choose a different output directory."); + } + } +} \ No newline at end of file diff --git a/Ultima/Maps/MapRegionCopy.cs b/Ultima/Maps/MapRegionCopy.cs new file mode 100644 index 00000000..009bc26e --- /dev/null +++ b/Ultima/Maps/MapRegionCopy.cs @@ -0,0 +1,880 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading; +using Ultima.Statics; + +namespace Ultima.Maps +{ + /// + /// A rectangle of blocks, and the tile rectangle it covers. + /// + public readonly struct BlockRectangle + { + public BlockRectangle(int blockX1, int blockY1, int blockX2, int blockY2) + { + BlockX1 = blockX1; + BlockY1 = blockY1; + BlockX2 = blockX2; + BlockY2 = blockY2; + } + + public int BlockX1 { get; } + + public int BlockY1 { get; } + + public int BlockX2 { get; } + + public int BlockY2 { get; } + + public int BlockWidth => BlockX2 - BlockX1 + 1; + + public int BlockHeight => BlockY2 - BlockY1 + 1; + + public int TileX1 => BlockX1 << 3; + + public int TileY1 => BlockY1 << 3; + + public int TileX2 => (BlockX2 << 3) + 7; + + public int TileY2 => (BlockY2 << 3) + 7; + + public override string ToString() + { + return string.Format(CultureInfo.InvariantCulture, + "{0},{1} - {2},{3} (blocks {4},{5} - {6},{7}, {8} x {9})", + TileX1, TileY1, TileX2, TileY2, BlockX1, BlockY1, BlockX2, BlockY2, BlockWidth, BlockHeight); + } + } + + public sealed class MapCopyProgress + { + public string Stage { get; init; } + + public int BlocksDone { get; init; } + + public int BlocksTotal { get; init; } + } + + public sealed class MapRegionCopyException : Exception + { + public MapRegionCopyException(string message) : base(message) + { + } + + public MapRegionCopyException(string message, Exception innerException) : base(message, innerException) + { + } + } + + public sealed class MapRegionCopyOptions + { + /// Folder holding the client files to copy from. + public string SourceDirectory { get; set; } + + public int SourceFileIndex { get; set; } + + /// Block grid of the source facet. Detect it rather than assume it. + public MapSize SourceSize { get; set; } + + /// The loaded map being copied into. Its files supply everything outside the region. + public Map Destination { get; set; } + + /// Region to take, in source tile coordinates, inclusive. Reversed values are normalised. + public int SourceX1 { get; set; } + + public int SourceY1 { get; set; } + + public int SourceX2 { get; set; } + + public int SourceY2 { get; set; } + + /// Where the region lands, in destination tile coordinates. + public int DestinationX { get; set; } + + public int DestinationY { get; set; } + + public bool CopyLand { get; set; } = true; + + public bool CopyStatics { get; set; } = true; + + public MapOutputFormat MapFormat { get; set; } = MapOutputFormat.Mul; + + /// + /// Applied to the statics of every block written. Leave null to copy them through untouched. + /// + public StaticsTileFilter StaticsFilter { get; set; } + + /// Replaces land ids of 0x4000 and up with 0. Land art only reaches 0x3FFF. + public bool SanitizeLandIds { get; set; } + + /// + /// Added to the z of every land tile and static in the copied region. Land and statics move + /// together, so a building keeps its footing on the terrain it was copied with. + /// + public int ZAdjust { get; set; } + + /// + /// What to do with a tile the adjustment would push outside the -128..127 the files hold. + /// Refusing is the default: a shift that does not fit is almost always the wrong shift. + /// + public ZOverflowAction ZOverflow { get; set; } = ZOverflowAction.Refuse; + + public string OutputDirectory { get; set; } + + public IProgress Progress { get; set; } + + public CancellationToken CancellationToken { get; set; } + } + + public sealed class MapRegionCopyResult : IStaticsFilterStats + { + /// Folder the region came from, so a verification can read it back. + public string SourceDirectory { get; set; } + + public int SourceFileIndex { get; set; } + + /// The facet that was copied into. + public int DestinationFileIndex { get; set; } + + public BlockRectangle Source { get; set; } + + public BlockRectangle DestinationRegion { get; set; } + + public int RequestedX1 { get; set; } + + public int RequestedY1 { get; set; } + + public int RequestedX2 { get; set; } + + public int RequestedY2 { get; set; } + + public MapSize SourceSize { get; set; } + + public MapSize DestinationSize { get; set; } + + public string OutputMapPath { get; set; } + + public string OutputIndexPath { get; set; } + + public string OutputStaticsPath { get; set; } + + public long LandBlocksCopied { get; set; } + + public long LandBlocksCarried { get; set; } + + public long StaticBlocksCopied { get; set; } + + public long StaticBlocksCarried { get; set; } + + public long StaticsRead { get; set; } + + public long StaticsWritten { get; set; } + + public long LandIdsSanitized { get; set; } + + public int ZAdjust { get; set; } + + /// The z the region carried before the adjustment. Null when nothing was adjusted. + public MapRegionZSurvey SourceZ { get; set; } + + public long LandZClamped { get; set; } + + public long StaticsZClamped { get; set; } + + public long DroppedInvalidItemId { get; set; } + + public long DroppedOutOfBlock { get; set; } + + public long MaskedOutOfBlock { get; set; } + + public long DroppedInvalidZ { get; set; } + + public long DuplicatesRemoved { get; set; } + + public long HuesNormalized { get; set; } + + public int HighestItemIdSeen { get; set; } + + public TimeSpan Elapsed { get; set; } + + public List Warnings { get; } = new List(); + + public List RejectSamples { get; } = new List(); + + public int RejectSampleLimit { get; set; } = 200; + + public long StaticsAccountedFor => DroppedInvalidItemId + DroppedOutOfBlock + DroppedInvalidZ + DuplicatesRemoved; + + /// Whether the block snap widened what the user asked for. + public bool RegionWasSnapped => + Source.TileX1 != RequestedX1 || Source.TileY1 != RequestedY1 || + Source.TileX2 != RequestedX2 || Source.TileY2 != RequestedY2; + + void IStaticsFilterStats.TileRejected(int blockX, int blockY, StaticTile tile, RejectReason reason) + { + switch (reason) + { + case RejectReason.InvalidItemId: + ++DroppedInvalidItemId; + break; + + case RejectReason.OutOfBlockOffset: + ++DroppedOutOfBlock; + break; + + case RejectReason.InvalidZ: + ++DroppedInvalidZ; + break; + + case RejectReason.Duplicate: + ++DuplicatesRemoved; + break; + } + + if (RejectSamples.Count < RejectSampleLimit) + { + RejectSamples.Add(new RejectedStaticTile(blockX, blockY, tile, reason)); + } + } + + void IStaticsFilterStats.HueNormalized() => ++HuesNormalized; + + void IStaticsFilterStats.OutOfBlockMasked() => ++MaskedOutOfBlock; + + public string ToReport() + { + var sb = new StringBuilder(); + + sb.AppendLine(Line("Requested region : {0},{1} - {2},{3}", RequestedX1, RequestedY1, RequestedX2, RequestedY2)); + sb.AppendLine(Line("Copied from : {0}", Source)); + sb.AppendLine(Line("Copied to : {0}", DestinationRegion)); + + if (RegionWasSnapped) + { + sb.AppendLine(" the request was widened to whole 8-tile blocks"); + } + + sb.AppendLine(); + + if (ZAdjust != 0) + { + sb.AppendLine(Line("Z adjusted by : {0:+#;-#;0}", ZAdjust)); + sb.AppendLine(); + } + + sb.AppendLine(Line("Source map size : {0}", SourceSize)); + sb.AppendLine(Line("Target map size : {0}", DestinationSize)); + sb.AppendLine(); + + if (OutputMapPath != null) + { + sb.AppendLine(Line("Map written : {0}", OutputMapPath)); + sb.AppendLine(Line(" blocks : {0:N0} copied, {1:N0} carried over", LandBlocksCopied, LandBlocksCarried)); + + if (LandIdsSanitized > 0) + { + sb.AppendLine(Line(" land ids reset : {0:N0} were 0x4000 or above", LandIdsSanitized)); + } + + if (ZAdjust != 0 && SourceZ != null) + { + sb.AppendLine(Line(" land z : {0} -> {1}{2}", SourceZ.Land, + SourceZ.Land.Describe(ZAdjust), + LandZClamped > 0 ? Line(", {0:N0} held at the limit", LandZClamped) : string.Empty)); + } + } + + if (OutputIndexPath != null) + { + sb.AppendLine(Line("Statics written : {0}", OutputStaticsPath)); + sb.AppendLine(Line(" blocks : {0:N0} copied, {1:N0} carried over", StaticBlocksCopied, StaticBlocksCarried)); + sb.AppendLine(Line(" statics : {0:N0} read, {1:N0} written", StaticsRead, StaticsWritten)); + sb.AppendLine(Line(" highest id : 0x{0:X4}", HighestItemIdSeen)); + + if (ZAdjust != 0 && SourceZ != null) + { + sb.AppendLine(Line(" static z : {0} -> {1}{2}", SourceZ.Statics, + SourceZ.Statics.Describe(ZAdjust), + StaticsZClamped > 0 ? Line(", {0:N0} held at the limit", StaticsZClamped) : string.Empty)); + } + + if (StaticsAccountedFor > 0 || HuesNormalized > 0 || MaskedOutOfBlock > 0) + { + sb.AppendLine(Line(" removed : {0:N0} invalid id, {1:N0} out-of-block ({2:N0} masked), {3:N0} bad z, {4:N0} duplicates", + DroppedInvalidItemId, DroppedOutOfBlock, MaskedOutOfBlock, DroppedInvalidZ, DuplicatesRemoved)); + sb.AppendLine(Line(" hues reset : {0:N0}", HuesNormalized)); + } + } + + sb.AppendLine(); + sb.AppendLine(Line("Elapsed : {0:hh\\:mm\\:ss\\.fff}", Elapsed)); + + if (Warnings.Count > 0) + { + sb.AppendLine(); + sb.AppendLine(Line("Warnings ({0}):", Warnings.Count)); + + foreach (string warning in Warnings) + { + sb.AppendLine(" " + warning); + } + } + + if (RejectSamples.Count > 0) + { + sb.AppendLine(); + sb.AppendLine(Line("Removed static samples (first {0}):", RejectSamples.Count)); + + foreach (RejectedStaticTile sample in RejectSamples) + { + sb.AppendLine(" " + sample); + } + } + + return sb.ToString(); + } + + private static string Line(string format, params object[] args) + { + return string.Format(CultureInfo.InvariantCulture, format, args); + } + } + + /// + /// Copies a rectangle of blocks from one client's facet into another, rewriting the destination + /// facet's map and statics files in full. + /// + public static class MapRegionCopier + { + private const int ProgressInterval = 256; + + public static MapRegionCopyResult Run(MapRegionCopyOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (options.Destination == null) + { + throw new MapRegionCopyException("No destination map was given."); + } + + var result = new MapRegionCopyResult(); + var stopwatch = Stopwatch.StartNew(); + + MapSize source = options.SourceSize; + var destination = new MapSize(options.Destination.Width, options.Destination.Height); + + result.SourceSize = source; + result.DestinationSize = destination; + result.SourceDirectory = options.SourceDirectory; + result.SourceFileIndex = options.SourceFileIndex; + result.DestinationFileIndex = options.Destination.FileIndex; + + if (source.IsEmpty) + { + throw new MapRegionCopyException("The source map size is not known."); + } + + Normalise(options, result, source, destination); + + WarnAboutOverrides(options.SourceDirectory, options.SourceFileIndex, "source", result); + WarnAboutOverrides(ClientDirectory(options.Destination.FileIndex), options.Destination.FileIndex, "destination", result); + + result.ZAdjust = options.ZAdjust; + + if (options.ZAdjust != 0) + { + SurveyZ(options, result, source); + } + + if (options.CopyLand) + { + CopyLand(options, result, source, destination); + } + + if (options.CopyStatics) + { + CopyStatics(options, result, source, destination); + } + + result.Elapsed = stopwatch.Elapsed; + + return result; + } + + /// + /// Reads the region's z before a byte is written, so a shift that does not fit is refused + /// while refusing still costs nothing. The tally is kept for the report either way. + /// + private static void SurveyZ(MapRegionCopyOptions options, MapRegionCopyResult result, MapSize source) + { + MapRegionZSurvey survey = MapRegionZSurvey.Survey(options.SourceDirectory, options.SourceFileIndex, + source, result.Source, options.CopyLand, options.CopyStatics, options.Progress, + options.CancellationToken); + + result.SourceZ = survey; + + foreach (string warning in survey.Warnings) + { + result.Warnings.Add(warning); + } + + if (options.ZOverflow == ZOverflowAction.Clamp || !survey.Overflows(options.ZAdjust)) + { + return; + } + + long land = survey.Land.OutOfRangeAfter(options.ZAdjust); + long statics = survey.Statics.OutOfRangeAfter(options.ZAdjust); + + var sb = new StringBuilder(); + + sb.Append(string.Format(CultureInfo.InvariantCulture, + "Adjusting z by {0:+#;-#;0} would push tiles outside the -128..127 the files hold.", options.ZAdjust)); + + if (land > 0) + { + sb.Append(string.Format(CultureInfo.InvariantCulture, + " Land runs {0} and {1:N0} tiles would not fit.", survey.Land, land)); + } + + if (statics > 0) + { + sb.Append(string.Format(CultureInfo.InvariantCulture, + " Statics run {0} and {1:N0} would not fit.", survey.Statics, statics)); + } + + sb.Append(string.Format(CultureInfo.InvariantCulture, + " The region takes {0:+#;-#;0} to {1:+#;-#;0} without losing anything.", + -Headroom(survey, false), Headroom(survey, true))); + + throw new MapRegionCopyException(sb.ToString()); + } + + /// The largest shift the region takes in one direction with nothing hitting a limit. + private static int Headroom(MapRegionZSurvey survey, bool up) + { + int land = up ? survey.Land.HeadroomUp : survey.Land.HeadroomDown; + int statics = up ? survey.Statics.HeadroomUp : survey.Statics.HeadroomDown; + + if (!survey.Land.HasTiles) + { + return statics; + } + + if (!survey.Statics.HasTiles) + { + return land; + } + + return Math.Min(land, statics); + } + + /// Moves a static's z, holding it at the limit rather than wrapping round it. + private static void ShiftStaticsZ(List tiles, int adjust, MapRegionCopyResult result) + { + for (int i = 0; i < tiles.Count; ++i) + { + StaticTile tile = tiles[i]; + + int z = tile.Z + adjust; + + if (z < ZHistogram.MinZ || z > ZHistogram.MaxZ) + { + z = Math.Clamp(z, ZHistogram.MinZ, ZHistogram.MaxZ); + ++result.StaticsZClamped; + } + + tile.Z = (sbyte)z; + tiles[i] = tile; + } + } + + /// Moves the z of a land block's 64 cells, in the block's own bytes. + private static long ShiftLandZ(Span block, int adjust) + { + long clamped = 0; + + for (int i = 0; i < 64; ++i) + { + int at = TileMatrix.BlockHeaderSize + (i * 3) + 2; + + int z = (sbyte)block[at] + adjust; + + if (z < ZHistogram.MinZ || z > ZHistogram.MaxZ) + { + z = Math.Clamp(z, ZHistogram.MinZ, ZHistogram.MaxZ); + ++clamped; + } + + block[at] = (byte)(sbyte)z; + } + + return clamped; + } + + private static void Normalise(MapRegionCopyOptions options, MapRegionCopyResult result, + MapSize source, MapSize destination) + { + int x1 = options.SourceX1; + int y1 = options.SourceY1; + int x2 = options.SourceX2; + int y2 = options.SourceY2; + + // A reversed rectangle is a slip, not an error worth refusing over. + if (x1 > x2) + { + (x1, x2) = (x2, x1); + } + + if (y1 > y2) + { + (y1, y2) = (y2, y1); + } + + result.RequestedX1 = x1; + result.RequestedY1 = y1; + result.RequestedX2 = x2; + result.RequestedY2 = y2; + + Require(x1 >= 0 && x1 < source.Width, $"Source X1 {x1} is outside the source map, which is {source.Width} tiles wide."); + Require(x2 >= 0 && x2 < source.Width, $"Source X2 {x2} is outside the source map, which is {source.Width} tiles wide."); + Require(y1 >= 0 && y1 < source.Height, $"Source Y1 {y1} is outside the source map, which is {source.Height} tiles tall."); + Require(y2 >= 0 && y2 < source.Height, $"Source Y2 {y2} is outside the source map, which is {source.Height} tiles tall."); + + // Whole blocks only - the files have no finer granularity. + var sourceBlocks = new BlockRectangle(x1 >> 3, y1 >> 3, x2 >> 3, y2 >> 3); + + int destinationBlockX = options.DestinationX >> 3; + int destinationBlockY = options.DestinationY >> 3; + + var destinationBlocks = new BlockRectangle( + destinationBlockX, + destinationBlockY, + destinationBlockX + sourceBlocks.BlockWidth - 1, + destinationBlockY + sourceBlocks.BlockHeight - 1); + + result.Source = sourceBlocks; + result.DestinationRegion = destinationBlocks; + + Require(sourceBlocks.BlockX2 < source.BlockWidth, + $"The region reaches source block column {sourceBlocks.BlockX2}, but the source map has {source.BlockWidth}."); + Require(sourceBlocks.BlockY2 < source.BlockHeight, + $"The region reaches source block row {sourceBlocks.BlockY2}, but the source map has {source.BlockHeight}."); + + Require(destinationBlocks.BlockX1 >= 0 && destinationBlocks.BlockY1 >= 0, + "The destination position is negative."); + Require(destinationBlocks.BlockX2 < destination.BlockWidth, + $"The region would reach destination block column {destinationBlocks.BlockX2}, but the destination map has {destination.BlockWidth}."); + Require(destinationBlocks.BlockY2 < destination.BlockHeight, + $"The region would reach destination block row {destinationBlocks.BlockY2}, but the destination map has {destination.BlockHeight}."); + } + + private static void Require(bool condition, string message) + { + if (!condition) + { + throw new MapRegionCopyException(message); + } + } + + private static void CopyLand(MapRegionCopyOptions options, MapRegionCopyResult result, + MapSize source, MapSize destination) + { + var sourceMatrix = new TileMatrix(options.SourceFileIndex, options.SourceFileIndex, + source.Width, source.Height, options.SourceDirectory); + + TileMatrix destinationMatrix = options.Destination.Tiles; + + try + { + long blockCount = destination.BlockCount; + int done = 0; + + using (IMapBlockSink sink = MapBlockSink.Create(options.OutputDirectory, + options.Destination.FileIndex, options.MapFormat, blockCount)) + { + Span block = stackalloc byte[TileMatrix.MapBlockSize]; + + for (int x = 0; x < destination.BlockWidth; ++x) + { + for (int y = 0; y < destination.BlockHeight; ++y) + { + bool inRegion = InRegion(result.DestinationRegion, x, y); + + if (inRegion) + { + sourceMatrix.ReadLandBlockBytes( + x - result.DestinationRegion.BlockX1 + result.Source.BlockX1, + y - result.DestinationRegion.BlockY1 + result.Source.BlockY1, + block); + + if (options.ZAdjust != 0) + { + result.LandZClamped += ShiftLandZ(block, options.ZAdjust); + } + + ++result.LandBlocksCopied; + } + else + { + destinationMatrix.ReadLandBlockBytes(x, y, block); + ++result.LandBlocksCarried; + } + + if (options.SanitizeLandIds) + { + result.LandIdsSanitized += SanitizeLandIds(block); + } + + sink.WriteBlock(block); + + if ((++done & (ProgressInterval - 1)) == 0) + { + options.CancellationToken.ThrowIfCancellationRequested(); + Report(options, "Copying land", done, (int)blockCount); + } + } + } + + sink.Complete(); + result.OutputMapPath = sink.OutputPath; + } + + Report(options, "Copying land", (int)blockCount, (int)blockCount); + } + finally + { + sourceMatrix.CloseStreams(); + } + } + + /// + /// Land art only reaches 0x3FFF; anything above that is not a land tile the client can draw. + /// + private static int SanitizeLandIds(Span block) + { + int reset = 0; + + for (int i = 0; i < 64; ++i) + { + int at = TileMatrix.BlockHeaderSize + (i * 3); + ushort id = (ushort)(block[at] | (block[at + 1] << 8)); + + if (id < 0x4000) + { + continue; + } + + block[at] = 0; + block[at + 1] = 0; + ++reset; + } + + return reset; + } + + private static void CopyStatics(MapRegionCopyOptions options, MapRegionCopyResult result, + MapSize source, MapSize destination) + { + string sourceIndex = Require(Path.Combine(options.SourceDirectory, $"staidx{options.SourceFileIndex}.mul")); + string sourceStatics = Require(Path.Combine(options.SourceDirectory, $"statics{options.SourceFileIndex}.mul")); + + int destinationIndexFile = options.Destination.FileIndex; + + string destinationIndex = ResolveLoaded($"staidx{destinationIndexFile}.mul"); + string destinationStatics = ResolveLoaded($"statics{destinationIndexFile}.mul"); + + string outputIndex = Path.Combine(options.OutputDirectory, $"staidx{destinationIndexFile}.mul"); + string outputStatics = Path.Combine(options.OutputDirectory, $"statics{destinationIndexFile}.mul"); + + RefuseToOverwrite(sourceIndex, outputIndex); + RefuseToOverwrite(destinationIndex, outputIndex); + RefuseToOverwrite(sourceStatics, outputStatics); + RefuseToOverwrite(destinationStatics, outputStatics); + + Directory.CreateDirectory(options.OutputDirectory); + + string tempIndex = outputIndex + ".tmp-" + Guid.NewGuid().ToString("N"); + string tempStatics = outputStatics + ".tmp-" + Guid.NewGuid().ToString("N"); + + var problems = new StaticsBlockProblems(); + var tiles = new List(256); + + try + { + using (StaticsIndexReader sourceReader = StaticsIndexReader.Open(sourceIndex, sourceStatics, + source.BlockWidth, source.BlockHeight, result.Warnings)) + using (StaticsIndexReader destinationReader = StaticsIndexReader.Open(destinationIndex, destinationStatics, + destination.BlockWidth, destination.BlockHeight, result.Warnings)) + using (var outIndexStream = new FileStream(tempIndex, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1 << 20)) + using (var outStaticsStream = new FileStream(tempStatics, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1 << 20)) + using (var writer = new StaticsBlockWriter(outIndexStream, outStaticsStream, + destination.BlockWidth, destination.BlockHeight, EmptyBlockStyle.NegativeOne, true)) + { + int done = 0; + int blockCount = destination.BlockWidth * destination.BlockHeight; + + for (int x = 0; x < destination.BlockWidth; ++x) + { + for (int y = 0; y < destination.BlockHeight; ++y) + { + tiles.Clear(); + + bool inRegion = InRegion(result.DestinationRegion, x, y); + + int readX = inRegion ? x - result.DestinationRegion.BlockX1 + result.Source.BlockX1 : x; + int readY = inRegion ? y - result.DestinationRegion.BlockY1 + result.Source.BlockY1 : y; + + StaticsIndexReader reader = inRegion ? sourceReader : destinationReader; + + result.StaticsRead += reader.ReadBlock(readX, readY, tiles, result.Warnings, problems); + + if (inRegion) + { + ++result.StaticBlocksCopied; + } + else + { + ++result.StaticBlocksCarried; + } + + options.StaticsFilter?.Apply(tiles, x, y, result); + + // After the filter: what it judges is the source data, not a shifted + // copy of it, so a static sitting at the sentinel z is still seen as one. + if (inRegion && options.ZAdjust != 0) + { + ShiftStaticsZ(tiles, options.ZAdjust, result); + } + + writer.WriteBlock(x, y, tiles, reader.GetEntry(readX, readY).Extra); + + if ((++done & (ProgressInterval - 1)) == 0) + { + options.CancellationToken.ThrowIfCancellationRequested(); + Report(options, "Copying statics", done, blockCount); + } + } + } + + writer.Complete(); + + result.StaticsWritten = writer.TilesWritten; + + Report(options, "Copying statics", blockCount, blockCount); + } + + File.Move(tempStatics, outputStatics, true); + tempStatics = null; + + File.Move(tempIndex, outputIndex, true); + tempIndex = null; + + result.OutputIndexPath = Path.GetFullPath(outputIndex); + result.OutputStaticsPath = Path.GetFullPath(outputStatics); + + if (options.StaticsFilter != null) + { + result.HighestItemIdSeen = options.StaticsFilter.HighestItemIdSeen; + } + + if (problems.BadLookup > 0 || problems.BadLength > 0 || problems.OutOfRange > 0) + { + result.Warnings.Add(string.Format(CultureInfo.InvariantCulture, + "Damaged index records: {0:N0} bad lookup, {1:N0} bad length, {2:N0} out of range.", + problems.BadLookup, problems.BadLength, problems.OutOfRange)); + } + } + finally + { + MapBlockSink.TryDelete(tempIndex); + MapBlockSink.TryDelete(tempStatics); + } + } + + private static bool InRegion(BlockRectangle region, int x, int y) + { + return x >= region.BlockX1 && x <= region.BlockX2 && y >= region.BlockY1 && y <= region.BlockY2; + } + + private static void Report(MapRegionCopyOptions options, string stage, int done, int total) + { + options.Progress?.Report(new MapCopyProgress { Stage = stage, BlocksDone = done, BlocksTotal = total }); + } + + private static string Require(string path) + { + if (!File.Exists(path)) + { + throw new MapRegionCopyException($"{path} was not found."); + } + + return Path.GetFullPath(path); + } + + private static string ResolveLoaded(string fileName) + { + string path = Files.GetFilePath(fileName); + + if (path == null) + { + throw new MapRegionCopyException( + $"{fileName} was not found. Check the path settings for the loaded client."); + } + + return Path.GetFullPath(path); + } + + private static void RefuseToOverwrite(string sourcePath, string outputPath) + { + if (!string.Equals(sourcePath, Path.GetFullPath(outputPath), StringComparison.OrdinalIgnoreCase)) + { + return; + } + + throw new MapRegionCopyException( + $"The output directory holds a file being read ({outputPath}). Choose a different output directory."); + } + + private static string ClientDirectory(int fileIndex) + { + string path = Files.GetFilePath($"staidx{fileIndex}.mul") ?? Files.GetFilePath($"map{fileIndex}.mul"); + + return path == null ? null : Path.GetDirectoryName(path); + } + + private static void WarnAboutOverrides(string directory, int fileIndex, string which, MapRegionCopyResult result) + { + if (directory == null) + { + return; + } + + if (File.Exists(Path.Combine(directory, $"staidx{fileIndex}x.mul")) || + File.Exists(Path.Combine(directory, $"map{fileIndex}xLegacyMUL.uop"))) + { + result.Warnings.Add( + $"The {which} client ships facet {fileIndex} override files (staidx{fileIndex}x.mul or map{fileIndex}xLegacyMUL.uop). " + + "The client prefers those over the pair being written here, so the result may not show up in game."); + } + } + } +} \ No newline at end of file diff --git a/Ultima/Maps/MapRegionZ.cs b/Ultima/Maps/MapRegionZ.cs new file mode 100644 index 00000000..bb06c4d0 --- /dev/null +++ b/Ultima/Maps/MapRegionZ.cs @@ -0,0 +1,246 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Threading; +using Ultima.Statics; + +namespace Ultima.Maps +{ + /// + /// What to do with a tile an adjustment would push outside the z the files can hold. + /// + public enum ZOverflowAction + { + /// Refuse the run, before anything is written. + Refuse, + + /// Hold the tile at the limit it ran past. + Clamp + } + + /// + /// A tally of the z values in a region, one bucket per value the format can hold. Small enough + /// to keep around, which means any question about shifting the region is answered without + /// reading the files again. + /// + /// + /// Both a land tile's z and a static's z are a signed byte, so -128 to 127 is the whole of what + /// either file can carry. + /// + public sealed class ZHistogram + { + /// Lowest z either file format can hold. + public const int MinZ = sbyte.MinValue; + + /// Highest z either file format can hold. + public const int MaxZ = sbyte.MaxValue; + + private readonly long[] _counts = new long[256]; + + public long Total { get; private set; } + + public bool HasTiles => Total > 0; + + public int Min { get; private set; } = MaxZ; + + public int Max { get; private set; } = MinZ; + + public void Add(int z) + { + ++_counts[z - MinZ]; + ++Total; + + if (z < Min) + { + Min = z; + } + + if (z > Max) + { + Max = z; + } + } + + /// How many tiles a shift of would push past a limit. + public long OutOfRangeAfter(int adjust) + { + if (!HasTiles || adjust == 0) + { + return 0; + } + + long count = 0; + + for (int z = Min; z <= Max; ++z) + { + int shifted = z + adjust; + + if (shifted < MinZ || shifted > MaxZ) + { + count += _counts[z - MinZ]; + } + } + + return count; + } + + /// The largest shift up that keeps every tile inside the format, or 0 when none is needed. + public int HeadroomUp => HasTiles ? MaxZ - Max : 0; + + /// The largest shift down that keeps every tile inside the format. + public int HeadroomDown => HasTiles ? Min - MinZ : 0; + + public override string ToString() + { + return HasTiles + ? string.Format(CultureInfo.InvariantCulture, "{0} to {1}", Min, Max) + : "none"; + } + + /// The range this becomes when shifted, clamped to what the format holds. + public string Describe(int adjust) + { + if (!HasTiles) + { + return "none"; + } + + return string.Format(CultureInfo.InvariantCulture, "{0} to {1}", + Math.Clamp(Min + adjust, MinZ, MaxZ), Math.Clamp(Max + adjust, MinZ, MaxZ)); + } + } + + /// + /// The z a region carries, land and statics separately, read straight from the files. + /// + public sealed class MapRegionZSurvey + { + public ZHistogram Land { get; } = new ZHistogram(); + + public ZHistogram Statics { get; } = new ZHistogram(); + + public List Warnings { get; } = new List(); + + /// True when a shift of would push something past a limit. + public bool Overflows(int adjust) => OutOfRange(adjust) > 0; + + public long OutOfRange(int adjust) => Land.OutOfRangeAfter(adjust) + Statics.OutOfRangeAfter(adjust); + + /// + /// Reads the z of every land tile and static in a region of a facet on disk. Proportional to + /// the region, not the facet, so it is worth doing before a copy rather than during one. + /// + public static MapRegionZSurvey Survey(string directory, int fileIndex, MapSize size, + BlockRectangle region, bool land, bool statics, + IProgress progress = null, CancellationToken cancellationToken = default) + { + if (size.IsEmpty) + { + throw new ArgumentException("The map size is not known.", nameof(size)); + } + + var survey = new MapRegionZSurvey(); + + int blocks = region.BlockWidth * region.BlockHeight; + int done = 0; + + if (land) + { + var matrix = new TileMatrix(fileIndex, fileIndex, size.Width, size.Height, directory); + + try + { + Span block = stackalloc byte[TileMatrix.MapBlockSize]; + + for (int x = region.BlockX1; x <= region.BlockX2; ++x) + { + for (int y = region.BlockY1; y <= region.BlockY2; ++y) + { + matrix.ReadLandBlockBytes(x, y, block); + + for (int i = 0; i < 64; ++i) + { + survey.Land.Add((sbyte)block[TileMatrix.BlockHeaderSize + (i * 3) + 2]); + } + + if ((++done & 255) == 0) + { + cancellationToken.ThrowIfCancellationRequested(); + Report(progress, "Reading land heights", done, blocks); + } + } + } + } + finally + { + matrix.CloseStreams(); + } + } + + if (!statics) + { + return survey; + } + + string indexPath = Path.Combine(directory, $"staidx{fileIndex}.mul"); + string staticsPath = Path.Combine(directory, $"statics{fileIndex}.mul"); + + if (!File.Exists(indexPath) || !File.Exists(staticsPath)) + { + survey.Warnings.Add($"staidx{fileIndex}.mul or statics{fileIndex}.mul was not found in {directory}."); + + return survey; + } + + var problems = new StaticsBlockProblems(); + var tiles = new List(256); + + done = 0; + + using (StaticsIndexReader reader = StaticsIndexReader.Open(indexPath, staticsPath, + size.BlockWidth, size.BlockHeight, survey.Warnings)) + { + for (int x = region.BlockX1; x <= region.BlockX2; ++x) + { + for (int y = region.BlockY1; y <= region.BlockY2; ++y) + { + tiles.Clear(); + reader.ReadBlock(x, y, tiles, survey.Warnings, problems); + + foreach (StaticTile tile in tiles) + { + survey.Statics.Add(tile.Z); + } + + if ((++done & 255) == 0) + { + cancellationToken.ThrowIfCancellationRequested(); + Report(progress, "Reading static heights", done, blocks); + } + } + } + } + + Report(progress, "Reading static heights", blocks, blocks); + + return survey; + } + + private static void Report(IProgress progress, string stage, int done, int total) + { + progress?.Report(new MapCopyProgress { Stage = stage, BlocksDone = done, BlocksTotal = total }); + } + } +} \ No newline at end of file diff --git a/Ultima/Maps/MapSizes.cs b/Ultima/Maps/MapSizes.cs new file mode 100644 index 00000000..e7b4126d --- /dev/null +++ b/Ultima/Maps/MapSizes.cs @@ -0,0 +1,220 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using Ultima.Uop; + +namespace Ultima.Maps +{ + public readonly struct MapSize : IEquatable + { + public MapSize(int width, int height) + { + Width = width; + Height = height; + } + + public int Width { get; } + + public int Height { get; } + + public int BlockWidth => Width >> 3; + + public int BlockHeight => Height >> 3; + + public long BlockCount => (long)BlockWidth * BlockHeight; + + public bool IsEmpty => Width <= 0 || Height <= 0; + + public bool Equals(MapSize other) + { + return Width == other.Width && Height == other.Height; + } + + public override bool Equals(object obj) + { + return obj is MapSize other && Equals(other); + } + + public override int GetHashCode() + { + return (Width * 397) ^ Height; + } + + public static bool operator ==(MapSize left, MapSize right) => left.Equals(right); + + public static bool operator !=(MapSize left, MapSize right) => !left.Equals(right); + + public override string ToString() + { + return string.Format(CultureInfo.InvariantCulture, "{0} x {1}", Width, Height); + } + } + + /// + /// Works out how big a facet's files actually are, rather than trusting a hardcoded table. + /// + /// + /// A facet cannot be identified by file length alone - map3.mul and map5.mul are both + /// 16,056,320 bytes for different block grids - so detection is always per file index. + /// staidx{N}.mul is the preferred signal because it is a flat array of 12-byte records with no + /// container around it and it ships even on clients whose maps are UOP-only. + /// + public static class MapSizes + { + private const int IndexRecordSize = 12; + private const int MapBlockSize = 196; + + private static readonly MapSize[] _facet01 = { new MapSize(6144, 4096), new MapSize(7168, 4096) }; + private static readonly MapSize[] _facet2 = { new MapSize(2304, 1600) }; + private static readonly MapSize[] _facet3 = { new MapSize(2560, 2048) }; + private static readonly MapSize[] _facet4 = { new MapSize(1448, 1448) }; + private static readonly MapSize[] _facet5 = { new MapSize(1280, 4096) }; + + /// + /// The shapes a facet is known to ship in. Facets 0 and 1 both have a pre-T2A 6144-wide + /// form and the modern 7168-wide one, and an install can legitimately mix the two. + /// + public static IReadOnlyList Candidates(int fileIndex) + { + switch (fileIndex) + { + case 0: + case 1: return _facet01; + case 2: return _facet2; + case 3: return _facet3; + case 4: return _facet4; + case 5: return _facet5; + default: return Array.Empty(); + } + } + + /// + /// What to assume when nothing can be measured. + /// + public static MapSize Fallback(int fileIndex) + { + IReadOnlyList candidates = Candidates(fileIndex); + + // For facets 0 and 1 the modern shape is the safer guess: reading a 7168-wide grid off a + // 6144-wide file yields empty tail blocks, while the reverse silently loses the east edge. + return candidates.Count == 0 ? default : candidates[candidates.Count - 1]; + } + + /// + /// Detects the shape of a facet in the given directory. + /// + /// Always set: what was measured, in a form fit to show a user. + /// True when the measured block count matched a known shape. + public static bool TryDetect(string directory, int fileIndex, out MapSize size, out string evidence) + { + return TryDetect(name => Resolve(directory, name), fileIndex, out size, out evidence); + } + + /// + /// Detects the shape of a facet in the currently loaded client. + /// + public static bool TryDetect(int fileIndex, out MapSize size, out string evidence) + { + return TryDetect(Files.GetFilePath, fileIndex, out size, out evidence); + } + + private static bool TryDetect(Func resolve, int fileIndex, out MapSize size, out string evidence) + { + if (!TryMeasureBlocks(resolve, fileIndex, out long blocks, out string source)) + { + size = Fallback(fileIndex); + evidence = string.Format(CultureInfo.InvariantCulture, + "no map or statics index found for facet {0}; assuming {1}", fileIndex, size); + + return false; + } + + foreach (MapSize candidate in Candidates(fileIndex)) + { + if (candidate.BlockCount != blocks) + { + continue; + } + + size = candidate; + evidence = string.Format(CultureInfo.InvariantCulture, + "{0} holds {1:N0} blocks, which is {2}", source, blocks, candidate); + + return true; + } + + size = Fallback(fileIndex); + evidence = string.Format(CultureInfo.InvariantCulture, + "{0} holds {1:N0} blocks, which matches no known shape for facet {2}; assuming {3}", + source, blocks, fileIndex, size); + + return false; + } + + private static bool TryMeasureBlocks(Func resolve, int fileIndex, out long blocks, out string source) + { + string indexPath = resolve($"staidx{fileIndex}.mul"); + + if (indexPath != null) + { + blocks = new FileInfo(indexPath).Length / IndexRecordSize; + source = $"staidx{fileIndex}.mul"; + + return true; + } + + string mapPath = resolve($"map{fileIndex}.mul"); + + if (mapPath != null) + { + blocks = new FileInfo(mapPath).Length / MapBlockSize; + source = $"map{fileIndex}.mul"; + + return true; + } + + string uopPath = resolve($"map{fileIndex}LegacyMUL.uop"); + + if (uopPath != null) + { + // Every shipped container carries one block more than the facet has, so the payload + // length overshoots by exactly one block. + long payloadBlocks = MapUopReader.TotalPayloadLength(MapUopReader.ReadEntryTable(uopPath)) / MapBlockSize; + + blocks = payloadBlocks - 1; + source = $"map{fileIndex}LegacyMUL.uop"; + + return true; + } + + blocks = 0; + source = null; + + return false; + } + + private static string Resolve(string directory, string fileName) + { + if (string.IsNullOrEmpty(directory)) + { + return null; + } + + string path = Path.Combine(directory, fileName); + + return File.Exists(path) ? path : null; + } + } +} \ No newline at end of file diff --git a/Ultima/ModifiedIndexTracker.cs b/Ultima/ModifiedIndexTracker.cs new file mode 100644 index 00000000..cf284eb6 --- /dev/null +++ b/Ultima/ModifiedIndexTracker.cs @@ -0,0 +1,55 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System.Collections.Generic; + +namespace Ultima +{ + /// + /// Tracks which indexes of a file type have been edited since the data was loaded or last saved. + /// Kept deliberately apart from the replaced-bitmap dictionaries: those hold the live edits and + /// must survive a save, whereas a mark here only means "changed and not written out yet". + /// + public sealed class ModifiedIndexTracker + { + private readonly HashSet _marked = new HashSet(); + + /// + /// Number of indexes currently marked as modified. + /// + public int Count => _marked.Count; + + /// + /// Marks as modified. + /// + public void Mark(int index) + { + _marked.Add(index); + } + + /// + /// Tests whether was modified. Called once per tile per paint, so the + /// empty case skips hashing altogether. + /// + public bool IsMarked(int index) + { + return _marked.Count != 0 && _marked.Contains(index); + } + + /// + /// Drops every mark. Called on reload and after a successful save. + /// + public void Clear() + { + _marked.Clear(); + } + } +} diff --git a/Ultima/Statics/StaticsBlockIo.cs b/Ultima/Statics/StaticsBlockIo.cs new file mode 100644 index 00000000..796b16ac --- /dev/null +++ b/Ultima/Statics/StaticsBlockIo.cs @@ -0,0 +1,614 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; + +namespace Ultima.Statics +{ + /// + /// Where a filter reports what it removed. Implemented by the defrag and copy result types so + /// each feature counts into its own report. + /// + public interface IStaticsFilterStats + { + void TileRejected(int blockX, int blockY, StaticTile tile, RejectReason reason); + + void HueNormalized(); + + void OutOfBlockMasked(); + } + + /// + /// Reads a staidx/statics pair block by block, with the bounds and length checks the format + /// needs. Shared by the defrag, the region copy and the diff apply so those checks exist once. + /// + /// + /// The index is a flat array of 12-byte {lookup, length, extra} records addressed as + /// blockX * blockHeight + blockY, and lookup == -1 means the block has no statics. + /// + public sealed class StaticsIndexReader : IDisposable + { + internal const int IndexRecordSize = 12; + internal const int TileRecordSize = 7; + + private readonly Stream _statics; + private readonly bool _leaveOpen; + private readonly Stream _index; + + private byte[] _buffer = Array.Empty(); + + public StaticsIndexReader(Stream index, Stream statics, int blockWidth, int blockHeight, + IList warnings = null, bool leaveOpen = false, string label = null) + { + _index = index ?? throw new ArgumentNullException(nameof(index)); + _statics = statics ?? throw new ArgumentNullException(nameof(statics)); + _leaveOpen = leaveOpen; + + BlockWidth = blockWidth; + BlockHeight = blockHeight; + Label = label ?? "staidx"; + + int blockCount = blockWidth * blockHeight; + + EntriesInFile = (int)Math.Min(index.Length / IndexRecordSize, int.MaxValue / IndexRecordSize); + Entries = new Entry3D[Math.Max(EntriesInFile, blockCount)]; + + index.Seek(0, SeekOrigin.Begin); + index.ReadExactly(MemoryMarshal.AsBytes(Entries.AsSpan(0, EntriesInFile))); + + // A short index is not an error - the tail is simply empty, which is how TileMatrix + // treats it too. + for (int i = EntriesInFile; i < Entries.Length; ++i) + { + Entries[i].Lookup = -1; + Entries[i].Length = -1; + Entries[i].Extra = -1; + } + + if (EntriesInFile < blockCount) + { + warnings?.Add(string.Format(CultureInfo.InvariantCulture, + "{0} only covers {1:N0} of the {2:N0} blocks the configured map size needs. The remaining blocks are empty.", + Label, EntriesInFile, blockCount)); + } + } + + public static StaticsIndexReader Open(string indexPath, string staticsPath, int blockWidth, int blockHeight, + IList warnings = null) + { + FileStream index = null; + FileStream statics = null; + + try + { + index = new FileStream(indexPath, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 20, FileOptions.SequentialScan); + statics = new FileStream(staticsPath, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 20); + + return new StaticsIndexReader(index, statics, blockWidth, blockHeight, warnings, false, + Path.GetFileName(indexPath)); + } + catch + { + index?.Dispose(); + statics?.Dispose(); + + throw; + } + } + + public int BlockWidth { get; } + + public int BlockHeight { get; } + + /// How many 12-byte records the index file actually holds. + public int EntriesInFile { get; } + + /// One entry per block of the configured grid, padded with the empty sentinel. + public Entry3D[] Entries { get; } + + public string Label { get; } + + public long StaticsLength => _statics.Length; + + /// Whether a block coordinate is inside the configured grid. + public bool Contains(int blockX, int blockY) + { + return blockX >= 0 && blockY >= 0 && blockX < BlockWidth && blockY < BlockHeight; + } + + public Entry3D GetEntry(int blockX, int blockY) + { + if (!Contains(blockX, blockY)) + { + return new Entry3D { Lookup = -1, Length = -1, Extra = -1 }; + } + + return Entries[(blockX * BlockHeight) + blockY]; + } + + /// + /// Appends a block's statics to , which is not cleared. A block that + /// is empty, out of range or damaged adds nothing and is reported rather than thrown. + /// + /// How many statics were appended. + public int ReadBlock(int blockX, int blockY, List into, IList warnings = null, + StaticsBlockProblems problems = null) + { + if (!Contains(blockX, blockY)) + { + warnings?.Add(string.Format(CultureInfo.InvariantCulture, + "Block {0},{1} is outside the {2} x {3} block grid of {4} and was read as empty.", + blockX, blockY, BlockWidth, BlockHeight, Label)); + + problems?.CountOutOfRange(); + + return 0; + } + + Entry3D entry = Entries[(blockX * BlockHeight) + blockY]; + + if (entry.Lookup < 0 || entry.Length <= 0) + { + return 0; + } + + int length = entry.Length; + + if (entry.Lookup >= _statics.Length) + { + warnings?.Add(string.Format(CultureInfo.InvariantCulture, + "Block {0},{1} points at offset {2:N0}, past the end of the statics file. It was treated as empty.", + blockX, blockY, entry.Lookup)); + + problems?.CountBadLookup(); + + return 0; + } + + long available = _statics.Length - entry.Lookup; + + if (length > available) + { + warnings?.Add(string.Format(CultureInfo.InvariantCulture, + "Block {0},{1} claims {2:N0} bytes but only {3:N0} remain in the statics file. It was clamped.", + blockX, blockY, length, available)); + + problems?.CountBadLookup(); + + length = (int)(available - (available % TileRecordSize)); + } + + if (length % TileRecordSize != 0) + { + warnings?.Add(string.Format(CultureInfo.InvariantCulture, + "Block {0},{1} has a length of {2} bytes, which is not a whole number of {3}-byte statics. The trailing {4} bytes were dropped.", + blockX, blockY, length, TileRecordSize, length % TileRecordSize)); + + problems?.CountBadLength(); + + length -= length % TileRecordSize; + } + + if (length <= 0) + { + return 0; + } + + if (_buffer.Length < length) + { + _buffer = new byte[Math.Max(length, 4096)]; + } + + _statics.Seek(entry.Lookup, SeekOrigin.Begin); + _statics.ReadExactly(_buffer, 0, length); + + ReadOnlySpan source = MemoryMarshal.Cast(_buffer.AsSpan(0, length)); + + for (int i = 0; i < source.Length; ++i) + { + into.Add(source[i]); + } + + return source.Length; + } + + public void Dispose() + { + if (_leaveOpen) + { + return; + } + + _index.Dispose(); + _statics.Dispose(); + } + } + + /// + /// Counts the damaged-index conditions works around. + /// + public sealed class StaticsBlockProblems + { + public int BadLookup { get; private set; } + + public int BadLength { get; private set; } + + public int OutOfRange { get; private set; } + + public void CountBadLookup() => ++BadLookup; + + public void CountBadLength() => ++BadLength; + + public void CountOutOfRange() => ++OutOfRange; + } + + /// + /// Writes a compacted staidx/statics pair: blocks laid down back to back with no gaps, one + /// 12-byte index record per block of the grid. + /// + public sealed class StaticsBlockWriter : IDisposable + { + private readonly Stream _index; + private readonly Stream _statics; + private readonly bool _leaveOpen; + private readonly EmptyBlockStyle _emptyStyle; + private readonly byte[] _indexBytes; + + private long _staticsPosition; + private bool _completed; + + public StaticsBlockWriter(Stream index, Stream statics, int blockWidth, int blockHeight, + EmptyBlockStyle emptyStyle = EmptyBlockStyle.NegativeOne, bool leaveOpen = false) + { + _index = index ?? throw new ArgumentNullException(nameof(index)); + _statics = statics ?? throw new ArgumentNullException(nameof(statics)); + _leaveOpen = leaveOpen; + _emptyStyle = emptyStyle; + + BlockWidth = blockWidth; + BlockHeight = blockHeight; + + long bytes = (long)blockWidth * blockHeight * StaticsIndexReader.IndexRecordSize; + + if (bytes > int.MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(blockWidth), + "The configured map size needs a statics index larger than 2 GB."); + } + + _indexBytes = new byte[bytes]; + + // Blocks that are never written stay empty rather than zero. + for (int i = 0; i < blockWidth * blockHeight; ++i) + { + WriteEmptyEntry(_indexBytes.AsSpan(i * StaticsIndexReader.IndexRecordSize, StaticsIndexReader.IndexRecordSize)); + } + } + + public int BlockWidth { get; } + + public int BlockHeight { get; } + + /// Bytes written to the statics file so far. + public long StaticsLength => _staticsPosition; + + public int BlocksWithStatics { get; private set; } + + public long TilesWritten { get; private set; } + + public void WriteBlock(int blockX, int blockY, IReadOnlyList tiles, int extra) + { + if (_completed) + { + throw new InvalidOperationException("The statics pair has already been completed."); + } + + if (blockX < 0 || blockY < 0 || blockX >= BlockWidth || blockY >= BlockHeight) + { + throw new ArgumentOutOfRangeException(nameof(blockX), + $"Block {blockX},{blockY} is outside the {BlockWidth} x {BlockHeight} block grid."); + } + + int blockId = (blockX * BlockHeight) + blockY; + Span record = _indexBytes.AsSpan(blockId * StaticsIndexReader.IndexRecordSize, StaticsIndexReader.IndexRecordSize); + + if (tiles == null || tiles.Count == 0) + { + WriteEmptyEntry(record); + + return; + } + + int length = tiles.Count * StaticsIndexReader.TileRecordSize; + + if (_staticsPosition + length > int.MaxValue) + { + throw new InvalidOperationException( + "The output statics file would pass 2 GB, which the 32-bit index lookup cannot address."); + } + + if (extra == -1) + { + extra = 0; + } + + BinaryPrimitives.WriteInt32LittleEndian(record, (int)_staticsPosition); + BinaryPrimitives.WriteInt32LittleEndian(record.Slice(4), length); + BinaryPrimitives.WriteInt32LittleEndian(record.Slice(8), extra); + + if (tiles is List list) + { + _statics.Write(MemoryMarshal.AsBytes(CollectionsMarshal.AsSpan(list))); + } + else + { + var copy = new StaticTile[tiles.Count]; + + for (int i = 0; i < tiles.Count; ++i) + { + copy[i] = tiles[i]; + } + + _statics.Write(MemoryMarshal.AsBytes(copy.AsSpan())); + } + + _staticsPosition += length; + TilesWritten += tiles.Count; + ++BlocksWithStatics; + } + + public void Complete() + { + if (_completed) + { + return; + } + + _index.Write(_indexBytes, 0, _indexBytes.Length); + _index.Flush(); + _statics.Flush(); + + _completed = true; + } + + public void Dispose() + { + if (_leaveOpen) + { + return; + } + + _index.Dispose(); + _statics.Dispose(); + } + + private void WriteEmptyEntry(Span record) + { + int empty = _emptyStyle == EmptyBlockStyle.Zero ? 0 : -1; + + BinaryPrimitives.WriteInt32LittleEndian(record, -1); + BinaryPrimitives.WriteInt32LittleEndian(record.Slice(4), empty); + BinaryPrimitives.WriteInt32LittleEndian(record.Slice(8), empty); + } + } + + /// + /// The per-static decisions every statics rewrite has to make: is the item id one the client + /// knows, is the offset inside its block, is the z legal, is the hue sane, and is this static a + /// copy of one already in the block. + /// + public sealed class StaticsTileFilter + { + private readonly Dictionary _duplicateIndex = new Dictionary(); + + /// Drops statics whose id is above . + public bool DropInvalidItemIds { get; set; } + + public int MaxItemId { get; set; } = 0xFFFF; + + /// + /// What to do with an offset outside 0..7. The client masks those with & 7 when reading, + /// so a static with one draws in the wrong cell. + /// + public OutOfBlockAction OutOfBlockTiles { get; set; } = OutOfBlockAction.Keep; + + /// Drops statics at z == -128. + public bool DropInvalidZ { get; set; } + + public bool NormalizeNegativeHue { get; set; } + + /// Removes statics sharing id, x, y and z. + public bool RemoveDuplicates { get; set; } + + /// Adds hue to the duplicate key. Two water tiles in a cell differing only in hue then both survive. + public bool DuplicatesCompareHue { get; set; } + + /// The highest item id any static passed through this filter carried. + public int HighestItemIdSeen { get; private set; } + + /// + /// Decides on one static, adjusting it in place where the filter normalises rather than drops. + /// + public bool Accept(ref StaticTile tile, int blockX, int blockY, IStaticsFilterStats stats) + { + if (tile.Id > HighestItemIdSeen) + { + HighestItemIdSeen = tile.Id; + } + + if (DropInvalidItemIds && tile.Id > MaxItemId) + { + stats?.TileRejected(blockX, blockY, tile, RejectReason.InvalidItemId); + + return false; + } + + if (tile.X > 7 || tile.Y > 7) + { + switch (OutOfBlockTiles) + { + case OutOfBlockAction.Drop: + stats?.TileRejected(blockX, blockY, tile, RejectReason.OutOfBlockOffset); + + return false; + + case OutOfBlockAction.Mask: + tile.X &= 0x7; + tile.Y &= 0x7; + stats?.OutOfBlockMasked(); + + break; + } + } + + if (DropInvalidZ && tile.Z == sbyte.MinValue) + { + stats?.TileRejected(blockX, blockY, tile, RejectReason.InvalidZ); + + return false; + } + + if (NormalizeNegativeHue && tile.Hue < 0) + { + tile.Hue = 0; + stats?.HueNormalized(); + } + + return true; + } + + /// + /// Runs over a block's statics in place, then removes duplicates when + /// asked. The survivor of a duplicate keeps the position of the first occurrence and the + /// payload of the last. + /// + public void Apply(List tiles, int blockX, int blockY, IStaticsFilterStats stats) + { + if (tiles == null || tiles.Count == 0) + { + return; + } + + int write = 0; + + for (int i = 0; i < tiles.Count; ++i) + { + StaticTile tile = tiles[i]; + + if (Accept(ref tile, blockX, blockY, stats)) + { + tiles[write++] = tile; + } + } + + tiles.RemoveRange(write, tiles.Count - write); + + if (!RemoveDuplicates || tiles.Count < 2) + { + return; + } + + _duplicateIndex.Clear(); + write = 0; + + for (int i = 0; i < tiles.Count; ++i) + { + StaticTile tile = tiles[i]; + ulong key = DuplicateKey(tile); + + if (_duplicateIndex.TryGetValue(key, out int existing)) + { + tiles[existing] = tile; + stats?.TileRejected(blockX, blockY, tile, RejectReason.Duplicate); + + continue; + } + + _duplicateIndex[key] = write; + tiles[write++] = tile; + } + + tiles.RemoveRange(write, tiles.Count - write); + } + + private ulong DuplicateKey(StaticTile tile) + { + ulong key = ((ulong)tile.Id << 14) | + ((ulong)(byte)tile.Z << 6) | + ((ulong)(uint)(tile.X & 0x7) << 3) | + (uint)(tile.Y & 0x7); + + if (DuplicatesCompareHue) + { + key |= (ulong)(ushort)tile.Hue << 30; + } + + return key; + } + } + + /// + /// Turns the 8x8 grid hands back into the flat list the writer takes. + /// + public static class StaticsBlockConversion + { + public static int FromHuedBlock(HuedTile[][][] block, List into) + { + if (block == null) + { + return 0; + } + + int added = 0; + + for (int x = 0; x < block.Length; ++x) + { + HuedTile[][] column = block[x]; + + if (column == null) + { + continue; + } + + for (int y = 0; y < column.Length; ++y) + { + HuedTile[] cell = column[y]; + + if (cell == null) + { + continue; + } + + for (int i = 0; i < cell.Length; ++i) + { + into.Add(new StaticTile + { + Id = cell[i].Id, + X = (byte)x, + Y = (byte)y, + Z = cell[i].Z, + Hue = (short)cell[i].Hue + }); + + ++added; + } + } + } + + return added; + } + } +} \ No newline at end of file diff --git a/Ultima/Statics/StaticsComparer.cs b/Ultima/Statics/StaticsComparer.cs new file mode 100644 index 00000000..d74299b3 --- /dev/null +++ b/Ultima/Statics/StaticsComparer.cs @@ -0,0 +1,344 @@ +// /*************************************************************************** +// * +// * $Author: Turley +// * +// * "THE BEER-WARE LICENSE" +// * As long as you retain this notice you can do whatever you want with +// * this stuff. If we meet some day, and you think this stuff is worth it, +// * you can buy me a beer in return. +// * +// ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +namespace Ultima.Statics +{ + public enum StaticsCompareMode + { + /// + /// Every block must hold the same statics on both sides. Use after a run with no filters: + /// the output should differ from the source only in layout. + /// + Identical, + + /// + /// The second file may only be missing statics, never hold one the first does not. Use after + /// a filtered run, then check the missing count against the filter counters. + /// + Subset + } + + public sealed class StaticsCompareResult + { + public StaticsCompareMode Mode { get; set; } + + public int BlockWidth { get; set; } + + public int BlockHeight { get; set; } + + public int BlocksCompared { get; set; } + + public int BlocksDiffering { get; set; } + + public long TilesLeft { get; set; } + + public long TilesRight { get; set; } + + /// Statics the first file holds that the second does not. + public long TilesMissing { get; set; } + + /// Statics the second file holds that the first does not. Always a defect. + public long TilesAdded { get; set; } + + public List StructuralProblems { get; } = new List(); + + public List Differences { get; } = new List(); + + public bool Passed => StructuralProblems.Count == 0 && TilesAdded == 0 && + (Mode == StaticsCompareMode.Subset || TilesMissing == 0); + + public string ToReport() + { + var sb = new StringBuilder(); + + sb.AppendLine(Passed ? "PASSED" : "FAILED"); + sb.AppendLine(); + sb.AppendLine(Line("Mode : {0}", Mode)); + sb.AppendLine(Line("Block grid : {0} x {1}", BlockWidth, BlockHeight)); + sb.AppendLine(Line("Blocks compared : {0:N0} ({1:N0} differ)", BlocksCompared, BlocksDiffering)); + sb.AppendLine(Line("Statics : {0:N0} -> {1:N0}", TilesLeft, TilesRight)); + sb.AppendLine(Line(" missing : {0:N0}", TilesMissing)); + sb.AppendLine(Line(" added : {0:N0}", TilesAdded)); + + if (StructuralProblems.Count > 0) + { + sb.AppendLine(); + sb.AppendLine(Line("Structural problems ({0}):", StructuralProblems.Count)); + + foreach (string problem in StructuralProblems) + { + sb.AppendLine(" " + problem); + } + } + + if (Differences.Count > 0) + { + sb.AppendLine(); + sb.AppendLine(Line("Differing blocks (first {0}):", Differences.Count)); + + foreach (string difference in Differences) + { + sb.AppendLine(" " + difference); + } + } + + return sb.ToString(); + } + + private static string Line(string format, params object[] args) + { + return string.Format(CultureInfo.InvariantCulture, format, args); + } + } + + /// + /// Compares two staidx/statics pairs block by block, as multisets of statics, so a defrag can be + /// checked against the file it was produced from. Layout differences are ignored on purpose - + /// compacting is the whole point of a defrag; what matters is that no static was lost or invented. + /// + public static class StaticsComparer + { + private const int IndexRecordSize = 12; + private const int TileRecordSize = 7; + private const int MaxReportedDifferences = 50; + + public static StaticsCompareResult Compare(string leftIndexPath, string leftStaticsPath, + string rightIndexPath, string rightStaticsPath, int blockWidth, int blockHeight, + StaticsCompareMode mode) + { + using (var leftIndex = File.OpenRead(leftIndexPath)) + using (var leftStatics = File.OpenRead(leftStaticsPath)) + using (var rightIndex = File.OpenRead(rightIndexPath)) + using (var rightStatics = File.OpenRead(rightStaticsPath)) + { + return Compare(leftIndex, leftStatics, rightIndex, rightStatics, blockWidth, blockHeight, mode); + } + } + + public static StaticsCompareResult Compare(Stream leftIndex, Stream leftStatics, + Stream rightIndex, Stream rightStatics, int blockWidth, int blockHeight, + StaticsCompareMode mode) + { + var result = new StaticsCompareResult + { + Mode = mode, + BlockWidth = blockWidth, + BlockHeight = blockHeight + }; + + int blockCount = blockWidth * blockHeight; + + Entry3D[] left = ReadIndex(leftIndex, blockCount); + Entry3D[] right = ReadIndex(rightIndex, blockCount); + + CheckStructure(right, rightStatics.Length, result); + + var leftTiles = new List(128); + var rightTiles = new List(128); + var counts = new Dictionary(); + + byte[] leftBuffer = Array.Empty(); + byte[] rightBuffer = Array.Empty(); + + for (int blockId = 0; blockId < blockCount; ++blockId) + { + ReadBlock(leftStatics, left[blockId], ref leftBuffer, leftTiles); + ReadBlock(rightStatics, right[blockId], ref rightBuffer, rightTiles); + + result.BlocksCompared++; + result.TilesLeft += leftTiles.Count; + result.TilesRight += rightTiles.Count; + + if (leftTiles.Count == 0 && rightTiles.Count == 0) + { + continue; + } + + counts.Clear(); + + foreach (StaticTile tile in leftTiles) + { + ulong key = TileKey(tile); + counts[key] = counts.TryGetValue(key, out int count) ? count + 1 : 1; + } + + int added = 0; + + foreach (StaticTile tile in rightTiles) + { + ulong key = TileKey(tile); + + if (counts.TryGetValue(key, out int count) && count > 0) + { + counts[key] = count - 1; + } + else + { + ++added; + } + } + + int missing = 0; + + foreach (int remaining in counts.Values) + { + missing += remaining; + } + + if (missing == 0 && added == 0) + { + continue; + } + + result.TilesMissing += missing; + result.TilesAdded += added; + result.BlocksDiffering++; + + if (result.Differences.Count < MaxReportedDifferences) + { + int blockX = blockId / blockHeight; + int blockY = blockId % blockHeight; + + result.Differences.Add(string.Format(CultureInfo.InvariantCulture, + "block {0},{1} (world {2},{3}): {4} statics -> {5}, {6} missing, {7} added", + blockX, blockY, blockX << 3, blockY << 3, leftTiles.Count, rightTiles.Count, missing, added)); + } + } + + return result; + } + + /// + /// Invariants the output of a defrag has to satisfy on its own, regardless of the source: + /// one index record per block, lookups walking forward with no gaps, and every block length a + /// whole number of statics. + /// + private static void CheckStructure(Entry3D[] entries, long staticsLength, StaticsCompareResult result) + { + long expected = 0; + + for (int i = 0; i < entries.Length; ++i) + { + Entry3D entry = entries[i]; + + if (entry.Lookup < 0 || entry.Length <= 0) + { + continue; + } + + if (entry.Length % TileRecordSize != 0) + { + Add(result, string.Format(CultureInfo.InvariantCulture, + "block {0} has a length of {1}, which is not a multiple of {2}.", i, entry.Length, TileRecordSize)); + } + + if (entry.Lookup != expected) + { + Add(result, string.Format(CultureInfo.InvariantCulture, + "block {0} starts at {1:N0}, expected {2:N0} - the file is not compact.", i, entry.Lookup, expected)); + } + + if (entry.Lookup + (long)entry.Length > staticsLength) + { + Add(result, string.Format(CultureInfo.InvariantCulture, + "block {0} reaches past the end of the statics file.", i)); + } + + expected = entry.Lookup + (long)entry.Length; + } + + if (expected != staticsLength) + { + Add(result, string.Format(CultureInfo.InvariantCulture, + "the statics file is {0:N0} bytes but the index accounts for {1:N0}.", staticsLength, expected)); + } + } + + private static void Add(StaticsCompareResult result, string problem) + { + if (result.StructuralProblems.Count < MaxReportedDifferences) + { + result.StructuralProblems.Add(problem); + } + } + + private static Entry3D[] ReadIndex(Stream index, int blockCount) + { + var entries = new Entry3D[blockCount]; + int entriesInFile = (int)Math.Min(index.Length / IndexRecordSize, blockCount); + + index.Seek(0, SeekOrigin.Begin); + index.ReadExactly(MemoryMarshal.AsBytes(entries.AsSpan(0, entriesInFile))); + + for (int i = entriesInFile; i < blockCount; ++i) + { + entries[i].Lookup = -1; + entries[i].Length = -1; + entries[i].Extra = -1; + } + + return entries; + } + + private static void ReadBlock(Stream statics, Entry3D entry, ref byte[] buffer, List tiles) + { + tiles.Clear(); + + if (entry.Lookup < 0 || entry.Length <= 0 || entry.Lookup >= statics.Length) + { + return; + } + + long available = statics.Length - entry.Lookup; + int length = (int)Math.Min(entry.Length, available); + + length -= length % TileRecordSize; + + if (length <= 0) + { + return; + } + + if (buffer.Length < length) + { + buffer = new byte[Math.Max(length, 4096)]; + } + + statics.Seek(entry.Lookup, SeekOrigin.Begin); + statics.ReadExactly(buffer, 0, length); + + ReadOnlySpan source = MemoryMarshal.Cast(buffer.AsSpan(0, length)); + + for (int i = 0; i < source.Length; ++i) + { + tiles.Add(source[i]); + } + } + + private static ulong TileKey(StaticTile tile) + { + // Raw x and y, not masked: two statics that differ only in an out-of-block offset are + // different statics, and a comparison has to be able to see that. + return ((ulong)(ushort)tile.Hue << 40) | + ((ulong)tile.Id << 24) | + ((ulong)(byte)tile.Z << 16) | + ((ulong)tile.X << 8) | + tile.Y; + } + } +} \ No newline at end of file diff --git a/Ultima/Statics/StaticsDefragException.cs b/Ultima/Statics/StaticsDefragException.cs new file mode 100644 index 00000000..6263a151 --- /dev/null +++ b/Ultima/Statics/StaticsDefragException.cs @@ -0,0 +1,30 @@ +// /*************************************************************************** +// * +// * $Author: Turley +// * +// * "THE BEER-WARE LICENSE" +// * As long as you retain this notice you can do whatever you want with +// * this stuff. If we meet some day, and you think this stuff is worth it, +// * you can buy me a beer in return. +// * +// ***************************************************************************/ + +using System; + +namespace Ultima.Statics +{ + /// + /// Raised when a statics defrag run cannot continue. Unlike the routine it replaces, the + /// defragmenter never swallows an error and never silently emits a partial file. + /// + public sealed class StaticsDefragException : Exception + { + public StaticsDefragException(string message) : base(message) + { + } + + public StaticsDefragException(string message, Exception innerException) : base(message, innerException) + { + } + } +} \ No newline at end of file diff --git a/Ultima/Statics/StaticsDefragOptions.cs b/Ultima/Statics/StaticsDefragOptions.cs new file mode 100644 index 00000000..2db5429a --- /dev/null +++ b/Ultima/Statics/StaticsDefragOptions.cs @@ -0,0 +1,180 @@ +// /*************************************************************************** +// * +// * $Author: Turley +// * +// * "THE BEER-WARE LICENSE" +// * As long as you retain this notice you can do whatever you want with +// * this stuff. If we meet some day, and you think this stuff is worth it, +// * you can buy me a beer in return. +// * +// ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Threading; + +namespace Ultima.Statics +{ + /// + /// What to do with a static whose in-block offset is outside the 0..7 range the format allows. + /// The client masks these with & 7 when reading, so they show up in the wrong cell. + /// + public enum OutOfBlockAction + { + Drop, + Mask, + Keep + } + + /// + /// Byte pattern written for a block that ends up with no statics. + /// + public enum EmptyBlockStyle + { + /// lookup/length/extra all -1, as shipped by the client and written by UOFiddler today. + NegativeOne, + + /// lookup -1, length 0, extra 0. + Zero + } + + /// + /// Where the "this item id does not exist" ceiling comes from. + /// + public enum ItemIdCeiling + { + /// Highest id described by tiledata.mul. The count the client can actually resolve. + TileData, + + /// Highest id reported by . + Art, + + /// 0x3FFF, the pre-ML item id limit. + Legacy + } + + public sealed class StaticsDefragProgress + { + public int BlocksDone { get; init; } + public int BlocksTotal { get; init; } + public long TilesWritten { get; init; } + } + + public sealed class StaticsDefragOptions + { + /// Facet file index, used to resolve staidx{N}.mul / statics{N}.mul. + public int FileIndex { get; set; } + + /// Explicit source override. When null the files are resolved through . + public string SourceIndexPath { get; set; } + + public string SourceStaticsPath { get; set; } + + /// + /// Optional. Supplies frozen/melted in-memory edits and the land tiles the below-terrain + /// filter needs. Without it those features are unavailable. + /// + public Map Map { get; set; } + + public int BlockWidth { get; set; } + + public int BlockHeight { get; set; } + + /// + /// Permits writing fewer blocks than the source index holds. Without it a source index + /// that reaches past the configured map size is refused rather than truncated. + /// + public bool AllowGeometryTruncation { get; set; } + + public bool DropInvalidItemIds { get; set; } = true; + + public ItemIdCeiling ItemIdCeiling { get; set; } = ItemIdCeiling.TileData; + + /// Overrides when greater than zero. + public int MaxItemId { get; set; } + + public OutOfBlockAction OutOfBlockTiles { get; set; } = OutOfBlockAction.Drop; + + /// Drops statics at z == -128, the sentinel the client will not place. + public bool DropInvalidZ { get; set; } = true; + + public bool NormalizeNegativeHue { get; set; } = true; + + /// Drops statics buried under the land tile. Requires . + public bool DropBelowTerrain { get; set; } + + /// Removes statics sharing id, x, y and z. Hue is not part of the key. + public bool RemoveDuplicates { get; set; } + + /// Adds hue to the duplicate key, which is what the old routine did. + public bool DuplicatesCompareHue { get; set; } + + /// Collapses eligible statics sharing a cell down to one. + public bool CollapseStacks { get; set; } + + /// A static is eligible for stack collapsing when any of these flags is set on its tile data. + public TileFlag CollapseFlagMask { get; set; } = TileFlag.Wet; + + /// A static is also eligible when its id appears here. + public HashSet CollapseIds { get; } = new HashSet(); + + /// Groups stack candidates by x/y only, ignoring z. + public bool CollapseIgnoreZ { get; set; } + + public EmptyBlockStyle EmptyBlocks { get; set; } = EmptyBlockStyle.NegativeOne; + + /// Carries the source index extra field through instead of always writing 0. + public bool PreserveExtra { get; set; } = true; + + /// Emits each block's statics in a stable order. Off by default so a no-filter run is a pure compaction. + public bool SortTiles { get; set; } + + public string OutputDirectory { get; set; } + + public bool DryRun { get; set; } + + public int RejectSampleLimit { get; set; } = 200; + + public IProgress Progress { get; set; } + + public CancellationToken CancellationToken { get; set; } + + /// + /// Resolves the item id ceiling actually in force. Ids above it are considered invalid. + /// + public int ResolveMaxItemId() + { + if (MaxItemId > 0) + { + return MaxItemId; + } + + switch (ItemIdCeiling) + { + case ItemIdCeiling.Legacy: + return 0x3FFF; + + case ItemIdCeiling.Art: + return Art.GetMaxItemId(); + + default: + int count = TileData.ItemTable?.Length ?? 0; + return count > 0 ? count - 1 : Art.GetMaxItemId(); + } + } + + /// + /// Reproduces the call shape of the old Map.DefragStatics overload. + /// + public static StaticsDefragOptions Legacy(string outputDirectory, Map map, bool removeDuplicates) + { + return new StaticsDefragOptions + { + FileIndex = map.FileIndex, + Map = map, + OutputDirectory = outputDirectory, + RemoveDuplicates = removeDuplicates + }; + } + } +} \ No newline at end of file diff --git a/Ultima/Statics/StaticsDefragResult.cs b/Ultima/Statics/StaticsDefragResult.cs new file mode 100644 index 00000000..8fc77127 --- /dev/null +++ b/Ultima/Statics/StaticsDefragResult.cs @@ -0,0 +1,286 @@ +// /*************************************************************************** +// * +// * $Author: Turley +// * +// * "THE BEER-WARE LICENSE" +// * As long as you retain this notice you can do whatever you want with +// * this stuff. If we meet some day, and you think this stuff is worth it, +// * you can buy me a beer in return. +// * +// ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace Ultima.Statics +{ + public enum RejectReason + { + InvalidItemId, + OutOfBlockOffset, + InvalidZ, + BelowTerrain, + Duplicate, + CollapsedStack + } + + public readonly struct RejectedStaticTile + { + public RejectedStaticTile(int blockX, int blockY, StaticTile tile, RejectReason reason) + { + BlockX = blockX; + BlockY = blockY; + Tile = tile; + Reason = reason; + } + + public int BlockX { get; } + + public int BlockY { get; } + + public StaticTile Tile { get; } + + public RejectReason Reason { get; } + + public int WorldX => (BlockX << 3) + (Tile.X & 0x7); + + public int WorldY => (BlockY << 3) + (Tile.Y & 0x7); + + public override string ToString() + { + return string.Format(CultureInfo.InvariantCulture, + "block {0},{1} tile 0x{2:X4} at {3} {4} {5} hue {6} - {7}", + BlockX, BlockY, Tile.Id, WorldX, WorldY, Tile.Z, Tile.Hue, Reason); + } + } + + public sealed class StaticsDefragResult : IStaticsFilterStats + { + public int FileIndex { get; set; } + + public bool DryRun { get; set; } + + public string SourceIndexPath { get; set; } + + public string SourceStaticsPath { get; set; } + + public string OutputIndexPath { get; set; } + + public string OutputStaticsPath { get; set; } + + public int BlockWidth { get; set; } + + public int BlockHeight { get; set; } + + public int BlockCount => BlockWidth * BlockHeight; + + public long SourceIndexEntries { get; set; } + + public string GeometryEvidence { get; set; } + + public int MaxItemIdUsed { get; set; } + + public int HighestItemIdSeen { get; set; } + + public long SourceStaticsBytes { get; set; } + + public long OutputStaticsBytes { get; set; } + + public int BlocksProcessed { get; set; } + + public int SourceBlocksWithStatics { get; set; } + + public int OutputBlocksWithStatics { get; set; } + + public int BlocksClearedByRemove { get; set; } + + public int BlocksWithBadLength { get; set; } + + public int BlocksWithBadLookup { get; set; } + + public long TilesRead { get; set; } + + public long TilesWritten { get; set; } + + public long PendingTilesAdded { get; set; } + + public long DroppedInvalidItemId { get; set; } + + public long DroppedOutOfBlock { get; set; } + + public long MaskedOutOfBlock { get; set; } + + public long DroppedInvalidZ { get; set; } + + public long DroppedBelowTerrain { get; set; } + + public long DuplicatesRemoved { get; set; } + + public long StacksCollapsed { get; set; } + + public long HuesNormalized { get; set; } + + public TimeSpan Elapsed { get; set; } + + public List Warnings { get; } = new List(); + + public List RejectSamples { get; } = new List(); + + /// + /// Every source tile the output does not carry, as accounted for by the filters. When a real + /// comparison of the two files disagrees with this number the engine lost or invented data. + /// + public long TilesAccountedFor => DroppedInvalidItemId + DroppedOutOfBlock + DroppedInvalidZ + + DroppedBelowTerrain + DuplicatesRemoved + StacksCollapsed; + + void IStaticsFilterStats.TileRejected(int blockX, int blockY, StaticTile tile, RejectReason reason) + { + switch (reason) + { + case RejectReason.InvalidItemId: + ++DroppedInvalidItemId; + break; + + case RejectReason.OutOfBlockOffset: + ++DroppedOutOfBlock; + break; + + case RejectReason.InvalidZ: + ++DroppedInvalidZ; + break; + + case RejectReason.BelowTerrain: + ++DroppedBelowTerrain; + break; + + case RejectReason.Duplicate: + ++DuplicatesRemoved; + break; + + case RejectReason.CollapsedStack: + ++StacksCollapsed; + break; + } + + if (RejectSamples.Count < RejectSampleLimit) + { + RejectSamples.Add(new RejectedStaticTile(blockX, blockY, tile, reason)); + } + } + + void IStaticsFilterStats.HueNormalized() + { + ++HuesNormalized; + } + + void IStaticsFilterStats.OutOfBlockMasked() + { + ++MaskedOutOfBlock; + } + + /// How many removed statics to keep as examples. + public int RejectSampleLimit { get; set; } = 200; + + public string ToReport() + { + var sb = new StringBuilder(); + + if (DryRun) + { + sb.AppendLine("DRY RUN - no files were written."); + sb.AppendLine(); + } + + sb.AppendLine(Line("Facet : {0}", FileIndex)); + sb.AppendLine(Line("Source index : {0}", SourceIndexPath)); + sb.AppendLine(Line("Source statics : {0} ({1:N0} bytes)", SourceStaticsPath, SourceStaticsBytes)); + + if (!DryRun) + { + sb.AppendLine(Line("Output index : {0}", OutputIndexPath)); + sb.AppendLine(Line("Output statics : {0}", OutputStaticsPath)); + } + + sb.AppendLine(Line("Output size : {0:N0} bytes ({1:+#,0;-#,0;0} vs source)", + OutputStaticsBytes, OutputStaticsBytes - SourceStaticsBytes)); + sb.AppendLine(); + + sb.AppendLine(Line("Block grid : {0} x {1} = {2:N0} blocks", BlockWidth, BlockHeight, BlockCount)); + sb.AppendLine(Line("Source index : {0:N0} entries", SourceIndexEntries)); + + if (!string.IsNullOrEmpty(GeometryEvidence)) + { + sb.AppendLine(Line("Geometry : {0}", GeometryEvidence)); + } + + sb.AppendLine(); + sb.AppendLine(Line("Item id ceiling : 0x{0:X4} (highest id present: 0x{1:X4})", MaxItemIdUsed, HighestItemIdSeen)); + sb.AppendLine(); + sb.AppendLine(Line("Tiles read : {0:N0}", TilesRead)); + sb.AppendLine(Line("Tiles written : {0:N0}", TilesWritten)); + + if (PendingTilesAdded > 0) + { + sb.AppendLine(Line(" of which added : {0:N0} (frozen in memory)", PendingTilesAdded)); + } + + sb.AppendLine(); + sb.AppendLine("Removed:"); + sb.AppendLine(Line(" invalid item id : {0:N0}", DroppedInvalidItemId)); + sb.AppendLine(Line(" out-of-block x/y : {0:N0} dropped, {1:N0} masked", DroppedOutOfBlock, MaskedOutOfBlock)); + sb.AppendLine(Line(" invalid z : {0:N0}", DroppedInvalidZ)); + sb.AppendLine(Line(" below terrain : {0:N0}", DroppedBelowTerrain)); + sb.AppendLine(Line(" duplicates : {0:N0}", DuplicatesRemoved)); + sb.AppendLine(Line(" collapsed stacks : {0:N0}", StacksCollapsed)); + sb.AppendLine(Line(" hues normalized : {0:N0}", HuesNormalized)); + sb.AppendLine(); + + sb.AppendLine(Line("Blocks processed : {0:N0}", BlocksProcessed)); + sb.AppendLine(Line(" with statics : {0:N0} source -> {1:N0} output", SourceBlocksWithStatics, OutputBlocksWithStatics)); + + if (BlocksClearedByRemove > 0) + { + sb.AppendLine(Line(" cleared : {0:N0} (removed in memory)", BlocksClearedByRemove)); + } + + if (BlocksWithBadLength > 0 || BlocksWithBadLookup > 0) + { + sb.AppendLine(Line(" damaged index : {0:N0} bad lookup, {1:N0} bad length", BlocksWithBadLookup, BlocksWithBadLength)); + } + + sb.AppendLine(); + sb.AppendLine(Line("Elapsed : {0:hh\\:mm\\:ss\\.fff}", Elapsed)); + + if (Warnings.Count > 0) + { + sb.AppendLine(); + sb.AppendLine(Line("Warnings ({0}):", Warnings.Count)); + + foreach (string warning in Warnings) + { + sb.AppendLine(" " + warning); + } + } + + if (RejectSamples.Count > 0) + { + sb.AppendLine(); + sb.AppendLine(Line("Removed tile samples (first {0}):", RejectSamples.Count)); + + foreach (RejectedStaticTile sample in RejectSamples) + { + sb.AppendLine(" " + sample); + } + } + + return sb.ToString(); + } + + private static string Line(string format, params object[] args) + { + return string.Format(CultureInfo.InvariantCulture, format, args); + } + } +} \ No newline at end of file diff --git a/Ultima/Statics/StaticsDefragmenter.cs b/Ultima/Statics/StaticsDefragmenter.cs new file mode 100644 index 00000000..c048c7d7 --- /dev/null +++ b/Ultima/Statics/StaticsDefragmenter.cs @@ -0,0 +1,642 @@ +// /*************************************************************************** +// * +// * $Author: Turley +// * +// * "THE BEER-WARE LICENSE" +// * As long as you retain this notice you can do whatever you want with +// * this stuff. If we meet some day, and you think this stuff is worth it, +// * you can buy me a beer in return. +// * +// ***************************************************************************/ + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; + +namespace Ultima.Statics +{ + /// + /// Rewrites a staidx{N}.mul / statics{N}.mul pair, compacting it and optionally filtering out + /// statics the client cannot render correctly. + /// + /// + /// The index holds one 12-byte + /// {lookup, length, extra} record per 8x8 block addressed as blockX * blockHeight + blockY, the + /// data file holds 7-byte {id, x, y, z, hue} records, and a block with no statics is signalled by + /// lookup == -1. The client reads x and y masked with & 7 and sorts by z at draw time, so it + /// needs no particular tile order but will happily render an out-of-block offset in the wrong cell. + /// + public sealed class StaticsDefragmenter + { + private const int IndexRecordSize = 12; + private const int TileRecordSize = 7; + private const int ProgressInterval = 256; + private const int StreamBufferSize = 1 << 20; + + private readonly StaticsDefragOptions _options; + private readonly List _tiles = new List(256); + private readonly Dictionary _stackIndex = new Dictionary(); + private readonly StaticsTileFilter _filter = new StaticsTileFilter(); + + private int _maxItemId; + private int _itemTableLength; + + public StaticsDefragmenter(StaticsDefragOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + public static StaticsDefragResult Defrag(StaticsDefragOptions options) + { + return new StaticsDefragmenter(options).Run(); + } + + /// + /// Resolves the source files, validates the geometry, writes to temporary files and moves + /// them into place only once the whole run has succeeded. + /// + public StaticsDefragResult Run() + { + var result = new StaticsDefragResult + { + FileIndex = _options.FileIndex, + DryRun = _options.DryRun + }; + + string indexPath = ResolveSource(_options.SourceIndexPath, $"staidx{_options.FileIndex}.mul"); + string staticsPath = ResolveSource(_options.SourceStaticsPath, $"statics{_options.FileIndex}.mul"); + + result.SourceIndexPath = indexPath; + result.SourceStaticsPath = staticsPath; + result.SourceStaticsBytes = new FileInfo(staticsPath).Length; + + WarnAboutOverrideFiles(indexPath, result); + + string outputIndexPath = null; + string outputStaticsPath = null; + + if (!_options.DryRun) + { + string outputDirectory = _options.OutputDirectory; + if (string.IsNullOrWhiteSpace(outputDirectory)) + { + throw new StaticsDefragException("No output directory was given."); + } + + Directory.CreateDirectory(outputDirectory); + + outputIndexPath = Path.GetFullPath(Path.Combine(outputDirectory, $"staidx{_options.FileIndex}.mul")); + outputStaticsPath = Path.GetFullPath(Path.Combine(outputDirectory, $"statics{_options.FileIndex}.mul")); + + RefuseToOverwriteSource(indexPath, outputIndexPath); + RefuseToOverwriteSource(staticsPath, outputStaticsPath); + + result.OutputIndexPath = outputIndexPath; + result.OutputStaticsPath = outputStaticsPath; + } + + string tempIndexPath = null; + string tempStaticsPath = null; + + try + { + using (var index = new FileStream(indexPath, FileMode.Open, FileAccess.Read, FileShare.Read, StreamBufferSize, FileOptions.SequentialScan)) + using (var statics = new FileStream(staticsPath, FileMode.Open, FileAccess.Read, FileShare.Read, StreamBufferSize)) + { + if (_options.DryRun) + { + Run(index, statics, Stream.Null, Stream.Null, result); + } + else + { + string suffix = Guid.NewGuid().ToString("N"); + tempIndexPath = outputIndexPath + ".tmp-" + suffix; + tempStaticsPath = outputStaticsPath + ".tmp-" + suffix; + + using (var outIndex = new FileStream(tempIndexPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, StreamBufferSize)) + using (var outStatics = new FileStream(tempStaticsPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, StreamBufferSize)) + { + Run(index, statics, outIndex, outStatics, result); + } + } + } + + if (!_options.DryRun) + { + MoveIntoPlace(tempIndexPath, outputIndexPath, tempStaticsPath, outputStaticsPath); + tempIndexPath = null; + tempStaticsPath = null; + } + } + catch (StaticsDefragException) + { + throw; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + throw new StaticsDefragException($"Defrag of statics{_options.FileIndex}.mul failed: {ex.Message}", ex); + } + finally + { + TryDelete(tempIndexPath); + TryDelete(tempStaticsPath); + } + + return result; + } + + /// + /// The whole algorithm, over streams. Everything that touches the file system lives in the + /// other overload, so this one can be driven from memory buffers. + /// + public StaticsDefragResult Run(Stream index, Stream statics, Stream outIndex, Stream outStatics, StaticsDefragResult result = null) + { + result ??= new StaticsDefragResult { FileIndex = _options.FileIndex, DryRun = _options.DryRun }; + + var stopwatch = Stopwatch.StartNew(); + + _itemTableLength = TileData.ItemTable?.Length ?? 0; + _maxItemId = _options.ResolveMaxItemId(); + result.MaxItemIdUsed = _maxItemId; + + ResolveGeometry(index.Length, result); + + int blockWidth = result.BlockWidth; + int blockHeight = result.BlockHeight; + int blockCount = blockWidth * blockHeight; + + ConfigureFilter(); + + var problems = new StaticsBlockProblems(); + + using (var reader = new StaticsIndexReader(index, statics, blockWidth, blockHeight, + result.Warnings, true, $"staidx{_options.FileIndex}.mul")) + using (var writer = new StaticsBlockWriter(outIndex, outStatics, blockWidth, blockHeight, + _options.EmptyBlocks, true)) + { + CheckForSurplusBlocks(reader.Entries, blockCount, statics.Length, result); + + TileMatrix tiles = _options.Map?.Tiles; + + for (int bx = 0; bx < blockWidth; ++bx) + { + for (int by = 0; by < blockHeight; ++by) + { + _tiles.Clear(); + + Entry3D entry = reader.GetEntry(bx, by); + bool removed = tiles != null && tiles.IsStaticBlockRemoved(bx, by); + + if (removed) + { + ++result.BlocksClearedByRemove; + } + else + { + if (entry.Lookup >= 0 && entry.Length > 0) + { + ++result.SourceBlocksWithStatics; + result.TilesRead += reader.ReadBlock(bx, by, _tiles, result.Warnings, problems); + } + + CollectPendingStatics(tiles, bx, by, result); + ApplyBlockFilters(bx, by, result); + } + + writer.WriteBlock(bx, by, _tiles, _options.PreserveExtra ? entry.Extra : 0); + + ++result.BlocksProcessed; + + if ((result.BlocksProcessed & (ProgressInterval - 1)) == 0) + { + _options.CancellationToken.ThrowIfCancellationRequested(); + ReportProgress(result, blockCount); + } + } + } + + writer.Complete(); + + result.OutputStaticsBytes = writer.StaticsLength; + result.TilesWritten = writer.TilesWritten; + result.OutputBlocksWithStatics = writer.BlocksWithStatics; + } + + result.BlocksWithBadLookup = problems.BadLookup; + result.BlocksWithBadLength = problems.BadLength; + result.HighestItemIdSeen = _filter.HighestItemIdSeen; + result.Elapsed = stopwatch.Elapsed; + ReportProgress(result, blockCount); + + return result; + } + + private void ConfigureFilter() + { + _filter.DropInvalidItemIds = _options.DropInvalidItemIds; + _filter.MaxItemId = _maxItemId; + _filter.OutOfBlockTiles = _options.OutOfBlockTiles; + _filter.DropInvalidZ = _options.DropInvalidZ; + _filter.NormalizeNegativeHue = _options.NormalizeNegativeHue; + _filter.RemoveDuplicates = _options.RemoveDuplicates; + _filter.DuplicatesCompareHue = _options.DuplicatesCompareHue; + } + + private void ReportProgress(StaticsDefragResult result, int blockCount) + { + _options.Progress?.Report(new StaticsDefragProgress + { + BlocksDone = result.BlocksProcessed, + BlocksTotal = blockCount, + TilesWritten = result.TilesWritten + }); + } + + private void ResolveGeometry(long indexLength, StaticsDefragResult result) + { + int blockWidth = _options.BlockWidth; + int blockHeight = _options.BlockHeight; + + if (blockWidth <= 0 || blockHeight <= 0) + { + Map map = _options.Map; + if (map == null) + { + throw new StaticsDefragException( + "No block dimensions were given and no map was supplied to derive them from."); + } + + blockWidth = map.Width >> 3; + blockHeight = map.Height >> 3; + } + + if (blockWidth <= 0 || blockHeight <= 0) + { + throw new StaticsDefragException($"Invalid block grid {blockWidth} x {blockHeight}."); + } + + result.BlockWidth = blockWidth; + result.BlockHeight = blockHeight; + result.SourceIndexEntries = indexLength / IndexRecordSize; + + if (indexLength % IndexRecordSize != 0) + { + result.Warnings.Add(string.Format(CultureInfo.InvariantCulture, + "staidx{0}.mul is {1:N0} bytes, which is not a whole number of {2}-byte records. The trailing {3} bytes were ignored.", + _options.FileIndex, indexLength, IndexRecordSize, indexLength % IndexRecordSize)); + } + + var evidence = new List + { + string.Format(CultureInfo.InvariantCulture, "staidx holds {0:N0} blocks", result.SourceIndexEntries) + }; + + string mapPath = Files.GetFilePath($"map{_options.FileIndex}.mul"); + if (mapPath != null) + { + // Skipped for map{N}LegacyMUL.uop - the UOP chunking makes length / 196 meaningless. + long mapBlocks = new FileInfo(mapPath).Length / 196; + evidence.Add(string.Format(CultureInfo.InvariantCulture, "map{0}.mul holds {1:N0} blocks", _options.FileIndex, mapBlocks)); + + if (mapBlocks != (long)blockWidth * blockHeight) + { + result.Warnings.Add(string.Format(CultureInfo.InvariantCulture, + "map{0}.mul covers {1:N0} blocks but the configured map size covers {2:N0}. Check the map size setting.", + _options.FileIndex, mapBlocks, (long)blockWidth * blockHeight)); + } + } + + evidence.Add(string.Format(CultureInfo.InvariantCulture, "configured grid {0} x {1} = {2:N0}", + blockWidth, blockHeight, (long)blockWidth * blockHeight)); + + result.GeometryEvidence = string.Join("; ", evidence); + } + + private void CheckForSurplusBlocks(Entry3D[] entries, int blockCount, long staticsLength, StaticsDefragResult result) + { + if (entries.Length <= blockCount) + { + return; + } + + int surplusBlocks = 0; + long surplusTiles = 0; + + for (int i = blockCount; i < entries.Length; ++i) + { + if (entries[i].Lookup < 0 || entries[i].Length <= 0 || entries[i].Lookup >= staticsLength) + { + continue; + } + + ++surplusBlocks; + surplusTiles += entries[i].Length / TileRecordSize; + } + + if (surplusBlocks == 0) + { + return; + } + + string message = string.Format(CultureInfo.InvariantCulture, + "staidx{0}.mul holds {1:N0} blocks but the configured map size covers only {2:N0}. " + + "{3:N0} of the surplus blocks hold statics ({4:N0} tiles) and would be discarded.", + _options.FileIndex, entries.Length, blockCount, surplusBlocks, surplusTiles); + + if (!_options.AllowGeometryTruncation) + { + throw new StaticsDefragException(message + + " Correct the map size setting, or allow truncation if the loss is intended."); + } + + result.Warnings.Add(message + " Truncation was allowed, so they were discarded."); + } + + private void CollectPendingStatics(TileMatrix tiles, int bx, int by, StaticsDefragResult result) + { + if (tiles == null || !tiles.PendingStatic(bx, by)) + { + return; + } + + StaticTile[] pending = tiles.GetPendingStatics(bx, by); + if (pending == null) + { + return; + } + + for (int i = 0; i < pending.Length; ++i) + { + _tiles.Add(pending[i]); + ++result.PendingTilesAdded; + } + } + + private void DropBelowTerrain(int bx, int by, StaticsDefragResult result) + { + Tile[] land = GetLandBlock(bx, by); + + if (land == null) + { + return; + } + + int write = 0; + + for (int i = 0; i < _tiles.Count; ++i) + { + StaticTile tile = _tiles[i]; + + if (IsBelowTerrain(tile, land)) + { + ((IStaticsFilterStats)result).TileRejected(bx, by, tile, RejectReason.BelowTerrain); + + continue; + } + + _tiles[write++] = tile; + } + + _tiles.RemoveRange(write, _tiles.Count - write); + } + + private Tile[] GetLandBlock(int bx, int by) + { + if (_options.Map == null) + { + return null; + } + + Tile[] land = _options.Map.Tiles.GetLandBlock(bx, by); + + return land != null && land.Length >= 64 ? land : null; + } + + /// + /// The predicate already uses: the static and + /// everything stacked on top of it sit under the land tile, so the client never draws it. + /// + private bool IsBelowTerrain(StaticTile tile, Tile[] land) + { + if (tile.Id >= _itemTableLength) + { + return false; + } + + Tile landTile = land[((tile.Y & 0x7) << 3) + (tile.X & 0x7)]; + + return tile.Z < landTile.Z && TileData.ItemTable[tile.Id].Height + tile.Z < landTile.Z; + } + + private void ApplyBlockFilters(int bx, int by, StaticsDefragResult result) + { + _filter.Apply(_tiles, bx, by, result); + + if (_options.DropBelowTerrain && _tiles.Count > 0) + { + DropBelowTerrain(bx, by, result); + } + + if (_options.CollapseStacks && _tiles.Count > 1) + { + CollapseStacks(bx, by, result); + } + + if (_options.SortTiles && _tiles.Count > 1) + { + _tiles.Sort(CompareTiles); + } + } + + /// + /// Drops statics sharing id, x, y and z. Hue is left out of the key on purpose: the old + /// routine included it, so two water tiles in the same cell differing only in hue both + /// survived and fought over the same pixels. + /// + /// + /// Keeps a single eligible static per cell. Only tiles matching the flag mask or the id list + /// take part, so a floor with a chair on it is left alone while a cell holding six stacked + /// water tiles is reduced to one. + /// + private void CollapseStacks(int bx, int by, StaticsDefragResult result) + { + _stackIndex.Clear(); + + int write = 0; + + for (int i = 0; i < _tiles.Count; ++i) + { + StaticTile tile = _tiles[i]; + + if (!IsCollapsible(tile.Id)) + { + _tiles[write++] = tile; + continue; + } + + uint key = CollapseKey(tile); + + if (_stackIndex.ContainsKey(key)) + { + ((IStaticsFilterStats)result).TileRejected(bx, by, tile, RejectReason.CollapsedStack); + + continue; + } + + _stackIndex[key] = write; + _tiles[write++] = tile; + } + + _tiles.RemoveRange(write, _tiles.Count - write); + } + + private bool IsCollapsible(ushort id) + { + if (_options.CollapseIds.Contains(id)) + { + return true; + } + + if (_options.CollapseFlagMask == 0 || id >= _itemTableLength) + { + return false; + } + + return (TileData.ItemTable[id].Flags & _options.CollapseFlagMask) != 0; + } + + private uint CollapseKey(StaticTile tile) + { + uint key = ((uint)(tile.X & 0x7) << 3) | (uint)(tile.Y & 0x7); + + if (!_options.CollapseIgnoreZ) + { + key |= (uint)((byte)tile.Z) << 6; + } + + return key; + } + + private static int CompareTiles(StaticTile left, StaticTile right) + { + int result = left.Y.CompareTo(right.Y); + if (result != 0) + { + return result; + } + + result = left.X.CompareTo(right.X); + if (result != 0) + { + return result; + } + + result = left.Z.CompareTo(right.Z); + if (result != 0) + { + return result; + } + + result = left.Id.CompareTo(right.Id); + + return result != 0 ? result : left.Hue.CompareTo(right.Hue); + } + + private static string ResolveSource(string explicitPath, string fileName) + { + if (!string.IsNullOrEmpty(explicitPath)) + { + if (!File.Exists(explicitPath)) + { + throw new StaticsDefragException($"{explicitPath} does not exist."); + } + + return Path.GetFullPath(explicitPath); + } + + string path = Files.GetFilePath(fileName); + + if (path == null) + { + throw new StaticsDefragException( + $"{fileName} was not found. Check the path settings for the loaded client."); + } + + return Path.GetFullPath(path); + } + + private static void RefuseToOverwriteSource(string sourcePath, string outputPath) + { + if (!string.Equals(sourcePath, outputPath, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + throw new StaticsDefragException( + $"The output directory holds the file being read ({outputPath}). Choose a different output directory."); + } + + private void WarnAboutOverrideFiles(string indexPath, StaticsDefragResult result) + { + string directory = Path.GetDirectoryName(indexPath); + if (directory == null) + { + return; + } + + string overrideIndex = Path.Combine(directory, $"staidx{_options.FileIndex}x.mul"); + + if (File.Exists(overrideIndex)) + { + result.Warnings.Add( + $"staidx{_options.FileIndex}x.mul is present. The client prefers those override files over the pair being rewritten here, so the result may not show up in game."); + } + } + + private static void MoveIntoPlace(string tempIndexPath, string indexPath, string tempStaticsPath, string staticsPath) + { + // Both files are complete on disk by now, so the pair moves back to back. If the second + // move still fails the caller is told which half made it, because an index and a data + // file from different runs do not describe the same world. + File.Move(tempStaticsPath, staticsPath, true); + + try + { + File.Move(tempIndexPath, indexPath, true); + } + catch (Exception ex) + { + throw new StaticsDefragException( + $"{staticsPath} was replaced but {indexPath} could not be: {ex.Message}. The two files no longer match.", ex); + } + } + + private static void TryDelete(string path) + { + if (path == null || !File.Exists(path)) + { + return; + } + + try + { + File.Delete(path); + } + catch (IOException) + { + // Nothing useful to do - the temporary file is named so it cannot be mistaken for output. + } + catch (UnauthorizedAccessException) + { + } + } + } +} \ No newline at end of file diff --git a/Ultima/Textures.cs b/Ultima/Textures.cs index f4e80ffb..f2390751 100644 --- a/Ultima/Textures.cs +++ b/Ultima/Textures.cs @@ -14,6 +14,9 @@ public sealed class Textures private static Bitmap[] _cache = new Bitmap[0x4000]; private static bool[] _removed = new bool[0x4000]; private static readonly Dictionary _patched = new Dictionary(); + // Indexes edited since load or since the last save. Replace() writes straight into _cache, + // which is also the read cache, so there is no other way to tell an edit from a disk load. + private static readonly ModifiedIndexTracker _modified = new ModifiedIndexTracker(); private struct Checksums { @@ -31,6 +34,7 @@ public static void Reload() _cache = new Bitmap[0x4000]; _removed = new bool[0x4000]; _patched.Clear(); + _modified.Clear(); } public static int GetIdxLength() @@ -45,6 +49,7 @@ public static int GetIdxLength() public static void Remove(int index) { _removed[index] = true; + _modified.Mark(index & 0x3FFF); } /// @@ -57,6 +62,29 @@ public static void Replace(int index, Bitmap bmp) _cache[index] = bmp; _removed[index] = false; _patched.Remove(index); + _modified.Mark(index & 0x3FFF); + } + + /// + /// Tests if the Texture at was replaced or removed since the textures + /// were loaded or last saved. + /// + public static bool IsModified(int index) + { + return _modified.IsMarked(index & 0x3FFF); + } + + /// + /// Number of Textures edited since the textures were loaded or last saved. + /// + public static int ModifiedCount => _modified.Count; + + /// + /// Drops every modified mark without touching the edits themselves. + /// + public static void ClearModified() + { + _modified.Clear(); } /// @@ -133,6 +161,14 @@ public static unsafe Bitmap GetTexture(int index, out bool patched) int max = size * size * 2; + // An entry that claims fewer bytes than the size flag implies is truncated - reading + // the full tile off it would run past the end of the stream, and this is called from a + // paint handler, where an exception takes the whole window down. + if (length < max) + { + return null; + } + byte[] streamBuffer = ArrayPool.Shared.Rent(max); try { @@ -268,6 +304,8 @@ public static unsafe void Save(string path) } memIdx.Dispose(); + + _modified.Clear(); } private static int GetExtraFlag(int length) diff --git a/Ultima/TileMatrix.cs b/Ultima/TileMatrix.cs index 4c956978..f6972a02 100644 --- a/Ultima/TileMatrix.cs +++ b/Ultima/TileMatrix.cs @@ -3,11 +3,20 @@ using System.IO; using System.Runtime.InteropServices; using Ultima.Helpers; +using Ultima.Uop; namespace Ultima { public sealed class TileMatrix { + /// On disk size of one land block: a 4-byte header plus 64 three-byte tiles. + public const int MapBlockSize = 196; + + /// Size of the per-block header the renderer ignores. + public const int BlockHeaderSize = 4; + + private const int MapStreamBufferSize = 1 << 20; + private readonly HuedTile[][][][][] _staticTiles; private readonly Tile[][][] _landTiles; private bool[][] _removedStaticBlock; @@ -17,7 +26,6 @@ public sealed class TileMatrix public static HuedTile[][][] EmptyStaticBlock { get; private set; } private FileStream _map; - private BinaryReader _uopReader; private FileStream _statics; private Entry3D[] _staticIndex; @@ -40,7 +48,6 @@ public sealed class TileMatrix public void CloseStreams() { _map?.Close(); - _uopReader?.Close(); _statics?.Close(); } @@ -125,7 +132,10 @@ public TileMatrix(int fileIndex, int mapId, int width, int height, string path) } } - InvalidLandBlock = new Tile[196]; + // 64 tiles, not 196 - 196 is the on-disk byte size of a block (4-byte header plus + // 64 three-byte tiles), which is a different thing. A caller that enumerates the block + // rather than indexing 0..63 used to get 196 tiles back and write a malformed block. + InvalidLandBlock = new Tile[64]; _landTiles = new Tile[BlockWidth][][]; _staticTiles = new HuedTile[BlockWidth][][][][]; @@ -339,142 +349,108 @@ private unsafe HuedTile[][][] ReadStaticBlock(int x, int y) /* * UOP map files support code, written by Wyatt (c) www.ruosi.org - * It's not possible if some entry has unknown hash. Thrown exception - * means that EA changed maps UOPs again. + * The container walk now lives in Ultima.Uop.MapUopReader so the map writer and size + * detection can use it without building a TileMatrix. */ public bool IsUOPFormat { get; set; } public bool IsUOPAlreadyRead { get; set; } - private readonly struct UopFile - { - public readonly long Offset; - public readonly int Length; - - public UopFile(long offset, int length) - { - Offset = offset; - Length = length; - } - } - - private UopFile[] UOPFiles { get; set; } + private MapUopEntry[] UOPFiles { get; set; } private long UOPLength { get { return _map.Length; } } private void ReadUOPFiles(string pattern) { - _uopReader = new BinaryReader(_map); + UOPFiles = MapUopReader.ReadEntryTable(_map, pattern); + } - _uopReader.BaseStream.Seek(0, SeekOrigin.Begin); + private long CalculateOffsetFromUOP(long offset) + { + long pos = 0; - if (_uopReader.ReadInt32() != 0x50594D) + foreach (MapUopEntry t in UOPFiles) { - throw new ArgumentException("Bad UOP file."); - } + long currentPosition = pos + t.Length; - _uopReader.ReadInt64(); // version + signature - long nextBlock = _uopReader.ReadInt64(); - _uopReader.ReadInt32(); // block capacity - int count = _uopReader.ReadInt32(); + if (offset < currentPosition) + { + return t.Offset + (offset - pos); + } - UOPFiles = new UopFile[count]; + pos = currentPosition; + } - var hashes = new Dictionary(); + return UOPLength; + } - for (int i = 0; i < count; i++) + /// + /// Reads one land block as it sits on disk: the 4-byte block header followed by 64 + /// three-byte tiles. Copying through this rather than through + /// keeps the header, which the renderer ignores but which real files do carry - a shard map + /// may hold the same constant in every block, a shipped one the block index. + /// + public void ReadLandBlockBytes(int x, int y, Span destination) + { + if (destination.Length != MapBlockSize) { - string file = $"build/{pattern}/{i:D8}.dat"; - ulong hash = UopUtils.HashFileName(file); - - hashes.TryAdd(hash, i); + throw new ArgumentException($"A land block is {MapBlockSize} bytes.", nameof(destination)); } - _uopReader.BaseStream.Seek(nextBlock, SeekOrigin.Begin); + destination.Clear(); - do + if (x < 0 || y < 0 || x >= BlockWidth || y >= BlockHeight) { - int filesCount = _uopReader.ReadInt32(); - nextBlock = _uopReader.ReadInt64(); + return; + } - for (int i = 0; i < filesCount; i++) - { - long offset = _uopReader.ReadInt64(); - int headerLength = _uopReader.ReadInt32(); - int compressedLength = _uopReader.ReadInt32(); - _uopReader.ReadInt32(); // decompressed length - equal to the compressed one while stored - ulong hash = _uopReader.ReadUInt64(); - _uopReader.ReadUInt32(); // Adler32 - short flag = _uopReader.ReadInt16(); - - if (offset == 0) - { - continue; - } + EnsureMapStream(); - // This reader addresses map blocks by slicing straight into the file, so it can only handle - // stored entries. Every map*LegacyMUL.uop EA ships uses flag 0, but the UOP packer can be told - // to zlib them. compressedLength is the byte count on disk; decompressedLength only matches - // it while the entry is uncompressed. - if (flag != 0) - { - throw new NotSupportedException( - $"{pattern}: compressed map UOP entries are not supported " + - $"(entry uses compression flag {flag}). Repack the map with compression set to None."); - } + if (_map == null) + { + return; + } - if (hashes.TryGetValue(hash, out int idx)) - { - if (idx < 0 || idx >= UOPFiles.Length) - { - throw new IndexOutOfRangeException("hashes dictionary and files collection have different count of entries!"); - } + long offset = ((x * BlockHeight) + y) * (long)MapBlockSize; - UOPFiles[idx] = new UopFile(offset + headerLength, compressedLength); - } - else - { - throw new ArgumentException($"File with hash 0x{hash:X8} was not found in hashes dictionary! EA Mythic changed UOP format!"); - } - } + if (IsUOPFormat) + { + offset = CalculateOffsetFromUOP(offset); + } + + if (offset < 0 || offset + destination.Length > _map.Length) + { + return; } - while (_uopReader.BaseStream.Seek(nextBlock, SeekOrigin.Begin) != 0); + + _map.Seek(offset, SeekOrigin.Begin); + _map.ReadExactly(destination); } - private long CalculateOffsetFromUOP(long offset) + private void EnsureMapStream() { - long pos = 0; - - foreach (UopFile t in UOPFiles) + if (_map?.CanRead == true && _map.CanSeek) { - long currentPosition = pos + t.Length; + return; + } - if (offset < currentPosition) - { - return t.Offset + (offset - pos); - } + // Land blocks are 196 bytes and are normally walked in index order, so the default + // 4 KB buffer means one physical read per twenty blocks. A megabyte turns a full-facet + // pass from hundreds of thousands of reads into a few hundred. + _map = _mapPath == null + ? null + : new FileStream(_mapPath, FileMode.Open, FileAccess.Read, FileShare.Read, MapStreamBufferSize); - pos = currentPosition; + if (!IsUOPFormat || _mapPath == null || IsUOPAlreadyRead) + { + return; } - return UOPLength; + ReadUOPFiles(MapUopReader.PatternFromPath(_mapPath)); + IsUOPAlreadyRead = true; } private Tile[] ReadLandBlock(int x, int y) { - if (_map?.CanRead != true || !_map.CanSeek) - { - _map = _mapPath == null - ? null - : new FileStream(_mapPath, FileMode.Open, FileAccess.Read, FileShare.Read); - - if (IsUOPFormat && _mapPath != null && !IsUOPAlreadyRead) - { - var fi = new FileInfo(_mapPath); - string uopPattern = fi.Name.Replace(fi.Extension, "").ToLowerInvariant(); - - ReadUOPFiles(uopPattern); - IsUOPAlreadyRead = true; - } - } + EnsureMapStream(); var tiles = new Tile[64]; if (_map == null) @@ -482,16 +458,27 @@ private Tile[] ReadLandBlock(int x, int y) return tiles; } - long offset = (((x * BlockHeight) + y) * 196) + 4; + long offset = (((x * BlockHeight) + y) * (long)MapBlockSize) + BlockHeaderSize; if (IsUOPFormat) { offset = CalculateOffsetFromUOP(offset); } + Span destination = MemoryMarshal.AsBytes(tiles.AsSpan()); + + // The configured map size can cover more blocks than the file actually holds - a client + // whose map0 is the pre-T2A 6144 wide one read as the modern 7168 grid, say - and the + // tail blocks are then simply absent. Reading them as empty is what the statics index + // already does with a short staidx, and it beats throwing at whoever asked to draw them. + if (offset < 0 || offset + destination.Length > _map.Length) + { + return tiles; + } + _map.Seek(offset, SeekOrigin.Begin); - _map.ReadExactly(MemoryMarshal.AsBytes(tiles.AsSpan())); + _map.ReadExactly(destination); return tiles; } diff --git a/Ultima/TileMatrixPatch.cs b/Ultima/TileMatrixPatch.cs index d63f2c2e..dcfdce36 100644 --- a/Ultima/TileMatrixPatch.cs +++ b/Ultima/TileMatrixPatch.cs @@ -16,6 +16,11 @@ public sealed class TileMatrixPatch private static StaticTile[] _tileBuffer = new StaticTile[128]; + /// + /// The arrays are only allocated when the matching diff files are present, and a client can + /// ship one half of the set without the other - 7.0.114.4 has stadif but no mapdif - so the + /// accessors have to cope with a null array rather than assume the files were there. + /// public bool IsLandBlockPatched(int x, int y) { if (x < 0 || y < 0 || x >= _blockWidth || y >= _blockHeight) @@ -23,12 +28,7 @@ public bool IsLandBlockPatched(int x, int y) return false; } - if (LandBlocks[x] == null) - { - return false; - } - - if (LandBlocks[x][y] == null) + if (LandBlocks?[x]?[y] == null) { return false; } @@ -43,12 +43,7 @@ public Tile[] GetLandBlock(int x, int y) return TileMatrix.InvalidLandBlock; } - if (LandBlocks[x]==null) - { - return TileMatrix.InvalidLandBlock; - } - - return LandBlocks[x][y]; + return LandBlocks?[x]?[y] ?? TileMatrix.InvalidLandBlock; } public Tile GetLandTile(int x, int y) @@ -63,12 +58,7 @@ public bool IsStaticBlockPatched(int x, int y) return false; } - if (StaticBlocks[x] == null) - { - return false; - } - - if (StaticBlocks[x][y] == null) + if (StaticBlocks?[x]?[y] == null) { return false; } @@ -83,12 +73,7 @@ public HuedTile[][][] GetStaticBlock(int x, int y) return TileMatrix.EmptyStaticBlock; } - if (StaticBlocks[x] == null) - { - return TileMatrix.EmptyStaticBlock; - } - - return StaticBlocks[x][y]; + return StaticBlocks?[x]?[y] ?? TileMatrix.EmptyStaticBlock; } public HuedTile[] GetStaticTiles(int x, int y) @@ -99,7 +84,7 @@ public HuedTile[] GetStaticTiles(int x, int y) public TileMatrixPatch(TileMatrix matrix, int index, string path) { _blockWidth = matrix.BlockWidth; - _blockHeight = matrix.BlockWidth; + _blockHeight = matrix.BlockHeight; LandBlocksCount = StaticBlocksCount = 0; string mapDataPath, mapIndexPath; diff --git a/Ultima/Ultima.csproj b/Ultima/Ultima.csproj index 0207699f..c1db92c3 100644 --- a/Ultima/Ultima.csproj +++ b/Ultima/Ultima.csproj @@ -13,6 +13,7 @@ true + diff --git a/Ultima/Uop/ClientFileSaver.cs b/Ultima/Uop/ClientFileSaver.cs new file mode 100644 index 00000000..bc60f98b --- /dev/null +++ b/Ultima/Uop/ClientFileSaver.cs @@ -0,0 +1,286 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.IO; + +namespace Ultima.Uop +{ + /// + /// Which container a save writes. The same two choices the map sinks already offer, for the file + /// types that are packed whole rather than streamed block by block. + /// + public enum ContainerFormat + { + Mul, + Uop + } + + /// What a save wrote, and anything the user should know about it. + public sealed class ClientFileSaveResult + { + internal ClientFileSaveResult(string directory, IReadOnlyList filesWritten, + IReadOnlyList warnings) + { + Directory = directory; + FilesWritten = filesWritten; + Warnings = warnings; + } + + /// Folder the files landed in. + public string Directory { get; } + + /// File names, without their directory. + public IReadOnlyList FilesWritten { get; } + + /// Losses worth telling the user about. Empty on a clean save. + public IReadOnlyList Warnings { get; } + } + + /// + /// Saves one of the packable file types as either a mul/idx pair or a uop, from whatever the + /// in-memory model holds. + /// + /// + /// packs file to file, so a uop save writes the mul into a + /// temporary folder with the domain's own save method, packs that, and throws the temporary copy + /// away. Nothing in the domain classes has to learn about uop. + /// + public static class ClientFileSaver + { + /// + /// Whether the loaded client keeps this type in a uop. The choice is per file - a client can be + /// uop art and mul gumps - so it is asked per type rather than once for the whole client. + /// + public static bool ClientUsesUop(FileType type, int mapIndex = 0) + { + return UopFileNames.ClientUopPath(type, mapIndex) != null; + } + + /// + /// What the user should be asked to confirm before this save runs. Empty when there is nothing + /// to weigh up. Conditions that make a save impossible are not listed here - those throw from + /// . + /// + public static IReadOnlyList Preflight(FileType type, ContainerFormat format, int mapIndex = 0) + { + if (type != FileType.MultiCollection || format != ContainerFormat.Uop) + { + return Array.Empty(); + } + + if (UopFileNames.ClientUopPath(FileType.MultiCollection) == null) + { + return Array.Empty(); + } + + // The multi list is read from multi.idx and multi.mul, and on a modern client those describe + // a reduced collection next to MultiCollection.uop: the live 7.0.114.4 client ships 800 + // multis of 62 177 tiles in the mul against the uop's 872 of 188 349. Packing the list + // therefore writes a smaller collection than the client shipped, and the file itself will + // not show it. + int multisInModel = 0; + int multisInClientUop = 0; + long tilesInModel = 0; + long tilesInClientUop = 0; + + for (int index = 0; index < Multis.MaximumMultiIndex; ++index) + { + MultiComponentList fromMul = Multis.GetComponents(index); + + if (fromMul != MultiComponentList.Empty) + { + ++multisInModel; + tilesInModel += fromMul.SortedTiles.Length; + } + + MultiComponentList fromUop = Multis.GetUopComponents(index); + + if (fromUop != MultiComponentList.Empty) + { + ++multisInClientUop; + tilesInClientUop += fromUop.SortedTiles.Length; + } + } + + if (multisInClientUop <= multisInModel && tilesInClientUop <= tilesInModel) + { + return Array.Empty(); + } + + return new[] + { + $"This client's MultiCollection.uop describes {multisInClientUop:N0} multis of " + + $"{tilesInClientUop:N0} tiles. Its multi.mul, which is what the Multis tab shows and " + + $"what a save writes, describes {multisInModel:N0} of {tilesInModel:N0}. Saving as " + + "MultiCollection.uop writes what the tab holds and nothing else, so the difference " + + "would be lost. Save as multi.mul instead, or unpack the client's MultiCollection.uop " + + "with the UOP Packer and point the profile at the result first." + }; + } + + /// + /// Writes into in the requested + /// container. is the domain's existing save method, which is handed + /// the directory to write its mul and idx into. + /// + public static ClientFileSaveResult Save(FileType type, string outputDirectory, ContainerFormat format, + Action writeMul, int mapIndex = 0, IProgress progress = null) + { + if (string.IsNullOrWhiteSpace(outputDirectory)) + { + throw new ArgumentException("No output directory was given.", nameof(outputDirectory)); + } + + if (writeMul == null) + { + throw new ArgumentNullException(nameof(writeMul)); + } + + Directory.CreateDirectory(outputDirectory); + + var (mulName, idxName, uopName) = UopFileNames.For(type, mapIndex); + + if (format == ContainerFormat.Mul) + { + writeMul(outputDirectory); + + string[] written = idxName == null + ? new[] { mulName } + : new[] { mulName, idxName }; + + return new ClientFileSaveResult(outputDirectory, written, Array.Empty()); + } + + string temporaryDirectory = Path.Combine(Path.GetTempPath(), + "UoFiddler-save-" + Guid.NewGuid().ToString("N")); + + Directory.CreateDirectory(temporaryDirectory); + + try + { + string temporaryMul = Path.Combine(temporaryDirectory, mulName); + string temporaryIdx = idxName == null ? null : Path.Combine(temporaryDirectory, idxName); + var warnings = new List(); + + string housingBin = string.Empty; + string componentsFile = string.Empty; + + if (type == FileType.MultiCollection) + { + // Unpacks the client's own file first, for the two things the in-memory multi model + // cannot supply. writeMul then overwrites the mul and idx it left behind. + (housingBin, componentsFile) = PrepareMultiCollection(temporaryDirectory, temporaryMul, + temporaryIdx, warnings); + } + + writeMul(temporaryDirectory); + + if (!File.Exists(temporaryMul)) + { + throw new FileNotFoundException( + $"The save wrote no {mulName}, so there is nothing to pack into {uopName}.", temporaryMul); + } + + string outputUop = Path.Combine(outputDirectory, uopName); + + LegacyMulFileConverter.ToUop(temporaryMul, temporaryIdx, outputUop, type, mapIndex, + UopFileNames.DefaultCompression(type), housingBin, progress, componentsFile); + + return new ClientFileSaveResult(outputDirectory, new[] { uopName }, warnings); + } + finally + { + TryDeleteDirectory(temporaryDirectory); + } + } + + /// + /// Produces housing.bin and the component id sidecar in by + /// unpacking the loaded client's MultiCollection.uop. + /// + /// + /// MultiCollection.uop carries two things multi.mul does not: build/multicollection/housing.bin, + /// the custom housing piece catalog, and a component id per tile that marks its interactive role - + /// a boat without its tiller man cannot be steered and a house door stops being a door. The reader + /// drops the component ids (see Multis.LoadUop) and multi.mul has nowhere to keep either, so the + /// only honest source for both is the client's own file. + /// + private static (string HousingBin, string ComponentsFile) PrepareMultiCollection(string temporaryDirectory, + string temporaryMul, string temporaryIdx, ICollection warnings) + { + string clientUop = UopFileNames.ClientUopPath(FileType.MultiCollection); + + if (clientUop == null) + { + throw new InvalidOperationException( + "Multis can only be saved as MultiCollection.uop when the loaded client has one to take " + + "build/multicollection/housing.bin and the tile component ids from. This client has no " + + "MultiCollection.uop, so save multis as multi.mul instead, or use the UOP Packer with a " + + "housing.bin of your own."); + } + + if (UopFileNames.ClientMulPath(FileType.MultiCollection) == null) + { + throw new InvalidOperationException( + "This client has no multi.mul, so the multi list was never loaded and packing it would " + + "write an empty MultiCollection.uop. Unpack the client's MultiCollection.uop with the " + + "UOP Packer first, point the profile at the result, then save."); + } + + string housingBin = Path.Combine(temporaryDirectory, "housing.bin"); + string componentsFile = MultiComponentSidecar.GetDefaultPath(temporaryMul); + + new LegacyMulFileConverter().FromUop(clientUop, temporaryMul, temporaryIdx, + FileType.MultiCollection, 0, housingBin, null, componentsFile); + + if (!File.Exists(housingBin)) + { + throw new InvalidOperationException( + $"{Path.GetFileName(clientUop)} contains no build/multicollection/housing.bin, which the " + + "client needs to place customisable house pieces. Packing without it would produce a " + + "file the client cannot use."); + } + + MultiComponentSidecar.Status sidecar = MultiComponentSidecar.Probe(temporaryMul, componentsFile); + + if (sidecar.IsEmpty) + { + warnings.Add( + $"{Path.GetFileName(clientUop)} carries no tile component ids, so every tile is written " + + "with none. Boats lose their tiller man, hatch and planks, and customisable houses lose " + + "their doors."); + } + + return (housingBin, componentsFile); + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + } + catch (IOException) + { + // A leftover temporary folder is untidy, not a failure; the original result stands. + } + catch (UnauthorizedAccessException) + { + // As above. + } + } + } +} \ No newline at end of file diff --git a/UoFiddler.Plugin.UopPacker/Classes/FileType.cs b/Ultima/Uop/FileType.cs similarity index 75% rename from UoFiddler.Plugin.UopPacker/Classes/FileType.cs rename to Ultima/Uop/FileType.cs index 0a53a3ad..abae6b56 100644 --- a/UoFiddler.Plugin.UopPacker/Classes/FileType.cs +++ b/Ultima/Uop/FileType.cs @@ -1,4 +1,4 @@ -namespace UoFiddler.Plugin.UopPacker.Classes +namespace Ultima.Uop { public enum FileType { @@ -8,4 +8,4 @@ public enum FileType SoundLegacyMul, MultiCollection } -} +} \ No newline at end of file diff --git a/UoFiddler.Plugin.UopPacker/Classes/LegacyMulFileConverter.cs b/Ultima/Uop/LegacyMulFileConverter.cs similarity index 86% rename from UoFiddler.Plugin.UopPacker/Classes/LegacyMulFileConverter.cs rename to Ultima/Uop/LegacyMulFileConverter.cs index aa1039a6..fe3c7d2b 100644 --- a/UoFiddler.Plugin.UopPacker/Classes/LegacyMulFileConverter.cs +++ b/Ultima/Uop/LegacyMulFileConverter.cs @@ -1,25 +1,20 @@ -using System; +using System; using System.Buffers.Binary; using System.Collections.Generic; using System.IO; using System.IO.Compression; using Microsoft.Extensions.Logging; -using Ultima; using Ultima.Helpers; -using UoFiddler.Controls.Classes; +using Ultima.Maps; -namespace UoFiddler.Plugin.UopPacker.Classes +namespace Ultima.Uop { public class LegacyMulFileConverter { - private struct IdxEntry - { - public int Id; - public int Offset; - public int Size; - public int Extra; - } - + /// + /// One row of a block's entry table, as read back when unpacking. The pack direction hands + /// its rows to instead. + /// private struct TableEntry { public long Offset; @@ -32,6 +27,14 @@ private struct TableEntry public bool Compressed; } + private struct IdxEntry + { + public int Id; + public int Offset; + public int Size; + public int Extra; + } + // // IO shortcuts // @@ -221,9 +224,6 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi || type == FileType.SoundLegacyMul || type == FileType.MultiCollection; - int tableSize = version4Layout ? 0x64 : 0x3E8; - long firstTable = version4Layout ? 0x28 : 0x200; - // Stamped once per file, not per entry, so a repack of the same input is byte identical. The // shipped files vary it per entry (a build machine timestamp), but nothing reads it back. long entryHeaderTimestamp = DateTime.UtcNow.ToFileTimeUtc(); @@ -316,48 +316,16 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi }); } - // File header - writer.Write(0x50594D); // MYP - writer.Write(version4Layout ? 4 : 5); // version - writer.Write(0xFD23EC43); // format timestamp? - writer.Write(firstTable); // first table - writer.Write(tableSize); // table size - writer.Write(idxEntries.Count); // file count - writer.Write(0); // modified count? (wseq, version 5 only) - writer.Write(0); // ? (cseq, version 5 only) - writer.Write(0); // reserved - - // Padding - for (long i = 0x28; i < firstTable; ++i) - { - writer.Write((byte)0); - } - - int tableCount = (int)Math.Ceiling((double)idxEntries.Count / tableSize); - TableEntry[] tableEntries = new TableEntry[tableSize]; - string[] hashFormat = GetHashFormat(type, typeIndex, out int _); int totalEntries = idxEntries.Count; int lastReportedPct = -1; progress?.Report(0); - for (int i = 0; i < tableCount; ++i) + using (var container = new UopContainerWriter(writer.BaseStream, + version4Layout ? UopLayout.Version4 : UopLayout.Version5, idxEntries.Count, true)) { - long thisTable = writer.BaseStream.Position; - - int idxStart = i * tableSize; - int idxEnd = Math.Min((i + 1) * tableSize, idxEntries.Count); - - // Table header - writer.Write(idxEnd - idxStart); - writer.Write((long)0); // next table, filled in later - writer.Seek(_tableEntrySize * tableSize, SeekOrigin.Current); // table entries, filled in later - - // Data - int tableIdx = 0; - - for (int j = idxStart; j < idxEnd; ++j, ++tableIdx) + for (int j = 0; j < idxEntries.Count; ++j) { byte[] data; @@ -371,10 +339,9 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi data = reader.ReadBytes(idxEntries[j].Size); } - tableEntries[tableIdx].Offset = writer.BaseStream.Position; - tableEntries[tableIdx].DecompressedSize = data.Length; - tableEntries[tableIdx].CompressionFlag = (short)compressionFlag; - tableEntries[tableIdx].HeaderLength = 0; + byte[] payload = null; + ulong identifier; + int decompressedSize = data.Length; /* * Every entry of every shipped version 4 UOP carries a 12 byte header block in front @@ -388,8 +355,6 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi if (version4Layout) { entryHeader = BuildEntryHeader(entryHeaderTimestamp); - writer.Write(entryHeader); - tableEntries[tableIdx].HeaderLength = entryHeader.Length; } // hash 906142efe9fdb38a, which is file 0009834.tga (and no others, as 7.0.59.5) use a different name format (7 digits instead of 8); @@ -397,23 +362,22 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi // (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)) { - tableEntries[tableIdx].Identifier = HashLittle2(string.Format(hashFormat[1], idxEntries[j].Id)); + identifier = HashLittle2(string.Format(hashFormat[1], idxEntries[j].Id)); } else if (type == FileType.MultiCollection && idxEntries[j].Id == _housingBinSentinelId) { - tableEntries[tableIdx].Identifier = _housingBinIdentifier; + identifier = _housingBinIdentifier; } else { - tableEntries[tableIdx].Identifier = HashLittle2(string.Format(hashFormat[0], idxEntries[j].Id)); + identifier = HashLittle2(string.Format(hashFormat[0], idxEntries[j].Id)); } if (type == FileType.MultiCollection && idxEntries[j].Id != _housingBinSentinelId) { byte[] multiData = BuildMultiUopEntryFromMul(data, idxEntries[j].Id, componentTable); - tableEntries[tableIdx].DecompressedSize = multiData.Length; - tableEntries[tableIdx].Size = multiData.Length; + decompressedSize = multiData.Length; if (compressionFlag >= CompressionFlag.Zlib) { @@ -423,11 +387,9 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi throw new InvalidDataException($"Compression failed for multi {idxEntries[j].Id}."); } multiData = result.compressedData; - tableEntries[tableIdx].Size = multiData.Length; } - tableEntries[tableIdx].Hash = HashAdler32(multiData); - writer.Write(multiData); + payload = multiData; } else if (type == FileType.GumpartLegacyMul) { @@ -442,8 +404,7 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi gumpArtWriter.Write(height); gumpArtWriter.Write(data); - tableEntries[tableIdx].DecompressedSize += 8; - tableEntries[tableIdx].Size = tableEntries[tableIdx].DecompressedSize; + decompressedSize += 8; } if (compressionFlag == CompressionFlag.Mythic) @@ -460,8 +421,7 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi } } gumpArtData = gumpArtData2; - tableEntries[tableIdx].DecompressedSize = (int)gumpArtData.Length; - tableEntries[tableIdx].Size = tableEntries[tableIdx].DecompressedSize; + decompressedSize = (int)gumpArtData.Length; } if (compressionFlag >= CompressionFlag.Zlib) { @@ -471,17 +431,14 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi throw new InvalidDataException($"Compression failed for gump {idxEntries[j].Id}."); } - tableEntries[tableIdx].Size = result.compressedData.Length; gumpArtData = result.compressedData; } - tableEntries[tableIdx].Hash = HashAdler32(gumpArtData); - writer.Write(gumpArtData); + payload = gumpArtData; } else if (type == FileType.MultiCollection && idxEntries[j].Id == _housingBinSentinelId) { byte[] binData = data; - tableEntries[tableIdx].DecompressedSize = binData.Length; - tableEntries[tableIdx].Size = binData.Length; + decompressedSize = binData.Length; if (compressionFlag >= CompressionFlag.Zlib) { @@ -491,18 +448,16 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi throw new InvalidDataException("Compression failed for housing.bin."); } binData = result.compressedData; - tableEntries[tableIdx].Size = binData.Length; } - tableEntries[tableIdx].Hash = HashAdler32(binData); - writer.Write(binData); + payload = binData; } else { // Art / Map / Sound. The compression flag was already stamped on the entry above, so // the data has to actually be compressed here - otherwise the entry claims zlib over // raw bytes and neither the client nor FromUop can read it back. - byte[] payload = data; + byte[] storedPayload = data; if (compressionFlag == CompressionFlag.Mythic) { @@ -513,24 +468,20 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi if (compressionFlag == CompressionFlag.Zlib) { - var result = UopUtils.Compress(payload); + var result = UopUtils.Compress(storedPayload); if (!result.success) { throw new InvalidDataException($"Compression failed for chunk {idxEntries[j].Id}."); } - payload = result.compressedData; + storedPayload = result.compressedData; } - tableEntries[tableIdx].Size = payload.Length; - tableEntries[tableIdx].Hash = HashAdler32(payload); - writer.Write(payload); + payload = storedPayload; } - if (entryHeader != null) - { - tableEntries[tableIdx].Hash = HashAdler32(entryHeader); - } + container.WriteEntry(identifier, payload, decompressedSize, + (short)compressionFlag, entryHeader ?? ReadOnlySpan.Empty); if (totalEntries > 0) { @@ -543,41 +494,7 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi } } - long nextTable = writer.BaseStream.Position; - - // Go back and fix table header - if (i < tableCount - 1) - { - writer.BaseStream.Seek(thisTable + _nextBlockOffsetField, SeekOrigin.Begin); - writer.Write(nextTable); - } - else - { - writer.BaseStream.Seek(thisTable + _blockHeaderSize, SeekOrigin.Begin); - // No need to fix the next table address, it's the last - } - - // Table entries - tableIdx = 0; - - for (int j = idxStart; j < idxEnd; ++j, ++tableIdx) - { - writer.Write(tableEntries[tableIdx].Offset); - writer.Write(tableEntries[tableIdx].HeaderLength); // header length - writer.Write(tableEntries[tableIdx].Size); // compressed size - writer.Write(tableEntries[tableIdx].DecompressedSize); // decompressed size - writer.Write(tableEntries[tableIdx].Identifier); - writer.Write(tableEntries[tableIdx].Hash); - writer.Write(tableEntries[tableIdx].CompressionFlag); // compression method - } - - // Fill remainder with empty entries - for (; tableIdx < tableSize; ++tableIdx) - { - writer.Write(_emptyTableEntry); - } - - writer.BaseStream.Seek(nextTable, SeekOrigin.Begin); + container.Complete(); } } } @@ -606,20 +523,6 @@ private static void ReportComponentSidecarProblems(MultiComponentSidecar.Table c /// private const int _uoahsArtIdxEntryCount = 0x13FDC; - /// - /// On disk size of one entry in a block's entry table: - /// offset(8) headerLength(4) compressedSize(4) decompressedSize(4) identifier(8) hash(4) flag(2). - /// - private const int _tableEntrySize = 8 + 4 + 4 + 4 + 8 + 4 + 2; - - /// Size of a block header: usedEntryCount(4) nextBlockOffset(8). - private const int _blockHeaderSize = 4 + 8; - - /// Offset of the next-block pointer inside a block header. - private const int _nextBlockOffsetField = 4; - - private static readonly byte[] _emptyTableEntry = new byte[_tableEntrySize]; - /// /// The 12 byte block the client writes in front of every entry payload in a version 4 UOP: /// two constant shorts (3, 8) followed by a FILETIME. The (3, 8) pair holds across every @@ -954,27 +857,54 @@ private static void CheckAndFixMapFiles(string outFile, FileType type, int typeI return; } - int expectedSize = GetExpectedMapFileSize(typeIndex); - - if (expectedSize == 0) - { - // do nothing. Map file is wrong, or it's some weird size we don't know about - return; - } - using (var mapFile = File.Open(outFile, FileMode.Open, FileAccess.ReadWrite)) { + long blocks = mapFile.Length / MapUopWriter.MapBlockSize; + + /* + * Every shipped container carries one block more than its facet holds, and our own + * writer reproduces that, so the usual overshoot is a single block. Recognising it + * against the known shapes rather than against one hardcoded size per facet is what + * lets a pre-T2A 6144-wide map0 come back out at its own size instead of keeping the + * padding because it does not match the modern 7168-wide one. + */ + foreach (MapSize candidate in MapSizes.Candidates(typeIndex)) + { + if (candidate.BlockCount == blocks) + { + return; + } + } + + foreach (MapSize candidate in MapSizes.Candidates(typeIndex)) + { + if (candidate.BlockCount == blocks - 1) + { + mapFile.SetLength(candidate.BlockCount * MapUopWriter.MapBlockSize); + + return; + } + } + + int expectedSize = GetExpectedMapFileSize(typeIndex); + + if (expectedSize == 0) + { + // Some shape we do not know about. Leave it alone rather than guess. + return; + } + long sizeDiff = mapFile.Length - expectedSize; + if (sizeDiff <= 0) { return; } /* - * The overshoot we are here to remove is chunk padding: the UOP stores the map in 0xC4000 byte - * chunks, so the last one runs past the end of the facet by less than a chunk (752 640 bytes for - * map2, 1 372 for map4, nothing for map0/1). Anything larger is a custom map that is genuinely - * bigger than the stock facet, and truncating it would throw away real terrain. + * Anything left is chunk padding from a packer that rounded the last chunk up. More + * than a chunk of it means a custom map genuinely bigger than the stock facet, and + * truncating that would throw away real terrain. */ if (sizeDiff >= _mapChunkSize) { @@ -992,16 +922,9 @@ private static void CheckAndFixMapFiles(string outFile, FileType type, int typeI private static int GetExpectedMapFileSize(int typeIndex) { - return typeIndex switch - { - 0 => 89_915_392, - 1 => 89_915_392, - 2 => 11_289_600, - 3 => 16_056_320, - 4 => 6_421_156, - 5 => 16_056_320, - _ => 0 - }; + MapSize size = MapSizes.Fallback(typeIndex); + + return size.IsEmpty ? 0 : (int)(size.BlockCount * 196); } // @@ -1054,20 +977,6 @@ private static string[] GetHashFormat(FileType type, int typeIndex, out int maxI /// private static ulong HashLittle2(string input) => UopUtils.HashFileName(input); - private static uint HashAdler32(byte[] d) - { - uint a = 1; - uint b = 0; - - for (int i = 0; i < d.Length; i++) - { - a = (a + d[i]) % 65521; - b = (b + a) % 65521; - } - - return b << 16 | a; - } - /* * MUL row layout: [itemId:2][x:2][y:2][z:2][flag:4][extra:4] = 16 bytes (High Seas / 7.0.9+) * UOP tile: [itemId:2][x:2][y:2][z:2][flag:2][componentCount:4] = 14 bytes, followed by @@ -1230,4 +1139,4 @@ private static byte[] BuildMultiUopEntryFromMul(byte[] mulData, int multiId, Mul return result; } } -} +} \ No newline at end of file diff --git a/Ultima/Uop/MapUopReader.cs b/Ultima/Uop/MapUopReader.cs new file mode 100644 index 00000000..36c09f08 --- /dev/null +++ b/Ultima/Uop/MapUopReader.cs @@ -0,0 +1,165 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.IO; +using Ultima.Helpers; + +namespace Ultima.Uop +{ + /// + /// One entry of a map UOP, resolved to where its payload actually starts in the file. + /// + public readonly struct MapUopEntry + { + public MapUopEntry(long offset, int length) + { + Offset = offset; + Length = length; + } + + /// Byte offset of the payload, past the entry header. + public long Offset { get; } + + /// Payload length in bytes. Map entries are stored, so this is also the decompressed length. + public int Length { get; } + + public bool IsPresent => Length > 0; + } + + /// + /// Reads the entry table of a map{N}LegacyMUL.uop. + /// + /// + /// Originally written by Wyatt (c) www.ruosi.org as part of TileMatrix; lifted out so map size + /// detection and the writer's verification can walk a container without building a TileMatrix. + /// Entries are addressed by the hash of their name, not by their position in the table, so a + /// container whose entries sit in a different order than their chunk index still reads correctly. + /// + public static class MapUopReader + { + private const int Magic = 0x50594D; + + /// + /// Derives the entry-name pattern from a container's file name, e.g. map0legacymul. + /// + public static string PatternFromPath(string path) + { + var info = new FileInfo(path); + + return info.Name.Replace(info.Extension, string.Empty).ToLowerInvariant(); + } + + public static MapUopEntry[] ReadEntryTable(string path) + { + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + return ReadEntryTable(stream, PatternFromPath(path)); + } + } + + /// + /// Walks the block list and returns one entry per chunk index, in chunk order. + /// + public static MapUopEntry[] ReadEntryTable(Stream stream, string pattern) + { + var reader = new BinaryReader(stream); + + stream.Seek(0, SeekOrigin.Begin); + + if (reader.ReadInt32() != Magic) + { + throw new ArgumentException($"{pattern}: not a UOP file."); + } + + reader.ReadInt64(); // version + signature + long nextBlock = reader.ReadInt64(); + reader.ReadInt32(); // block capacity + int count = reader.ReadInt32(); + + var entries = new MapUopEntry[count]; + var hashes = new Dictionary(count); + + for (int i = 0; i < count; i++) + { + hashes.TryAdd(UopUtils.HashFileName($"build/{pattern}/{i:D8}.dat"), i); + } + + stream.Seek(nextBlock, SeekOrigin.Begin); + + do + { + int filesCount = reader.ReadInt32(); + nextBlock = reader.ReadInt64(); + + for (int i = 0; i < filesCount; i++) + { + long offset = reader.ReadInt64(); + int headerLength = reader.ReadInt32(); + int compressedLength = reader.ReadInt32(); + reader.ReadInt32(); // decompressed length - equal to the compressed one while stored + ulong hash = reader.ReadUInt64(); + reader.ReadUInt32(); // Adler32 + short flag = reader.ReadInt16(); + + if (offset == 0) + { + continue; + } + + // Map blocks are addressed by slicing straight into the file, so only stored + // entries can be handled. Every map*LegacyMUL.uop EA ships uses flag 0, but the + // UOP packer can be told to zlib them. + if (flag != 0) + { + throw new NotSupportedException( + $"{pattern}: compressed map UOP entries are not supported " + + $"(entry uses compression flag {flag}). Repack the map with compression set to None."); + } + + if (!hashes.TryGetValue(hash, out int idx)) + { + throw new ArgumentException( + $"File with hash 0x{hash:X8} was not found in hashes dictionary! EA Mythic changed UOP format!"); + } + + if (idx < 0 || idx >= entries.Length) + { + throw new IndexOutOfRangeException( + "hashes dictionary and files collection have different count of entries!"); + } + + entries[idx] = new MapUopEntry(offset + headerLength, compressedLength); + } + } + while (stream.Seek(nextBlock, SeekOrigin.Begin) != 0); + + return entries; + } + + /// + /// 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. + /// + public static long TotalPayloadLength(MapUopEntry[] entries) + { + long total = 0; + + foreach (MapUopEntry entry in entries) + { + total += entry.Length; + } + + return total; + } + } +} \ No newline at end of file diff --git a/Ultima/Uop/MapUopWriter.cs b/Ultima/Uop/MapUopWriter.cs new file mode 100644 index 00000000..7a66bbbd --- /dev/null +++ b/Ultima/Uop/MapUopWriter.cs @@ -0,0 +1,196 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.IO; +using Ultima.Helpers; + +namespace Ultima.Uop +{ + /// + /// What to put in the extra block every shipped map container carries past the end of its facet. + /// + public enum MapTrailingBlock + { + /// Write only the facet's own blocks. Smaller, and the client reads it fine. + None, + + /// Write one empty block past the end, matching the shape of the shipped files. + Empty, + + /// Write a block supplied by the caller, for passing a source container's through. + Supplied + } + + /// + /// Writes a map{N}LegacyMUL.uop one land block at a time, so a caller that is already producing + /// blocks does not have to spill a whole map{N}.mul to disk first. + /// + /// + /// Blocks are packed 4096 to an entry, uncompressed, under names of the form + /// build/map{N}legacymul/00000000.dat. Every shipped facet carries exactly one block more than + /// its grid holds, which is why the trailing block is written by default. The reader never + /// addresses it. + /// + public sealed class MapUopWriter : IDisposable + { + public const int MapBlockSize = 196; + + /// Land blocks per container entry. + public const int ChunkBlocks = 4096; + + /// Bytes per container entry, 0xC4000. + public const int ChunkBytes = ChunkBlocks * MapBlockSize; + + private readonly UopContainerWriter _container; + private readonly Stream _output; + private readonly bool _leaveOpen; + private readonly string _pattern; + private readonly long _totalBlocks; + private readonly byte[] _chunk = new byte[ChunkBytes]; + private readonly byte[] _trailing = new byte[MapBlockSize]; + + private readonly MapTrailingBlock _trailingMode; + + private int _chunkLength; + private int _chunkIndex; + private long _blocksWritten; + private bool _completed; + + public MapUopWriter(Stream output, int mapId, long blockCount, + MapTrailingBlock trailing = MapTrailingBlock.Empty, bool leaveOpen = false) + { + if (blockCount < 0) + { + throw new ArgumentOutOfRangeException(nameof(blockCount)); + } + + _output = output ?? throw new ArgumentNullException(nameof(output)); + _leaveOpen = leaveOpen; + _trailingMode = trailing; + _pattern = $"map{mapId}legacymul"; + + BlockCount = blockCount; + _totalBlocks = blockCount + (trailing == MapTrailingBlock.None ? 0 : 1); + + long bytes = _totalBlocks * MapBlockSize; + int entryCount = (int)((bytes + ChunkBytes - 1) / ChunkBytes); + + _container = new UopContainerWriter(output, UopLayout.Version5, entryCount, true); + } + + /// Blocks the facet itself holds, not counting the trailing one. + public long BlockCount { get; } + + /// + /// Supplies the block written past the end of the facet. Only used with + /// . + /// + public void SetTrailingBlock(ReadOnlySpan block) + { + if (block.Length != MapBlockSize) + { + throw new ArgumentException($"A land block is {MapBlockSize} bytes.", nameof(block)); + } + + block.CopyTo(_trailing); + } + + /// + /// Appends one 196-byte land block: a 4-byte header followed by 64 three-byte tiles. + /// Blocks must arrive in index order, blockX * blockHeight + blockY. + /// + public void WriteBlock(ReadOnlySpan block) + { + if (block.Length != MapBlockSize) + { + throw new ArgumentException($"A land block is {MapBlockSize} bytes.", nameof(block)); + } + + if (_completed) + { + throw new InvalidOperationException("The container has already been completed."); + } + + if (_blocksWritten >= BlockCount) + { + throw new InvalidOperationException( + $"More blocks were written than the {BlockCount:N0} this facet holds."); + } + + Append(block); + ++_blocksWritten; + } + + public void Complete() + { + if (_completed) + { + return; + } + + if (_blocksWritten != BlockCount) + { + throw new InvalidOperationException( + $"{_blocksWritten:N0} blocks were written but the facet holds {BlockCount:N0}."); + } + + if (_trailingMode != MapTrailingBlock.None) + { + Append(_trailing); + } + + if (_chunkLength > 0) + { + FlushChunk(); + } + + _container.Complete(); + _output.Flush(); + _completed = true; + } + + public void Dispose() + { + _container.Dispose(); + + if (!_leaveOpen) + { + _output.Dispose(); + } + } + + private void Append(ReadOnlySpan block) + { + block.CopyTo(_chunk.AsSpan(_chunkLength)); + _chunkLength += MapBlockSize; + + if (_chunkLength == ChunkBytes) + { + FlushChunk(); + } + } + + private void FlushChunk() + { + ReadOnlySpan payload = _chunk.AsSpan(0, _chunkLength); + + _container.WriteEntry( + UopUtils.HashFileName($"build/{_pattern}/{_chunkIndex:D8}.dat"), + payload, + _chunkLength, + 0); + + ++_chunkIndex; + _chunkLength = 0; + } + } +} \ No newline at end of file diff --git a/UoFiddler.Plugin.UopPacker/Classes/MultiComponentSidecar.cs b/Ultima/Uop/MultiComponentSidecar.cs similarity index 77% rename from UoFiddler.Plugin.UopPacker/Classes/MultiComponentSidecar.cs rename to Ultima/Uop/MultiComponentSidecar.cs index dde978b1..c1a9bcb9 100644 --- a/UoFiddler.Plugin.UopPacker/Classes/MultiComponentSidecar.cs +++ b/Ultima/Uop/MultiComponentSidecar.cs @@ -15,7 +15,7 @@ using System.IO; using System.Text; -namespace UoFiddler.Plugin.UopPacker.Classes +namespace Ultima.Uop { /// /// Side storage for the per tile component ids carried by MultiCollection.uop entries. @@ -25,16 +25,10 @@ namespace UoFiddler.Plugin.UopPacker.Classes /// componentCount 32 bit component ids. A multi.mul row is a fixed 16 bytes and has nowhere to put /// those ids, so they are written next to the mul/idx pair instead and merged back in when packing. /// - /// In the shipped client file 3200 of 186695 tiles carry ids, drawn from a shared vocabulary of only - /// 59 values (119404 - 119462) reused across 304 multis, so ids for newly authored multis can be - /// written by hand. - /// - /// A component id marks a tile's interactive role within the multi, not its graphic and not a cliloc: - /// every tile carrying 119405 is a "tiller man" in tiledata, every 119406 is a "hatch", 119404 is the - /// hull (mast/deck), 119407/119408 are the planks, and 119453/119454 sit on doors. All 24 boat multis - /// (6 hulls x 4 facings) share the same 119404-119408 signature, and 1121 of the 1273 item ids that - /// carry a component always carry the same one. That is why dropping them breaks a client: a boat - /// without its tiller man cannot be steered and a house door stops being a door. + /// A component id marks a tile's interactive role within the multi, not its graphic and not a + /// cliloc - tiller man, hatch, hull, plank, door. They come from a small shared vocabulary reused + /// across multis. Dropping them breaks a client: a boat without its tiller man cannot be steered + /// and a house door stops being a door. /// public static class MultiComponentSidecar { @@ -259,6 +253,8 @@ public sealed class Table private readonly Dictionary<(int MultiId, int TileIndex), Row> _rows; private readonly List _problems; + private Dictionary<(int MultiId, ushort ItemId, short X, short Y, short Z), uint[]> _byIdentity; + internal Table(string path, Dictionary<(int MultiId, int TileIndex), Row> rows, List malformed) { Path = path; @@ -289,26 +285,81 @@ public int ComponentCount public IReadOnlyList Problems => _problems; /// - /// Component ids for a tile, or an empty span when the sidecar has no entry for it. A row whose - /// itemId/x/y/z disagree with the mul row is dropped and recorded in - - /// that happens when a multi's tile list was re-authored after the sidecar was written. + /// Component ids for a tile, or an empty span when the sidecar describes no such tile. The row + /// at the tile's index is used when its itemId/x/y/z agree; otherwise the tile is looked up by + /// that identity instead, which covers a mul written in a different tile order from the sidecar. + /// Only when neither finds it are the ids dropped and the mismatch recorded in + /// - that happens when a multi's tile list was re-authored after the + /// sidecar was written. /// public uint[] GetComponentIds(int multiId, int tileIndex, ushort itemId, short x, short y, short z) { - if (!_rows.TryGetValue((multiId, tileIndex), out Row row)) + bool found = _rows.TryGetValue((multiId, tileIndex), out Row row); + + if (found && row.ItemId == itemId && row.X == x && row.Y == y && row.Z == z) + { + return row.ComponentIds; + } + + // A sidecar taken from a uop lists tiles in that file's order, while the mul being packed + // is written in the editor's, and the two need not agree even when nothing was edited. So + // a tile whose index no longer lines up is looked up by what it is instead of where it sits. + uint[] byIdentity = LookupByIdentity(multiId, itemId, x, y, z); + + if (byIdentity != null) { - return _none; + return byIdentity; } - if (row.ItemId != itemId || row.X != x || row.Y != y || row.Z != z) + if (found) { _problems.Add( $"multi {multiId} tile {tileIndex}: sidecar describes 0x{row.ItemId:X4} at ({row.X},{row.Y},{row.Z}) " + $"but multi.mul has 0x{itemId:X4} at ({x},{y},{z}) - component ids dropped"); - return _none; } - return row.ComponentIds; + return _none; + } + + /// + /// Component ids for the one row describing this tile, or null when no row does or when more + /// than one does and they disagree - an ambiguous match is no better than none. + /// + private uint[] LookupByIdentity(int multiId, ushort itemId, short x, short y, short z) + { + _byIdentity ??= BuildIdentityIndex(); + + return _byIdentity.TryGetValue((multiId, itemId, x, y, z), out uint[] ids) ? ids : null; + } + + private Dictionary<(int MultiId, ushort ItemId, short X, short Y, short Z), uint[]> BuildIdentityIndex() + { + var index = new Dictionary<(int MultiId, ushort ItemId, short X, short Y, short Z), uint[]>(); + var ambiguous = new List<(int MultiId, ushort ItemId, short X, short Y, short Z)>(); + + foreach (KeyValuePair<(int MultiId, int TileIndex), Row> pair in _rows) + { + Row row = pair.Value; + var key = (pair.Key.MultiId, row.ItemId, row.X, row.Y, row.Z); + + if (!index.TryGetValue(key, out uint[] existing)) + { + index[key] = row.ComponentIds; + continue; + } + + if (!existing.AsSpan().SequenceEqual(row.ComponentIds)) + { + ambiguous.Add(key); + } + } + + foreach (var key in ambiguous) + { + index.Remove(key); + } + + return index; } } @@ -324,4 +375,4 @@ private static bool TryParse(string field, out long value) return long.TryParse(span, NumberStyles.Integer, CultureInfo.InvariantCulture, out value); } } -} +} \ No newline at end of file diff --git a/Ultima/Uop/UopContainerWriter.cs b/Ultima/Uop/UopContainerWriter.cs new file mode 100644 index 00000000..502431a3 --- /dev/null +++ b/Ultima/Uop/UopContainerWriter.cs @@ -0,0 +1,260 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.IO; +using Ultima.Helpers; + +namespace Ultima.Uop +{ + /// + /// The two container shapes the shipped client files come in. + /// + /// + /// Which shape a file type uses depends on the client build rather than on the type alone. + /// Version 4 holds 100 entries per block with the first block right behind the 0x28 byte header + /// and a 12 byte header in front of every entry payload - MultiCollection and tileart in every + /// build that has them, sound from 7.0.65.4 on, gumpart from 7.0.114.4 on. + /// Version 5 holds 1000 entries per block after a gap, and its entry headers are 135..137 bytes + /// whose tail is high entropy and cannot be reproduced, so we write none - art and maps in every + /// build, sound and gumpart in the older ones. + /// + public enum UopLayout + { + Version4, + Version5 + } + + /// + /// Writes the framing of a UOP container: the file header, the linked list of blocks and each + /// block's entry table. Callers supply one payload per entry and own whatever transform or + /// compression that payload needed. + /// + public sealed class UopContainerWriter : IDisposable + { + private const int Magic = 0x50594D; + private const uint Signature = 0xFD23EC43; + private const int FileHeaderSize = 0x28; + + /// + /// On disk size of one entry in a block's entry table: + /// offset(8) headerLength(4) compressedSize(4) decompressedSize(4) identifier(8) hash(4) flag(2). + /// + internal const int TableEntrySize = 8 + 4 + 4 + 4 + 8 + 4 + 2; + + /// Size of a block header: usedEntryCount(4) nextBlockOffset(8). + internal const int BlockHeaderSize = 4 + 8; + + /// Offset of the next-block pointer inside a block header. + internal const int NextBlockOffsetField = 4; + + private static readonly byte[] _emptyTableEntry = new byte[TableEntrySize]; + + private readonly BinaryWriter _writer; + private readonly bool _leaveOpen; + private readonly int _blockCapacity; + private readonly int _entryCount; + private readonly TableEntry[] _tableEntries; + + private int _entriesWritten; + private int _indexInBlock; + private long _blockStart = -1; + private bool _completed; + + public UopContainerWriter(Stream output, UopLayout layout, int entryCount, bool leaveOpen = false) + { + if (output == null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (entryCount < 0) + { + throw new ArgumentOutOfRangeException(nameof(entryCount)); + } + + _writer = new BinaryWriter(output); + _leaveOpen = leaveOpen; + _entryCount = entryCount; + _blockCapacity = layout == UopLayout.Version4 ? 0x64 : 0x3E8; + _tableEntries = new TableEntry[_blockCapacity]; + + long firstBlock = layout == UopLayout.Version4 ? 0x28 : 0x200; + + _writer.Write(Magic); + _writer.Write(layout == UopLayout.Version4 ? 4 : 5); + _writer.Write(Signature); + _writer.Write(firstBlock); + _writer.Write(_blockCapacity); + _writer.Write(entryCount); + _writer.Write(0); // modified count (wseq, version 5 only) + _writer.Write(0); // cseq, version 5 only + _writer.Write(0); // reserved + + for (long i = FileHeaderSize; i < firstBlock; ++i) + { + _writer.Write((byte)0); + } + } + + public int BlockCapacity => _blockCapacity; + + /// + /// Appends one entry. The entry's hash field is the Adler32 of + /// when one is given and of the payload otherwise, which is what the shipped files carry. + /// + /// + /// Size of the payload before compression. Equal to the payload length for a stored entry. + /// + public void WriteEntry(ulong identifier, ReadOnlySpan payload, int decompressedSize, + short compressionFlag, ReadOnlySpan entryHeader = default) + { + if (_completed) + { + throw new InvalidOperationException("The container has already been completed."); + } + + if (_entriesWritten >= _entryCount) + { + throw new InvalidOperationException( + $"More entries were written than the {_entryCount} declared in the header."); + } + + if (_blockStart < 0) + { + BeginBlock(); + } + + ref TableEntry entry = ref _tableEntries[_indexInBlock]; + + entry.Offset = _writer.BaseStream.Position; + entry.HeaderLength = entryHeader.Length; + entry.Size = payload.Length; + entry.DecompressedSize = decompressedSize; + entry.Identifier = identifier; + entry.CompressionFlag = compressionFlag; + + if (!entryHeader.IsEmpty) + { + _writer.Write(entryHeader); + } + + _writer.Write(payload); + + entry.Hash = UopUtils.HashAdler32(entryHeader.IsEmpty ? payload : entryHeader); + + ++_entriesWritten; + ++_indexInBlock; + + if (_indexInBlock == _blockCapacity) + { + EndBlock(_entriesWritten < _entryCount); + } + } + + /// + /// Closes the trailing block and back-fills its entry table. Must be called before the + /// stream is used for anything else. + /// + public void Complete() + { + if (_completed) + { + return; + } + + if (_entriesWritten != _entryCount) + { + throw new InvalidOperationException( + $"{_entriesWritten} entries were written but the header declares {_entryCount}."); + } + + if (_blockStart >= 0) + { + EndBlock(false); + } + + _writer.Flush(); + _completed = true; + } + + public void Dispose() + { + if (!_leaveOpen) + { + _writer.Dispose(); + } + } + + private void BeginBlock() + { + _blockStart = _writer.BaseStream.Position; + _indexInBlock = 0; + + int used = Math.Min(_blockCapacity, _entryCount - _entriesWritten); + + _writer.Write(used); + _writer.Write((long)0); // next block, back-filled once this one is full + _writer.Seek(TableEntrySize * _blockCapacity, SeekOrigin.Current); + } + + private void EndBlock(bool moreToCome) + { + long afterBlock = _writer.BaseStream.Position; + + if (moreToCome) + { + _writer.BaseStream.Seek(_blockStart + NextBlockOffsetField, SeekOrigin.Begin); + _writer.Write(afterBlock); + } + else + { + _writer.BaseStream.Seek(_blockStart + BlockHeaderSize, SeekOrigin.Begin); + } + + for (int i = 0; i < _indexInBlock; ++i) + { + _writer.Write(_tableEntries[i].Offset); + _writer.Write(_tableEntries[i].HeaderLength); + _writer.Write(_tableEntries[i].Size); + _writer.Write(_tableEntries[i].DecompressedSize); + _writer.Write(_tableEntries[i].Identifier); + _writer.Write(_tableEntries[i].Hash); + _writer.Write(_tableEntries[i].CompressionFlag); + } + + for (int i = _indexInBlock; i < _blockCapacity; ++i) + { + _writer.Write(_emptyTableEntry); + } + + _writer.BaseStream.Seek(afterBlock, SeekOrigin.Begin); + + _blockStart = -1; + + if (moreToCome) + { + BeginBlock(); + } + } + + private struct TableEntry + { + public long Offset; + public int HeaderLength; + public int Size; + public int DecompressedSize; + public ulong Identifier; + public uint Hash; + public short CompressionFlag; + } + } +} \ No newline at end of file diff --git a/Ultima/Uop/UopFileNames.cs b/Ultima/Uop/UopFileNames.cs new file mode 100644 index 00000000..1422ce68 --- /dev/null +++ b/Ultima/Uop/UopFileNames.cs @@ -0,0 +1,82 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; + +namespace Ultima.Uop +{ + /// + /// The names the client gives each packable file type, and the compression its shipped UOPs use. + /// One table, so a save, a pack and a batch repack cannot disagree about what to write. + /// + public static class UopFileNames + { + /// + /// The conventional file names for a type: the mul, its idx (null for maps, which have none) + /// and the uop that replaces the pair. + /// + public static (string Mul, string Idx, string Uop) For(FileType type, int mapIndex = 0) + { + return type switch + { + FileType.ArtLegacyMul => ("art.mul", "artidx.mul", "artLegacyMUL.uop"), + FileType.GumpartLegacyMul => ("gumpart.mul", "gumpidx.mul", "gumpartLegacyMUL.uop"), + FileType.MapLegacyMul => ($"map{mapIndex}.mul", null, $"map{mapIndex}LegacyMUL.uop"), + FileType.SoundLegacyMul => ("sound.mul", "soundidx.mul", "soundLegacyMUL.uop"), + FileType.MultiCollection => ("multi.mul", "multi.idx", "MultiCollection.uop"), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown file type.") + }; + } + + /// + /// Asks the loaded client for the type's uop. is keyed case + /// insensitively, so the conventional name doubles as the lookup key. + /// + public static string ClientUopPath(FileType type, int mapIndex = 0) + { + return Files.GetFilePath(For(type, mapIndex).Uop); + } + + /// + /// Asks the loaded client for the type's mul. + /// + public static string ClientMulPath(FileType type, int mapIndex = 0) + { + return Files.GetFilePath(For(type, mapIndex).Mul); + } + + /// + /// What the shipped clients compress a type with. + /// + /// + /// Every entry of every shipped MultiCollection.uop is zlib compressed: packing it uncompressed + /// produces a file several times larger than the original, and Mythic is not a valid compression + /// for this type at all, so the choice is fixed rather than merely defaulted + /// (see ). + /// Every art, map and sound entry of every shipped client is stored uncompressed, and UOFiddler's + /// own map reader can only address stored entries. Gumpart is stored in the shipped files too, but + /// the client does accept zlib and Mythic there, so that one is a default rather than a rule. + /// + public static CompressionFlag DefaultCompression(FileType type) + { + return type == FileType.MultiCollection ? CompressionFlag.Zlib : CompressionFlag.None; + } + + /// + /// True when the type accepts only and the caller must not offer + /// a choice. + /// + public static bool IsCompressionFixed(FileType type) + { + return type == FileType.MultiCollection; + } + } +} \ No newline at end of file diff --git a/UoFiddler.Controls/Classes/ClientFileSaveCommand.cs b/UoFiddler.Controls/Classes/ClientFileSaveCommand.cs new file mode 100644 index 00000000..a87d79ba --- /dev/null +++ b/UoFiddler.Controls/Classes/ClientFileSaveCommand.cs @@ -0,0 +1,221 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Windows.Forms; +using Microsoft.Extensions.Logging; +using Ultima.Helpers; +using Ultima.Uop; +using UoFiddler.Controls.Forms; + +namespace UoFiddler.Controls.Classes +{ + /// + /// One save, from the format question through to the dialog that says where the files went. + /// The five tabs whose file type the client ships in either container all go through here. + /// + public static class ClientFileSaveCommand + { + /// + /// Resolves the container, writes the files and reports the outcome. Returns false when the + /// user cancelled or the save failed, in which case the dirty flag is left alone. + /// + /// Control the dialogs belong to. + /// What is being saved. + /// + /// The domain's own save method. It is handed a directory to write its mul and idx into, which + /// is not necessarily the output directory - a uop save writes the mul somewhere temporary. + /// + /// Key in to clear on success. + /// + /// Optional progress dialog for the slow types. It is created before the save and disposed after, + /// and its caption carries the pack percentage once the mul has been written. + /// + public static bool Run(Control owner, FileType type, Action writeMul, string dirtyKey = null, + int mapIndex = 0, Func createProgress = null) + { + string outputDirectory = Options.OutputPath; + + if (!SaveFormatResolver.TryResolve(owner, type, outputDirectory, out ContainerFormat format, mapIndex)) + { + return false; + } + + if (!ConfirmPreflight(owner, type, format, mapIndex)) + { + return false; + } + + string uopName = UopFileNames.For(type, mapIndex).Uop; + ClientFileSaveResult result; + + try + { + using (new WaitCursorScope(owner)) + { + ProgressBarDialog progressDialog = createProgress?.Invoke(); + + try + { + IProgress packProgress = progressDialog == null || format != ContainerFormat.Uop + ? null + : new CaptionProgress(progressDialog, uopName); + + result = ClientFileSaver.Save(type, outputDirectory, format, writeMul, mapIndex, packProgress); + } + finally + { + progressDialog?.Dispose(); + } + } + } + catch (Exception error) + { + ShowError(owner, type, error); + return false; + } + + if (!string.IsNullOrEmpty(dirtyKey)) + { + Options.ChangedUltimaClass[dirtyKey] = false; + } + + FileSavedDialog.Show(owner?.FindForm(), outputDirectory, BuildMessage(result)); + + return true; + } + + /// + /// Puts anything the save would quietly cost in front of the user before it runs. Answering no + /// leaves the output folder untouched. + /// + private static bool ConfirmPreflight(Control owner, FileType type, ContainerFormat format, int mapIndex) + { + IReadOnlyList concerns; + + try + { + concerns = ClientFileSaver.Preflight(type, format, mapIndex); + } + catch (Exception error) + { + ShowError(owner, type, error); + return false; + } + + if (concerns.Count == 0) + { + return true; + } + + var sb = new StringBuilder(); + + foreach (string concern in concerns) + { + if (sb.Length > 0) + { + sb.AppendLine().AppendLine(); + } + + sb.Append(concern); + } + + sb.AppendLine().AppendLine().Append("Save anyway?"); + + return MessageBox.Show(owner?.FindForm(), sb.ToString(), "Save", MessageBoxButtons.YesNo, + MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2) == DialogResult.Yes; + } + + private static string BuildMessage(ClientFileSaveResult result) + { + var sb = new StringBuilder(); + + sb.Append("Saved ").Append(string.Join(", ", result.FilesWritten)).Append('.'); + + foreach (string warning in result.Warnings) + { + sb.AppendLine().AppendLine().Append(warning); + } + + return sb.ToString(); + } + + /// + /// Shows what actually went wrong rather than a bare framework message, and logs the rest. + /// + private static void ShowError(Control owner, FileType type, Exception error) + { + AppLog.For(typeof(ClientFileSaveCommand)).LogError(error, "Saving {Type} failed.", type); + + var sb = new StringBuilder(); + + for (Exception current = error; current != null; current = current.InnerException) + { + sb.AppendLine(current.Message); + + if (current.InnerException != null) + { + sb.AppendLine(); + } + } + + sb.AppendLine(); + sb.AppendLine(error.GetType().FullName); + + string where = error.StackTrace? + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault()? + .Trim(); + + if (!string.IsNullOrEmpty(where)) + { + sb.AppendLine(where); + } + + MessageBox.Show(owner?.FindForm(), sb.ToString(), "Save failed", MessageBoxButtons.OK, + MessageBoxIcon.Error); + } + + /// + /// Puts the pack percentage in the progress dialog's caption. The save blocks the UI thread, so + /// the report has to be applied and repainted where it happens rather than posted back. + /// + private sealed class CaptionProgress : IProgress + { + private readonly ProgressBarDialog _dialog; + private readonly string _fileName; + + private int _lastReported = -1; + + public CaptionProgress(ProgressBarDialog dialog, string fileName) + { + _dialog = dialog; + _fileName = fileName; + } + + public void Report(int value) + { + if (value == _lastReported || _dialog.IsDisposed) + { + return; + } + + _lastReported = value; + + _dialog.Text = $"Packing {_fileName} - {value}%"; + _dialog.Refresh(); + } + } + } +} \ No newline at end of file diff --git a/UoFiddler.Controls/Classes/ClientFileSaveFormat.cs b/UoFiddler.Controls/Classes/ClientFileSaveFormat.cs new file mode 100644 index 00000000..19b087e7 --- /dev/null +++ b/UoFiddler.Controls/Classes/ClientFileSaveFormat.cs @@ -0,0 +1,78 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System.Collections.Generic; + +namespace UoFiddler.Controls.Classes +{ + /// + /// What a save writes for the file types the client ships in either container. + /// + public enum ClientFileSaveFormat + { + /// Whatever the loaded client keeps that type in. Asked per type, not per client. + FollowSource, + + /// Always the mul and idx pair, even on a uop client. + Mul, + + /// Always the uop, even on a mul client. + Uop, + + /// Choose at save time. + Ask + } + + public static class ClientFileSaveFormats + { + public static IReadOnlyList All { get; } = new[] + { + ClientFileSaveFormat.FollowSource, + ClientFileSaveFormat.Mul, + ClientFileSaveFormat.Uop, + ClientFileSaveFormat.Ask + }; + + /// Position of a format in , for driving a combo box. + public static int IndexOf(ClientFileSaveFormat format) + { + for (int i = 0; i < All.Count; ++i) + { + if (All[i] == format) + { + return i; + } + } + + return 0; + } + + /// + /// Which entry of a "the same format as this client / .mul / .uop" list the option opens on. + /// Ask opens on the source format: a form carrying its own format control asks by being there. + /// + public static int DefaultIndex(ClientFileSaveFormat format) => format switch + { + ClientFileSaveFormat.Mul => 1, + ClientFileSaveFormat.Uop => 2, + _ => 0 + }; + + public static string DisplayName(ClientFileSaveFormat format) => format switch + { + ClientFileSaveFormat.FollowSource => "The same format as this client", + ClientFileSaveFormat.Mul => "Always .mul and .idx", + ClientFileSaveFormat.Uop => "Always .uop", + ClientFileSaveFormat.Ask => "Ask every time I save", + _ => format.ToString() + }; + } +} \ No newline at end of file diff --git a/UoFiddler.Controls/Classes/DynamicItemsConfig.cs b/UoFiddler.Controls/Classes/DynamicItemsConfig.cs index fb1c928e..290f336c 100644 --- a/UoFiddler.Controls/Classes/DynamicItemsConfig.cs +++ b/UoFiddler.Controls/Classes/DynamicItemsConfig.cs @@ -16,6 +16,7 @@ using System.Xml; using Microsoft.Extensions.Logging; using Ultima; +using Ultima.Helpers; namespace UoFiddler.Controls.Classes { diff --git a/UoFiddler.Controls/Classes/FormLayout.cs b/UoFiddler.Controls/Classes/FormLayout.cs new file mode 100644 index 00000000..6014476e --- /dev/null +++ b/UoFiddler.Controls/Classes/FormLayout.cs @@ -0,0 +1,71 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Drawing; +using System.Windows.Forms; + +namespace UoFiddler.Controls.Classes +{ + public static class FormLayout + { + /// + /// Shrinks a dialog that is laid out larger than the screen it opens on, so a designer size + /// picked for a wide monitor does not push the buttons off the bottom of a small one. The + /// form keeps its own minimum size, and nothing happens when it already fits. + /// + public static void FitToScreen(Form form) + { + if (form == null) + { + return; + } + + Rectangle work = Screen.FromControl(form).WorkingArea; + + int width = form.Width; + int height = form.Height; + + if (width > work.Width) + { + width = work.Width; + } + + if (height > work.Height) + { + height = work.Height; + } + + if (width == form.Width && height == form.Height) + { + return; + } + + form.Size = new Size(width, height); + + // Centring happened against the old size, so put it back inside the screen. + int left = form.Left; + int top = form.Top; + + if (left + form.Width > work.Right) + { + left = work.Right - form.Width; + } + + if (top + form.Height > work.Bottom) + { + top = work.Bottom - form.Height; + } + + form.Location = new Point(Math.Max(work.Left, left), Math.Max(work.Top, top)); + } + } +} \ No newline at end of file diff --git a/UoFiddler.Controls/Classes/ImageClipboard.cs b/UoFiddler.Controls/Classes/ImageClipboard.cs new file mode 100644 index 00000000..b0088c53 --- /dev/null +++ b/UoFiddler.Controls/Classes/ImageClipboard.cs @@ -0,0 +1,276 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.IO; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace UoFiddler.Controls.Classes +{ + /// + /// Moves single images between the graphic tabs and the system clipboard. + /// + public static class ImageClipboard + { + /// + /// Clipboard format name Photoshop, GIMP, Paint.NET and Krita read and write. It is the only + /// one of the flavours we handle that carries an alpha channel - CF_DIB flattens it. + /// + private const string PngFormat = "PNG"; + + private static readonly string[] _acceptedDropExtensions = + { + ".png", ".bmp", ".tif", ".tiff", ".jpg", ".jpeg", ".gif" + }; + + /// + /// True when the clipboard holds something we could paste as an image. + /// + public static bool ContainsImage() + { + try + { + IDataObject data = Clipboard.GetDataObject(); + if (data == null) + { + return false; + } + + return data.GetDataPresent(PngFormat) + || data.GetDataPresent(DataFormats.Bitmap) + || data.GetDataPresent(DataFormats.Dib) + || GetDroppedImagePath(data) != null; + } + catch (ExternalException) + { + // Another process has the clipboard open. Treat it as "nothing to paste" rather than + // popping a dialog from a context menu Opening handler. + return false; + } + } + + /// + /// Puts on the clipboard as both a DIB (so Paint, Word and chat + /// clients see it) and a PNG stream (so image editors keep the transparent areas). + /// + public static bool TryCopy(Bitmap source, out string error) + { + error = null; + + if (source == null) + { + error = "There is no image to copy."; + return false; + } + + Bitmap flattened = null; + MemoryStream png = null; + + try + { + // The tabs hand us 16bppArgb1555 bitmaps straight out of the SDK cache; other + // processes cope far better with a plain 32bpp copy. + flattened = ToArgb32(source); + + png = new MemoryStream(); + flattened.Save(png, ImageFormat.Png); + png.Position = 0; + + DataObject data = new DataObject(); + data.SetImage(flattened); + data.SetData(PngFormat, false, png); + + // copy: true flushes everything to the OS clipboard now, so the bitmap and the + // stream can be disposed as soon as this returns. + Clipboard.SetDataObject(data, true); + return true; + } + catch (ExternalException) + { + error = "The clipboard is in use by another program. Try again."; + return false; + } + finally + { + png?.Dispose(); + flattened?.Dispose(); + } + } + + /// + /// Reads an image off the clipboard as a 32bppArgb bitmap owned by the caller, or null with a + /// message in . + /// + public static Bitmap TryPaste(out string error) + { + error = null; + + IDataObject data; + try + { + data = Clipboard.GetDataObject(); + } + catch (ExternalException) + { + error = "The clipboard is in use by another program. Try again."; + return null; + } + + if (data == null) + { + error = "The clipboard does not contain an image."; + return null; + } + + // PNG first - it is the only flavour that survives with transparency intact. + try + { + if (data.GetDataPresent(PngFormat) && data.GetData(PngFormat) is Stream pngStream) + { + using (pngStream) + using (Image png = Image.FromStream(pngStream)) + { + return ToArgb32(png); + } + } + } + catch (Exception ex) when (ex is ArgumentException || ex is ExternalException) + { + // Malformed PNG flavour - fall through and try the DIB. + } + + try + { + Image dib = Clipboard.GetImage(); + if (dib != null) + { + using (dib) + { + Bitmap bitmap = ToArgb32(dib); + + // Clipboard DIBs routinely arrive with the alpha byte left at zero, which + // would otherwise read as a fully transparent image. Nothing useful is ever + // wholly transparent, so treat that as "no alpha information". + MakeOpaqueIfFullyTransparent(bitmap); + return bitmap; + } + } + } + catch (ExternalException) + { + // Fall through to the file drop. + } + + string path = GetDroppedImagePath(data); + if (path != null) + { + try + { + using (Image fromFile = Image.FromFile(path)) + { + return ToArgb32(fromFile); + } + } + catch (Exception ex) when (ex is OutOfMemoryException || ex is IOException || ex is ArgumentException) + { + error = $"'{Path.GetFileName(path)}' could not be read as an image."; + return null; + } + } + + error = "The clipboard does not contain an image."; + return null; + } + + private static Bitmap ToArgb32(Image source) + { + Bitmap result = new Bitmap(source.Width, source.Height, PixelFormat.Format32bppArgb); + + using (Graphics graphics = Graphics.FromImage(result)) + { + // SourceCopy so the alpha channel arrives byte for byte rather than being blended + // against the transparent backdrop. + graphics.CompositingMode = CompositingMode.SourceCopy; + graphics.DrawImageUnscaled(source, 0, 0); + } + + return result; + } + + private static unsafe void MakeOpaqueIfFullyTransparent(Bitmap bitmap) + { + BitmapData data = bitmap.LockBits(new Rectangle(0, 0, bitmap.Width, bitmap.Height), + ImageLockMode.ReadWrite, PixelFormat.Format32bppArgb); + + try + { + int delta = data.Stride >> 2; + uint* line = (uint*)data.Scan0; + + for (int y = 0; y < bitmap.Height; ++y, line += delta) + { + for (int x = 0; x < bitmap.Width; ++x) + { + if ((line[x] & 0xFF000000) != 0) + { + return; + } + } + } + + line = (uint*)data.Scan0; + for (int y = 0; y < bitmap.Height; ++y, line += delta) + { + for (int x = 0; x < bitmap.Width; ++x) + { + line[x] |= 0xFF000000; + } + } + } + finally + { + bitmap.UnlockBits(data); + } + } + + private static string GetDroppedImagePath(IDataObject data) + { + if (!data.GetDataPresent(DataFormats.FileDrop)) + { + return null; + } + + if (data.GetData(DataFormats.FileDrop) is not string[] files) + { + return null; + } + + foreach (string file in files) + { + string extension = Path.GetExtension(file); + + foreach (string accepted in _acceptedDropExtensions) + { + if (string.Equals(extension, accepted, StringComparison.OrdinalIgnoreCase)) + { + return file; + } + } + } + + return null; + } + } +} diff --git a/UoFiddler.Controls/Classes/ModifiedMarker.cs b/UoFiddler.Controls/Classes/ModifiedMarker.cs new file mode 100644 index 00000000..8da5e18a --- /dev/null +++ b/UoFiddler.Controls/Classes/ModifiedMarker.cs @@ -0,0 +1,51 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Drawing; + +namespace UoFiddler.Controls.Classes +{ + /// + /// Draws the corner wedge that flags a graphic edited since the file type was loaded or last + /// saved. Shared so the Items, LandTiles, Gumps and Textures tabs all mark entries the same way. + /// + public static class ModifiedMarker + { + private const int MaxSize = 9; + private const int MinSize = 4; + + // Kept as statics: Draw runs once per visible tile on every paint, so nothing may allocate. + // The outline matters because Options.PreviewBackgroundColor is user configurable and the + // wedge would otherwise vanish against an orange background. + private static readonly Brush _fill = new SolidBrush(Color.FromArgb(255, 120, 0)); + private static readonly Pen _outline = new Pen(Color.FromArgb(40, 40, 40)); + + /// + /// Draws the wedge into the top left corner of . + /// + public static void Draw(Graphics graphics, Rectangle bounds) + { + int size = Math.Min(MaxSize, Math.Min(bounds.Width, bounds.Height)); + if (size < MinSize) + { + return; + } + + Point corner = new Point(bounds.X, bounds.Y); + Point right = new Point(bounds.X + size, bounds.Y); + Point down = new Point(bounds.X, bounds.Y + size); + + graphics.FillPolygon(_fill, new[] { corner, right, down }); + graphics.DrawLine(_outline, right, down); + } + } +} diff --git a/UoFiddler.Controls/Classes/Options.cs b/UoFiddler.Controls/Classes/Options.cs index 1cceb134..3f291d8a 100644 --- a/UoFiddler.Controls/Classes/Options.cs +++ b/UoFiddler.Controls/Classes/Options.cs @@ -43,6 +43,12 @@ public static class Options /// public static bool PolSoundIdOffset { get; set; } + /// + /// Which container a save writes for the file types the client ships as either a mul/idx pair + /// or a uop - art, gumpart, sound, multis and maps. Everything else has only one format. + /// + public static ClientFileSaveFormat SaveFormat { get; set; } = ClientFileSaveFormat.FollowSource; + /// /// Runtime flag set from AppSettings at startup. Not persisted in profiles. /// diff --git a/UoFiddler.Controls/Classes/SaveFormatResolver.cs b/UoFiddler.Controls/Classes/SaveFormatResolver.cs new file mode 100644 index 00000000..3474fcce --- /dev/null +++ b/UoFiddler.Controls/Classes/SaveFormatResolver.cs @@ -0,0 +1,72 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System.Windows.Forms; +using Ultima.Uop; +using UoFiddler.Controls.Forms; + +namespace UoFiddler.Controls.Classes +{ + /// + /// Turns the save format option into the one container a save is about to write. + /// + public static class SaveFormatResolver + { + /// + /// The container to write without asking anything. Use this where a prompt would be wrong - + /// a batch, or a form that already has its own format control. + /// + public static ContainerFormat Resolve(FileType type, int mapIndex = 0) + { + switch (Options.SaveFormat) + { + case ClientFileSaveFormat.Mul: + return ContainerFormat.Mul; + + case ClientFileSaveFormat.Uop: + return ContainerFormat.Uop; + + default: + // Ask falls back to the source format: a batch must not stop on a dialog. + return ClientFileSaver.ClientUsesUop(type, mapIndex) + ? ContainerFormat.Uop + : ContainerFormat.Mul; + } + } + + /// + /// The container to write, prompting when the option says to ask. Returns false when the user + /// cancelled, in which case nothing should be written. + /// + public static bool TryResolve(IWin32Window owner, FileType type, string outputDirectory, + out ContainerFormat format, int mapIndex = 0) + { + format = Resolve(type, mapIndex); + + if (Options.SaveFormat != ClientFileSaveFormat.Ask) + { + return true; + } + + using (var dialog = new SaveFormatDialog(type, outputDirectory, format, mapIndex)) + { + if (dialog.ShowDialog(owner) != DialogResult.OK) + { + return false; + } + + format = dialog.SelectedFormat; + } + + return true; + } + } +} \ No newline at end of file diff --git a/UoFiddler.Controls/Classes/TileDataBulkEdit.cs b/UoFiddler.Controls/Classes/TileDataBulkEdit.cs new file mode 100644 index 00000000..8af6768b --- /dev/null +++ b/UoFiddler.Controls/Classes/TileDataBulkEdit.cs @@ -0,0 +1,311 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Text; +using Ultima; + +namespace UoFiddler.Controls.Classes +{ + /// + /// Sparse description of an edit to an entry. Every member + /// left null - and every flag left out of both masks - means "leave the target's + /// current value alone". That is what lets a single edit be applied across a whole + /// selection without flattening the fields the user never touched. + /// + public sealed class ItemDataEdit + { + public string Name { get; set; } + public short? Animation { get; set; } + public byte? Weight { get; set; } + public byte? Quality { get; set; } + public byte? Quantity { get; set; } + public byte? Hue { get; set; } + public byte? StackingOffset { get; set; } + public byte? Value { get; set; } + public byte? Height { get; set; } + public short? MiscData { get; set; } + public byte? Unk2 { get; set; } + public byte? Unk3 { get; set; } + + /// Flags to turn on for every target. + public TileFlag SetFlags { get; set; } + + /// Flags to turn off for every target. + public TileFlag ClearFlags { get; set; } + } + + /// + /// Sparse description of an edit to a entry. + /// See for the "null means leave alone" contract. + /// + public sealed class LandDataEdit + { + public string Name { get; set; } + public ushort? TextureId { get; set; } + public TileFlag SetFlags { get; set; } + public TileFlag ClearFlags { get; set; } + } + + /// + /// Applies sparse tiledata edits and describes them for confirmation prompts. + /// Shared by the TileData tab's multi-selection "Save Changes" and by + /// "Paste special", so both routes behave identically. + /// + public static class TileDataBulkEdit + { + /// tiledata.mul stores names as 20 ASCII bytes. + public const int MaxNameLength = 20; + + /// How many flag names a description spells out before it gives up and counts. + private const int MaxDescribedFlags = 6; + + public static ItemData Apply(ItemData data, ItemDataEdit edit) + { + if (edit == null) + { + return data; + } + + if (edit.Name != null) + { + data.Name = TruncateName(edit.Name); + } + + if (edit.Animation.HasValue) + { + data.Animation = edit.Animation.Value; + } + + if (edit.Weight.HasValue) + { + data.Weight = edit.Weight.Value; + } + + if (edit.Quality.HasValue) + { + data.Quality = edit.Quality.Value; + } + + if (edit.Quantity.HasValue) + { + data.Quantity = edit.Quantity.Value; + } + + if (edit.Hue.HasValue) + { + data.Hue = edit.Hue.Value; + } + + if (edit.StackingOffset.HasValue) + { + data.StackingOffset = edit.StackingOffset.Value; + } + + if (edit.Value.HasValue) + { + data.Value = edit.Value.Value; + } + + if (edit.Height.HasValue) + { + data.Height = edit.Height.Value; + } + + if (edit.MiscData.HasValue) + { + data.MiscData = edit.MiscData.Value; + } + + if (edit.Unk2.HasValue) + { + data.Unk2 = edit.Unk2.Value; + } + + if (edit.Unk3.HasValue) + { + data.Unk3 = edit.Unk3.Value; + } + + data.Flags = (data.Flags | edit.SetFlags) & ~edit.ClearFlags; + + return data; + } + + public static LandData Apply(LandData data, LandDataEdit edit) + { + if (edit == null) + { + return data; + } + + if (edit.Name != null) + { + data.Name = TruncateName(edit.Name); + } + + if (edit.TextureId.HasValue) + { + data.TextureId = edit.TextureId.Value; + } + + data.Flags = (data.Flags | edit.SetFlags) & ~edit.ClearFlags; + + return data; + } + + public static bool IsEmpty(ItemDataEdit edit) + { + return edit == null + || (edit.Name == null + && !edit.Animation.HasValue + && !edit.Weight.HasValue + && !edit.Quality.HasValue + && !edit.Quantity.HasValue + && !edit.Hue.HasValue + && !edit.StackingOffset.HasValue + && !edit.Value.HasValue + && !edit.Height.HasValue + && !edit.MiscData.HasValue + && !edit.Unk2.HasValue + && !edit.Unk3.HasValue + && edit.SetFlags == TileFlag.None + && edit.ClearFlags == TileFlag.None); + } + + public static bool IsEmpty(LandDataEdit edit) + { + return edit == null + || (edit.Name == null + && !edit.TextureId.HasValue + && edit.SetFlags == TileFlag.None + && edit.ClearFlags == TileFlag.None); + } + + /// + /// Human readable summary of what an edit will change, e.g. + /// "Name, Weight, Height, +Impassable, -Wall". Used in the confirmation prompt + /// so a bulk write always says what it is about to touch. + /// + public static string Describe(ItemDataEdit edit) + { + if (IsEmpty(edit)) + { + return string.Empty; + } + + var parts = new List(); + + AddIf(parts, edit.Name != null, "Name"); + AddIf(parts, edit.Animation.HasValue, "Anim"); + AddIf(parts, edit.Weight.HasValue, "Weight"); + AddIf(parts, edit.Quality.HasValue, "Layer"); + AddIf(parts, edit.Quantity.HasValue, "Quantity"); + AddIf(parts, edit.Hue.HasValue, "Hue"); + AddIf(parts, edit.StackingOffset.HasValue, "StackOff"); + AddIf(parts, edit.Value.HasValue, "Value"); + AddIf(parts, edit.Height.HasValue, "Height"); + AddIf(parts, edit.MiscData.HasValue, "MiscData"); + AddIf(parts, edit.Unk2.HasValue, "Unk2"); + AddIf(parts, edit.Unk3.HasValue, "Unk3"); + + AppendFlags(parts, edit.SetFlags, '+'); + AppendFlags(parts, edit.ClearFlags, '-'); + + return string.Join(", ", parts); + } + + public static string Describe(LandDataEdit edit) + { + if (IsEmpty(edit)) + { + return string.Empty; + } + + var parts = new List(); + + AddIf(parts, edit.Name != null, "Name"); + AddIf(parts, edit.TextureId.HasValue, "TexID"); + + AppendFlags(parts, edit.SetFlags, '+'); + AppendFlags(parts, edit.ClearFlags, '-'); + + return string.Join(", ", parts); + } + + public static string TruncateName(string name) + { + if (name == null) + { + return string.Empty; + } + + return name.Length > MaxNameLength ? name.Substring(0, MaxNameLength) : name; + } + + private static void AddIf(List parts, bool condition, string text) + { + if (condition) + { + parts.Add(text); + } + } + + private static void AppendFlags(List parts, TileFlag flags, char prefix) + { + if (flags == TileFlag.None) + { + return; + } + + Array enumValues = Enum.GetValues(typeof(TileFlag)); + var builder = new StringBuilder(); + int named = 0; + int total = 0; + + for (int i = 1; i < enumValues.Length; ++i) + { + var flag = (TileFlag)enumValues.GetValue(i); + if ((flags & flag) == 0) + { + continue; + } + + ++total; + if (named >= MaxDescribedFlags) + { + continue; + } + + if (named > 0) + { + builder.Append(", "); + } + + builder.Append(prefix).Append(flag); + ++named; + } + + if (total == 0) + { + return; + } + + if (total > named) + { + builder.Append(", ").Append(prefix).Append('(').Append(total - named).Append(" more)"); + } + + parts.Add(builder.ToString()); + } + } +} diff --git a/UoFiddler.Controls/Classes/TileDataBulkUndo.cs b/UoFiddler.Controls/Classes/TileDataBulkUndo.cs new file mode 100644 index 00000000..24f823a8 --- /dev/null +++ b/UoFiddler.Controls/Classes/TileDataBulkUndo.cs @@ -0,0 +1,74 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using Ultima; + +namespace UoFiddler.Controls.Classes +{ + /// + /// Snapshot of the tiledata entries a single bulk apply was about to overwrite, + /// so one misplaced "apply to 4000 entries" can be taken back without reloading + /// tiledata.mul and losing every other unsaved edit. + /// + /// Only one level is kept - this is an escape hatch for the misclick, not an + /// edit history. + /// + /// + public sealed class TileDataBulkUndo + { + private TileDataBulkUndo(bool land, int[] ids, ItemData[] items, LandData[] lands, string description) + { + Land = land; + Ids = ids; + Items = items; + Lands = lands; + Description = description; + } + + public bool Land { get; } + + /// Graphic ids that were overwritten, in the order they were applied. + public int[] Ids { get; } + + /// Pre-edit item entries, parallel to . Null when . + public ItemData[] Items { get; } + + /// Pre-edit land entries, parallel to . Null unless . + public LandData[] Lands { get; } + + /// What the apply changed, e.g. "Weight, +Impassable" - shown in the undo prompt. + public string Description { get; } + + public int Count => Ids.Length; + + public static TileDataBulkUndo ForItems(int[] ids, string description) + { + var snapshot = new ItemData[ids.Length]; + for (int i = 0; i < ids.Length; ++i) + { + snapshot[i] = TileData.ItemTable[ids[i]]; + } + + return new TileDataBulkUndo(false, ids, snapshot, null, description); + } + + public static TileDataBulkUndo ForLand(int[] ids, string description) + { + var snapshot = new LandData[ids.Length]; + for (int i = 0; i < ids.Length; ++i) + { + snapshot[i] = TileData.LandTable[ids[i]]; + } + + return new TileDataBulkUndo(true, ids, null, snapshot, description); + } + } +} diff --git a/UoFiddler.Controls/Classes/Utils.cs b/UoFiddler.Controls/Classes/Utils.cs index a55c1544..9ee7c287 100644 --- a/UoFiddler.Controls/Classes/Utils.cs +++ b/UoFiddler.Controls/Classes/Utils.cs @@ -18,6 +18,11 @@ namespace UoFiddler.Controls.Classes { public static class Utils { + /// + /// Alpha value at which a pasted pixel is still considered opaque. + /// + private const uint AlphaCutoff = 128; + /// /// Converts string to int with Hex recognition /// @@ -110,6 +115,96 @@ public static unsafe Bitmap ConvertBmp(Bitmap bmp) return bmpNew; } + /// + /// Converts an arbitrary bitmap into the 16bppArgb1555 form the mul save paths expect. + /// + /// + /// Which pixels end up transparent depends on what the source actually carries. An image with + /// a real alpha channel is taken at its word, so pure black stays black. An image without one + /// - a screenshot, a flattened bmp - falls back to , whose pure + /// black/pure white rule is what the Replace from file paths have always used. + /// + public static unsafe Bitmap ToUoBitmap(Bitmap source) + { + if (!HasTranslucency(source)) + { + return ConvertBmp(source); + } + + Rectangle rectangle = new Rectangle(0, 0, source.Width, source.Height); + + Bitmap result = new Bitmap(source.Width, source.Height, PixelFormat.Format16bppArgb1555); + BitmapData sourceData = source.LockBits(rectangle, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + BitmapData resultData = result.LockBits(rectangle, ImageLockMode.WriteOnly, PixelFormat.Format16bppArgb1555); + + try + { + uint* sourceLine = (uint*)sourceData.Scan0; + int sourceDelta = sourceData.Stride >> 2; + + ushort* resultLine = (ushort*)resultData.Scan0; + int resultDelta = resultData.Stride >> 1; + + for (int y = 0; y < source.Height; ++y, sourceLine += sourceDelta, resultLine += resultDelta) + { + for (int x = 0; x < source.Width; ++x) + { + uint argb = sourceLine[x]; + + // One bit of alpha is all the format has, so anything half transparent or + // more drops out entirely. + resultLine[x] = (argb >> 24) < AlphaCutoff + ? (ushort)0 + : (ushort)(0x8000 | ((argb >> 9) & 0x7C00) | ((argb >> 6) & 0x03E0) | ((argb >> 3) & 0x001F)); + } + } + } + finally + { + source.UnlockBits(sourceData); + result.UnlockBits(resultData); + } + + return result; + } + + /// + /// True when the bitmap declares an alpha channel and at least one pixel actually uses it. + /// + private static unsafe bool HasTranslucency(Bitmap bmp) + { + if (!Image.IsAlphaPixelFormat(bmp.PixelFormat)) + { + return false; + } + + BitmapData data = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), + ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + + try + { + uint* line = (uint*)data.Scan0; + int delta = data.Stride >> 2; + + for (int y = 0; y < bmp.Height; ++y, line += delta) + { + for (int x = 0; x < bmp.Width; ++x) + { + if ((line[x] >> 24) != 0xFF) + { + return true; + } + } + } + } + finally + { + bmp.UnlockBits(data); + } + + return false; + } + public static string GetFileExtensionFor(ImageFormat imageFormat) { if (Equals(imageFormat, ImageFormat.Bmp)) diff --git a/UoFiddler.Controls/Forms/MapDefragStaticsForm.Designer.cs b/UoFiddler.Controls/Forms/MapDefragStaticsForm.Designer.cs new file mode 100644 index 00000000..dc1b8b0b --- /dev/null +++ b/UoFiddler.Controls/Forms/MapDefragStaticsForm.Designer.cs @@ -0,0 +1,476 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +namespace UoFiddler.Controls.Forms +{ + partial class MapDefragStaticsForm + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + private void InitializeComponent() + { + this.components = new System.ComponentModel.Container(); + this.groupBoxSource = new System.Windows.Forms.GroupBox(); + this.textBoxSource = new System.Windows.Forms.TextBox(); + this.labelGeometry = new System.Windows.Forms.Label(); + this.checkBoxAllowTruncation = new System.Windows.Forms.CheckBox(); + this.groupBoxFilters = new System.Windows.Forms.GroupBox(); + this.checkBoxDropInvalidIds = new System.Windows.Forms.CheckBox(); + this.labelCeiling = new System.Windows.Forms.Label(); + this.comboBoxIdCeiling = new System.Windows.Forms.ComboBox(); + this.labelCeilingValue = new System.Windows.Forms.Label(); + this.labelOutOfBlock = new System.Windows.Forms.Label(); + this.comboBoxOutOfBlock = new System.Windows.Forms.ComboBox(); + this.checkBoxDropInvalidZ = new System.Windows.Forms.CheckBox(); + this.checkBoxNormalizeHue = new System.Windows.Forms.CheckBox(); + this.checkBoxBelowTerrain = new System.Windows.Forms.CheckBox(); + this.checkBoxRemoveDuplicates = new System.Windows.Forms.CheckBox(); + this.checkBoxDuplicatesHue = new System.Windows.Forms.CheckBox(); + this.checkBoxCollapseStacks = new System.Windows.Forms.CheckBox(); + this.labelCollapseFlags = new System.Windows.Forms.Label(); + this.checkBoxCollapseWet = new System.Windows.Forms.CheckBox(); + this.checkBoxCollapseSurface = new System.Windows.Forms.CheckBox(); + this.checkBoxCollapseIgnoreZ = new System.Windows.Forms.CheckBox(); + this.labelCollapseIds = new System.Windows.Forms.Label(); + this.textBoxCollapseIds = new System.Windows.Forms.TextBox(); + this.checkBoxSortTiles = new System.Windows.Forms.CheckBox(); + this.groupBoxOutput = new System.Windows.Forms.GroupBox(); + this.textBoxOutput = new System.Windows.Forms.TextBox(); + this.buttonBrowse = new System.Windows.Forms.Button(); + this.progressBar = new System.Windows.Forms.ProgressBar(); + this.labelStatus = new System.Windows.Forms.Label(); + this.buttonAnalyze = new System.Windows.Forms.Button(); + this.buttonDefrag = new System.Windows.Forms.Button(); + this.buttonCancel = new System.Windows.Forms.Button(); + this.buttonClose = new System.Windows.Forms.Button(); + this.worker = new System.ComponentModel.BackgroundWorker(); + this.components.Add(this.worker); + this.groupBoxSource.SuspendLayout(); + this.groupBoxFilters.SuspendLayout(); + this.groupBoxOutput.SuspendLayout(); + this.SuspendLayout(); + // + // groupBoxSource + // + this.groupBoxSource.Controls.Add(this.textBoxSource); + this.groupBoxSource.Controls.Add(this.labelGeometry); + this.groupBoxSource.Controls.Add(this.checkBoxAllowTruncation); + this.groupBoxSource.Location = new System.Drawing.Point(12, 12); + this.groupBoxSource.Name = "groupBoxSource"; + this.groupBoxSource.Size = new System.Drawing.Size(536, 146); + this.groupBoxSource.TabIndex = 0; + this.groupBoxSource.TabStop = false; + this.groupBoxSource.Text = "Source"; + // + // textBoxSource + // + this.textBoxSource.BackColor = System.Drawing.SystemColors.Control; + this.textBoxSource.BorderStyle = System.Windows.Forms.BorderStyle.None; + this.textBoxSource.Location = new System.Drawing.Point(12, 20); + this.textBoxSource.Multiline = true; + this.textBoxSource.Name = "textBoxSource"; + this.textBoxSource.ReadOnly = true; + this.textBoxSource.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; + this.textBoxSource.Size = new System.Drawing.Size(512, 62); + this.textBoxSource.TabIndex = 0; + this.textBoxSource.TabStop = false; + // + // labelGeometry + // + this.labelGeometry.Location = new System.Drawing.Point(12, 86); + this.labelGeometry.Name = "labelGeometry"; + this.labelGeometry.Size = new System.Drawing.Size(512, 32); + this.labelGeometry.TabIndex = 1; + // + // checkBoxAllowTruncation + // + this.checkBoxAllowTruncation.Enabled = false; + this.checkBoxAllowTruncation.Location = new System.Drawing.Point(12, 120); + this.checkBoxAllowTruncation.Name = "checkBoxAllowTruncation"; + this.checkBoxAllowTruncation.Size = new System.Drawing.Size(512, 21); + this.checkBoxAllowTruncation.TabIndex = 2; + this.checkBoxAllowTruncation.Text = "Discard the blocks past the configured map size"; + this.checkBoxAllowTruncation.UseVisualStyleBackColor = true; + // + // groupBoxFilters + // + this.groupBoxFilters.Controls.Add(this.checkBoxDropInvalidIds); + this.groupBoxFilters.Controls.Add(this.labelCeiling); + this.groupBoxFilters.Controls.Add(this.comboBoxIdCeiling); + this.groupBoxFilters.Controls.Add(this.labelCeilingValue); + this.groupBoxFilters.Controls.Add(this.labelOutOfBlock); + this.groupBoxFilters.Controls.Add(this.comboBoxOutOfBlock); + this.groupBoxFilters.Controls.Add(this.checkBoxDropInvalidZ); + this.groupBoxFilters.Controls.Add(this.checkBoxNormalizeHue); + this.groupBoxFilters.Controls.Add(this.checkBoxBelowTerrain); + this.groupBoxFilters.Controls.Add(this.checkBoxRemoveDuplicates); + this.groupBoxFilters.Controls.Add(this.checkBoxDuplicatesHue); + this.groupBoxFilters.Controls.Add(this.checkBoxCollapseStacks); + this.groupBoxFilters.Controls.Add(this.labelCollapseFlags); + this.groupBoxFilters.Controls.Add(this.checkBoxCollapseWet); + this.groupBoxFilters.Controls.Add(this.checkBoxCollapseSurface); + this.groupBoxFilters.Controls.Add(this.checkBoxCollapseIgnoreZ); + this.groupBoxFilters.Controls.Add(this.labelCollapseIds); + this.groupBoxFilters.Controls.Add(this.textBoxCollapseIds); + this.groupBoxFilters.Controls.Add(this.checkBoxSortTiles); + this.groupBoxFilters.Location = new System.Drawing.Point(12, 164); + this.groupBoxFilters.Name = "groupBoxFilters"; + this.groupBoxFilters.Size = new System.Drawing.Size(536, 290); + this.groupBoxFilters.TabIndex = 1; + this.groupBoxFilters.TabStop = false; + this.groupBoxFilters.Text = "Filters"; + // + // checkBoxDropInvalidIds + // + this.checkBoxDropInvalidIds.Location = new System.Drawing.Point(12, 22); + this.checkBoxDropInvalidIds.Name = "checkBoxDropInvalidIds"; + this.checkBoxDropInvalidIds.Size = new System.Drawing.Size(226, 21); + this.checkBoxDropInvalidIds.TabIndex = 0; + this.checkBoxDropInvalidIds.Text = "Drop statics with an unknown item id"; + this.checkBoxDropInvalidIds.UseVisualStyleBackColor = true; + this.checkBoxDropInvalidIds.CheckedChanged += new System.EventHandler(this.OnFilterChanged); + // + // labelCeiling + // + this.labelCeiling.Location = new System.Drawing.Point(244, 25); + this.labelCeiling.Name = "labelCeiling"; + this.labelCeiling.Size = new System.Drawing.Size(50, 17); + this.labelCeiling.TabIndex = 1; + this.labelCeiling.Text = "ceiling:"; + // + // comboBoxIdCeiling + // + this.comboBoxIdCeiling.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.comboBoxIdCeiling.Location = new System.Drawing.Point(296, 21); + this.comboBoxIdCeiling.Name = "comboBoxIdCeiling"; + this.comboBoxIdCeiling.Size = new System.Drawing.Size(126, 23); + this.comboBoxIdCeiling.TabIndex = 2; + this.comboBoxIdCeiling.SelectedIndexChanged += new System.EventHandler(this.OnFilterChanged); + // + // labelCeilingValue + // + this.labelCeilingValue.Location = new System.Drawing.Point(428, 25); + this.labelCeilingValue.Name = "labelCeilingValue"; + this.labelCeilingValue.Size = new System.Drawing.Size(96, 17); + this.labelCeilingValue.TabIndex = 3; + // + // labelOutOfBlock + // + this.labelOutOfBlock.Location = new System.Drawing.Point(12, 56); + this.labelOutOfBlock.Name = "labelOutOfBlock"; + this.labelOutOfBlock.Size = new System.Drawing.Size(162, 17); + this.labelOutOfBlock.TabIndex = 4; + this.labelOutOfBlock.Text = "Out-of-block x/y offsets:"; + // + // comboBoxOutOfBlock + // + this.comboBoxOutOfBlock.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.comboBoxOutOfBlock.Location = new System.Drawing.Point(180, 52); + this.comboBoxOutOfBlock.Name = "comboBoxOutOfBlock"; + this.comboBoxOutOfBlock.Size = new System.Drawing.Size(150, 23); + this.comboBoxOutOfBlock.TabIndex = 5; + // + // checkBoxDropInvalidZ + // + this.checkBoxDropInvalidZ.Location = new System.Drawing.Point(12, 82); + this.checkBoxDropInvalidZ.Name = "checkBoxDropInvalidZ"; + this.checkBoxDropInvalidZ.Size = new System.Drawing.Size(300, 21); + this.checkBoxDropInvalidZ.TabIndex = 6; + this.checkBoxDropInvalidZ.Text = "Drop statics at z = -128"; + this.checkBoxDropInvalidZ.UseVisualStyleBackColor = true; + // + // checkBoxNormalizeHue + // + this.checkBoxNormalizeHue.Location = new System.Drawing.Point(12, 106); + this.checkBoxNormalizeHue.Name = "checkBoxNormalizeHue"; + this.checkBoxNormalizeHue.Size = new System.Drawing.Size(300, 21); + this.checkBoxNormalizeHue.TabIndex = 7; + this.checkBoxNormalizeHue.Text = "Normalize negative hues to 0"; + this.checkBoxNormalizeHue.UseVisualStyleBackColor = true; + // + // checkBoxBelowTerrain + // + this.checkBoxBelowTerrain.Location = new System.Drawing.Point(12, 130); + this.checkBoxBelowTerrain.Name = "checkBoxBelowTerrain"; + this.checkBoxBelowTerrain.Size = new System.Drawing.Size(400, 21); + this.checkBoxBelowTerrain.TabIndex = 8; + this.checkBoxBelowTerrain.Text = "Drop statics buried under the land tile (never drawn)"; + this.checkBoxBelowTerrain.UseVisualStyleBackColor = true; + // + // checkBoxRemoveDuplicates + // + this.checkBoxRemoveDuplicates.Location = new System.Drawing.Point(12, 154); + this.checkBoxRemoveDuplicates.Name = "checkBoxRemoveDuplicates"; + this.checkBoxRemoveDuplicates.Size = new System.Drawing.Size(282, 21); + this.checkBoxRemoveDuplicates.TabIndex = 9; + this.checkBoxRemoveDuplicates.Text = "Remove duplicates (same id, x, y and z)"; + this.checkBoxRemoveDuplicates.UseVisualStyleBackColor = true; + this.checkBoxRemoveDuplicates.CheckedChanged += new System.EventHandler(this.OnFilterChanged); + // + // checkBoxDuplicatesHue + // + this.checkBoxDuplicatesHue.Location = new System.Drawing.Point(300, 154); + this.checkBoxDuplicatesHue.Name = "checkBoxDuplicatesHue"; + this.checkBoxDuplicatesHue.Size = new System.Drawing.Size(224, 21); + this.checkBoxDuplicatesHue.TabIndex = 10; + this.checkBoxDuplicatesHue.Text = "compare hue too (legacy)"; + this.checkBoxDuplicatesHue.UseVisualStyleBackColor = true; + // + // checkBoxCollapseStacks + // + this.checkBoxCollapseStacks.Location = new System.Drawing.Point(12, 178); + this.checkBoxCollapseStacks.Name = "checkBoxCollapseStacks"; + this.checkBoxCollapseStacks.Size = new System.Drawing.Size(400, 21); + this.checkBoxCollapseStacks.TabIndex = 11; + this.checkBoxCollapseStacks.Text = "Collapse stacked statics sharing a cell down to one"; + this.checkBoxCollapseStacks.UseVisualStyleBackColor = true; + this.checkBoxCollapseStacks.CheckedChanged += new System.EventHandler(this.OnFilterChanged); + // + // labelCollapseFlags + // + this.labelCollapseFlags.Location = new System.Drawing.Point(32, 205); + this.labelCollapseFlags.Name = "labelCollapseFlags"; + this.labelCollapseFlags.Size = new System.Drawing.Size(42, 17); + this.labelCollapseFlags.TabIndex = 12; + this.labelCollapseFlags.Text = "flags:"; + // + // checkBoxCollapseWet + // + this.checkBoxCollapseWet.Location = new System.Drawing.Point(76, 202); + this.checkBoxCollapseWet.Name = "checkBoxCollapseWet"; + this.checkBoxCollapseWet.Size = new System.Drawing.Size(60, 21); + this.checkBoxCollapseWet.TabIndex = 13; + this.checkBoxCollapseWet.Text = "Wet"; + this.checkBoxCollapseWet.UseVisualStyleBackColor = true; + // + // checkBoxCollapseSurface + // + this.checkBoxCollapseSurface.Location = new System.Drawing.Point(140, 202); + this.checkBoxCollapseSurface.Name = "checkBoxCollapseSurface"; + this.checkBoxCollapseSurface.Size = new System.Drawing.Size(78, 21); + this.checkBoxCollapseSurface.TabIndex = 14; + this.checkBoxCollapseSurface.Text = "Surface"; + this.checkBoxCollapseSurface.UseVisualStyleBackColor = true; + // + // checkBoxCollapseIgnoreZ + // + this.checkBoxCollapseIgnoreZ.Location = new System.Drawing.Point(228, 202); + this.checkBoxCollapseIgnoreZ.Name = "checkBoxCollapseIgnoreZ"; + this.checkBoxCollapseIgnoreZ.Size = new System.Drawing.Size(120, 21); + this.checkBoxCollapseIgnoreZ.TabIndex = 15; + this.checkBoxCollapseIgnoreZ.Text = "ignore z"; + this.checkBoxCollapseIgnoreZ.UseVisualStyleBackColor = true; + // + // labelCollapseIds + // + this.labelCollapseIds.Location = new System.Drawing.Point(32, 231); + this.labelCollapseIds.Name = "labelCollapseIds"; + this.labelCollapseIds.Size = new System.Drawing.Size(62, 17); + this.labelCollapseIds.TabIndex = 16; + this.labelCollapseIds.Text = "item ids:"; + // + // textBoxCollapseIds + // + this.textBoxCollapseIds.Location = new System.Drawing.Point(96, 228); + this.textBoxCollapseIds.Name = "textBoxCollapseIds"; + this.textBoxCollapseIds.Size = new System.Drawing.Size(428, 23); + this.textBoxCollapseIds.TabIndex = 17; + // + // checkBoxSortTiles + // + this.checkBoxSortTiles.Location = new System.Drawing.Point(12, 258); + this.checkBoxSortTiles.Name = "checkBoxSortTiles"; + this.checkBoxSortTiles.Size = new System.Drawing.Size(300, 21); + this.checkBoxSortTiles.TabIndex = 18; + this.checkBoxSortTiles.Text = "Sort the statics within each block"; + this.checkBoxSortTiles.UseVisualStyleBackColor = true; + // + // groupBoxOutput + // + this.groupBoxOutput.Controls.Add(this.textBoxOutput); + this.groupBoxOutput.Controls.Add(this.buttonBrowse); + this.groupBoxOutput.Location = new System.Drawing.Point(12, 462); + this.groupBoxOutput.Name = "groupBoxOutput"; + this.groupBoxOutput.Size = new System.Drawing.Size(536, 58); + this.groupBoxOutput.TabIndex = 2; + this.groupBoxOutput.TabStop = false; + this.groupBoxOutput.Text = "Output folder"; + // + // textBoxOutput + // + this.textBoxOutput.Location = new System.Drawing.Point(12, 22); + this.textBoxOutput.Name = "textBoxOutput"; + this.textBoxOutput.Size = new System.Drawing.Size(422, 23); + this.textBoxOutput.TabIndex = 0; + // + // buttonBrowse + // + this.buttonBrowse.Location = new System.Drawing.Point(440, 21); + this.buttonBrowse.Name = "buttonBrowse"; + this.buttonBrowse.Size = new System.Drawing.Size(84, 25); + this.buttonBrowse.TabIndex = 1; + this.buttonBrowse.Text = "Browse..."; + this.buttonBrowse.UseVisualStyleBackColor = true; + this.buttonBrowse.Click += new System.EventHandler(this.OnClickBrowse); + // + // progressBar + // + this.progressBar.Location = new System.Drawing.Point(12, 528); + this.progressBar.Name = "progressBar"; + this.progressBar.Size = new System.Drawing.Size(536, 18); + this.progressBar.TabIndex = 3; + // + // labelStatus + // + this.labelStatus.AutoEllipsis = true; + this.labelStatus.Location = new System.Drawing.Point(12, 551); + this.labelStatus.Name = "labelStatus"; + this.labelStatus.Size = new System.Drawing.Size(536, 17); + this.labelStatus.TabIndex = 4; + // + // buttonAnalyze + // + this.buttonAnalyze.Location = new System.Drawing.Point(190, 574); + this.buttonAnalyze.Name = "buttonAnalyze"; + this.buttonAnalyze.Size = new System.Drawing.Size(96, 28); + this.buttonAnalyze.TabIndex = 5; + this.buttonAnalyze.Text = "Analyze"; + this.buttonAnalyze.UseVisualStyleBackColor = true; + this.buttonAnalyze.Click += new System.EventHandler(this.OnClickAnalyze); + // + // buttonDefrag + // + this.buttonDefrag.Location = new System.Drawing.Point(292, 574); + this.buttonDefrag.Name = "buttonDefrag"; + this.buttonDefrag.Size = new System.Drawing.Size(96, 28); + this.buttonDefrag.TabIndex = 6; + this.buttonDefrag.Text = "Defrag"; + this.buttonDefrag.UseVisualStyleBackColor = true; + this.buttonDefrag.Click += new System.EventHandler(this.OnClickDefrag); + // + // buttonCancel + // + this.buttonCancel.Enabled = false; + this.buttonCancel.Location = new System.Drawing.Point(394, 574); + this.buttonCancel.Name = "buttonCancel"; + this.buttonCancel.Size = new System.Drawing.Size(72, 28); + this.buttonCancel.TabIndex = 7; + this.buttonCancel.Text = "Cancel"; + this.buttonCancel.UseVisualStyleBackColor = true; + this.buttonCancel.Click += new System.EventHandler(this.OnClickCancel); + // + // buttonClose + // + this.buttonClose.DialogResult = System.Windows.Forms.DialogResult.Cancel; + this.buttonClose.Location = new System.Drawing.Point(472, 574); + this.buttonClose.Name = "buttonClose"; + this.buttonClose.Size = new System.Drawing.Size(76, 28); + this.buttonClose.TabIndex = 8; + this.buttonClose.Text = "Close"; + this.buttonClose.UseVisualStyleBackColor = true; + // + // worker + // + this.worker.WorkerReportsProgress = true; + this.worker.DoWork += new System.ComponentModel.DoWorkEventHandler(this.OnWorkerDoWork); + this.worker.ProgressChanged += new System.ComponentModel.ProgressChangedEventHandler(this.OnWorkerProgressChanged); + this.worker.RunWorkerCompleted += new System.ComponentModel.RunWorkerCompletedEventHandler(this.OnWorkerCompleted); + // + // MapDefragStaticsForm + // + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.CancelButton = this.buttonClose; + this.ClientSize = new System.Drawing.Size(560, 614); + this.Controls.Add(this.groupBoxSource); + this.Controls.Add(this.groupBoxFilters); + this.Controls.Add(this.groupBoxOutput); + this.Controls.Add(this.progressBar); + this.Controls.Add(this.labelStatus); + this.Controls.Add(this.buttonAnalyze); + this.Controls.Add(this.buttonDefrag); + this.Controls.Add(this.buttonCancel); + this.Controls.Add(this.buttonClose); + this.DoubleBuffered = true; + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; + this.MaximizeBox = false; + this.MinimizeBox = false; + this.Name = "MapDefragStaticsForm"; + this.ShowInTaskbar = false; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + this.Text = "Defrag Statics"; + this.groupBoxSource.ResumeLayout(false); + this.groupBoxSource.PerformLayout(); + this.groupBoxFilters.ResumeLayout(false); + this.groupBoxFilters.PerformLayout(); + this.groupBoxOutput.ResumeLayout(false); + this.groupBoxOutput.PerformLayout(); + this.ResumeLayout(false); + + } + + #endregion + + private System.ComponentModel.BackgroundWorker worker; + private System.Windows.Forms.Button buttonAnalyze; + private System.Windows.Forms.Button buttonBrowse; + private System.Windows.Forms.Button buttonCancel; + private System.Windows.Forms.Button buttonClose; + private System.Windows.Forms.Button buttonDefrag; + private System.Windows.Forms.CheckBox checkBoxAllowTruncation; + private System.Windows.Forms.CheckBox checkBoxBelowTerrain; + private System.Windows.Forms.CheckBox checkBoxCollapseIgnoreZ; + private System.Windows.Forms.CheckBox checkBoxCollapseStacks; + private System.Windows.Forms.CheckBox checkBoxCollapseSurface; + private System.Windows.Forms.CheckBox checkBoxCollapseWet; + private System.Windows.Forms.CheckBox checkBoxDropInvalidIds; + private System.Windows.Forms.CheckBox checkBoxDropInvalidZ; + private System.Windows.Forms.CheckBox checkBoxDuplicatesHue; + private System.Windows.Forms.CheckBox checkBoxNormalizeHue; + private System.Windows.Forms.CheckBox checkBoxRemoveDuplicates; + private System.Windows.Forms.CheckBox checkBoxSortTiles; + private System.Windows.Forms.ComboBox comboBoxIdCeiling; + private System.Windows.Forms.ComboBox comboBoxOutOfBlock; + private System.Windows.Forms.GroupBox groupBoxFilters; + private System.Windows.Forms.GroupBox groupBoxOutput; + private System.Windows.Forms.GroupBox groupBoxSource; + private System.Windows.Forms.Label labelCeiling; + private System.Windows.Forms.Label labelCeilingValue; + private System.Windows.Forms.Label labelCollapseFlags; + private System.Windows.Forms.Label labelCollapseIds; + private System.Windows.Forms.Label labelGeometry; + private System.Windows.Forms.Label labelOutOfBlock; + private System.Windows.Forms.Label labelStatus; + private System.Windows.Forms.ProgressBar progressBar; + private System.Windows.Forms.TextBox textBoxCollapseIds; + private System.Windows.Forms.TextBox textBoxOutput; + private System.Windows.Forms.TextBox textBoxSource; + } +} \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/MapDefragStaticsForm.cs b/UoFiddler.Controls/Forms/MapDefragStaticsForm.cs new file mode 100644 index 00000000..1223bdcf --- /dev/null +++ b/UoFiddler.Controls/Forms/MapDefragStaticsForm.cs @@ -0,0 +1,432 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Drawing; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading; +using System.Windows.Forms; +using Ultima; +using Ultima.Statics; +using UoFiddler.Controls.Classes; + +namespace UoFiddler.Controls.Forms +{ + public sealed partial class MapDefragStaticsForm : Form + { + private const int IndexRecordSize = 12; + + /// Water, which is what the stack filter was written for. + private const string DefaultCollapseIds = "0x1797-0x179C"; + + private readonly Map _map; + + private CancellationTokenSource _cancellation; + private bool _lastRunUsedFilters; + private bool _truncationAvailable; + + public MapDefragStaticsForm(Map map, string outputPath) + { + InitializeComponent(); + + Icon = Options.GetFiddlerIcon(); + + _map = map ?? throw new ArgumentNullException(nameof(map)); + + comboBoxIdCeiling.Items.AddRange(new object[] { "tiledata", "art", "legacy 0x3FFF" }); + comboBoxIdCeiling.SelectedIndex = 0; + + comboBoxOutOfBlock.Items.AddRange(new object[] { "Drop", "Mask to 0-7", "Keep" }); + comboBoxOutOfBlock.SelectedIndex = 0; + + checkBoxDropInvalidIds.Checked = true; + checkBoxDropInvalidZ.Checked = true; + checkBoxNormalizeHue.Checked = true; + checkBoxRemoveDuplicates.Checked = true; + checkBoxCollapseWet.Checked = true; + textBoxCollapseIds.Text = DefaultCollapseIds; + + textBoxOutput.Text = outputPath; + + DescribeSource(); + OnFilterChanged(this, EventArgs.Empty); + + ActiveControl = buttonAnalyze; + } + + /// + /// Shows what is about to be read and how it lines up with the configured map size. The + /// engine performs the authoritative check; this is here so the mismatch is visible before + /// anyone presses a button. + /// + private void DescribeSource() + { + string indexPath = Files.GetFilePath($"staidx{_map.FileIndex}.mul"); + string staticsPath = Files.GetFilePath($"statics{_map.FileIndex}.mul"); + + var sb = new StringBuilder(); + + sb.AppendLine($"Facet {_map.FileIndex} map size {_map.Width} x {_map.Height} blocks {_map.Width >> 3} x {_map.Height >> 3}"); + + if (indexPath == null || staticsPath == null) + { + sb.AppendLine($"staidx{_map.FileIndex}.mul or statics{_map.FileIndex}.mul was not found in the loaded client."); + + textBoxSource.Text = sb.ToString(); + buttonAnalyze.Enabled = false; + buttonDefrag.Enabled = false; + + return; + } + + long indexLength = new FileInfo(indexPath).Length; + long staticsLength = new FileInfo(staticsPath).Length; + + sb.AppendLine($"{indexPath} ({indexLength:N0} bytes)"); + sb.AppendLine($"{staticsPath} ({staticsLength:N0} bytes)"); + + if (File.Exists(Path.Combine(Path.GetDirectoryName(indexPath) ?? string.Empty, $"staidx{_map.FileIndex}x.mul"))) + { + sb.AppendLine($"staidx{_map.FileIndex}x.mul is present - the client prefers those override files over this pair."); + } + + textBoxSource.Text = sb.ToString(); + + long indexBlocks = indexLength / IndexRecordSize; + long configuredBlocks = (long)(_map.Width >> 3) * (_map.Height >> 3); + + if (indexBlocks > configuredBlocks) + { + labelGeometry.ForeColor = Options.DarkMode ? Color.OrangeRed : Color.Red; + labelGeometry.Text = string.Format(CultureInfo.InvariantCulture, + "staidx holds {0:N0} blocks but the configured map size covers only {1:N0}." + Environment.NewLine + + "The statics in the surplus blocks would be discarded.", + indexBlocks, configuredBlocks); + + _truncationAvailable = true; + checkBoxAllowTruncation.Enabled = true; + } + else if (indexBlocks < configuredBlocks) + { + labelGeometry.ForeColor = SystemColors.ControlText; + labelGeometry.Text = string.Format(CultureInfo.InvariantCulture, + "staidx holds {0:N0} blocks, the configured map size covers {1:N0}." + Environment.NewLine + + "The blocks past the end of the index will be written empty.", + indexBlocks, configuredBlocks); + } + else + { + labelGeometry.ForeColor = SystemColors.ControlText; + labelGeometry.Text = string.Format(CultureInfo.InvariantCulture, + "staidx holds {0:N0} blocks, matching the configured map size.", indexBlocks); + } + } + + private void OnFilterChanged(object sender, EventArgs e) + { + comboBoxIdCeiling.Enabled = checkBoxDropInvalidIds.Checked; + labelCeilingValue.Text = checkBoxDropInvalidIds.Checked + ? $"= 0x{BuildOptions(true).ResolveMaxItemId():X4}" + : string.Empty; + + checkBoxDuplicatesHue.Enabled = checkBoxRemoveDuplicates.Checked; + + bool collapse = checkBoxCollapseStacks.Checked; + checkBoxCollapseWet.Enabled = collapse; + checkBoxCollapseSurface.Enabled = collapse; + checkBoxCollapseIgnoreZ.Enabled = collapse; + textBoxCollapseIds.Enabled = collapse; + } + + private StaticsDefragOptions BuildOptions(bool dryRun) + { + var options = new StaticsDefragOptions + { + FileIndex = _map.FileIndex, + Map = _map, + BlockWidth = _map.Width >> 3, + BlockHeight = _map.Height >> 3, + AllowGeometryTruncation = checkBoxAllowTruncation.Checked, + OutputDirectory = textBoxOutput.Text, + DryRun = dryRun, + DropInvalidItemIds = checkBoxDropInvalidIds.Checked, + ItemIdCeiling = comboBoxIdCeiling.SelectedIndex switch + { + 1 => ItemIdCeiling.Art, + 2 => ItemIdCeiling.Legacy, + _ => ItemIdCeiling.TileData + }, + OutOfBlockTiles = comboBoxOutOfBlock.SelectedIndex switch + { + 1 => OutOfBlockAction.Mask, + 2 => OutOfBlockAction.Keep, + _ => OutOfBlockAction.Drop + }, + DropInvalidZ = checkBoxDropInvalidZ.Checked, + NormalizeNegativeHue = checkBoxNormalizeHue.Checked, + DropBelowTerrain = checkBoxBelowTerrain.Checked, + RemoveDuplicates = checkBoxRemoveDuplicates.Checked, + DuplicatesCompareHue = checkBoxRemoveDuplicates.Checked && checkBoxDuplicatesHue.Checked, + CollapseStacks = checkBoxCollapseStacks.Checked, + CollapseIgnoreZ = checkBoxCollapseIgnoreZ.Checked, + SortTiles = checkBoxSortTiles.Checked + }; + + if (options.CollapseStacks) + { + TileFlag mask = 0; + + if (checkBoxCollapseWet.Checked) + { + mask |= TileFlag.Wet; + } + + if (checkBoxCollapseSurface.Checked) + { + mask |= TileFlag.Surface; + } + + options.CollapseFlagMask = mask; + options.CollapseIds.UnionWith(ParseIds(textBoxCollapseIds.Text)); + } + else + { + options.CollapseFlagMask = 0; + } + + return options; + } + + /// + /// Accepts decimal and 0x values separated by commas or spaces, plus inclusive ranges + /// written with a dash, so "0x1797-0x179C, 6100" works. + /// + private static IEnumerable ParseIds(string text) + { + var ids = new List(); + + if (string.IsNullOrWhiteSpace(text)) + { + return ids; + } + + foreach (string part in text.Split(new[] { ',', ';', ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries)) + { + int dash = part.IndexOf('-', 1); + + if (dash > 0) + { + if (TryParseId(part.Substring(0, dash), out int from) && + TryParseId(part.Substring(dash + 1), out int to) && to >= from) + { + for (int id = from; id <= to; ++id) + { + ids.Add(id); + } + } + + continue; + } + + if (TryParseId(part, out int single)) + { + ids.Add(single); + } + } + + return ids; + } + + private static bool TryParseId(string text, out int id) + { + text = text.Trim(); + + if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + return int.TryParse(text.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out id); + } + + return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out id); + } + + private void OnClickBrowse(object sender, EventArgs e) + { + using (var dialog = new FolderBrowserDialog + { + Description = "Choose where the rewritten statics files go", + SelectedPath = Directory.Exists(textBoxOutput.Text) ? textBoxOutput.Text : Options.OutputPath + }) + { + if (dialog.ShowDialog(this) == DialogResult.OK) + { + textBoxOutput.Text = dialog.SelectedPath; + } + } + } + + private void OnClickAnalyze(object sender, EventArgs e) + { + Start(true); + } + + private void OnClickDefrag(object sender, EventArgs e) + { + Start(false); + } + + private void Start(bool dryRun) + { + if (worker.IsBusy) + { + return; + } + + StaticsDefragOptions options; + + try + { + options = BuildOptions(dryRun); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Defrag Statics", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + + _lastRunUsedFilters = UsesFilters(options); + + _cancellation?.Dispose(); + _cancellation = new CancellationTokenSource(); + options.CancellationToken = _cancellation.Token; + options.Progress = new Progress(OnProgress); + + SetRunning(true); + + progressBar.Style = ProgressBarStyle.Continuous; + progressBar.Value = 0; + labelStatus.Text = dryRun ? "Analyzing..." : "Defragging..."; + + worker.RunWorkerAsync(options); + } + + /// + /// Whether the run could remove anything. Decides which mode the verification runs in. + /// + private static bool UsesFilters(StaticsDefragOptions options) + { + return options.DropInvalidItemIds || + options.OutOfBlockTiles != OutOfBlockAction.Keep || + options.DropInvalidZ || + options.NormalizeNegativeHue || + options.DropBelowTerrain || + options.RemoveDuplicates || + options.CollapseStacks; + } + + private void OnProgress(StaticsDefragProgress progress) + { + if (progress.BlocksTotal <= 0) + { + return; + } + + int percent = (int)(progress.BlocksDone * 100L / progress.BlocksTotal); + + progressBar.Value = Math.Min(100, Math.Max(0, percent)); + labelStatus.Text = string.Format(CultureInfo.InvariantCulture, + "{0:N0} of {1:N0} blocks, {2:N0} statics written", progress.BlocksDone, progress.BlocksTotal, progress.TilesWritten); + } + + private void OnWorkerDoWork(object sender, DoWorkEventArgs e) + { + e.Result = StaticsDefragmenter.Defrag((StaticsDefragOptions)e.Argument); + } + + private void OnWorkerProgressChanged(object sender, ProgressChangedEventArgs e) + { + progressBar.Value = Math.Min(100, Math.Max(0, e.ProgressPercentage)); + } + + private void OnWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) + { + SetRunning(false); + + if (e.Error is OperationCanceledException) + { + labelStatus.Text = "Cancelled. Nothing was written."; + progressBar.Value = 0; + return; + } + + if (e.Error != null) + { + labelStatus.Text = "Failed."; + progressBar.Value = 0; + + MessageBox.Show(this, e.Error.Message, "Defrag Statics", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + + var result = (StaticsDefragResult)e.Result; + + progressBar.Value = 100; + labelStatus.Text = result.DryRun + ? "Analyzed. Nothing was written." + : $"Done. {result.TilesWritten:N0} statics written."; + + using (var form = new MapDefragStaticsResultForm(result, _lastRunUsedFilters)) + { + form.ShowDialog(this); + } + } + + private void OnClickCancel(object sender, EventArgs e) + { + _cancellation?.Cancel(); + labelStatus.Text = "Cancelling..."; + } + + private void SetRunning(bool running) + { + buttonAnalyze.Enabled = !running; + buttonDefrag.Enabled = !running; + buttonClose.Enabled = !running; + buttonCancel.Enabled = running; + groupBoxFilters.Enabled = !running; + groupBoxOutput.Enabled = !running; + checkBoxAllowTruncation.Enabled = !running && _truncationAvailable; + } + + protected override void OnFormClosing(FormClosingEventArgs e) + { + if (worker.IsBusy) + { + _cancellation?.Cancel(); + e.Cancel = true; + return; + } + + base.OnFormClosing(e); + } + + protected override void OnFormClosed(FormClosedEventArgs e) + { + _cancellation?.Dispose(); + _cancellation = null; + + base.OnFormClosed(e); + } + } +} \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/MapDefragStaticsForm.resx b/UoFiddler.Controls/Forms/MapDefragStaticsForm.resx new file mode 100644 index 00000000..6dae11dd --- /dev/null +++ b/UoFiddler.Controls/Forms/MapDefragStaticsForm.resx @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.Designer.cs b/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.Designer.cs new file mode 100644 index 00000000..07654df0 --- /dev/null +++ b/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.Designer.cs @@ -0,0 +1,151 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +namespace UoFiddler.Controls.Forms +{ + partial class MapDefragStaticsResultForm + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + private void InitializeComponent() + { + this.reportTextBox = new System.Windows.Forms.TextBox(); + this.buttonVerify = new System.Windows.Forms.Button(); + this.buttonSave = new System.Windows.Forms.Button(); + this.buttonCopy = new System.Windows.Forms.Button(); + this.buttonOpenFolder = new System.Windows.Forms.Button(); + this.buttonClose = new System.Windows.Forms.Button(); + this.SuspendLayout(); + // + // reportTextBox + // + this.reportTextBox.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.reportTextBox.Font = new System.Drawing.Font("Consolas", 9F); + this.reportTextBox.Location = new System.Drawing.Point(12, 12); + this.reportTextBox.Multiline = true; + this.reportTextBox.Name = "reportTextBox"; + this.reportTextBox.ReadOnly = true; + this.reportTextBox.ScrollBars = System.Windows.Forms.ScrollBars.Both; + this.reportTextBox.Size = new System.Drawing.Size(660, 420); + this.reportTextBox.TabIndex = 0; + this.reportTextBox.WordWrap = false; + // + // buttonVerify + // + this.buttonVerify.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.buttonVerify.Location = new System.Drawing.Point(12, 442); + this.buttonVerify.Name = "buttonVerify"; + this.buttonVerify.Size = new System.Drawing.Size(110, 27); + this.buttonVerify.TabIndex = 1; + this.buttonVerify.Text = "Verify output"; + this.buttonVerify.UseVisualStyleBackColor = true; + this.buttonVerify.Click += new System.EventHandler(this.OnClickVerify); + // + // buttonSave + // + this.buttonSave.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.buttonSave.Location = new System.Drawing.Point(128, 442); + this.buttonSave.Name = "buttonSave"; + this.buttonSave.Size = new System.Drawing.Size(110, 27); + this.buttonSave.TabIndex = 2; + this.buttonSave.Text = "Save report..."; + this.buttonSave.UseVisualStyleBackColor = true; + this.buttonSave.Click += new System.EventHandler(this.OnClickSave); + // + // buttonCopy + // + this.buttonCopy.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.buttonCopy.Location = new System.Drawing.Point(244, 442); + this.buttonCopy.Name = "buttonCopy"; + this.buttonCopy.Size = new System.Drawing.Size(80, 27); + this.buttonCopy.TabIndex = 3; + this.buttonCopy.Text = "Copy"; + this.buttonCopy.UseVisualStyleBackColor = true; + this.buttonCopy.Click += new System.EventHandler(this.OnClickCopy); + // + // buttonOpenFolder + // + this.buttonOpenFolder.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.buttonOpenFolder.Location = new System.Drawing.Point(452, 442); + this.buttonOpenFolder.Name = "buttonOpenFolder"; + this.buttonOpenFolder.Size = new System.Drawing.Size(130, 27); + this.buttonOpenFolder.TabIndex = 4; + this.buttonOpenFolder.Text = "Open output folder"; + this.buttonOpenFolder.UseVisualStyleBackColor = true; + this.buttonOpenFolder.Click += new System.EventHandler(this.OnClickOpenFolder); + // + // buttonClose + // + this.buttonClose.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.buttonClose.DialogResult = System.Windows.Forms.DialogResult.OK; + this.buttonClose.Location = new System.Drawing.Point(588, 442); + this.buttonClose.Name = "buttonClose"; + this.buttonClose.Size = new System.Drawing.Size(84, 27); + this.buttonClose.TabIndex = 5; + this.buttonClose.Text = "Close"; + this.buttonClose.UseVisualStyleBackColor = true; + // + // MapDefragStaticsResultForm + // + this.AcceptButton = this.buttonClose; + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.CancelButton = this.buttonClose; + this.ClientSize = new System.Drawing.Size(684, 481); + this.Controls.Add(this.buttonClose); + this.Controls.Add(this.buttonOpenFolder); + this.Controls.Add(this.buttonCopy); + this.Controls.Add(this.buttonSave); + this.Controls.Add(this.buttonVerify); + this.Controls.Add(this.reportTextBox); + this.DoubleBuffered = true; + this.MinimizeBox = false; + this.MinimumSize = new System.Drawing.Size(520, 320); + this.Name = "MapDefragStaticsResultForm"; + this.ShowInTaskbar = false; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + this.Text = "Defrag Statics - Result"; + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.Button buttonClose; + private System.Windows.Forms.Button buttonCopy; + private System.Windows.Forms.Button buttonOpenFolder; + private System.Windows.Forms.Button buttonSave; + private System.Windows.Forms.Button buttonVerify; + private System.Windows.Forms.TextBox reportTextBox; + } +} \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.cs b/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.cs new file mode 100644 index 00000000..3f00fae7 --- /dev/null +++ b/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.cs @@ -0,0 +1,137 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Diagnostics; +using System.IO; +using System.Windows.Forms; +using Ultima.Statics; +using UoFiddler.Controls.Classes; + +namespace UoFiddler.Controls.Forms +{ + public sealed partial class MapDefragStaticsResultForm : Form + { + private readonly StaticsDefragResult _result; + private readonly bool _filtered; + + public MapDefragStaticsResultForm(StaticsDefragResult result, bool filtered) + { + InitializeComponent(); + + Icon = Options.GetFiddlerIcon(); + + _result = result; + _filtered = filtered; + + reportTextBox.Text = result.ToReport(); + + // Nothing was written in a dry run, so there is no output to verify or to open. + buttonVerify.Enabled = !result.DryRun; + buttonOpenFolder.Enabled = !result.DryRun; + } + + /// + /// Reads the output back and compares it against the file it was made from. With no filters + /// the two must hold exactly the same statics; with filters the output may only be missing + /// statics, and the number missing has to match what the filters reported removing. + /// + private void OnClickVerify(object sender, EventArgs e) + { + using (new WaitCursorScope(this)) + { + try + { + StaticsCompareMode mode = _filtered ? StaticsCompareMode.Subset : StaticsCompareMode.Identical; + + StaticsCompareResult compare = StaticsComparer.Compare( + _result.SourceIndexPath, _result.SourceStaticsPath, + _result.OutputIndexPath, _result.OutputStaticsPath, + _result.BlockWidth, _result.BlockHeight, mode); + + string reconciliation = string.Empty; + + if (_filtered) + { + bool reconciled = compare.TilesMissing == _result.TilesAccountedFor; + + reconciliation = Environment.NewLine + + $"Filters reported removing {_result.TilesAccountedFor:N0} statics, the comparison found {compare.TilesMissing:N0} missing: " + + (reconciled ? "reconciled." : "MISMATCH - statics were lost outside the filters.") + + Environment.NewLine; + } + + reportTextBox.Text = compare.ToReport() + reconciliation + + Environment.NewLine + new string('-', 60) + Environment.NewLine + + _result.ToReport(); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Verify failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } + + private void OnClickSave(object sender, EventArgs e) + { + using (var dialog = new SaveFileDialog + { + Title = "Save the defrag report", + Filter = "Text files (*.txt)|*.txt|All files (*.*)|*.*", + FileName = $"defrag-statics{_result.FileIndex}.txt", + InitialDirectory = Options.OutputPath + }) + { + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + try + { + File.WriteAllText(dialog.FileName, reportTextBox.Text); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Save failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } + + private void OnClickCopy(object sender, EventArgs e) + { + if (reportTextBox.TextLength > 0) + { + Clipboard.SetText(reportTextBox.Text); + } + } + + private void OnClickOpenFolder(object sender, EventArgs e) + { + string folder = Path.GetDirectoryName(_result.OutputIndexPath); + + if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder)) + { + return; + } + + try + { + Process.Start(new ProcessStartInfo { FileName = folder, UseShellExecute = true }); + } + catch (Exception ex) + { + MessageBox.Show(this, $"Unable to open folder: {ex.Message}", "Error", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } +} \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.resx b/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.resx new file mode 100644 index 00000000..6dae11dd --- /dev/null +++ b/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.resx @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/MapDiffApplyResultForm.Designer.cs b/UoFiddler.Controls/Forms/MapDiffApplyResultForm.Designer.cs new file mode 100644 index 00000000..7641e907 --- /dev/null +++ b/UoFiddler.Controls/Forms/MapDiffApplyResultForm.Designer.cs @@ -0,0 +1,151 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +namespace UoFiddler.Controls.Forms +{ + partial class MapDiffApplyResultForm + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + private void InitializeComponent() + { + this.reportTextBox = new System.Windows.Forms.TextBox(); + this.buttonVerify = new System.Windows.Forms.Button(); + this.buttonSave = new System.Windows.Forms.Button(); + this.buttonCopy = new System.Windows.Forms.Button(); + this.buttonOpenFolder = new System.Windows.Forms.Button(); + this.buttonClose = new System.Windows.Forms.Button(); + this.SuspendLayout(); + // + // reportTextBox + // + this.reportTextBox.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.reportTextBox.Font = new System.Drawing.Font("Consolas", 9F); + this.reportTextBox.Location = new System.Drawing.Point(12, 12); + this.reportTextBox.Multiline = true; + this.reportTextBox.Name = "reportTextBox"; + this.reportTextBox.ReadOnly = true; + this.reportTextBox.ScrollBars = System.Windows.Forms.ScrollBars.Both; + this.reportTextBox.Size = new System.Drawing.Size(660, 420); + this.reportTextBox.TabIndex = 0; + this.reportTextBox.WordWrap = false; + // + // buttonVerify + // + this.buttonVerify.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.buttonVerify.Location = new System.Drawing.Point(12, 442); + this.buttonVerify.Name = "buttonVerify"; + this.buttonVerify.Size = new System.Drawing.Size(110, 27); + this.buttonVerify.TabIndex = 1; + this.buttonVerify.Text = "Verify output"; + this.buttonVerify.UseVisualStyleBackColor = true; + this.buttonVerify.Click += new System.EventHandler(this.OnClickVerify); + // + // buttonSave + // + this.buttonSave.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.buttonSave.Location = new System.Drawing.Point(128, 442); + this.buttonSave.Name = "buttonSave"; + this.buttonSave.Size = new System.Drawing.Size(110, 27); + this.buttonSave.TabIndex = 2; + this.buttonSave.Text = "Save report..."; + this.buttonSave.UseVisualStyleBackColor = true; + this.buttonSave.Click += new System.EventHandler(this.OnClickSave); + // + // buttonCopy + // + this.buttonCopy.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.buttonCopy.Location = new System.Drawing.Point(244, 442); + this.buttonCopy.Name = "buttonCopy"; + this.buttonCopy.Size = new System.Drawing.Size(80, 27); + this.buttonCopy.TabIndex = 3; + this.buttonCopy.Text = "Copy"; + this.buttonCopy.UseVisualStyleBackColor = true; + this.buttonCopy.Click += new System.EventHandler(this.OnClickCopy); + // + // buttonOpenFolder + // + this.buttonOpenFolder.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.buttonOpenFolder.Location = new System.Drawing.Point(452, 442); + this.buttonOpenFolder.Name = "buttonOpenFolder"; + this.buttonOpenFolder.Size = new System.Drawing.Size(130, 27); + this.buttonOpenFolder.TabIndex = 4; + this.buttonOpenFolder.Text = "Open output folder"; + this.buttonOpenFolder.UseVisualStyleBackColor = true; + this.buttonOpenFolder.Click += new System.EventHandler(this.OnClickOpenFolder); + // + // buttonClose + // + this.buttonClose.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.buttonClose.DialogResult = System.Windows.Forms.DialogResult.OK; + this.buttonClose.Location = new System.Drawing.Point(588, 442); + this.buttonClose.Name = "buttonClose"; + this.buttonClose.Size = new System.Drawing.Size(84, 27); + this.buttonClose.TabIndex = 5; + this.buttonClose.Text = "Close"; + this.buttonClose.UseVisualStyleBackColor = true; + // + // MapDiffApplyResultForm + // + this.AcceptButton = this.buttonClose; + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.CancelButton = this.buttonClose; + this.ClientSize = new System.Drawing.Size(684, 481); + this.Controls.Add(this.buttonClose); + this.Controls.Add(this.buttonOpenFolder); + this.Controls.Add(this.buttonCopy); + this.Controls.Add(this.buttonSave); + this.Controls.Add(this.buttonVerify); + this.Controls.Add(this.reportTextBox); + this.DoubleBuffered = true; + this.MinimizeBox = false; + this.MinimumSize = new System.Drawing.Size(520, 320); + this.Name = "MapDiffApplyResultForm"; + this.ShowInTaskbar = false; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + this.Text = "Diff to Map Copy - Result"; + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.Button buttonClose; + private System.Windows.Forms.Button buttonCopy; + private System.Windows.Forms.Button buttonOpenFolder; + private System.Windows.Forms.Button buttonSave; + private System.Windows.Forms.Button buttonVerify; + private System.Windows.Forms.TextBox reportTextBox; + } +} \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/MapDiffApplyResultForm.cs b/UoFiddler.Controls/Forms/MapDiffApplyResultForm.cs new file mode 100644 index 00000000..3aee339b --- /dev/null +++ b/UoFiddler.Controls/Forms/MapDiffApplyResultForm.cs @@ -0,0 +1,212 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Windows.Forms; +using Ultima; +using Ultima.Maps; +using UoFiddler.Controls.Classes; + +namespace UoFiddler.Controls.Forms +{ + public sealed partial class MapDiffApplyResultForm : Form + { + private readonly MapDiffApplyResult _result; + + public MapDiffApplyResultForm(MapDiffApplyResult result) + { + InitializeComponent(); + + Icon = Options.GetFiddlerIcon(); + + _result = result; + + reportTextBox.Text = result.ToReport(); + + buttonVerify.Enabled = result.OutputMapPath != null; + buttonOpenFolder.Enabled = result.OutputMapPath != null || result.OutputIndexPath != null; + } + + /// + /// Reads the written map back and checks every block against what it should hold: the patch + /// where the diff covers a block inside the region, the unpatched map everywhere else. + /// + private void OnClickVerify(object sender, EventArgs e) + { + using (new WaitCursorScope(this)) + { + try + { + reportTextBox.Text = Verify() + Environment.NewLine + + new string('-', 60) + Environment.NewLine + _result.ToReport(); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Verify failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } + + private string Verify() + { + var sb = new StringBuilder(); + + MapSize size = _result.MapSize; + string outputDirectory = Path.GetDirectoryName(_result.OutputMapPath); + + var written = new TileMatrix(_result.FileIndex, _result.FileIndex, size.Width, size.Height, outputDirectory); + var original = new TileMatrix(_result.FileIndex, _result.FileIndex, size.Width, size.Height, null); + + try + { + var actual = new byte[TileMatrix.MapBlockSize]; + var expected = new byte[TileMatrix.MapBlockSize]; + + long fromDiff = 0; + long untouched = 0; + long mismatches = 0; + string firstMismatch = null; + + TileMatrixPatch patch = original.Patch; + + for (int x = 0; x < size.BlockWidth; ++x) + { + for (int y = 0; y < size.BlockHeight; ++y) + { + bool inRegion = x >= _result.Region.BlockX1 && x <= _result.Region.BlockX2 && + y >= _result.Region.BlockY1 && y <= _result.Region.BlockY2; + + bool patched = inRegion && patch.IsLandBlockPatched(x, y); + + written.ReadLandBlockBytes(x, y, actual); + + if (patched) + { + ++fromDiff; + + Array.Clear(expected); + MemoryMarshal.AsBytes(patch.GetLandBlock(x, y).AsSpan()) + .CopyTo(expected.AsSpan(TileMatrix.BlockHeaderSize)); + } + else + { + ++untouched; + original.ReadLandBlockBytes(x, y, expected); + } + + if (actual.AsSpan().SequenceEqual(expected)) + { + continue; + } + + ++mismatches; + + firstMismatch ??= Line("block {0},{1} (world {2},{3}) {4}", + x, y, x << 3, y << 3, + patched ? "does not match the diff" : "does not match the unpatched map"); + } + } + + bool countsAgree = fromDiff == _result.LandBlocksApplied; + + sb.AppendLine(mismatches == 0 && countsAgree ? "PASSED" : "FAILED"); + sb.AppendLine(); + sb.AppendLine(Line("Read back : {0}", _result.OutputMapPath)); + sb.AppendLine(Line("Blocks compared : {0:N0}", fromDiff + untouched)); + sb.AppendLine(Line(" from the diff : {0:N0}", fromDiff)); + sb.AppendLine(Line(" left alone : {0:N0}", untouched)); + sb.AppendLine(Line("Blocks differing : {0:N0}", mismatches)); + + if (firstMismatch != null) + { + sb.AppendLine(Line("First difference : {0}", firstMismatch)); + } + + if (!countsAgree) + { + sb.AppendLine(Line("Counts disagree with the insert report, which says {0:N0} blocks came from the diff.", + _result.LandBlocksApplied)); + } + } + finally + { + written.CloseStreams(); + original.CloseStreams(); + } + + return sb.ToString(); + } + + private void OnClickSave(object sender, EventArgs e) + { + using (var dialog = new SaveFileDialog + { + Title = "Save the insert report", + Filter = "Text files (*.txt)|*.txt|All files (*.*)|*.*", + FileName = "map-diff-insert.txt", + InitialDirectory = Options.OutputPath + }) + { + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + try + { + File.WriteAllText(dialog.FileName, reportTextBox.Text); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Save failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } + + private void OnClickCopy(object sender, EventArgs e) + { + if (reportTextBox.TextLength > 0) + { + Clipboard.SetText(reportTextBox.Text); + } + } + + private void OnClickOpenFolder(object sender, EventArgs e) + { + string folder = Path.GetDirectoryName(_result.OutputMapPath ?? _result.OutputIndexPath); + + if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder)) + { + return; + } + + try + { + Process.Start(new ProcessStartInfo { FileName = folder, UseShellExecute = true }); + } + catch (Exception ex) + { + MessageBox.Show(this, $"Unable to open folder: {ex.Message}", "Error", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private static string Line(string format, params object[] args) + { + return string.Format(CultureInfo.InvariantCulture, format, args); + } + } +} \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/MapDiffApplyResultForm.resx b/UoFiddler.Controls/Forms/MapDiffApplyResultForm.resx new file mode 100644 index 00000000..6dae11dd --- /dev/null +++ b/UoFiddler.Controls/Forms/MapDiffApplyResultForm.resx @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/MapDiffInsertForm.Designer.cs b/UoFiddler.Controls/Forms/MapDiffInsertForm.Designer.cs index 2c63ffab..eeac4c2e 100644 --- a/UoFiddler.Controls/Forms/MapDiffInsertForm.Designer.cs +++ b/UoFiddler.Controls/Forms/MapDiffInsertForm.Designer.cs @@ -1,9 +1,9 @@ /*************************************************************************** * * $Author: Turley - * + * * "THE BEER-WARE LICENSE" - * As long as you retain this notice you can do whatever you want with + * As long as you retain this notice you can do whatever you want with * this stuff. If we meet some day, and you think this stuff is worth it, * you can buy me a beer in return. * @@ -28,279 +28,388 @@ protected override void Dispose(bool disposing) { components.Dispose(); } + base.Dispose(disposing); } #region Windows Form Designer generated code - /// - /// Required method for Designer support - do not modify - /// the contents of this method with the code editor. - /// private void InitializeComponent() { + this.components = new System.ComponentModel.Container(); + this.groupBoxWhat = new System.Windows.Forms.GroupBox(); this.checkBoxMap = new System.Windows.Forms.CheckBox(); + this.labelMapFormat = new System.Windows.Forms.Label(); + this.comboBoxMapFormat = new System.Windows.Forms.ComboBox(); this.checkBoxStatics = new System.Windows.Forms.CheckBox(); - this.numericUpDownX1 = new System.Windows.Forms.NumericUpDown(); + this.RemoveDupl = new System.Windows.Forms.CheckBox(); + this.checkBoxDuplicatesHue = new System.Windows.Forms.CheckBox(); + this.groupBoxFrom = new System.Windows.Forms.GroupBox(); this.label1 = new System.Windows.Forms.Label(); + this.numericUpDownX1 = new System.Windows.Forms.NumericUpDown(); this.label2 = new System.Windows.Forms.Label(); this.numericUpDownY1 = new System.Windows.Forms.NumericUpDown(); this.label3 = new System.Windows.Forms.Label(); this.numericUpDownX2 = new System.Windows.Forms.NumericUpDown(); this.label4 = new System.Windows.Forms.Label(); this.numericUpDownY2 = new System.Windows.Forms.NumericUpDown(); - this.button2 = new System.Windows.Forms.Button(); + this.groupBoxPreview = new System.Windows.Forms.GroupBox(); + this.preview = new UoFiddler.Controls.UserControls.MapRegionPreview(); + this.checkBoxPreviewStatics = new System.Windows.Forms.CheckBox(); + this.checkBoxPreviewPatched = new System.Windows.Forms.CheckBox(); + this.textBoxPreview = new System.Windows.Forms.TextBox(); this.progressBar1 = new System.Windows.Forms.ProgressBar(); - this.RemoveDupl = new System.Windows.Forms.CheckBox(); - this.groupBox1 = new System.Windows.Forms.GroupBox(); - this.groupBox2 = new System.Windows.Forms.GroupBox(); - this.groupBox3 = new System.Windows.Forms.GroupBox(); + this.labelStatus = new System.Windows.Forms.Label(); + this.buttonCopy = new System.Windows.Forms.Button(); + this.buttonCancel = new System.Windows.Forms.Button(); + this.buttonClose = new System.Windows.Forms.Button(); + this.worker = new System.ComponentModel.BackgroundWorker(); + this.components.Add(this.worker); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownX1)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownY1)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownX2)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownY2)).BeginInit(); - this.groupBox1.SuspendLayout(); - this.groupBox2.SuspendLayout(); - this.groupBox3.SuspendLayout(); + this.groupBoxWhat.SuspendLayout(); + this.groupBoxFrom.SuspendLayout(); + this.groupBoxPreview.SuspendLayout(); this.SuspendLayout(); - // + // + // groupBoxWhat + // + this.groupBoxWhat.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); + this.groupBoxWhat.Controls.Add(this.checkBoxMap); + this.groupBoxWhat.Controls.Add(this.labelMapFormat); + this.groupBoxWhat.Controls.Add(this.comboBoxMapFormat); + this.groupBoxWhat.Controls.Add(this.checkBoxStatics); + this.groupBoxWhat.Controls.Add(this.RemoveDupl); + this.groupBoxWhat.Controls.Add(this.checkBoxDuplicatesHue); + this.groupBoxWhat.Location = new System.Drawing.Point(12, 12); + this.groupBoxWhat.Name = "groupBoxWhat"; + this.groupBoxWhat.Size = new System.Drawing.Size(960, 86); + this.groupBoxWhat.TabIndex = 1; + this.groupBoxWhat.TabStop = false; + this.groupBoxWhat.Text = "Insert"; + // // checkBoxMap - // - this.checkBoxMap.AutoSize = true; - this.checkBoxMap.Location = new System.Drawing.Point(7, 22); - this.checkBoxMap.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + // + this.checkBoxMap.Location = new System.Drawing.Point(16, 22); this.checkBoxMap.Name = "checkBoxMap"; - this.checkBoxMap.Size = new System.Drawing.Size(82, 19); - this.checkBoxMap.TabIndex = 2; - this.checkBoxMap.Text = "Insert Map"; + this.checkBoxMap.Size = new System.Drawing.Size(110, 21); + this.checkBoxMap.TabIndex = 0; + this.checkBoxMap.Text = "Map"; this.checkBoxMap.UseVisualStyleBackColor = true; - // + this.checkBoxMap.CheckedChanged += new System.EventHandler(this.OnOptionChanged); + // + // labelMapFormat + // + this.labelMapFormat.Location = new System.Drawing.Point(140, 25); + this.labelMapFormat.Name = "labelMapFormat"; + this.labelMapFormat.Size = new System.Drawing.Size(80, 17); + this.labelMapFormat.TabIndex = 1; + this.labelMapFormat.Text = "written as:"; + // + // comboBoxMapFormat + // + this.comboBoxMapFormat.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.comboBoxMapFormat.Location = new System.Drawing.Point(224, 21); + this.comboBoxMapFormat.Name = "comboBoxMapFormat"; + this.comboBoxMapFormat.Size = new System.Drawing.Size(256, 23); + this.comboBoxMapFormat.TabIndex = 2; + // // checkBoxStatics - // - this.checkBoxStatics.AutoSize = true; - this.checkBoxStatics.Location = new System.Drawing.Point(13, 22); - this.checkBoxStatics.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + // + this.checkBoxStatics.Location = new System.Drawing.Point(16, 52); this.checkBoxStatics.Name = "checkBoxStatics"; - this.checkBoxStatics.Size = new System.Drawing.Size(92, 19); + this.checkBoxStatics.Size = new System.Drawing.Size(110, 21); this.checkBoxStatics.TabIndex = 3; - this.checkBoxStatics.Text = "Insert Statics"; + this.checkBoxStatics.Text = "Statics"; this.checkBoxStatics.UseVisualStyleBackColor = true; - // - // numericUpDownX1 - // - this.numericUpDownX1.Increment = new decimal(new int[] { - 8, - 0, - 0, - 0}); - this.numericUpDownX1.Location = new System.Drawing.Point(99, 22); - this.numericUpDownX1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.numericUpDownX1.Name = "numericUpDownX1"; - this.numericUpDownX1.Size = new System.Drawing.Size(63, 23); - this.numericUpDownX1.TabIndex = 4; - // + this.checkBoxStatics.CheckedChanged += new System.EventHandler(this.OnOptionChanged); + // + // RemoveDupl + // + this.RemoveDupl.Location = new System.Drawing.Point(140, 52); + this.RemoveDupl.Name = "RemoveDupl"; + this.RemoveDupl.Size = new System.Drawing.Size(150, 21); + this.RemoveDupl.TabIndex = 4; + this.RemoveDupl.Text = "remove duplicates"; + this.RemoveDupl.UseVisualStyleBackColor = true; + this.RemoveDupl.CheckedChanged += new System.EventHandler(this.OnOptionChanged); + // + // checkBoxDuplicatesHue + // + this.checkBoxDuplicatesHue.Location = new System.Drawing.Point(296, 52); + this.checkBoxDuplicatesHue.Name = "checkBoxDuplicatesHue"; + this.checkBoxDuplicatesHue.Size = new System.Drawing.Size(184, 21); + this.checkBoxDuplicatesHue.TabIndex = 5; + this.checkBoxDuplicatesHue.Text = "comparing hue too (legacy)"; + this.checkBoxDuplicatesHue.UseVisualStyleBackColor = true; + // + // groupBoxFrom + // + this.groupBoxFrom.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); + this.groupBoxFrom.Controls.Add(this.label1); + this.groupBoxFrom.Controls.Add(this.numericUpDownX1); + this.groupBoxFrom.Controls.Add(this.label2); + this.groupBoxFrom.Controls.Add(this.numericUpDownY1); + this.groupBoxFrom.Controls.Add(this.label3); + this.groupBoxFrom.Controls.Add(this.numericUpDownX2); + this.groupBoxFrom.Controls.Add(this.label4); + this.groupBoxFrom.Controls.Add(this.numericUpDownY2); + this.groupBoxFrom.Location = new System.Drawing.Point(12, 104); + this.groupBoxFrom.Name = "groupBoxFrom"; + this.groupBoxFrom.Size = new System.Drawing.Size(960, 60); + this.groupBoxFrom.TabIndex = 2; + this.groupBoxFrom.TabStop = false; + this.groupBoxFrom.Text = "Region, in map tiles"; + // // label1 - // - this.label1.AutoSize = true; - this.label1.Location = new System.Drawing.Point(63, 24); - this.label1.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + // + this.label1.Location = new System.Drawing.Point(16, 26); this.label1.Name = "label1"; - this.label1.Size = new System.Drawing.Size(20, 15); - this.label1.TabIndex = 5; + this.label1.Size = new System.Drawing.Size(24, 17); + this.label1.TabIndex = 0; this.label1.Text = "X1"; - // + // + // numericUpDownX1 + // + this.numericUpDownX1.Increment = new decimal(new int[] { 8, 0, 0, 0 }); + this.numericUpDownX1.Location = new System.Drawing.Point(44, 23); + this.numericUpDownX1.Name = "numericUpDownX1"; + this.numericUpDownX1.Size = new System.Drawing.Size(70, 23); + this.numericUpDownX1.TabIndex = 1; + this.numericUpDownX1.ValueChanged += new System.EventHandler(this.OnRegionChanged); + // // label2 - // - this.label2.AutoSize = true; - this.label2.Location = new System.Drawing.Point(63, 54); - this.label2.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + // + this.label2.Location = new System.Drawing.Point(124, 26); this.label2.Name = "label2"; - this.label2.Size = new System.Drawing.Size(20, 15); - this.label2.TabIndex = 7; + this.label2.Size = new System.Drawing.Size(24, 17); + this.label2.TabIndex = 2; this.label2.Text = "Y1"; - // + // // numericUpDownY1 - // - this.numericUpDownY1.Increment = new decimal(new int[] { - 8, - 0, - 0, - 0}); - this.numericUpDownY1.Location = new System.Drawing.Point(99, 52); - this.numericUpDownY1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + // + this.numericUpDownY1.Increment = new decimal(new int[] { 8, 0, 0, 0 }); + this.numericUpDownY1.Location = new System.Drawing.Point(152, 23); this.numericUpDownY1.Name = "numericUpDownY1"; - this.numericUpDownY1.Size = new System.Drawing.Size(63, 23); - this.numericUpDownY1.TabIndex = 6; - // + this.numericUpDownY1.Size = new System.Drawing.Size(70, 23); + this.numericUpDownY1.TabIndex = 3; + this.numericUpDownY1.ValueChanged += new System.EventHandler(this.OnRegionChanged); + // // label3 - // - this.label3.AutoSize = true; - this.label3.Location = new System.Drawing.Point(192, 24); - this.label3.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + // + this.label3.Location = new System.Drawing.Point(248, 26); this.label3.Name = "label3"; - this.label3.Size = new System.Drawing.Size(20, 15); - this.label3.TabIndex = 9; + this.label3.Size = new System.Drawing.Size(24, 17); + this.label3.TabIndex = 4; this.label3.Text = "X2"; - // + // // numericUpDownX2 - // - this.numericUpDownX2.Increment = new decimal(new int[] { - 8, - 0, - 0, - 0}); - this.numericUpDownX2.Location = new System.Drawing.Point(229, 22); - this.numericUpDownX2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + // + this.numericUpDownX2.Increment = new decimal(new int[] { 8, 0, 0, 0 }); + this.numericUpDownX2.Location = new System.Drawing.Point(276, 23); this.numericUpDownX2.Name = "numericUpDownX2"; - this.numericUpDownX2.Size = new System.Drawing.Size(63, 23); - this.numericUpDownX2.TabIndex = 8; - // + this.numericUpDownX2.Size = new System.Drawing.Size(70, 23); + this.numericUpDownX2.TabIndex = 5; + this.numericUpDownX2.ValueChanged += new System.EventHandler(this.OnRegionChanged); + // // label4 - // - this.label4.AutoSize = true; - this.label4.Location = new System.Drawing.Point(192, 57); - this.label4.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + // + this.label4.Location = new System.Drawing.Point(356, 26); this.label4.Name = "label4"; - this.label4.Size = new System.Drawing.Size(20, 15); - this.label4.TabIndex = 11; + this.label4.Size = new System.Drawing.Size(24, 17); + this.label4.TabIndex = 6; this.label4.Text = "Y2"; - // + // // numericUpDownY2 - // - this.numericUpDownY2.Increment = new decimal(new int[] { - 8, - 0, - 0, - 0}); - this.numericUpDownY2.Location = new System.Drawing.Point(229, 54); - this.numericUpDownY2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + // + this.numericUpDownY2.Increment = new decimal(new int[] { 8, 0, 0, 0 }); + this.numericUpDownY2.Location = new System.Drawing.Point(384, 23); this.numericUpDownY2.Name = "numericUpDownY2"; - this.numericUpDownY2.Size = new System.Drawing.Size(63, 23); - this.numericUpDownY2.TabIndex = 10; - // - // button2 - // - this.button2.Location = new System.Drawing.Point(147, 188); - this.button2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.button2.Name = "button2"; - this.button2.Size = new System.Drawing.Size(88, 27); - this.button2.TabIndex = 12; - this.button2.Text = "Insert"; - this.button2.UseVisualStyleBackColor = true; - this.button2.Click += new System.EventHandler(this.OnClickCopy); - // + this.numericUpDownY2.Size = new System.Drawing.Size(70, 23); + this.numericUpDownY2.TabIndex = 7; + this.numericUpDownY2.ValueChanged += new System.EventHandler(this.OnRegionChanged); + // + // groupBoxPreview + // + this.groupBoxPreview.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); + this.groupBoxPreview.Controls.Add(this.preview); + this.groupBoxPreview.Controls.Add(this.checkBoxPreviewStatics); + this.groupBoxPreview.Controls.Add(this.checkBoxPreviewPatched); + this.groupBoxPreview.Controls.Add(this.textBoxPreview); + this.groupBoxPreview.Location = new System.Drawing.Point(12, 170); + this.groupBoxPreview.Name = "groupBoxPreview"; + this.groupBoxPreview.Size = new System.Drawing.Size(960, 548); + this.groupBoxPreview.TabIndex = 4; + this.groupBoxPreview.TabStop = false; + this.groupBoxPreview.Text = "What will be inserted - drag to choose the region"; + // + // preview + // + this.preview.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); + this.preview.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.preview.Location = new System.Drawing.Point(16, 22); + this.preview.Name = "preview"; + this.preview.Size = new System.Drawing.Size(928, 428); + this.preview.TabIndex = 0; + // + // checkBoxPreviewStatics + // + this.checkBoxPreviewStatics.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.checkBoxPreviewStatics.Location = new System.Drawing.Point(16, 458); + this.checkBoxPreviewStatics.Name = "checkBoxPreviewStatics"; + this.checkBoxPreviewStatics.Size = new System.Drawing.Size(120, 21); + this.checkBoxPreviewStatics.TabIndex = 1; + this.checkBoxPreviewStatics.Text = "Show statics"; + this.checkBoxPreviewStatics.UseVisualStyleBackColor = true; + this.checkBoxPreviewStatics.CheckedChanged += new System.EventHandler(this.OnPreviewOptionChanged); + // + // checkBoxPreviewPatched + // + this.checkBoxPreviewPatched.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.checkBoxPreviewPatched.Location = new System.Drawing.Point(144, 458); + this.checkBoxPreviewPatched.Name = "checkBoxPreviewPatched"; + this.checkBoxPreviewPatched.Size = new System.Drawing.Size(230, 21); + this.checkBoxPreviewPatched.TabIndex = 2; + this.checkBoxPreviewPatched.Text = "Mark the blocks the diff covers"; + this.checkBoxPreviewPatched.UseVisualStyleBackColor = true; + this.checkBoxPreviewPatched.CheckedChanged += new System.EventHandler(this.OnPreviewOptionChanged); + // + // textBoxPreview + // + this.textBoxPreview.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); + this.textBoxPreview.BackColor = System.Drawing.SystemColors.Control; + this.textBoxPreview.BorderStyle = System.Windows.Forms.BorderStyle.None; + this.textBoxPreview.Location = new System.Drawing.Point(16, 484); + this.textBoxPreview.Multiline = true; + this.textBoxPreview.Name = "textBoxPreview"; + this.textBoxPreview.ReadOnly = true; + this.textBoxPreview.ScrollBars = System.Windows.Forms.ScrollBars.None; + this.textBoxPreview.Size = new System.Drawing.Size(928, 52); + this.textBoxPreview.TabIndex = 3; + this.textBoxPreview.TabStop = false; + // // progressBar1 - // - this.progressBar1.Dock = System.Windows.Forms.DockStyle.Bottom; - this.progressBar1.Location = new System.Drawing.Point(0, 234); - this.progressBar1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + // + this.progressBar1.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); + this.progressBar1.Location = new System.Drawing.Point(12, 726); this.progressBar1.Name = "progressBar1"; - this.progressBar1.Size = new System.Drawing.Size(384, 27); - this.progressBar1.TabIndex = 13; - // - // RemoveDupl - // - this.RemoveDupl.AutoSize = true; - this.RemoveDupl.Location = new System.Drawing.Point(13, 48); - this.RemoveDupl.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.RemoveDupl.Name = "RemoveDupl"; - this.RemoveDupl.Size = new System.Drawing.Size(127, 19); - this.RemoveDupl.TabIndex = 17; - this.RemoveDupl.Text = "Remove Duplicates"; - this.RemoveDupl.UseVisualStyleBackColor = true; - // - // groupBox1 - // - this.groupBox1.Controls.Add(this.checkBoxMap); - this.groupBox1.Location = new System.Drawing.Point(14, 13); - this.groupBox1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.groupBox1.Name = "groupBox1"; - this.groupBox1.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.groupBox1.Size = new System.Drawing.Size(169, 75); - this.groupBox1.TabIndex = 19; - this.groupBox1.TabStop = false; - this.groupBox1.Text = "Map"; - // - // groupBox2 - // - this.groupBox2.Controls.Add(this.checkBoxStatics); - this.groupBox2.Controls.Add(this.RemoveDupl); - this.groupBox2.Location = new System.Drawing.Point(198, 13); - this.groupBox2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.groupBox2.Name = "groupBox2"; - this.groupBox2.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.groupBox2.Size = new System.Drawing.Size(169, 75); - this.groupBox2.TabIndex = 20; - this.groupBox2.TabStop = false; - this.groupBox2.Text = "Statics"; - // - // groupBox3 - // - this.groupBox3.Controls.Add(this.label1); - this.groupBox3.Controls.Add(this.numericUpDownX1); - this.groupBox3.Controls.Add(this.numericUpDownY1); - this.groupBox3.Controls.Add(this.label2); - this.groupBox3.Controls.Add(this.numericUpDownX2); - this.groupBox3.Controls.Add(this.label4); - this.groupBox3.Controls.Add(this.label3); - this.groupBox3.Controls.Add(this.numericUpDownY2); - this.groupBox3.Location = new System.Drawing.Point(13, 96); - this.groupBox3.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.groupBox3.Name = "groupBox3"; - this.groupBox3.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.groupBox3.Size = new System.Drawing.Size(355, 85); - this.groupBox3.TabIndex = 21; - this.groupBox3.TabStop = false; - this.groupBox3.Text = "Region"; - // - // MapDiffInsertForm - // + this.progressBar1.Size = new System.Drawing.Size(960, 18); + this.progressBar1.TabIndex = 5; + // + // labelStatus + // + this.labelStatus.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); + this.labelStatus.AutoEllipsis = true; + this.labelStatus.Location = new System.Drawing.Point(12, 749); + this.labelStatus.Name = "labelStatus"; + this.labelStatus.Size = new System.Drawing.Size(960, 17); + this.labelStatus.TabIndex = 6; + // + // buttonCopy + // + this.buttonCopy.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.buttonCopy.Location = new System.Drawing.Point(716, 774); + this.buttonCopy.Name = "buttonCopy"; + this.buttonCopy.Size = new System.Drawing.Size(80, 28); + this.buttonCopy.TabIndex = 7; + this.buttonCopy.Text = "Insert"; + this.buttonCopy.UseVisualStyleBackColor = true; + this.buttonCopy.Click += new System.EventHandler(this.OnClickCopy); + // + // buttonCancel + // + this.buttonCancel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.buttonCancel.Enabled = false; + this.buttonCancel.Location = new System.Drawing.Point(804, 774); + this.buttonCancel.Name = "buttonCancel"; + this.buttonCancel.Size = new System.Drawing.Size(72, 28); + this.buttonCancel.TabIndex = 8; + this.buttonCancel.Text = "Cancel"; + this.buttonCancel.UseVisualStyleBackColor = true; + this.buttonCancel.Click += new System.EventHandler(this.OnClickCancel); + // + // buttonClose + // + this.buttonClose.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.buttonClose.DialogResult = System.Windows.Forms.DialogResult.Cancel; + this.buttonClose.Location = new System.Drawing.Point(892, 774); + this.buttonClose.Name = "buttonClose"; + this.buttonClose.Size = new System.Drawing.Size(80, 28); + this.buttonClose.TabIndex = 9; + this.buttonClose.Text = "Close"; + this.buttonClose.UseVisualStyleBackColor = true; + this.buttonClose.Click += new System.EventHandler(this.OnClickClose); + // + // worker + // + this.worker.WorkerSupportsCancellation = true; + this.worker.DoWork += new System.ComponentModel.DoWorkEventHandler(this.OnWorkerDoWork); + this.worker.RunWorkerCompleted += new System.ComponentModel.RunWorkerCompletedEventHandler(this.OnWorkerCompleted); + // + // MapReplaceForm + // this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(384, 261); - this.Controls.Add(this.groupBox3); - this.Controls.Add(this.groupBox2); - this.Controls.Add(this.groupBox1); + this.CancelButton = this.buttonClose; + this.ClientSize = new System.Drawing.Size(984, 814); + this.Controls.Add(this.groupBoxWhat); + this.Controls.Add(this.groupBoxFrom); + this.Controls.Add(this.groupBoxPreview); this.Controls.Add(this.progressBar1); - this.Controls.Add(this.button2); + this.Controls.Add(this.labelStatus); + this.Controls.Add(this.buttonCopy); + this.Controls.Add(this.buttonCancel); + this.Controls.Add(this.buttonClose); this.DoubleBuffered = true; - this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow; - this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.MaximizeBox = false; - this.MinimumSize = new System.Drawing.Size(388, 285); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.Sizable; + this.MinimizeBox = false; + this.MinimumSize = new System.Drawing.Size(700, 640); this.Name = "MapDiffInsertForm"; - this.Text = "Map Diff Insert"; + this.ShowInTaskbar = false; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + this.Text = "Diff to Map Copy"; ((System.ComponentModel.ISupportInitialize)(this.numericUpDownX1)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownY1)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownX2)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownY2)).EndInit(); - this.groupBox1.ResumeLayout(false); - this.groupBox1.PerformLayout(); - this.groupBox2.ResumeLayout(false); - this.groupBox2.PerformLayout(); - this.groupBox3.ResumeLayout(false); - this.groupBox3.PerformLayout(); + this.groupBoxWhat.ResumeLayout(false); + this.groupBoxFrom.ResumeLayout(false); + this.groupBoxPreview.ResumeLayout(false); + this.groupBoxPreview.PerformLayout(); this.ResumeLayout(false); } #endregion - private System.Windows.Forms.Button button2; + private System.ComponentModel.BackgroundWorker worker; + private System.Windows.Forms.Button buttonCancel; + private System.Windows.Forms.Button buttonClose; + private System.Windows.Forms.Button buttonCopy; + private System.Windows.Forms.CheckBox RemoveDupl; + private System.Windows.Forms.CheckBox checkBoxDuplicatesHue; private System.Windows.Forms.CheckBox checkBoxMap; private System.Windows.Forms.CheckBox checkBoxStatics; - private System.Windows.Forms.GroupBox groupBox1; - private System.Windows.Forms.GroupBox groupBox2; - private System.Windows.Forms.GroupBox groupBox3; + private System.Windows.Forms.ComboBox comboBoxMapFormat; + private System.Windows.Forms.GroupBox groupBoxFrom; + private System.Windows.Forms.GroupBox groupBoxPreview; + private System.Windows.Forms.GroupBox groupBoxWhat; private System.Windows.Forms.Label label1; private System.Windows.Forms.Label label2; private System.Windows.Forms.Label label3; private System.Windows.Forms.Label label4; + private System.Windows.Forms.Label labelMapFormat; + private System.Windows.Forms.Label labelStatus; private System.Windows.Forms.NumericUpDown numericUpDownX1; private System.Windows.Forms.NumericUpDown numericUpDownX2; private System.Windows.Forms.NumericUpDown numericUpDownY1; private System.Windows.Forms.NumericUpDown numericUpDownY2; private System.Windows.Forms.ProgressBar progressBar1; - private System.Windows.Forms.CheckBox RemoveDupl; + private System.Windows.Forms.CheckBox checkBoxPreviewPatched; + private System.Windows.Forms.CheckBox checkBoxPreviewStatics; + private System.Windows.Forms.TextBox textBoxPreview; + private UoFiddler.Controls.UserControls.MapRegionPreview preview; } } \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/MapDiffInsertForm.cs b/UoFiddler.Controls/Forms/MapDiffInsertForm.cs index a48d98ed..65b60a83 100644 --- a/UoFiddler.Controls/Forms/MapDiffInsertForm.cs +++ b/UoFiddler.Controls/Forms/MapDiffInsertForm.cs @@ -10,10 +10,19 @@ ***************************************************************************/ using System; -using System.IO; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading; using System.Windows.Forms; +using Microsoft.Extensions.Logging; using Ultima; +using Ultima.Helpers; +using Ultima.Maps; +using Ultima.Statics; using UoFiddler.Controls.Classes; +using UoFiddler.Controls.UserControls; namespace UoFiddler.Controls.Forms { @@ -21,473 +30,373 @@ public partial class MapDiffInsertForm : Form { private readonly Map _workingMap; + private CancellationTokenSource _cancellation; + + /// Guards the round trip between a drag on the panel and the spinners it writes to. + private bool _syncingPreview; + public MapDiffInsertForm(Map currentMap) { InitializeComponent(); + Icon = Options.GetFiddlerIcon(); - _workingMap = currentMap; - numericUpDownX1.Maximum = _workingMap.Width; - numericUpDownX2.Maximum = _workingMap.Width; - numericUpDownY1.Maximum = _workingMap.Height; - numericUpDownY2.Maximum = _workingMap.Height; - Text = $"Map Diff Insert ID:{_workingMap.FileIndex}"; + + _workingMap = currentMap ?? throw new ArgumentNullException(nameof(currentMap)); + + Text = $"Diff to Map Copy - map {_workingMap.FileIndex}"; + + bool uop = _workingMap.Tiles.IsUOPFormat; + + comboBoxMapFormat.Items.Add(uop + ? "the same format as this client (.uop)" + : "the same format as this client (.mul)"); + comboBoxMapFormat.Items.Add($"map{_workingMap.FileIndex}.mul"); + comboBoxMapFormat.Items.Add($"map{_workingMap.FileIndex}LegacyMUL.uop"); + comboBoxMapFormat.SelectedIndex = ClientFileSaveFormats.DefaultIndex(Options.SaveFormat); + + checkBoxMap.Text = "Map"; + checkBoxStatics.Text = "Statics"; + checkBoxMap.Checked = true; + checkBoxStatics.Checked = true; + + // Exclusive bounds: a map of width W has tiles 0..W-1, and the old check let W through, + // which becomes a block index one past the end once it is shifted. + numericUpDownX1.Maximum = Math.Max(0, _workingMap.Width - 1); + numericUpDownX2.Maximum = Math.Max(0, _workingMap.Width - 1); + numericUpDownY1.Maximum = Math.Max(0, _workingMap.Height - 1); + numericUpDownY2.Maximum = Math.Max(0, _workingMap.Height - 1); + numericUpDownX2.Value = numericUpDownX2.Maximum; + numericUpDownY2.Value = numericUpDownY2.Maximum; + + checkBoxPreviewStatics.Checked = true; + checkBoxPreviewPatched.Checked = true; + + preview.Mode = MapPreviewMode.Rectangle; + preview.Map = _workingMap; + preview.MapSize = new MapSize(_workingMap.Width, _workingMap.Height); + preview.SelectionChanged += OnPreviewSelectionChanged; + + OnPreviewOptionChanged(this, EventArgs.Empty); + OnOptionChanged(this, EventArgs.Empty); + + ActiveControl = buttonCopy; } - private void OnClickCopy(object sender, EventArgs e) + private void OnOptionChanged(object sender, EventArgs e) + { + comboBoxMapFormat.Enabled = checkBoxMap.Checked; + labelMapFormat.Enabled = checkBoxMap.Checked; + RemoveDupl.Enabled = checkBoxStatics.Checked; + checkBoxDuplicatesHue.Enabled = checkBoxStatics.Checked && RemoveDupl.Checked; + + UpdatePreview(); + } + + private void OnRegionChanged(object sender, EventArgs e) + { + UpdatePreview(); + } + + /// + /// Shows the block-snapped rectangle that will really be patched, and how much diff data + /// there is to patch with. The tile to block conversion rounds out to whole 8-tile blocks, + /// which used to happen silently. + /// + private void UpdatePreview() { int x1 = (int)numericUpDownX1.Value; - int x2 = (int)numericUpDownX2.Value; int y1 = (int)numericUpDownY1.Value; + int x2 = (int)numericUpDownX2.Value; int y2 = (int)numericUpDownY2.Value; - if (x1 < 0 || x1 > _workingMap.Width) + if (x1 > x2) { - MessageBox.Show("Invalid X1 coordinate!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); - return; + (x1, x2) = (x2, x1); + } + + if (y1 > y2) + { + (y1, y2) = (y2, y1); } - if (x2 < 0 || x2 > _workingMap.Width) + var region = new BlockRectangle(x1 >> 3, y1 >> 3, x2 >> 3, y2 >> 3); + + TileMatrixPatch patch = _workingMap.Tiles.Patch; + + var sb = new StringBuilder(); + + sb.AppendLine($"region {region}"); + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, + "diff data loaded: {0:N0} land blocks, {1:N0} static blocks", + patch.LandBlocksCount, patch.StaticBlocksCount)); + + if (region.TileX1 != x1 || region.TileY1 != y1 || region.TileX2 != x2 || region.TileY2 != y2) + { + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, + "the request {0},{1} - {2},{3} was widened to whole 8-tile blocks", x1, y1, x2, y2)); + } + + textBoxPreview.Text = sb.ToString(); + + if (_syncingPreview) { - MessageBox.Show("Invalid X2 coordinate!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); return; } - if (y1 < 0 || y1 > _workingMap.Height) + _syncingPreview = true; + + try + { + preview.Selection = region; + } + finally + { + _syncingPreview = false; + } + } + + private void OnPreviewOptionChanged(object sender, EventArgs e) + { + preview.ShowStatics = checkBoxPreviewStatics.Checked; + + // Tinting the blocks the diff lists turns a count into something a region can be aimed at. + TileMatrixPatch patch = _workingMap.Tiles.Patch; + + preview.BlockHighlight = checkBoxPreviewPatched.Checked + ? (x, y) => patch.IsLandBlockPatched(x, y) || patch.IsStaticBlockPatched(x, y) + : null; + + preview.Invalidate(); + } + + /// A drag on the panel writes the region back into the spinners. + private void OnPreviewSelectionChanged(object sender, EventArgs e) + { + if (_syncingPreview) { - MessageBox.Show("Invalid Y1 coordinate!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); return; } - if (y2 < 0 || y2 > _workingMap.Height) + BlockRectangle selection = preview.Selection; + + _syncingPreview = true; + + try + { + numericUpDownX1.Value = Clamp(numericUpDownX1, selection.TileX1); + numericUpDownY1.Value = Clamp(numericUpDownY1, selection.TileY1); + numericUpDownX2.Value = Clamp(numericUpDownX2, selection.TileX2); + numericUpDownY2.Value = Clamp(numericUpDownY2, selection.TileY2); + } + finally + { + _syncingPreview = false; + } + + UpdatePreview(); + } + + private static decimal Clamp(NumericUpDown control, int value) + { + return Math.Clamp(value, (int)control.Minimum, (int)control.Maximum); + } + + private void OnClickCopy(object sender, EventArgs e) + { + if (worker.IsBusy) { - MessageBox.Show("Invalid Y2 coordinate!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); return; } - if (x1 > x2 || y1 > y2) + if (!checkBoxMap.Checked && !checkBoxStatics.Checked) { - MessageBox.Show("X1 and Y1 cannot be bigger than X2 and Y2!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); + MessageBox.Show(this, "Nothing is selected to insert.", "Diff to Map Copy", + MessageBoxButtons.OK, MessageBoxIcon.Error); + return; } - x1 >>= 3; - x2 >>= 3; - y1 >>= 3; - y2 >>= 3; + var options = new MapDiffApplyOptions + { + Map = _workingMap, + X1 = (int)numericUpDownX1.Value, + Y1 = (int)numericUpDownY1.Value, + X2 = (int)numericUpDownX2.Value, + Y2 = (int)numericUpDownY2.Value, + ApplyLand = checkBoxMap.Checked, + ApplyStatics = checkBoxStatics.Checked, + MapFormat = ResolveFormat(), + OutputDirectory = Options.OutputPath + }; + + if (checkBoxStatics.Checked) + { + options.StaticsFilter = new StaticsTileFilter + { + // The rules this feature has always applied, so its output keeps its old shape. + DropInvalidItemIds = true, + MaxItemId = Art.GetMaxItemId(), + OutOfBlockTiles = OutOfBlockAction.Keep, + DropInvalidZ = false, + NormalizeNegativeHue = true, + RemoveDuplicates = RemoveDupl.Checked, + DuplicatesCompareHue = RemoveDupl.Checked && checkBoxDuplicatesHue.Checked + }; + } - int blockY = _workingMap.Height >> 3; - int blockX = _workingMap.Width >> 3; + _cancellation?.Dispose(); + _cancellation = new CancellationTokenSource(); + options.CancellationToken = _cancellation.Token; + options.Progress = new Progress(OnProgress); - progressBar1.Step = 1; + SetRunning(true); progressBar1.Value = 0; - progressBar1.Maximum = 0; + labelStatus.Text = "Inserting..."; + + worker.RunWorkerAsync(options); + } - if (checkBoxMap.Checked) + private MapOutputFormat ResolveFormat() + { + switch (comboBoxMapFormat.SelectedIndex) { - progressBar1.Maximum += blockY * blockX; + case 1: return MapOutputFormat.Mul; + case 2: return MapOutputFormat.Uop; + default: return _workingMap.Tiles.IsUOPFormat ? MapOutputFormat.Uop : MapOutputFormat.Mul; } + } - if (checkBoxStatics.Checked) + private void OnProgress(MapCopyProgress progress) + { + if (progress.BlocksTotal <= 0) { - progressBar1.Maximum += blockY * blockX; + return; } - if (checkBoxMap.Checked) + progressBar1.Value = Math.Min(100, Math.Max(0, (int)(progress.BlocksDone * 100L / progress.BlocksTotal))); + labelStatus.Text = string.Format(CultureInfo.InvariantCulture, "{0}: {1:N0} of {2:N0} blocks", + progress.Stage, progress.BlocksDone, progress.BlocksTotal); + } + + private void OnWorkerDoWork(object sender, DoWorkEventArgs e) + { + e.Result = MapDiffApplier.Run((MapDiffApplyOptions)e.Argument); + } + + private void OnWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) + { + SetRunning(false); + + if (e.Error is OperationCanceledException) { - string mapPath = Files.GetFilePath($"map{_workingMap.FileIndex}.mul"); - BinaryReader mMapReader; + progressBar1.Value = 0; + labelStatus.Text = "Cancelled. Nothing was written."; - if (mapPath != null) - { - var mMap = new FileStream(mapPath, FileMode.Open, FileAccess.Read, FileShare.Read); - mMapReader = new BinaryReader(mMap); - } - else - { - MessageBox.Show("Map file not found!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); - return; - } + return; + } - string mul = Path.Combine(Options.OutputPath, $"map{_workingMap.FileIndex}.mul"); - using (FileStream fsmul = new FileStream(mul, FileMode.Create, FileAccess.Write, FileShare.Write)) - { - using (BinaryWriter binmul = new BinaryWriter(fsmul)) - { - for (int x = 0; x < blockX; ++x) - { - for (int y = 0; y < blockY; ++y) - { - mMapReader.BaseStream.Seek(((x * blockY) + y) * 196, SeekOrigin.Begin); - int header = mMapReader.ReadInt32(); - binmul.Write(header); - ushort tileid; - sbyte z; - bool patched = false; - if (x1 <= x && x <= x2 && y1 <= y && y <= y2) - { - if (_workingMap.Tiles.Patch.IsLandBlockPatched(x, y)) - { - patched = true; - Tile[] patchtile = _workingMap.Tiles.Patch.GetLandBlock(x, y); - for (int i = 0; i < 64; ++i) - { - tileid = patchtile[i].Id; - z = (sbyte)patchtile[i].Z; - tileid = Art.GetLegalItemId(tileid); - if (z < -128) - { - z = -128; - } - - if (z > 127) - { - z = 127; - } - - binmul.Write(tileid); - binmul.Write(z); - } - } - } - - if (!patched) - { - for (int i = 0; i < 64; ++i) - { - tileid = mMapReader.ReadUInt16(); - z = mMapReader.ReadSByte(); - tileid = Art.GetLegalItemId(tileid); - if (z < -128) - { - z = -128; - } - - if (z > 127) - { - z = 127; - } - - binmul.Write(tileid); - binmul.Write(z); - } - } - - progressBar1.PerformStep(); - } - } - } - } + if (e.Error != null) + { + progressBar1.Value = 0; + labelStatus.Text = "Failed."; + + ShowError("Diff to Map Copy", e.Error); - mMapReader.Close(); + return; } - if (checkBoxStatics.Checked) + + var result = (MapDiffApplyResult)e.Result; + + progressBar1.Value = 100; + labelStatus.Text = "Done."; + + using (var form = new MapDiffApplyResultForm(result)) { - string indexPath = Files.GetFilePath($"staidx{_workingMap.FileIndex}.mul"); - BinaryReader mIndexReader; - if (indexPath != null) - { - var mIndex = new FileStream(indexPath, FileMode.Open, FileAccess.Read, FileShare.Read); - mIndexReader = new BinaryReader(mIndex); - } - else - { - MessageBox.Show("Static file not found!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); - return; - } + form.ShowDialog(this); + } + } - string staticsPath = Files.GetFilePath($"statics{_workingMap.FileIndex}.mul"); + /// + /// Shows what actually went wrong. A bare "Object reference not set to an instance of an + /// object" tells a user nothing and tells whoever gets the bug report even less, so the + /// exception type and the place it came from go in the dialog and the whole thing goes to + /// the log. + /// + private void ShowError(string title, Exception error) + { + AppLog.For(GetType()).LogError(error, "{Title} failed.", title); - FileStream mStatics; - BinaryReader mStaticsReader; - if (staticsPath != null) - { - mStatics = new FileStream(staticsPath, FileMode.Open, FileAccess.Read, FileShare.Read); - mStaticsReader = new BinaryReader(mStatics); - } - else - { - MessageBox.Show("Static file not found!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); - return; - } + var sb = new StringBuilder(); + + for (Exception current = error; current != null; current = current.InnerException) + { + sb.AppendLine(current.Message); - string idx = Path.Combine(Options.OutputPath, $"staidx{_workingMap.FileIndex}.mul"); - string mul = Path.Combine(Options.OutputPath, $"statics{_workingMap.FileIndex}.mul"); - using (FileStream fsidx = new FileStream(idx, FileMode.Create, FileAccess.Write, FileShare.Write), - fsmul = new FileStream(mul, FileMode.Create, FileAccess.Write, FileShare.Write)) + if (current.InnerException != null) { - using (BinaryWriter binidx = new BinaryWriter(fsidx), - binmul = new BinaryWriter(fsmul)) - { - for (int x = 0; x < blockX; ++x) - { - for (int y = 0; y < blockY; ++y) - { - mIndexReader.BaseStream.Seek(((x * blockY) + y) * 12, SeekOrigin.Begin); - var lookup = mIndexReader.ReadInt32(); - var length = mIndexReader.ReadInt32(); - var extra = mIndexReader.ReadInt32(); - bool patched = false; - if (x1 <= x && x <= x2 && y1 <= y && y <= y2) - { - if (_workingMap.Tiles.Patch.IsStaticBlockPatched(x, y)) - { - patched = true; - } - } - - if (patched) - { - HuedTile[][][] patchstat = _workingMap.Tiles.Patch.GetStaticBlock(x, y); - int count = 0; - for (int i = 0; i < 8; ++i) - { - for (int j = 0; j < 8; ++j) - { - if (patchstat[i][j] != null) - { - count += patchstat[i][j].Length; - } - } - } - - if (count == 0) - { - binidx.Write(-1); // lookup - binidx.Write(-1); // length - binidx.Write(-1); // extra - } - else - { - int fsmullength = (int)fsmul.Position; - if (RemoveDupl.Checked) - { - StaticTile[] tilelist = new StaticTile[count]; - int m = 0; - for (int i = 0; i < 8; ++i) - { - for (int j = 0; j < 8; ++j) - { - foreach (HuedTile htile in patchstat[i][j]) - { - StaticTile tile = new StaticTile - { - Id = htile.Id, - Z = (sbyte)htile.Z, - X = (byte)i, - Y = (byte)j, - Hue = (short)htile.Hue - }; - - if (tile.Id > Art.GetMaxItemId()) - { - continue; - } - - if (tile.Hue < 0) - { - tile.Hue = 0; - } - - bool first = true; - for (int k = 0; k < m; ++k) - { - if (tilelist[k].Id == tile.Id && tilelist[k].X == tile.X && tilelist[k].Y == tile.Y && tilelist[k].Z == tile.Z && tilelist[k].Hue == tile.Hue) - { - first = false; - break; - } - } - if (first) - { - tilelist[m] = tile; - ++m; - } - } - } - } - if (m > 0) - { - binidx.Write((int)fsmul.Position); //lookup - for (int i = 0; i < m; ++i) - { - binmul.Write(tilelist[i].Id); - binmul.Write(tilelist[i].X); - binmul.Write(tilelist[i].Y); - binmul.Write(tilelist[i].Z); - binmul.Write(tilelist[i].Hue); - } - } - } - else - { - bool firstItem = true; - for (int i = 0; i < 8; ++i) - { - for (int j = 0; j < 8; ++j) - { - foreach (HuedTile tile in patchstat[i][j]) - { - ushort graphic = tile.Id; - sbyte sz = (sbyte)tile.Z; - short sHue = (short)tile.Hue; - - if (graphic > Art.GetMaxItemId()) - { - continue; - } - - if (sHue < 0) - { - sHue = 0; - } - - if (firstItem) - { - binidx.Write((int)fsmul.Position); //lookup - firstItem = false; - } - binmul.Write(graphic); - binmul.Write((byte)i); //x - binmul.Write((byte)j); //y - binmul.Write(sz); - binmul.Write(sHue); - } - } - } - } - fsmullength = (int)fsmul.Position - fsmullength; - if (fsmullength > 0) - { - binidx.Write(fsmullength); //length - binidx.Write(extra); //extra - } - else - { - binidx.Write(-1); //lookup - binidx.Write(-1); //length - binidx.Write(-1); //extra - } - } - } - else - { - if (lookup < 0 || length <= 0) - { - binidx.Write(-1); // lookup - binidx.Write(-1); // length - binidx.Write(-1); // extra - } - else - { - mStatics.Seek(lookup, SeekOrigin.Begin); - int fsmullength = (int)fsmul.Position; - int count = length / 7; - - if (RemoveDupl.Checked) - { - StaticTile[] tilelist = new StaticTile[count]; - int j = 0; - for (int i = 0; i < count; ++i) - { - StaticTile tile = new StaticTile - { - Id = mStaticsReader.ReadUInt16(), - X = mStaticsReader.ReadByte(), - Y = mStaticsReader.ReadByte(), - Z = mStaticsReader.ReadSByte(), - Hue = mStaticsReader.ReadInt16() - }; - - if (tile.Id <= Art.GetMaxItemId()) - { - if (tile.Hue < 0) - { - tile.Hue = 0; - } - - bool first = true; - for (int k = 0; k < j; ++k) - { - if (tilelist[k].Id == tile.Id && tilelist[k].X == tile.X && tilelist[k].Y == tile.Y && tilelist[k].Z == tile.Z && tilelist[k].Hue == tile.Hue) - { - first = false; - break; - } - } - if (first) - { - tilelist[j++] = tile; - } - } - } - if (j > 0) - { - binidx.Write((int)fsmul.Position); //lookup - for (int i = 0; i < j; ++i) - { - binmul.Write(tilelist[i].Id); - binmul.Write(tilelist[i].X); - binmul.Write(tilelist[i].Y); - binmul.Write(tilelist[i].Z); - binmul.Write(tilelist[i].Hue); - } - } - } - else - { - bool firstItem = true; - for (int i = 0; i < count; ++i) - { - var graphic = mStaticsReader.ReadUInt16(); - var sx = mStaticsReader.ReadByte(); - var sy = mStaticsReader.ReadByte(); - var sz = mStaticsReader.ReadSByte(); - var shue = mStaticsReader.ReadInt16(); - - if (graphic <= Art.GetMaxItemId()) - { - if (shue < 0) - { - shue = 0; - } - - if (firstItem) - { - binidx.Write((int)fsmul.Position); //lookup - firstItem = false; - } - binmul.Write(graphic); - binmul.Write(sx); - binmul.Write(sy); - binmul.Write(sz); - binmul.Write(shue); - } - } - } - fsmullength = (int)fsmul.Position - fsmullength; - if (fsmullength > 0) - { - binidx.Write(fsmullength); //length - binidx.Write(extra); //extra - } - else - { - binidx.Write(-1); //lookup - binidx.Write(-1); //length - binidx.Write(-1); //extra - } - } - } - progressBar1.PerformStep(); - } - } - } + sb.AppendLine(); } - mIndexReader.Close(); - mStaticsReader.Close(); } - FileSavedDialog.Show(FindForm(), Options.OutputPath, "Files saved successfully."); + sb.AppendLine(); + sb.AppendLine(error.GetType().FullName); + + string where = error.StackTrace?.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim(); + + if (!string.IsNullOrEmpty(where)) + { + sb.AppendLine(where); + } + + MessageBox.Show(this, sb.ToString(), title, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + + private void OnClickCancel(object sender, EventArgs e) + { + _cancellation?.Cancel(); + labelStatus.Text = "Cancelling..."; + } + + private void OnClickClose(object sender, EventArgs e) + { + Close(); + } + + private void SetRunning(bool running) + { + buttonCopy.Enabled = !running; + buttonCancel.Enabled = running; + buttonClose.Enabled = !running; + groupBoxWhat.Enabled = !running; + groupBoxFrom.Enabled = !running; + } + + protected override void OnLoad(EventArgs e) + { + base.OnLoad(e); + + FormLayout.FitToScreen(this); + } + + protected override void OnFormClosing(FormClosingEventArgs e) + { + if (worker.IsBusy) + { + _cancellation?.Cancel(); + e.Cancel = true; + + return; + } + + base.OnFormClosing(e); + } + + protected override void OnFormClosed(FormClosedEventArgs e) + { + _cancellation?.Dispose(); + _cancellation = null; + + base.OnFormClosed(e); } } } diff --git a/UoFiddler.Controls/Forms/MapRegionCopyResultForm.Designer.cs b/UoFiddler.Controls/Forms/MapRegionCopyResultForm.Designer.cs new file mode 100644 index 00000000..65c77615 --- /dev/null +++ b/UoFiddler.Controls/Forms/MapRegionCopyResultForm.Designer.cs @@ -0,0 +1,151 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +namespace UoFiddler.Controls.Forms +{ + partial class MapRegionCopyResultForm + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + private void InitializeComponent() + { + this.reportTextBox = new System.Windows.Forms.TextBox(); + this.buttonVerify = new System.Windows.Forms.Button(); + this.buttonSave = new System.Windows.Forms.Button(); + this.buttonCopy = new System.Windows.Forms.Button(); + this.buttonOpenFolder = new System.Windows.Forms.Button(); + this.buttonClose = new System.Windows.Forms.Button(); + this.SuspendLayout(); + // + // reportTextBox + // + this.reportTextBox.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.reportTextBox.Font = new System.Drawing.Font("Consolas", 9F); + this.reportTextBox.Location = new System.Drawing.Point(12, 12); + this.reportTextBox.Multiline = true; + this.reportTextBox.Name = "reportTextBox"; + this.reportTextBox.ReadOnly = true; + this.reportTextBox.ScrollBars = System.Windows.Forms.ScrollBars.Both; + this.reportTextBox.Size = new System.Drawing.Size(660, 420); + this.reportTextBox.TabIndex = 0; + this.reportTextBox.WordWrap = false; + // + // buttonVerify + // + this.buttonVerify.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.buttonVerify.Location = new System.Drawing.Point(12, 442); + this.buttonVerify.Name = "buttonVerify"; + this.buttonVerify.Size = new System.Drawing.Size(110, 27); + this.buttonVerify.TabIndex = 1; + this.buttonVerify.Text = "Verify output"; + this.buttonVerify.UseVisualStyleBackColor = true; + this.buttonVerify.Click += new System.EventHandler(this.OnClickVerify); + // + // buttonSave + // + this.buttonSave.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.buttonSave.Location = new System.Drawing.Point(128, 442); + this.buttonSave.Name = "buttonSave"; + this.buttonSave.Size = new System.Drawing.Size(110, 27); + this.buttonSave.TabIndex = 2; + this.buttonSave.Text = "Save report..."; + this.buttonSave.UseVisualStyleBackColor = true; + this.buttonSave.Click += new System.EventHandler(this.OnClickSave); + // + // buttonCopy + // + this.buttonCopy.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.buttonCopy.Location = new System.Drawing.Point(244, 442); + this.buttonCopy.Name = "buttonCopy"; + this.buttonCopy.Size = new System.Drawing.Size(80, 27); + this.buttonCopy.TabIndex = 3; + this.buttonCopy.Text = "Copy"; + this.buttonCopy.UseVisualStyleBackColor = true; + this.buttonCopy.Click += new System.EventHandler(this.OnClickCopy); + // + // buttonOpenFolder + // + this.buttonOpenFolder.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.buttonOpenFolder.Location = new System.Drawing.Point(452, 442); + this.buttonOpenFolder.Name = "buttonOpenFolder"; + this.buttonOpenFolder.Size = new System.Drawing.Size(130, 27); + this.buttonOpenFolder.TabIndex = 4; + this.buttonOpenFolder.Text = "Open output folder"; + this.buttonOpenFolder.UseVisualStyleBackColor = true; + this.buttonOpenFolder.Click += new System.EventHandler(this.OnClickOpenFolder); + // + // buttonClose + // + this.buttonClose.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.buttonClose.DialogResult = System.Windows.Forms.DialogResult.OK; + this.buttonClose.Location = new System.Drawing.Point(588, 442); + this.buttonClose.Name = "buttonClose"; + this.buttonClose.Size = new System.Drawing.Size(84, 27); + this.buttonClose.TabIndex = 5; + this.buttonClose.Text = "Close"; + this.buttonClose.UseVisualStyleBackColor = true; + // + // MapRegionCopyResultForm + // + this.AcceptButton = this.buttonClose; + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.CancelButton = this.buttonClose; + this.ClientSize = new System.Drawing.Size(684, 481); + this.Controls.Add(this.buttonClose); + this.Controls.Add(this.buttonOpenFolder); + this.Controls.Add(this.buttonCopy); + this.Controls.Add(this.buttonSave); + this.Controls.Add(this.buttonVerify); + this.Controls.Add(this.reportTextBox); + this.DoubleBuffered = true; + this.MinimizeBox = false; + this.MinimumSize = new System.Drawing.Size(520, 320); + this.Name = "MapRegionCopyResultForm"; + this.ShowInTaskbar = false; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + this.Text = "Map and Statics Copy - Result"; + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.Button buttonClose; + private System.Windows.Forms.Button buttonCopy; + private System.Windows.Forms.Button buttonOpenFolder; + private System.Windows.Forms.Button buttonSave; + private System.Windows.Forms.Button buttonVerify; + private System.Windows.Forms.TextBox reportTextBox; + } +} \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/MapRegionCopyResultForm.cs b/UoFiddler.Controls/Forms/MapRegionCopyResultForm.cs new file mode 100644 index 00000000..2ec9e964 --- /dev/null +++ b/UoFiddler.Controls/Forms/MapRegionCopyResultForm.cs @@ -0,0 +1,238 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; +using System.Windows.Forms; +using Ultima; +using Ultima.Maps; +using UoFiddler.Controls.Classes; + +namespace UoFiddler.Controls.Forms +{ + public sealed partial class MapRegionCopyResultForm : Form + { + private readonly MapRegionCopyResult _result; + + public MapRegionCopyResultForm(MapRegionCopyResult result) + { + InitializeComponent(); + + Icon = Options.GetFiddlerIcon(); + + _result = result; + + reportTextBox.Text = result.ToReport(); + + buttonVerify.Enabled = result.OutputMapPath != null; + buttonOpenFolder.Enabled = result.OutputMapPath != null || result.OutputIndexPath != null; + } + + /// + /// Reads the written map back and checks it block by block: inside the pasted rectangle it + /// has to match the source, everywhere else the map it was made from. + /// + private void OnClickVerify(object sender, EventArgs e) + { + using (new WaitCursorScope(this)) + { + try + { + reportTextBox.Text = Verify() + Environment.NewLine + + new string('-', 60) + Environment.NewLine + _result.ToReport(); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Verify failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } + + private string Verify() + { + var sb = new StringBuilder(); + + MapSize destination = _result.DestinationSize; + MapSize source = _result.SourceSize; + + string outputDirectory = Path.GetDirectoryName(_result.OutputMapPath); + + // Three views: what was written, where the region came from, and the map it replaced. + var written = new TileMatrix(_result.DestinationFileIndex, _result.DestinationFileIndex, + destination.Width, destination.Height, outputDirectory); + var from = new TileMatrix(_result.SourceFileIndex, _result.SourceFileIndex, + source.Width, source.Height, _result.SourceDirectory); + var original = new TileMatrix(_result.DestinationFileIndex, _result.DestinationFileIndex, + destination.Width, destination.Height, null); + + try + { + var actual = new byte[TileMatrix.MapBlockSize]; + var expected = new byte[TileMatrix.MapBlockSize]; + + long inRegion = 0; + long carried = 0; + long mismatches = 0; + string firstMismatch = null; + + for (int x = 0; x < destination.BlockWidth; ++x) + { + for (int y = 0; y < destination.BlockHeight; ++y) + { + bool copied = x >= _result.DestinationRegion.BlockX1 && x <= _result.DestinationRegion.BlockX2 && + y >= _result.DestinationRegion.BlockY1 && y <= _result.DestinationRegion.BlockY2; + + written.ReadLandBlockBytes(x, y, actual); + + if (copied) + { + ++inRegion; + from.ReadLandBlockBytes( + x - _result.DestinationRegion.BlockX1 + _result.Source.BlockX1, + y - _result.DestinationRegion.BlockY1 + _result.Source.BlockY1, + expected); + + // The region was asked to move in z, so the source block is not what + // should have been written - the source block plus that shift is. + if (_result.ZAdjust != 0) + { + ShiftZ(expected, _result.ZAdjust); + } + } + else + { + ++carried; + original.ReadLandBlockBytes(x, y, expected); + } + + if (actual.AsSpan().SequenceEqual(expected)) + { + continue; + } + + ++mismatches; + + firstMismatch ??= Line("block {0},{1} (world {2},{3}) {4}", + x, y, x << 3, y << 3, copied ? "does not match the source" : "does not match the original map"); + } + } + + bool countsAgree = inRegion == _result.LandBlocksCopied && carried == _result.LandBlocksCarried; + + sb.AppendLine(mismatches == 0 && countsAgree ? "PASSED" : "FAILED"); + sb.AppendLine(); + sb.AppendLine(Line("Read back : {0}", _result.OutputMapPath)); + sb.AppendLine(Line("Blocks compared : {0:N0}", inRegion + carried)); + sb.AppendLine(Line(" from the source: {0:N0}", inRegion)); + sb.AppendLine(Line(" carried over : {0:N0}", carried)); + + if (_result.ZAdjust != 0) + { + sb.AppendLine(Line(" compared with : the source shifted by {0:+#;-#;0} in z", _result.ZAdjust)); + } + sb.AppendLine(Line("Blocks differing : {0:N0}", mismatches)); + + if (firstMismatch != null) + { + sb.AppendLine(Line("First difference : {0}", firstMismatch)); + } + + if (!countsAgree) + { + sb.AppendLine(Line("Counts disagree with the copy report: it says {0:N0} copied and {1:N0} carried.", + _result.LandBlocksCopied, _result.LandBlocksCarried)); + } + } + finally + { + written.CloseStreams(); + from.CloseStreams(); + original.CloseStreams(); + } + + return sb.ToString(); + } + + /// Moves a land block's 64 z values, the way the copier did on the way out. + private static void ShiftZ(byte[] block, int adjust) + { + for (int i = 0; i < 64; ++i) + { + int at = TileMatrix.BlockHeaderSize + (i * 3) + 2; + + block[at] = (byte)(sbyte)Math.Clamp((sbyte)block[at] + adjust, + ZHistogram.MinZ, ZHistogram.MaxZ); + } + } + + private void OnClickSave(object sender, EventArgs e) + { + using (var dialog = new SaveFileDialog + { + Title = "Save the copy report", + Filter = "Text files (*.txt)|*.txt|All files (*.*)|*.*", + FileName = "map-copy.txt", + InitialDirectory = Options.OutputPath + }) + { + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + try + { + File.WriteAllText(dialog.FileName, reportTextBox.Text); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Save failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } + + private void OnClickCopy(object sender, EventArgs e) + { + if (reportTextBox.TextLength > 0) + { + Clipboard.SetText(reportTextBox.Text); + } + } + + private void OnClickOpenFolder(object sender, EventArgs e) + { + string folder = Path.GetDirectoryName(_result.OutputMapPath ?? _result.OutputIndexPath); + + if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder)) + { + return; + } + + try + { + Process.Start(new ProcessStartInfo { FileName = folder, UseShellExecute = true }); + } + catch (Exception ex) + { + MessageBox.Show(this, $"Unable to open folder: {ex.Message}", "Error", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private static string Line(string format, params object[] args) + { + return string.Format(CultureInfo.InvariantCulture, format, args); + } + } +} \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/MapRegionCopyResultForm.resx b/UoFiddler.Controls/Forms/MapRegionCopyResultForm.resx new file mode 100644 index 00000000..6dae11dd --- /dev/null +++ b/UoFiddler.Controls/Forms/MapRegionCopyResultForm.resx @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/MapReplaceForm.Designer.cs b/UoFiddler.Controls/Forms/MapReplaceForm.Designer.cs index f9c028b3..6383be9e 100644 --- a/UoFiddler.Controls/Forms/MapReplaceForm.Designer.cs +++ b/UoFiddler.Controls/Forms/MapReplaceForm.Designer.cs @@ -1,9 +1,9 @@ /*************************************************************************** * * $Author: Turley - * + * * "THE BEER-WARE LICENSE" - * As long as you retain this notice you can do whatever you want with + * As long as you retain this notice you can do whatever you want with * this stuff. If we meet some day, and you think this stuff is worth it, * you can buy me a beer in return. * @@ -28,416 +28,613 @@ protected override void Dispose(bool disposing) { components.Dispose(); } + base.Dispose(disposing); } #region Windows Form Designer generated code - /// - /// Required method for Designer support - do not modify - /// the contents of this method with the code editor. - /// private void InitializeComponent() { - this.textBox1 = new System.Windows.Forms.TextBox(); - this.button1 = new System.Windows.Forms.Button(); + this.components = new System.ComponentModel.Container(); + this.groupBoxSource = new System.Windows.Forms.GroupBox(); + this.labelFolder = new System.Windows.Forms.Label(); + this.textBoxFolder = new System.Windows.Forms.TextBox(); + this.buttonBrowse = new System.Windows.Forms.Button(); + this.labelMap = new System.Windows.Forms.Label(); + this.comboBoxMapID = new System.Windows.Forms.ComboBox(); + this.labelDetected = new System.Windows.Forms.Label(); + this.labelSizeWarning = new System.Windows.Forms.Label(); + this.groupBoxWhat = new System.Windows.Forms.GroupBox(); this.checkBoxMap = new System.Windows.Forms.CheckBox(); + this.labelMapFormat = new System.Windows.Forms.Label(); + this.comboBoxMapFormat = new System.Windows.Forms.ComboBox(); this.checkBoxStatics = new System.Windows.Forms.CheckBox(); - this.numericUpDownX1 = new System.Windows.Forms.NumericUpDown(); + this.RemoveDupl = new System.Windows.Forms.CheckBox(); + this.checkBoxDuplicatesHue = new System.Windows.Forms.CheckBox(); + this.groupBoxFrom = new System.Windows.Forms.GroupBox(); this.label1 = new System.Windows.Forms.Label(); + this.numericUpDownX1 = new System.Windows.Forms.NumericUpDown(); this.label2 = new System.Windows.Forms.Label(); this.numericUpDownY1 = new System.Windows.Forms.NumericUpDown(); this.label3 = new System.Windows.Forms.Label(); this.numericUpDownX2 = new System.Windows.Forms.NumericUpDown(); this.label4 = new System.Windows.Forms.Label(); this.numericUpDownY2 = new System.Windows.Forms.NumericUpDown(); - this.button2 = new System.Windows.Forms.Button(); - this.progressBar1 = new System.Windows.Forms.ProgressBar(); - this.label5 = new System.Windows.Forms.Label(); - this.RemoveDupl = new System.Windows.Forms.CheckBox(); - this.groupBox1 = new System.Windows.Forms.GroupBox(); - this.groupBox2 = new System.Windows.Forms.GroupBox(); - this.groupBox3 = new System.Windows.Forms.GroupBox(); - this.groupBox4 = new System.Windows.Forms.GroupBox(); + this.groupBoxTo = new System.Windows.Forms.GroupBox(); this.label6 = new System.Windows.Forms.Label(); this.numericUpDownToX1 = new System.Windows.Forms.NumericUpDown(); - this.numericUpDownToY1 = new System.Windows.Forms.NumericUpDown(); this.label7 = new System.Windows.Forms.Label(); - this.comboBoxMapID = new System.Windows.Forms.ComboBox(); - this.label8 = new System.Windows.Forms.Label(); + this.numericUpDownToY1 = new System.Windows.Forms.NumericUpDown(); + this.labelZAdjust = new System.Windows.Forms.Label(); + this.numericUpDownZ = new System.Windows.Forms.NumericUpDown(); + this.checkBoxZClamp = new System.Windows.Forms.CheckBox(); + this.labelZRange = new System.Windows.Forms.Label(); + this.groupBoxPreview = new System.Windows.Forms.GroupBox(); + this.labelSourcePreview = new System.Windows.Forms.Label(); + this.previewSource = new UoFiddler.Controls.UserControls.MapRegionPreview(); + this.labelTargetPreview = new System.Windows.Forms.Label(); + this.previewTarget = new UoFiddler.Controls.UserControls.MapRegionPreview(); + this.checkBoxPreviewStatics = new System.Windows.Forms.CheckBox(); + this.checkBoxPreviewOverlay = new System.Windows.Forms.CheckBox(); + this.textBoxPreview = new System.Windows.Forms.TextBox(); + this.progressBar1 = new System.Windows.Forms.ProgressBar(); + this.labelStatus = new System.Windows.Forms.Label(); + this.buttonCopy = new System.Windows.Forms.Button(); + this.buttonCancel = new System.Windows.Forms.Button(); + this.buttonClose = new System.Windows.Forms.Button(); + this.worker = new System.ComponentModel.BackgroundWorker(); + this.components.Add(this.worker); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownX1)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownY1)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownX2)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownY2)).BeginInit(); - this.groupBox1.SuspendLayout(); - this.groupBox2.SuspendLayout(); - this.groupBox3.SuspendLayout(); - this.groupBox4.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownToX1)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownToY1)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.numericUpDownZ)).BeginInit(); + this.groupBoxSource.SuspendLayout(); + this.groupBoxWhat.SuspendLayout(); + this.groupBoxFrom.SuspendLayout(); + this.groupBoxTo.SuspendLayout(); + this.groupBoxPreview.SuspendLayout(); this.SuspendLayout(); - // - // textBox1 - // - this.textBox1.Location = new System.Drawing.Point(105, 14); - this.textBox1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.textBox1.Name = "textBox1"; - this.textBox1.Size = new System.Drawing.Size(227, 23); - this.textBox1.TabIndex = 0; - // - // button1 - // - this.button1.AutoSize = true; - this.button1.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink; - this.button1.Location = new System.Drawing.Point(340, 12); - this.button1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.button1.Name = "button1"; - this.button1.Size = new System.Drawing.Size(26, 25); - this.button1.TabIndex = 1; - this.button1.Text = "..."; - this.button1.UseVisualStyleBackColor = true; - this.button1.Click += new System.EventHandler(this.OnClickBrowse); - // + // + // groupBoxSource + // + this.groupBoxSource.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); + this.groupBoxSource.Controls.Add(this.labelFolder); + this.groupBoxSource.Controls.Add(this.textBoxFolder); + this.groupBoxSource.Controls.Add(this.buttonBrowse); + this.groupBoxSource.Controls.Add(this.labelMap); + this.groupBoxSource.Controls.Add(this.comboBoxMapID); + this.groupBoxSource.Controls.Add(this.labelDetected); + this.groupBoxSource.Controls.Add(this.labelSizeWarning); + this.groupBoxSource.Location = new System.Drawing.Point(12, 12); + this.groupBoxSource.Name = "groupBoxSource"; + this.groupBoxSource.Size = new System.Drawing.Size(1440, 122); + this.groupBoxSource.TabIndex = 0; + this.groupBoxSource.TabStop = false; + this.groupBoxSource.Text = "Copy from"; + // + // labelFolder + // + this.labelFolder.Location = new System.Drawing.Point(12, 26); + this.labelFolder.Name = "labelFolder"; + this.labelFolder.Size = new System.Drawing.Size(60, 17); + this.labelFolder.TabIndex = 0; + this.labelFolder.Text = "Folder:"; + // + // textBoxFolder + // + this.textBoxFolder.Location = new System.Drawing.Point(76, 23); + this.textBoxFolder.Name = "textBoxFolder"; + this.textBoxFolder.Size = new System.Drawing.Size(320, 23); + this.textBoxFolder.TabIndex = 1; + // + // buttonBrowse + // + this.buttonBrowse.Location = new System.Drawing.Point(404, 22); + this.buttonBrowse.Name = "buttonBrowse"; + this.buttonBrowse.Size = new System.Drawing.Size(80, 25); + this.buttonBrowse.TabIndex = 2; + this.buttonBrowse.Text = "Browse..."; + this.buttonBrowse.UseVisualStyleBackColor = true; + this.buttonBrowse.Click += new System.EventHandler(this.OnClickBrowse); + // + // labelMap + // + this.labelMap.Location = new System.Drawing.Point(12, 58); + this.labelMap.Name = "labelMap"; + this.labelMap.Size = new System.Drawing.Size(60, 17); + this.labelMap.TabIndex = 3; + this.labelMap.Text = "Map:"; + // + // comboBoxMapID + // + this.comboBoxMapID.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.comboBoxMapID.Location = new System.Drawing.Point(76, 55); + this.comboBoxMapID.Name = "comboBoxMapID"; + this.comboBoxMapID.Size = new System.Drawing.Size(220, 23); + this.comboBoxMapID.TabIndex = 4; + this.comboBoxMapID.SelectedIndexChanged += new System.EventHandler(this.OnSourceMapChanged); + // + // labelDetected + // + this.labelDetected.AutoEllipsis = true; + this.labelDetected.Location = new System.Drawing.Point(304, 58); + this.labelDetected.Name = "labelDetected"; + this.labelDetected.Size = new System.Drawing.Size(180, 17); + this.labelDetected.TabIndex = 5; + // + // labelSizeWarning + // + this.labelSizeWarning.Location = new System.Drawing.Point(12, 80); + this.labelSizeWarning.Name = "labelSizeWarning"; + this.labelSizeWarning.Size = new System.Drawing.Size(472, 36); + this.labelSizeWarning.TabIndex = 6; + // + // groupBoxWhat + // + this.groupBoxWhat.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); + this.groupBoxWhat.Controls.Add(this.checkBoxMap); + this.groupBoxWhat.Controls.Add(this.labelMapFormat); + this.groupBoxWhat.Controls.Add(this.comboBoxMapFormat); + this.groupBoxWhat.Controls.Add(this.checkBoxStatics); + this.groupBoxWhat.Controls.Add(this.RemoveDupl); + this.groupBoxWhat.Controls.Add(this.checkBoxDuplicatesHue); + this.groupBoxWhat.Location = new System.Drawing.Point(12, 140); + this.groupBoxWhat.Name = "groupBoxWhat"; + this.groupBoxWhat.Size = new System.Drawing.Size(1440, 86); + this.groupBoxWhat.TabIndex = 1; + this.groupBoxWhat.TabStop = false; + this.groupBoxWhat.Text = "Copy"; + // // checkBoxMap - // - this.checkBoxMap.AutoSize = true; - this.checkBoxMap.Location = new System.Drawing.Point(7, 22); - this.checkBoxMap.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + // + this.checkBoxMap.Location = new System.Drawing.Point(16, 22); this.checkBoxMap.Name = "checkBoxMap"; - this.checkBoxMap.Size = new System.Drawing.Size(81, 19); - this.checkBoxMap.TabIndex = 2; - this.checkBoxMap.Text = "Copy Map"; + this.checkBoxMap.Size = new System.Drawing.Size(110, 21); + this.checkBoxMap.TabIndex = 0; + this.checkBoxMap.Text = "Map"; this.checkBoxMap.UseVisualStyleBackColor = true; - // + this.checkBoxMap.CheckedChanged += new System.EventHandler(this.OnOptionChanged); + // + // labelMapFormat + // + this.labelMapFormat.Location = new System.Drawing.Point(140, 25); + this.labelMapFormat.Name = "labelMapFormat"; + this.labelMapFormat.Size = new System.Drawing.Size(80, 17); + this.labelMapFormat.TabIndex = 1; + this.labelMapFormat.Text = "written as:"; + // + // comboBoxMapFormat + // + this.comboBoxMapFormat.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.comboBoxMapFormat.Location = new System.Drawing.Point(224, 21); + this.comboBoxMapFormat.Name = "comboBoxMapFormat"; + this.comboBoxMapFormat.Size = new System.Drawing.Size(256, 23); + this.comboBoxMapFormat.TabIndex = 2; + // // checkBoxStatics - // - this.checkBoxStatics.AutoSize = true; - this.checkBoxStatics.Location = new System.Drawing.Point(13, 22); - this.checkBoxStatics.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + // + this.checkBoxStatics.Location = new System.Drawing.Point(16, 52); this.checkBoxStatics.Name = "checkBoxStatics"; - this.checkBoxStatics.Size = new System.Drawing.Size(91, 19); + this.checkBoxStatics.Size = new System.Drawing.Size(110, 21); this.checkBoxStatics.TabIndex = 3; - this.checkBoxStatics.Text = "Copy Statics"; + this.checkBoxStatics.Text = "Statics"; this.checkBoxStatics.UseVisualStyleBackColor = true; - // - // numericUpDownX1 - // - this.numericUpDownX1.Increment = new decimal(new int[] { - 8, - 0, - 0, - 0}); - this.numericUpDownX1.Location = new System.Drawing.Point(99, 22); - this.numericUpDownX1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.numericUpDownX1.Name = "numericUpDownX1"; - this.numericUpDownX1.Size = new System.Drawing.Size(63, 23); - this.numericUpDownX1.TabIndex = 4; - // + this.checkBoxStatics.CheckedChanged += new System.EventHandler(this.OnOptionChanged); + // + // RemoveDupl + // + this.RemoveDupl.Location = new System.Drawing.Point(140, 52); + this.RemoveDupl.Name = "RemoveDupl"; + this.RemoveDupl.Size = new System.Drawing.Size(150, 21); + this.RemoveDupl.TabIndex = 4; + this.RemoveDupl.Text = "remove duplicates"; + this.RemoveDupl.UseVisualStyleBackColor = true; + this.RemoveDupl.CheckedChanged += new System.EventHandler(this.OnOptionChanged); + // + // checkBoxDuplicatesHue + // + this.checkBoxDuplicatesHue.Location = new System.Drawing.Point(296, 52); + this.checkBoxDuplicatesHue.Name = "checkBoxDuplicatesHue"; + this.checkBoxDuplicatesHue.Size = new System.Drawing.Size(184, 21); + this.checkBoxDuplicatesHue.TabIndex = 5; + this.checkBoxDuplicatesHue.Text = "comparing hue too (legacy)"; + this.checkBoxDuplicatesHue.UseVisualStyleBackColor = true; + // + // groupBoxFrom + // + this.groupBoxFrom.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); + this.groupBoxFrom.Controls.Add(this.label1); + this.groupBoxFrom.Controls.Add(this.numericUpDownX1); + this.groupBoxFrom.Controls.Add(this.label2); + this.groupBoxFrom.Controls.Add(this.numericUpDownY1); + this.groupBoxFrom.Controls.Add(this.label3); + this.groupBoxFrom.Controls.Add(this.numericUpDownX2); + this.groupBoxFrom.Controls.Add(this.label4); + this.groupBoxFrom.Controls.Add(this.numericUpDownY2); + this.groupBoxFrom.Location = new System.Drawing.Point(12, 232); + this.groupBoxFrom.Name = "groupBoxFrom"; + this.groupBoxFrom.Size = new System.Drawing.Size(1440, 60); + this.groupBoxFrom.TabIndex = 2; + this.groupBoxFrom.TabStop = false; + this.groupBoxFrom.Text = "From region, in source map tiles"; + // // label1 - // - this.label1.AutoSize = true; - this.label1.Location = new System.Drawing.Point(63, 24); - this.label1.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + // + this.label1.Location = new System.Drawing.Point(16, 26); this.label1.Name = "label1"; - this.label1.Size = new System.Drawing.Size(20, 15); - this.label1.TabIndex = 5; + this.label1.Size = new System.Drawing.Size(24, 17); + this.label1.TabIndex = 0; this.label1.Text = "X1"; - // + // + // numericUpDownX1 + // + this.numericUpDownX1.Increment = new decimal(new int[] { 8, 0, 0, 0 }); + this.numericUpDownX1.Location = new System.Drawing.Point(44, 23); + this.numericUpDownX1.Name = "numericUpDownX1"; + this.numericUpDownX1.Size = new System.Drawing.Size(70, 23); + this.numericUpDownX1.TabIndex = 1; + this.numericUpDownX1.ValueChanged += new System.EventHandler(this.OnRegionChanged); + // // label2 - // - this.label2.AutoSize = true; - this.label2.Location = new System.Drawing.Point(63, 54); - this.label2.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + // + this.label2.Location = new System.Drawing.Point(124, 26); this.label2.Name = "label2"; - this.label2.Size = new System.Drawing.Size(20, 15); - this.label2.TabIndex = 7; + this.label2.Size = new System.Drawing.Size(24, 17); + this.label2.TabIndex = 2; this.label2.Text = "Y1"; - // + // // numericUpDownY1 - // - this.numericUpDownY1.Increment = new decimal(new int[] { - 8, - 0, - 0, - 0}); - this.numericUpDownY1.Location = new System.Drawing.Point(99, 52); - this.numericUpDownY1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + // + this.numericUpDownY1.Increment = new decimal(new int[] { 8, 0, 0, 0 }); + this.numericUpDownY1.Location = new System.Drawing.Point(152, 23); this.numericUpDownY1.Name = "numericUpDownY1"; - this.numericUpDownY1.Size = new System.Drawing.Size(63, 23); - this.numericUpDownY1.TabIndex = 6; - // + this.numericUpDownY1.Size = new System.Drawing.Size(70, 23); + this.numericUpDownY1.TabIndex = 3; + this.numericUpDownY1.ValueChanged += new System.EventHandler(this.OnRegionChanged); + // // label3 - // - this.label3.AutoSize = true; - this.label3.Location = new System.Drawing.Point(192, 24); - this.label3.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + // + this.label3.Location = new System.Drawing.Point(248, 26); this.label3.Name = "label3"; - this.label3.Size = new System.Drawing.Size(20, 15); - this.label3.TabIndex = 9; + this.label3.Size = new System.Drawing.Size(24, 17); + this.label3.TabIndex = 4; this.label3.Text = "X2"; - // + // // numericUpDownX2 - // - this.numericUpDownX2.Increment = new decimal(new int[] { - 8, - 0, - 0, - 0}); - this.numericUpDownX2.Location = new System.Drawing.Point(229, 22); - this.numericUpDownX2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + // + this.numericUpDownX2.Increment = new decimal(new int[] { 8, 0, 0, 0 }); + this.numericUpDownX2.Location = new System.Drawing.Point(276, 23); this.numericUpDownX2.Name = "numericUpDownX2"; - this.numericUpDownX2.Size = new System.Drawing.Size(63, 23); - this.numericUpDownX2.TabIndex = 8; - // + this.numericUpDownX2.Size = new System.Drawing.Size(70, 23); + this.numericUpDownX2.TabIndex = 5; + this.numericUpDownX2.ValueChanged += new System.EventHandler(this.OnRegionChanged); + // // label4 - // - this.label4.AutoSize = true; - this.label4.Location = new System.Drawing.Point(192, 57); - this.label4.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + // + this.label4.Location = new System.Drawing.Point(356, 26); this.label4.Name = "label4"; - this.label4.Size = new System.Drawing.Size(20, 15); - this.label4.TabIndex = 11; + this.label4.Size = new System.Drawing.Size(24, 17); + this.label4.TabIndex = 6; this.label4.Text = "Y2"; - // + // // numericUpDownY2 - // - this.numericUpDownY2.Increment = new decimal(new int[] { - 8, - 0, - 0, - 0}); - this.numericUpDownY2.Location = new System.Drawing.Point(229, 54); - this.numericUpDownY2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + // + this.numericUpDownY2.Increment = new decimal(new int[] { 8, 0, 0, 0 }); + this.numericUpDownY2.Location = new System.Drawing.Point(384, 23); this.numericUpDownY2.Name = "numericUpDownY2"; - this.numericUpDownY2.Size = new System.Drawing.Size(63, 23); - this.numericUpDownY2.TabIndex = 10; - // - // button2 - // - this.button2.Location = new System.Drawing.Point(144, 318); - this.button2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.button2.Name = "button2"; - this.button2.Size = new System.Drawing.Size(88, 27); - this.button2.TabIndex = 12; - this.button2.Text = "Replace"; - this.button2.UseVisualStyleBackColor = true; - this.button2.Click += new System.EventHandler(this.OnClickCopy); - // - // progressBar1 - // - this.progressBar1.Dock = System.Windows.Forms.DockStyle.Bottom; - this.progressBar1.Location = new System.Drawing.Point(0, 354); - this.progressBar1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.progressBar1.Name = "progressBar1"; - this.progressBar1.Size = new System.Drawing.Size(384, 27); - this.progressBar1.TabIndex = 13; - // - // label5 - // - this.label5.AutoSize = true; - this.label5.Location = new System.Drawing.Point(13, 17); - this.label5.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); - this.label5.Name = "label5"; - this.label5.Size = new System.Drawing.Size(79, 15); - this.label5.TabIndex = 14; - this.label5.Text = "Replace From"; - // - // RemoveDupl - // - this.RemoveDupl.AutoSize = true; - this.RemoveDupl.Location = new System.Drawing.Point(13, 48); - this.RemoveDupl.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.RemoveDupl.Name = "RemoveDupl"; - this.RemoveDupl.Size = new System.Drawing.Size(127, 19); - this.RemoveDupl.TabIndex = 17; - this.RemoveDupl.Text = "Remove Duplicates"; - this.RemoveDupl.UseVisualStyleBackColor = true; - // - // groupBox1 - // - this.groupBox1.Controls.Add(this.checkBoxMap); - this.groupBox1.Location = new System.Drawing.Point(15, 75); - this.groupBox1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.groupBox1.Name = "groupBox1"; - this.groupBox1.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.groupBox1.Size = new System.Drawing.Size(169, 75); - this.groupBox1.TabIndex = 19; - this.groupBox1.TabStop = false; - this.groupBox1.Text = "Map"; - // - // groupBox2 - // - this.groupBox2.Controls.Add(this.checkBoxStatics); - this.groupBox2.Controls.Add(this.RemoveDupl); - this.groupBox2.Location = new System.Drawing.Point(201, 75); - this.groupBox2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.groupBox2.Name = "groupBox2"; - this.groupBox2.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.groupBox2.Size = new System.Drawing.Size(169, 75); - this.groupBox2.TabIndex = 20; - this.groupBox2.TabStop = false; - this.groupBox2.Text = "Statics"; - // - // groupBox3 - // - this.groupBox3.Controls.Add(this.label1); - this.groupBox3.Controls.Add(this.numericUpDownX1); - this.groupBox3.Controls.Add(this.numericUpDownY1); - this.groupBox3.Controls.Add(this.label2); - this.groupBox3.Controls.Add(this.numericUpDownX2); - this.groupBox3.Controls.Add(this.label4); - this.groupBox3.Controls.Add(this.label3); - this.groupBox3.Controls.Add(this.numericUpDownY2); - this.groupBox3.Location = new System.Drawing.Point(15, 157); - this.groupBox3.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.groupBox3.Name = "groupBox3"; - this.groupBox3.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.groupBox3.Size = new System.Drawing.Size(355, 85); - this.groupBox3.TabIndex = 21; - this.groupBox3.TabStop = false; - this.groupBox3.Text = "From Region"; - // - // groupBox4 - // - this.groupBox4.Controls.Add(this.label6); - this.groupBox4.Controls.Add(this.numericUpDownToX1); - this.groupBox4.Controls.Add(this.numericUpDownToY1); - this.groupBox4.Controls.Add(this.label7); - this.groupBox4.Location = new System.Drawing.Point(15, 249); - this.groupBox4.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.groupBox4.Name = "groupBox4"; - this.groupBox4.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.groupBox4.Size = new System.Drawing.Size(355, 62); - this.groupBox4.TabIndex = 22; - this.groupBox4.TabStop = false; - this.groupBox4.Text = "To Region"; - // + this.numericUpDownY2.Size = new System.Drawing.Size(70, 23); + this.numericUpDownY2.TabIndex = 7; + this.numericUpDownY2.ValueChanged += new System.EventHandler(this.OnRegionChanged); + // + // groupBoxTo + // + this.groupBoxTo.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); + this.groupBoxTo.Controls.Add(this.label6); + this.groupBoxTo.Controls.Add(this.numericUpDownToX1); + this.groupBoxTo.Controls.Add(this.label7); + this.groupBoxTo.Controls.Add(this.numericUpDownToY1); + this.groupBoxTo.Controls.Add(this.labelZAdjust); + this.groupBoxTo.Controls.Add(this.numericUpDownZ); + this.groupBoxTo.Controls.Add(this.checkBoxZClamp); + this.groupBoxTo.Controls.Add(this.labelZRange); + this.groupBoxTo.Location = new System.Drawing.Point(12, 298); + this.groupBoxTo.Name = "groupBoxTo"; + this.groupBoxTo.Size = new System.Drawing.Size(1440, 60); + this.groupBoxTo.TabIndex = 3; + this.groupBoxTo.TabStop = false; + this.groupBoxTo.Text = "To position, in this map\'s tiles"; + // // label6 - // - this.label6.AutoSize = true; - this.label6.Location = new System.Drawing.Point(63, 24); - this.label6.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + // + this.label6.Location = new System.Drawing.Point(16, 26); this.label6.Name = "label6"; - this.label6.Size = new System.Drawing.Size(20, 15); - this.label6.TabIndex = 5; - this.label6.Text = "X1"; - // + this.label6.Size = new System.Drawing.Size(24, 17); + this.label6.TabIndex = 0; + this.label6.Text = "X"; + // // numericUpDownToX1 - // - this.numericUpDownToX1.Increment = new decimal(new int[] { - 8, - 0, - 0, - 0}); - this.numericUpDownToX1.Location = new System.Drawing.Point(99, 22); - this.numericUpDownToX1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + // + this.numericUpDownToX1.Increment = new decimal(new int[] { 8, 0, 0, 0 }); + this.numericUpDownToX1.Location = new System.Drawing.Point(44, 23); this.numericUpDownToX1.Name = "numericUpDownToX1"; - this.numericUpDownToX1.Size = new System.Drawing.Size(63, 23); - this.numericUpDownToX1.TabIndex = 4; - // - // numericUpDownToY1 - // - this.numericUpDownToY1.Increment = new decimal(new int[] { - 8, - 0, - 0, - 0}); - this.numericUpDownToY1.Location = new System.Drawing.Point(229, 22); - this.numericUpDownToY1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.numericUpDownToY1.Name = "numericUpDownToY1"; - this.numericUpDownToY1.Size = new System.Drawing.Size(63, 23); - this.numericUpDownToY1.TabIndex = 6; - // + this.numericUpDownToX1.Size = new System.Drawing.Size(70, 23); + this.numericUpDownToX1.TabIndex = 1; + this.numericUpDownToX1.ValueChanged += new System.EventHandler(this.OnRegionChanged); + // // label7 - // - this.label7.AutoSize = true; - this.label7.Location = new System.Drawing.Point(192, 24); - this.label7.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + // + this.label7.Location = new System.Drawing.Point(124, 26); this.label7.Name = "label7"; - this.label7.Size = new System.Drawing.Size(20, 15); - this.label7.TabIndex = 7; - this.label7.Text = "Y1"; - // - // comboBoxMapID - // - this.comboBoxMapID.FormattingEnabled = true; - this.comboBoxMapID.Location = new System.Drawing.Point(105, 44); - this.comboBoxMapID.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.comboBoxMapID.Name = "comboBoxMapID"; - this.comboBoxMapID.Size = new System.Drawing.Size(227, 23); - this.comboBoxMapID.TabIndex = 23; - // - // label8 - // - this.label8.AutoSize = true; - this.label8.Location = new System.Drawing.Point(13, 47); - this.label8.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); - this.label8.Name = "label8"; - this.label8.Size = new System.Drawing.Size(45, 15); - this.label8.TabIndex = 24; - this.label8.Text = "Map ID"; - // + this.label7.Size = new System.Drawing.Size(24, 17); + this.label7.TabIndex = 2; + this.label7.Text = "Y"; + // + // numericUpDownToY1 + // + this.numericUpDownToY1.Increment = new decimal(new int[] { 8, 0, 0, 0 }); + this.numericUpDownToY1.Location = new System.Drawing.Point(152, 23); + this.numericUpDownToY1.Name = "numericUpDownToY1"; + this.numericUpDownToY1.Size = new System.Drawing.Size(70, 23); + this.numericUpDownToY1.TabIndex = 3; + this.numericUpDownToY1.ValueChanged += new System.EventHandler(this.OnRegionChanged); + // + // labelZAdjust + // + this.labelZAdjust.Location = new System.Drawing.Point(248, 26); + this.labelZAdjust.Name = "labelZAdjust"; + this.labelZAdjust.Size = new System.Drawing.Size(64, 17); + this.labelZAdjust.TabIndex = 4; + this.labelZAdjust.Text = "Z adjust"; + // + // numericUpDownZ + // + this.numericUpDownZ.Location = new System.Drawing.Point(316, 23); + this.numericUpDownZ.Maximum = new decimal(new int[] { 255, 0, 0, 0 }); + this.numericUpDownZ.Minimum = new decimal(new int[] { 255, 0, 0, -2147483648 }); + this.numericUpDownZ.Name = "numericUpDownZ"; + this.numericUpDownZ.Size = new System.Drawing.Size(70, 23); + this.numericUpDownZ.TabIndex = 5; + this.numericUpDownZ.ValueChanged += new System.EventHandler(this.OnRegionChanged); + // + // checkBoxZClamp + // + this.checkBoxZClamp.Location = new System.Drawing.Point(396, 24); + this.checkBoxZClamp.Name = "checkBoxZClamp"; + this.checkBoxZClamp.Size = new System.Drawing.Size(206, 21); + this.checkBoxZClamp.TabIndex = 6; + this.checkBoxZClamp.Text = "hold what passes the limit"; + this.checkBoxZClamp.UseVisualStyleBackColor = true; + this.checkBoxZClamp.CheckedChanged += new System.EventHandler(this.OnRegionChanged); + // + // labelZRange + // + this.labelZRange.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); + this.labelZRange.AutoEllipsis = true; + this.labelZRange.Location = new System.Drawing.Point(612, 26); + this.labelZRange.Name = "labelZRange"; + this.labelZRange.Size = new System.Drawing.Size(812, 17); + this.labelZRange.TabIndex = 7; + // + // groupBoxPreview + // + this.groupBoxPreview.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); + this.groupBoxPreview.Controls.Add(this.labelSourcePreview); + this.groupBoxPreview.Controls.Add(this.previewSource); + this.groupBoxPreview.Controls.Add(this.labelTargetPreview); + this.groupBoxPreview.Controls.Add(this.previewTarget); + this.groupBoxPreview.Controls.Add(this.checkBoxPreviewStatics); + this.groupBoxPreview.Controls.Add(this.checkBoxPreviewOverlay); + this.groupBoxPreview.Controls.Add(this.textBoxPreview); + this.groupBoxPreview.Location = new System.Drawing.Point(12, 364); + this.groupBoxPreview.Name = "groupBoxPreview"; + this.groupBoxPreview.Size = new System.Drawing.Size(1440, 512); + this.groupBoxPreview.TabIndex = 4; + this.groupBoxPreview.TabStop = false; + this.groupBoxPreview.Text = "What will be copied"; + // + // labelSourcePreview + // + this.labelSourcePreview.Location = new System.Drawing.Point(16, 20); + this.labelSourcePreview.Name = "labelSourcePreview"; + this.labelSourcePreview.Size = new System.Drawing.Size(696, 17); + this.labelSourcePreview.TabIndex = 0; + this.labelSourcePreview.Text = "From - drag to choose the region"; + // + // previewSource + // + this.previewSource.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.previewSource.Location = new System.Drawing.Point(16, 40); + this.previewSource.Name = "previewSource"; + this.previewSource.Size = new System.Drawing.Size(696, 400); + this.previewSource.TabIndex = 1; + // + // labelTargetPreview + // + this.labelTargetPreview.Location = new System.Drawing.Point(728, 20); + this.labelTargetPreview.Name = "labelTargetPreview"; + this.labelTargetPreview.Size = new System.Drawing.Size(696, 17); + this.labelTargetPreview.TabIndex = 2; + this.labelTargetPreview.Text = "To - drag to place it"; + // + // previewTarget + // + this.previewTarget.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.previewTarget.Location = new System.Drawing.Point(728, 40); + this.previewTarget.Name = "previewTarget"; + this.previewTarget.Size = new System.Drawing.Size(696, 400); + this.previewTarget.TabIndex = 3; + // + // checkBoxPreviewStatics + // + this.checkBoxPreviewStatics.Location = new System.Drawing.Point(16, 448); + this.checkBoxPreviewStatics.Name = "checkBoxPreviewStatics"; + this.checkBoxPreviewStatics.Size = new System.Drawing.Size(120, 21); + this.checkBoxPreviewStatics.TabIndex = 4; + this.checkBoxPreviewStatics.Text = "Show statics"; + this.checkBoxPreviewStatics.UseVisualStyleBackColor = true; + this.checkBoxPreviewStatics.CheckedChanged += new System.EventHandler(this.OnPreviewOptionChanged); + // + // checkBoxPreviewOverlay + // + this.checkBoxPreviewOverlay.Location = new System.Drawing.Point(144, 448); + this.checkBoxPreviewOverlay.Name = "checkBoxPreviewOverlay"; + this.checkBoxPreviewOverlay.Size = new System.Drawing.Size(230, 21); + this.checkBoxPreviewOverlay.TabIndex = 5; + this.checkBoxPreviewOverlay.Text = "Show the piece in place"; + this.checkBoxPreviewOverlay.UseVisualStyleBackColor = true; + this.checkBoxPreviewOverlay.CheckedChanged += new System.EventHandler(this.OnPreviewOptionChanged); + // + // textBoxPreview + // + this.textBoxPreview.BackColor = System.Drawing.SystemColors.Control; + this.textBoxPreview.BorderStyle = System.Windows.Forms.BorderStyle.None; + this.textBoxPreview.Location = new System.Drawing.Point(728, 444); + this.textBoxPreview.Multiline = true; + this.textBoxPreview.Name = "textBoxPreview"; + this.textBoxPreview.ReadOnly = true; + this.textBoxPreview.ScrollBars = System.Windows.Forms.ScrollBars.None; + this.textBoxPreview.Size = new System.Drawing.Size(696, 56); + this.textBoxPreview.TabIndex = 6; + this.textBoxPreview.TabStop = false; + // + // progressBar1 + // + this.progressBar1.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); + this.progressBar1.Location = new System.Drawing.Point(12, 884); + this.progressBar1.Name = "progressBar1"; + this.progressBar1.Size = new System.Drawing.Size(1440, 18); + this.progressBar1.TabIndex = 5; + // + // labelStatus + // + this.labelStatus.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); + this.labelStatus.AutoEllipsis = true; + this.labelStatus.Location = new System.Drawing.Point(12, 907); + this.labelStatus.Name = "labelStatus"; + this.labelStatus.Size = new System.Drawing.Size(1440, 17); + this.labelStatus.TabIndex = 6; + // + // buttonCopy + // + this.buttonCopy.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.buttonCopy.Location = new System.Drawing.Point(1204, 932); + this.buttonCopy.Name = "buttonCopy"; + this.buttonCopy.Size = new System.Drawing.Size(80, 28); + this.buttonCopy.TabIndex = 7; + this.buttonCopy.Text = "Copy"; + this.buttonCopy.UseVisualStyleBackColor = true; + this.buttonCopy.Click += new System.EventHandler(this.OnClickCopy); + // + // buttonCancel + // + this.buttonCancel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.buttonCancel.Enabled = false; + this.buttonCancel.Location = new System.Drawing.Point(1292, 932); + this.buttonCancel.Name = "buttonCancel"; + this.buttonCancel.Size = new System.Drawing.Size(72, 28); + this.buttonCancel.TabIndex = 8; + this.buttonCancel.Text = "Cancel"; + this.buttonCancel.UseVisualStyleBackColor = true; + this.buttonCancel.Click += new System.EventHandler(this.OnClickCancel); + // + // buttonClose + // + this.buttonClose.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.buttonClose.DialogResult = System.Windows.Forms.DialogResult.Cancel; + this.buttonClose.Location = new System.Drawing.Point(1380, 932); + this.buttonClose.Name = "buttonClose"; + this.buttonClose.Size = new System.Drawing.Size(80, 28); + this.buttonClose.TabIndex = 9; + this.buttonClose.Text = "Close"; + this.buttonClose.UseVisualStyleBackColor = true; + this.buttonClose.Click += new System.EventHandler(this.OnClickClose); + // + // worker + // + this.worker.WorkerSupportsCancellation = true; + this.worker.DoWork += new System.ComponentModel.DoWorkEventHandler(this.OnWorkerDoWork); + this.worker.RunWorkerCompleted += new System.ComponentModel.RunWorkerCompletedEventHandler(this.OnWorkerCompleted); + // // MapReplaceForm - // + // this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(384, 381); - this.Controls.Add(this.label8); - this.Controls.Add(this.comboBoxMapID); - this.Controls.Add(this.groupBox4); - this.Controls.Add(this.groupBox3); - this.Controls.Add(this.groupBox2); - this.Controls.Add(this.groupBox1); - this.Controls.Add(this.label5); + this.CancelButton = this.buttonClose; + this.ClientSize = new System.Drawing.Size(1464, 972); + this.Controls.Add(this.groupBoxSource); + this.Controls.Add(this.groupBoxWhat); + this.Controls.Add(this.groupBoxFrom); + this.Controls.Add(this.groupBoxTo); + this.Controls.Add(this.groupBoxPreview); this.Controls.Add(this.progressBar1); - this.Controls.Add(this.button2); - this.Controls.Add(this.button1); - this.Controls.Add(this.textBox1); + this.Controls.Add(this.labelStatus); + this.Controls.Add(this.buttonCopy); + this.Controls.Add(this.buttonCancel); + this.Controls.Add(this.buttonClose); this.DoubleBuffered = true; - this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow; - this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.MaximizeBox = false; - this.MinimumSize = new System.Drawing.Size(388, 285); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.Sizable; + this.MinimizeBox = false; + this.MinimumSize = new System.Drawing.Size(900, 760); this.Name = "MapReplaceForm"; - this.Text = "MapReplace"; + this.ShowInTaskbar = false; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + this.Text = "Map and Statics Copy"; ((System.ComponentModel.ISupportInitialize)(this.numericUpDownX1)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownY1)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownX2)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownY2)).EndInit(); - this.groupBox1.ResumeLayout(false); - this.groupBox1.PerformLayout(); - this.groupBox2.ResumeLayout(false); - this.groupBox2.PerformLayout(); - this.groupBox3.ResumeLayout(false); - this.groupBox3.PerformLayout(); - this.groupBox4.ResumeLayout(false); - this.groupBox4.PerformLayout(); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownToX1)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.numericUpDownToY1)).EndInit(); + ((System.ComponentModel.ISupportInitialize)(this.numericUpDownZ)).EndInit(); + this.groupBoxSource.ResumeLayout(false); + this.groupBoxSource.PerformLayout(); + this.groupBoxWhat.ResumeLayout(false); + this.groupBoxFrom.ResumeLayout(false); + this.groupBoxTo.ResumeLayout(false); + this.groupBoxPreview.ResumeLayout(false); + this.groupBoxPreview.PerformLayout(); this.ResumeLayout(false); - this.PerformLayout(); } #endregion - private System.Windows.Forms.Button button1; - private System.Windows.Forms.Button button2; + private System.ComponentModel.BackgroundWorker worker; + private System.Windows.Forms.Button buttonBrowse; + private System.Windows.Forms.Button buttonCancel; + private System.Windows.Forms.Button buttonClose; + private System.Windows.Forms.Button buttonCopy; + private System.Windows.Forms.CheckBox RemoveDupl; + private System.Windows.Forms.CheckBox checkBoxDuplicatesHue; private System.Windows.Forms.CheckBox checkBoxMap; private System.Windows.Forms.CheckBox checkBoxStatics; + private System.Windows.Forms.ComboBox comboBoxMapFormat; private System.Windows.Forms.ComboBox comboBoxMapID; - private System.Windows.Forms.GroupBox groupBox1; - private System.Windows.Forms.GroupBox groupBox2; - private System.Windows.Forms.GroupBox groupBox3; - private System.Windows.Forms.GroupBox groupBox4; + private System.Windows.Forms.GroupBox groupBoxFrom; + private System.Windows.Forms.Label labelZAdjust; + private System.Windows.Forms.NumericUpDown numericUpDownZ; + private System.Windows.Forms.CheckBox checkBoxZClamp; + private System.Windows.Forms.Label labelZRange; + private System.Windows.Forms.GroupBox groupBoxPreview; + private System.Windows.Forms.GroupBox groupBoxSource; + private System.Windows.Forms.GroupBox groupBoxTo; + private System.Windows.Forms.GroupBox groupBoxWhat; private System.Windows.Forms.Label label1; private System.Windows.Forms.Label label2; private System.Windows.Forms.Label label3; private System.Windows.Forms.Label label4; - private System.Windows.Forms.Label label5; private System.Windows.Forms.Label label6; private System.Windows.Forms.Label label7; - private System.Windows.Forms.Label label8; + private System.Windows.Forms.Label labelDetected; + private System.Windows.Forms.Label labelFolder; + private System.Windows.Forms.Label labelMap; + private System.Windows.Forms.Label labelMapFormat; + private System.Windows.Forms.Label labelSizeWarning; + private System.Windows.Forms.Label labelStatus; private System.Windows.Forms.NumericUpDown numericUpDownToX1; private System.Windows.Forms.NumericUpDown numericUpDownToY1; private System.Windows.Forms.NumericUpDown numericUpDownX1; @@ -445,7 +642,13 @@ private void InitializeComponent() private System.Windows.Forms.NumericUpDown numericUpDownY1; private System.Windows.Forms.NumericUpDown numericUpDownY2; private System.Windows.Forms.ProgressBar progressBar1; - private System.Windows.Forms.CheckBox RemoveDupl; - private System.Windows.Forms.TextBox textBox1; + private System.Windows.Forms.TextBox textBoxFolder; + private System.Windows.Forms.CheckBox checkBoxPreviewOverlay; + private System.Windows.Forms.CheckBox checkBoxPreviewStatics; + private System.Windows.Forms.Label labelSourcePreview; + private System.Windows.Forms.Label labelTargetPreview; + private System.Windows.Forms.TextBox textBoxPreview; + private UoFiddler.Controls.UserControls.MapRegionPreview previewSource; + private UoFiddler.Controls.UserControls.MapRegionPreview previewTarget; } } \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/MapReplaceForm.cs b/UoFiddler.Controls/Forms/MapReplaceForm.cs index abe2f51a..4c6c7674 100644 --- a/UoFiddler.Controls/Forms/MapReplaceForm.cs +++ b/UoFiddler.Controls/Forms/MapReplaceForm.cs @@ -1,19 +1,30 @@ /*************************************************************************** * * $Author: Turley - * + * * "THE BEER-WARE LICENSE" - * As long as you retain this notice you can do whatever you want with + * As long as you retain this notice you can do whatever you want with * this stuff. If we meet some day, and you think this stuff is worth it, * you can buy me a beer in return. * ***************************************************************************/ using System; +using System.ComponentModel; +using System.Drawing; +using System.Globalization; +using System.Linq; using System.IO; +using System.Text; +using System.Threading; using System.Windows.Forms; +using Microsoft.Extensions.Logging; using Ultima; +using Ultima.Helpers; +using Ultima.Maps; +using Ultima.Statics; using UoFiddler.Controls.Classes; +using UoFiddler.Controls.UserControls; namespace UoFiddler.Controls.Forms { @@ -21,497 +32,921 @@ public partial class MapReplaceForm : Form { private readonly Map _workingMap; + private CancellationTokenSource _cancellation; + private MapSize _detectedSize; + private bool _detectionKnown; + private string _detectionEvidence; + + /// Built on the browsed folder so the source panel renders that install, not this one. + private Map _sourceMap; + + /// Guards the round trip between a drag on a panel and the spinners it writes to. + private bool _syncingPreview; + + /// + /// Reads the z of the region so the dialog can say what a z adjustment would do to it before + /// the copy runs. The tally is per region, not per adjustment, so turning the spinner + /// re-answers the question without touching the files again. + /// + private readonly BackgroundWorker _zWorker = new BackgroundWorker(); + + private readonly System.Windows.Forms.Timer _zDebounce = + new System.Windows.Forms.Timer { Interval = 350 }; + + private MapRegionZSurvey _zSurvey; + private string _zSurveyKey; + private ZSurveyRequest _zPending; + private CancellationTokenSource _zCancellation; + public MapReplaceForm(Map currentMap) { InitializeComponent(); + Icon = Options.GetFiddlerIcon(); - _workingMap = currentMap; - numericUpDownX1.Maximum = _workingMap.Width; - numericUpDownX2.Maximum = _workingMap.Width; - numericUpDownY1.Maximum = _workingMap.Height; - numericUpDownY2.Maximum = _workingMap.Height; - numericUpDownToX1.Maximum = _workingMap.Width; - numericUpDownToY1.Maximum = _workingMap.Height; - Text = $"MapReplace ID:{_workingMap.FileIndex}"; + + _workingMap = currentMap ?? throw new ArgumentNullException(nameof(currentMap)); + + Text = $"Map and Statics Copy - into map {_workingMap.FileIndex}"; + comboBoxMapID.BeginUpdate(); - comboBoxMapID.Items.Add(new RFeluccaOld()); - comboBoxMapID.Items.Add(new RFelucca()); - comboBoxMapID.Items.Add(new RTrammel()); - comboBoxMapID.Items.Add(new RIlshenar()); - comboBoxMapID.Items.Add(new RMalas()); - comboBoxMapID.Items.Add(new RTokuno()); - comboBoxMapID.Items.Add(new RTerMur()); + comboBoxMapID.Items.Add(new SupportedMap(0, Options.MapNames[0] + " (old)", 6144, 4096)); + comboBoxMapID.Items.Add(new SupportedMap(0, Options.MapNames[0], 7168, 4096)); + comboBoxMapID.Items.Add(new SupportedMap(1, Options.MapNames[1] + " (old)", 6144, 4096)); + comboBoxMapID.Items.Add(new SupportedMap(1, Options.MapNames[1], 7168, 4096)); + comboBoxMapID.Items.Add(new SupportedMap(2, Options.MapNames[2], 2304, 1600)); + comboBoxMapID.Items.Add(new SupportedMap(3, Options.MapNames[3], 2560, 2048)); + comboBoxMapID.Items.Add(new SupportedMap(4, Options.MapNames[4], 1448, 1448)); + comboBoxMapID.Items.Add(new SupportedMap(5, Options.MapNames[5], 1280, 4096)); comboBoxMapID.EndUpdate(); comboBoxMapID.SelectedIndex = 0; + + bool uop = _workingMap.Tiles.IsUOPFormat; + + comboBoxMapFormat.Items.Add(uop + ? "the same format as this client (.uop)" + : "the same format as this client (.mul)"); + comboBoxMapFormat.Items.Add($"map{_workingMap.FileIndex}.mul"); + comboBoxMapFormat.Items.Add($"map{_workingMap.FileIndex}LegacyMUL.uop"); + comboBoxMapFormat.SelectedIndex = ClientFileSaveFormats.DefaultIndex(Options.SaveFormat); + + checkBoxMap.Checked = true; + checkBoxStatics.Checked = true; + + numericUpDownToX1.Maximum = Math.Max(0, _workingMap.Width - 1); + numericUpDownToY1.Maximum = Math.Max(0, _workingMap.Height - 1); + + textBoxFolder.TextChanged += OnFolderChanged; + + checkBoxPreviewStatics.Checked = true; + checkBoxPreviewOverlay.Checked = true; + + previewSource.Mode = MapPreviewMode.Rectangle; + previewSource.SelectionChanged += OnSourcePreviewChanged; + + previewTarget.Mode = MapPreviewMode.MoveFixedSize; + previewTarget.Map = _workingMap; + previewTarget.MapSize = new MapSize(_workingMap.Width, _workingMap.Height); + previewTarget.SelectionChanged += OnTargetPreviewChanged; + + groupBoxPreview.SizeChanged += (sender, e) => LayoutPreviewPanels(); + LayoutPreviewPanels(); + + _zDebounce.Tick += OnZDebounceTick; + _zWorker.DoWork += OnZWorkerDoWork; + _zWorker.RunWorkerCompleted += OnZWorkerCompleted; + + OnSourceMapChanged(this, EventArgs.Empty); + OnOptionChanged(this, EventArgs.Empty); + + ActiveControl = buttonBrowse; + } + + private SupportedMap SelectedMap => comboBoxMapID.SelectedItem as SupportedMap; + + protected override void OnLoad(EventArgs e) + { + base.OnLoad(e); + + FormLayout.FitToScreen(this); + } + + /// + /// The two panels split the group box between them. Anchors cannot express that - they would + /// grow one panel and leave the other - so the split is arithmetic, redone whenever the group + /// box resizes with the form. + /// + private void LayoutPreviewPanels() + { + const int margin = 16; + const int gap = 16; + + int width = (groupBoxPreview.ClientSize.Width - (margin * 2) - gap) / 2; + // The checkbox row and the summary sit below the panels, and the group box needs a + // bottom margin of its own. + int height = groupBoxPreview.ClientSize.Height - previewSource.Top - 72; + + if (width < 40 || height < 40) + { + return; + } + + int right = margin + width + gap; + + labelSourcePreview.Width = width; + labelTargetPreview.Left = right; + labelTargetPreview.Width = width; + + previewSource.Size = new Size(width, height); + previewTarget.Location = new Point(right, previewTarget.Top); + previewTarget.Size = new Size(width, height); + + int below = previewSource.Bottom + 8; + + checkBoxPreviewStatics.Top = below; + checkBoxPreviewOverlay.Top = below; + + textBoxPreview.Location = new Point(right, below - 4); + textBoxPreview.Width = width; } private void OnClickBrowse(object sender, EventArgs e) { - FolderBrowserDialog dialog = new FolderBrowserDialog + using (var dialog = new FolderBrowserDialog { - Description = "Select directory containing the map files", - ShowNewFolderButton = false - }; + Description = "Select the folder holding the map files to copy from", + ShowNewFolderButton = false, + SelectedPath = Directory.Exists(textBoxFolder.Text) ? textBoxFolder.Text : string.Empty + }) + { + if (dialog.ShowDialog(this) == DialogResult.OK) + { + textBoxFolder.Text = dialog.SelectedPath; + } + } + } + + private void OnFolderChanged(object sender, EventArgs e) + { + Detect(); + } - if (dialog.ShowDialog() == DialogResult.OK) + /// + /// Rebinds the from-region spinners to the selected source map. They used to be bound to the + /// destination map, which is a different size, so a valid source coordinate could be + /// unreachable or a reachable one rejected. + /// + private void OnSourceMapChanged(object sender, EventArgs e) + { + SupportedMap map = SelectedMap; + + if (map == null) { - textBox1.Text = dialog.SelectedPath; + return; } - dialog.Dispose(); + SetMaximum(numericUpDownX1, map.Width - 1); + SetMaximum(numericUpDownX2, map.Width - 1); + SetMaximum(numericUpDownY1, map.Height - 1); + SetMaximum(numericUpDownY2, map.Height - 1); + + Detect(); } - private void OnClickCopy(object sender, EventArgs e) + private static void SetMaximum(NumericUpDown control, int maximum) { - string path = textBox1.Text; - if (!Directory.Exists(path)) + control.Maximum = Math.Max(0, maximum); + + if (control.Value > control.Maximum) { - MessageBox.Show("Path not found!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); + control.Value = control.Maximum; + } + } + + /// + /// Measures the chosen folder and says so, refusing later if it disagrees with the picked + /// entry. Nothing used to check the two against each other, so a wrong guess read the wrong + /// blocks or ran off the end of the index. + /// + private void Detect() + { + SupportedMap map = SelectedMap; + + _detectionKnown = false; + _detectionEvidence = null; + labelDetected.Text = string.Empty; + labelSizeWarning.Text = string.Empty; + + if (map == null || !Directory.Exists(textBoxFolder.Text)) + { + RebuildSourceMap(null); + UpdatePreview(); + return; } - if (!(comboBoxMapID.SelectedItem is SupportedMaps replaceMap)) + _detectionKnown = MapSizes.TryDetect(textBoxFolder.Text, map.Id, out _detectedSize, out _detectionEvidence); + + RebuildSourceMap(map); + + labelDetected.Text = _detectionKnown ? $"folder holds {_detectedSize}" : "size not recognised"; + + if (_detectionKnown && _detectedSize.Width == map.Width && _detectedSize.Height == map.Height) + { + labelSizeWarning.ForeColor = SystemColors.ControlText; + labelSizeWarning.Text = _detectionEvidence; + } + else + { + labelSizeWarning.ForeColor = Options.DarkMode ? Color.OrangeRed : Color.Red; + labelSizeWarning.Text = _detectionKnown + ? $"This folder holds {_detectedSize} for map {map.Id}, not the {map.Width}x{map.Height} selected.{Environment.NewLine}Pick the entry that matches." + : _detectionEvidence; + } + + UpdatePreview(); + } + + private void OnOptionChanged(object sender, EventArgs e) + { + comboBoxMapFormat.Enabled = checkBoxMap.Checked; + labelMapFormat.Enabled = checkBoxMap.Checked; + RemoveDupl.Enabled = checkBoxStatics.Checked; + checkBoxDuplicatesHue.Enabled = checkBoxStatics.Checked && RemoveDupl.Checked; + + UpdatePreview(); + } + + private void OnRegionChanged(object sender, EventArgs e) + { + UpdatePreview(); + } + + private sealed class ZSurveyRequest + { + public string Directory { get; init; } + + public int FileIndex { get; init; } + + public MapSize Size { get; init; } + + public BlockRectangle Region { get; init; } + + public bool Land { get; init; } + + public bool Statics { get; init; } + + public CancellationToken CancellationToken { get; set; } + + /// Everything the answer depends on. The adjustment is deliberately not part of it. + public string Key => string.Format(CultureInfo.InvariantCulture, "{0}|{1}|{2},{3}-{4},{5}|{6}{7}", + Directory, FileIndex, Region.BlockX1, Region.BlockY1, Region.BlockX2, Region.BlockY2, + Land ? "L" : string.Empty, Statics ? "S" : string.Empty); + } + + private sealed class ZSurveyAnswer + { + public string Key { get; init; } + + public MapRegionZSurvey Survey { get; init; } + } + + private void UpdatePreview() + { + UpdatePreviewRectangles(); + UpdateZ(); + } + + /// + /// Shows the block-snapped rectangles that will really be copied. The tile to block + /// conversion rounds out to whole 8-tile blocks, which used to happen silently. + /// + private void UpdatePreviewRectangles() + { + SupportedMap map = SelectedMap; + + if (map == null) { - MessageBox.Show("Invalid Map ID!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); + textBoxPreview.Text = "Choose a folder and a map."; + return; } int x1 = (int)numericUpDownX1.Value; - int x2 = (int)numericUpDownX2.Value; int y1 = (int)numericUpDownY1.Value; + int x2 = (int)numericUpDownX2.Value; int y2 = (int)numericUpDownY2.Value; - int tox = (int)numericUpDownToX1.Value; - int toy = (int)numericUpDownToY1.Value; - if (x1 < 0 || x1 > replaceMap.Width) + if (x1 > x2) + { + (x1, x2) = (x2, x1); + } + + if (y1 > y2) + { + (y1, y2) = (y2, y1); + } + + int toX = (int)numericUpDownToX1.Value; + int toY = (int)numericUpDownToY1.Value; + + var source = new BlockRectangle(x1 >> 3, y1 >> 3, x2 >> 3, y2 >> 3); + var destination = new BlockRectangle(toX >> 3, toY >> 3, + (toX >> 3) + source.BlockWidth - 1, (toY >> 3) + source.BlockHeight - 1); + + var sb = new StringBuilder(); + + // The panels carry the shape now, so this is only the numbers. + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "from {0},{1} - {2},{3} {4} x {5} blocks", + source.TileX1, source.TileY1, source.TileX2, source.TileY2, source.BlockWidth, source.BlockHeight)); + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "to {0},{1} - {2},{3}", + destination.TileX1, destination.TileY1, destination.TileX2, destination.TileY2)); + + bool snapped = source.TileX1 != x1 || source.TileY1 != y1 || source.TileX2 != x2 || source.TileY2 != y2; + + if (snapped) + { + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, + "{0},{1} - {2},{3} widened to whole blocks", x1, y1, x2, y2)); + } + + textBoxPreview.Text = sb.ToString(); + + if (_syncingPreview) { - MessageBox.Show("Invalid X1 coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); return; } - if (x2 < 0 || x2 > replaceMap.Width) + _syncingPreview = true; + + try + { + previewSource.Selection = source; + previewTarget.Selection = destination; + previewTarget.OverlaySelection = source; + } + finally + { + _syncingPreview = false; + } + } + + /// + /// Points the source panel at the browsed folder. The panel renders through a Map of its + /// own so it shows that install rather than the one loaded in the app. + /// + private void RebuildSourceMap(SupportedMap map) + { + _sourceMap?.Tiles.CloseStreams(); + _sourceMap = null; + + if (map != null && Directory.Exists(textBoxFolder.Text)) + { + _sourceMap = new Map(textBoxFolder.Text, map.Id, map.Id, map.Width, map.Height); + } + + previewSource.Map = _sourceMap; + previewSource.MapSize = map == null ? default : new MapSize(map.Width, map.Height); + previewSource.Message = _sourceMap == null ? "Choose a folder to copy from" : null; + + previewTarget.OverlayMap = checkBoxPreviewOverlay.Checked ? _sourceMap : null; + } + + private void OnPreviewOptionChanged(object sender, EventArgs e) + { + previewSource.ShowStatics = checkBoxPreviewStatics.Checked; + previewTarget.ShowStatics = checkBoxPreviewStatics.Checked; + previewTarget.OverlayMap = checkBoxPreviewOverlay.Checked ? _sourceMap : null; + + UpdatePreview(); + } + + /// A drag on the source panel writes the region back into the spinners. + private void OnSourcePreviewChanged(object sender, EventArgs e) + { + if (_syncingPreview) { - MessageBox.Show("Invalid X2 coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); return; } - if (y1 < 0 || y1 > replaceMap.Height) + BlockRectangle selection = previewSource.Selection; + + _syncingPreview = true; + + try + { + numericUpDownX1.Value = Clamp(numericUpDownX1, selection.TileX1); + numericUpDownY1.Value = Clamp(numericUpDownY1, selection.TileY1); + numericUpDownX2.Value = Clamp(numericUpDownX2, selection.TileX2); + numericUpDownY2.Value = Clamp(numericUpDownY2, selection.TileY2); + } + finally + { + _syncingPreview = false; + } + + UpdatePreview(); + } + + /// A drag on the destination panel writes the paste position back. + private void OnTargetPreviewChanged(object sender, EventArgs e) + { + if (_syncingPreview) { - MessageBox.Show("Invalid Y1 coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); return; } - if (y2 < 0 || y2 > replaceMap.Height) + BlockRectangle selection = previewTarget.Selection; + + _syncingPreview = true; + + try + { + numericUpDownToX1.Value = Clamp(numericUpDownToX1, selection.TileX1); + numericUpDownToY1.Value = Clamp(numericUpDownToY1, selection.TileY1); + } + finally { - MessageBox.Show("Invalid Y2 coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); + _syncingPreview = false; + } + + UpdatePreview(); + } + + private int ZAdjust => (int)numericUpDownZ.Value; + + /// + /// Works out what the region's z is, and says whether the adjustment asked for still fits in + /// the -128..127 a map or statics file can hold. + /// + private void UpdateZ() + { + SupportedMap map = SelectedMap; + + if (map == null || !_detectionKnown || !Directory.Exists(textBoxFolder.Text) || + (!checkBoxMap.Checked && !checkBoxStatics.Checked)) + { + _zSurvey = null; + _zSurveyKey = null; + _zDebounce.Stop(); + labelZRange.Text = string.Empty; + return; } - if (x1 > x2 || y1 > y2) + var region = new BlockRectangle( + (int)numericUpDownX1.Value >> 3, (int)numericUpDownY1.Value >> 3, + (int)numericUpDownX2.Value >> 3, (int)numericUpDownY2.Value >> 3); + + var request = new ZSurveyRequest { - MessageBox.Show("X1 and Y1 cannot be bigger than X2 and Y2!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); + Directory = textBoxFolder.Text, + FileIndex = map.Id, + Size = new MapSize(map.Width, map.Height), + Region = Normalise(region), + Land = checkBoxMap.Checked, + Statics = checkBoxStatics.Checked + }; + + if (_zSurveyKey == request.Key) + { + ShowZ(); + return; } - if (tox < 0 || tox > _workingMap.Width || tox + (x2 - x1) > _workingMap.Width) + _zSurvey = null; + _zSurveyKey = null; + _zPending = request; + + labelZRange.ForeColor = SystemColors.ControlText; + labelZRange.Text = "reading the heights in this region..."; + + _zDebounce.Stop(); + _zDebounce.Start(); + } + + private static BlockRectangle Normalise(BlockRectangle region) + { + return new BlockRectangle( + Math.Min(region.BlockX1, region.BlockX2), Math.Min(region.BlockY1, region.BlockY2), + Math.Max(region.BlockX1, region.BlockX2), Math.Max(region.BlockY1, region.BlockY2)); + } + + private void OnZDebounceTick(object sender, EventArgs e) + { + _zDebounce.Stop(); + + if (_zPending == null || IsDisposed) { - MessageBox.Show("Invalid toX coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); return; } - if (toy < 0 || toy > _workingMap.Height || toy + (y2 - y1) > _workingMap.Height) + if (_zWorker.IsBusy) { - MessageBox.Show("Invalid toX coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); + // Whatever is running is for a region nobody is asking about any more. + _zCancellation?.Cancel(); + _zDebounce.Start(); + return; } - x1 >>= 3; - x2 >>= 3; - y1 >>= 3; - y2 >>= 3; + _zCancellation?.Dispose(); + _zCancellation = new CancellationTokenSource(); + _zPending.CancellationToken = _zCancellation.Token; - tox >>= 3; - toy >>= 3; + _zWorker.RunWorkerAsync(_zPending); + } - int tox2 = x2 - x1 + tox; - int toy2 = y2 - y1 + toy; + private static void OnZWorkerDoWork(object sender, DoWorkEventArgs e) + { + var request = (ZSurveyRequest)e.Argument; - int blockY = _workingMap.Height >> 3; - int blockX = _workingMap.Width >> 3; - int blockYReplace = replaceMap.Height >> 3; - // int blockxreplace = replacemap.Width >> 3; // TODO: unused variable? + e.Result = new ZSurveyAnswer + { + Key = request.Key, + Survey = MapRegionZSurvey.Survey(request.Directory, request.FileIndex, request.Size, + request.Region, request.Land, request.Statics, null, request.CancellationToken) + }; + } - progressBar1.Step = 1; - progressBar1.Value = 0; - progressBar1.Maximum = 0; + private void OnZWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) + { + if (IsDisposed) + { + return; + } - if (checkBoxMap.Checked) + if (e.Error is OperationCanceledException) { - progressBar1.Maximum += blockY * blockX; + // Cancelled because the region moved. Whatever replaced it is already queued. + _zDebounce.Start(); + + return; } - if (checkBoxStatics.Checked) + if (e.Error != null) { - progressBar1.Maximum += blockY * blockX; + labelZRange.ForeColor = Options.DarkMode ? Color.OrangeRed : Color.Red; + labelZRange.Text = "the heights in this region could not be read: " + e.Error.Message; + + return; } - if (checkBoxMap.Checked) + if (e.Result is ZSurveyAnswer answer) { - string copyMapMul = Path.Combine(path, $"map{replaceMap.Id}.mul"); - string copyMapUop = Path.Combine(path, $"map{replaceMap.Id}LegacyMUL.uop"); - if (!File.Exists(copyMapMul) && !File.Exists(copyMapUop)) - { - MessageBox.Show("Map file not found!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, - MessageBoxDefaultButton.Button1); - return; - } + _zSurvey = answer.Survey; + _zSurveyKey = answer.Key; + } - string workingMapMul = Files.GetFilePath($"map{_workingMap.FileIndex}.mul"); - string workingMapUop = Files.GetFilePath($"map{_workingMap.FileIndex}LegacyMUL.uop"); - if (workingMapMul == null && workingMapUop == null) - { - MessageBox.Show("Map file not found!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, - MessageBoxDefaultButton.Button1); - return; - } + if (_zPending != null && _zPending.Key != _zSurveyKey) + { + // The region moved while that was in flight. + _zDebounce.Start(); - var copyTileMatrix = new TileMatrix(replaceMap.Id, replaceMap.Id, replaceMap.Width, replaceMap.Height, path); - var workTileMatrix = new TileMatrix(_workingMap.FileIndex, _workingMap.FileIndex, _workingMap.Width, _workingMap.Height, null); + return; + } - string mul = Path.Combine(Options.OutputPath, $"map{_workingMap.FileIndex}.mul"); - using (FileStream fsMul = new FileStream(mul, FileMode.Create, FileAccess.Write, FileShare.Write)) - { - using (BinaryWriter binMul = new BinaryWriter(fsMul)) - { - for (int x = 0; x < blockX; ++x) - { - for (int y = 0; y < blockY; ++y) - { - bool inRegion = tox <= x && x <= tox2 && toy <= y && y <= toy2; - Tile[] tiles = inRegion - ? copyTileMatrix.GetLandBlock(x - tox + x1, y - toy + y1, false) - : workTileMatrix.GetLandBlock(x, y, false); - - binMul.Write(0); // 4-byte block header - foreach (Tile tile in tiles) - { - ushort tileId = Art.GetLegalItemId(tile.Id); - sbyte z = tile.Z; - - if (z < -128) - { - z = -128; - } - - if (z > 127) - { - z = 127; - } - - binMul.Write(tileId); - binMul.Write(z); - } - progressBar1.PerformStep(); - } - } - } - } + ShowZ(); + } - copyTileMatrix.CloseStreams(); - workTileMatrix.CloseStreams(); + /// Says what the region's z is, and what the adjustment does to it. + private void ShowZ() + { + if (_zSurvey == null) + { + return; } - if (checkBoxStatics.Checked) + int adjust = ZAdjust; + + var sb = new StringBuilder(); + + // Kept short: this sits on one line beside the spinners, and an ellipsis in the middle + // of the warning is worse than no warning at all. + sb.Append(CultureInfo.InvariantCulture, + $"region z land {Span(_zSurvey.Land, 0)}, statics {Span(_zSurvey.Statics, 0)}"); + + if (adjust != 0) { - string indexPath = Files.GetFilePath($"staidx{_workingMap.FileIndex}.mul"); - BinaryReader mIndexReader; + sb.Append(CultureInfo.InvariantCulture, + $" -> land {Span(_zSurvey.Land, adjust)}, statics {Span(_zSurvey.Statics, adjust)}"); + } - if (indexPath != null) - { - FileStream mIndex = new FileStream(indexPath, FileMode.Open, FileAccess.Read, FileShare.Read); - mIndexReader = new BinaryReader(mIndex); - } - else - { - MessageBox.Show("Static file not found!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); - return; - } + long past = _zSurvey.OutOfRange(adjust); - string staticsPath = Files.GetFilePath($"statics{_workingMap.FileIndex}.mul"); - FileStream mStatics; - BinaryReader mStaticsReader; + if (past == 0) + { + labelZRange.ForeColor = SystemColors.ControlText; + labelZRange.Text = sb.ToString(); - if (staticsPath != null) - { - mStatics = new FileStream(staticsPath, FileMode.Open, FileAccess.Read, FileShare.Read); - mStaticsReader = new BinaryReader(mStatics); - } - else - { - MessageBox.Show("Static file not found!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); - return; - } + return; + } - string copyIndexPath = Path.Combine(path, $"staidx{replaceMap.Id}.mul"); - if (!File.Exists(copyIndexPath)) - { - MessageBox.Show("Static file not found!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); - return; - } + sb.Append(CultureInfo.InvariantCulture, + $" - {past:N0} past the limit, {(checkBoxZClamp.Checked ? "held there" : "refused")}"); + + labelZRange.ForeColor = Options.DarkMode ? Color.OrangeRed : Color.Red; + labelZRange.Text = sb.ToString(); + } - FileStream mIndexCopy = new FileStream(copyIndexPath, FileMode.Open, FileAccess.Read, FileShare.Read); - BinaryReader mIndexReaderCopy = new BinaryReader(mIndexCopy); + private static string Span(ZHistogram z, int adjust) + { + if (!z.HasTiles) + { + return "none"; + } - string copyStaticsPath = Path.Combine(path, $"statics{replaceMap.Id}.mul"); - if (!File.Exists(copyStaticsPath)) - { - MessageBox.Show("Static file not found!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); - return; - } + return string.Format(CultureInfo.InvariantCulture, "{0}..{1}", + Math.Clamp(z.Min + adjust, ZHistogram.MinZ, ZHistogram.MaxZ), + Math.Clamp(z.Max + adjust, ZHistogram.MinZ, ZHistogram.MaxZ)); + } - FileStream mStaticsCopy = new FileStream(copyStaticsPath, FileMode.Open, FileAccess.Read, FileShare.Read); - BinaryReader mStaticsReaderCopy = new BinaryReader(mStaticsCopy); + private static decimal Clamp(NumericUpDown control, int value) + { + return Math.Clamp(value, (int)control.Minimum, (int)control.Maximum); + } - string idx = Path.Combine(Options.OutputPath, $"staidx{_workingMap.FileIndex}.mul"); - string mul = Path.Combine(Options.OutputPath, $"statics{_workingMap.FileIndex}.mul"); - using (FileStream fsIdx = new FileStream(idx, FileMode.Create, FileAccess.Write, FileShare.Write), - fsMul = new FileStream(mul, FileMode.Create, FileAccess.Write, FileShare.Write)) + private void OnClickCopy(object sender, EventArgs e) + { + if (worker.IsBusy) + { + return; + } + + SupportedMap map = SelectedMap; + + if (map == null) + { + return; + } + + if (!Directory.Exists(textBoxFolder.Text)) + { + Fail("Choose the folder holding the map files to copy from."); + + return; + } + + if (!checkBoxMap.Checked && !checkBoxStatics.Checked) + { + Fail("Nothing is selected to copy."); + + return; + } + + if (!_detectionKnown) + { + Fail($"The size of map {map.Id} in that folder could not be worked out.{Environment.NewLine}{Environment.NewLine}{_detectionEvidence}"); + + return; + } + + if (_detectedSize.Width != map.Width || _detectedSize.Height != map.Height) + { + Fail($"That folder holds {_detectedSize} for map {map.Id}, but {map} is selected." + + $"{Environment.NewLine}{Environment.NewLine}{_detectionEvidence}" + + $"{Environment.NewLine}{Environment.NewLine}Pick the entry that matches, or the wrong blocks will be read."); + + return; + } + + MapRegionCopyOptions options = BuildOptions(map); + + _cancellation?.Dispose(); + _cancellation = new CancellationTokenSource(); + options.CancellationToken = _cancellation.Token; + options.Progress = new Progress(OnProgress); + + SetRunning(true); + progressBar1.Value = 0; + labelStatus.Text = "Copying..."; + + worker.RunWorkerAsync(options); + } + + private MapRegionCopyOptions BuildOptions(SupportedMap map) + { + int x1 = (int)numericUpDownX1.Value; + int y1 = (int)numericUpDownY1.Value; + int x2 = (int)numericUpDownX2.Value; + int y2 = (int)numericUpDownY2.Value; + + var options = new MapRegionCopyOptions + { + SourceDirectory = textBoxFolder.Text, + SourceFileIndex = map.Id, + SourceSize = new MapSize(map.Width, map.Height), + Destination = _workingMap, + SourceX1 = x1, + SourceY1 = y1, + SourceX2 = x2, + SourceY2 = y2, + DestinationX = (int)numericUpDownToX1.Value, + DestinationY = (int)numericUpDownToY1.Value, + CopyLand = checkBoxMap.Checked, + CopyStatics = checkBoxStatics.Checked, + MapFormat = ResolveFormat(), + ZAdjust = ZAdjust, + ZOverflow = checkBoxZClamp.Checked ? ZOverflowAction.Clamp : ZOverflowAction.Refuse, + OutputDirectory = Options.OutputPath + }; + + if (checkBoxStatics.Checked) + { + options.StaticsFilter = new StaticsTileFilter { - using (BinaryWriter binidx = new BinaryWriter(fsIdx), - binmul = new BinaryWriter(fsMul)) - { - for (int x = 0; x < blockX; ++x) - { - for (int y = 0; y < blockY; ++y) - { - int lookup, length, extra; - if (tox <= x && x <= tox2 && toy <= y && y <= toy2) - { - mIndexReaderCopy.BaseStream.Seek((((x - tox + x1) * blockYReplace) + (y - toy) + y1) * 12, SeekOrigin.Begin); - lookup = mIndexReaderCopy.ReadInt32(); - length = mIndexReaderCopy.ReadInt32(); - extra = mIndexReaderCopy.ReadInt32(); - } - else - { - mIndexReader.BaseStream.Seek(((x * blockY) + y) * 12, SeekOrigin.Begin); - lookup = mIndexReader.ReadInt32(); - length = mIndexReader.ReadInt32(); - extra = mIndexReader.ReadInt32(); - } - - if (lookup < 0 || length <= 0) - { - binidx.Write(-1); // lookup - binidx.Write(-1); // length - binidx.Write(-1); // extra - } - else - { - if (tox <= x && x <= tox2 && toy <= y && y <= toy2) - { - mStaticsCopy.Seek(lookup, SeekOrigin.Begin); - } - else - { - mStatics.Seek(lookup, SeekOrigin.Begin); - } - - int fsMulLength = (int)fsMul.Position; - int count = length / 7; - if (RemoveDupl.Checked) - { - var tileList = new StaticTile[count]; - int j = 0; - for (int i = 0; i < count; ++i) - { - StaticTile tile = new StaticTile(); - if (tox <= x && x <= tox2 && toy <= y && y <= toy2) - { - tile.Id = mStaticsReaderCopy.ReadUInt16(); - tile.X = mStaticsReaderCopy.ReadByte(); - tile.Y = mStaticsReaderCopy.ReadByte(); - tile.Z = mStaticsReaderCopy.ReadSByte(); - tile.Hue = mStaticsReaderCopy.ReadInt16(); - } - else - { - tile.Id = mStaticsReader.ReadUInt16(); - tile.X = mStaticsReader.ReadByte(); - tile.Y = mStaticsReader.ReadByte(); - tile.Z = mStaticsReader.ReadSByte(); - tile.Hue = mStaticsReader.ReadInt16(); - } - - if (tile.Id > Art.GetMaxItemId()) - { - continue; - } - - if (tile.Hue < 0) - { - tile.Hue = 0; - } - - bool first = true; - for (int k = 0; k < j; ++k) - { - if (tileList[k].Id == tile.Id && tileList[k].X == tile.X && tileList[k].Y == tile.Y && tileList[k].Z == tile.Z && tileList[k].Hue == tile.Hue) - { - first = false; - break; - } - } - if (first) - { - tileList[j++] = tile; - } - } - if (j > 0) - { - binidx.Write((int)fsMul.Position); //lookup - for (int i = 0; i < j; ++i) - { - binmul.Write(tileList[i].Id); - binmul.Write(tileList[i].X); - binmul.Write(tileList[i].Y); - binmul.Write(tileList[i].Z); - binmul.Write(tileList[i].Hue); - } - } - } - else - { - bool firstItem = true; - for (int i = 0; i < count; ++i) - { - ushort graphic; - short sHue; - byte sx, sy; - sbyte sz; - if (tox <= x && x <= tox2 && toy <= y && y <= toy2) - { - graphic = mStaticsReaderCopy.ReadUInt16(); - sx = mStaticsReaderCopy.ReadByte(); - sy = mStaticsReaderCopy.ReadByte(); - sz = mStaticsReaderCopy.ReadSByte(); - sHue = mStaticsReaderCopy.ReadInt16(); - } - else - { - graphic = mStaticsReader.ReadUInt16(); - sx = mStaticsReader.ReadByte(); - sy = mStaticsReader.ReadByte(); - sz = mStaticsReader.ReadSByte(); - sHue = mStaticsReader.ReadInt16(); - } - - if (graphic > Art.GetMaxItemId()) - { - continue; - } - - if (sHue < 0) - { - sHue = 0; - } - - if (firstItem) - { - binidx.Write((int)fsMul.Position); // lookup - firstItem = false; - } - binmul.Write(graphic); - binmul.Write(sx); - binmul.Write(sy); - binmul.Write(sz); - binmul.Write(sHue); - } - } - - fsMulLength = (int)fsMul.Position - fsMulLength; - if (fsMulLength > 0) - { - binidx.Write(fsMulLength); // length - binidx.Write(extra); // extra - } - else - { - binidx.Write(-1); // lookup - binidx.Write(-1); // length - binidx.Write(-1); // extra - } - } - - progressBar1.PerformStep(); - } - } - } - } + // The same rules this feature has always applied, so a copy keeps its old shape. + DropInvalidItemIds = true, + MaxItemId = Art.GetMaxItemId(), + OutOfBlockTiles = OutOfBlockAction.Keep, + DropInvalidZ = false, + NormalizeNegativeHue = true, + RemoveDuplicates = RemoveDupl.Checked, + DuplicatesCompareHue = RemoveDupl.Checked && checkBoxDuplicatesHue.Checked + }; + } + + return options; + } - mIndexReader.Close(); - mStaticsReader.Close(); - mIndexCopy.Close(); - mStaticsReaderCopy.Close(); + private MapOutputFormat ResolveFormat() + { + switch (comboBoxMapFormat.SelectedIndex) + { + case 1: return MapOutputFormat.Mul; + case 2: return MapOutputFormat.Uop; + default: return _workingMap.Tiles.IsUOPFormat ? MapOutputFormat.Uop : MapOutputFormat.Mul; } + } - FileSavedDialog.Show(FindForm(), Options.OutputPath, "Files saved successfully."); + private void OnProgress(MapCopyProgress progress) + { + if (progress.BlocksTotal <= 0) + { + return; + } + + progressBar1.Value = Math.Min(100, Math.Max(0, (int)(progress.BlocksDone * 100L / progress.BlocksTotal))); + labelStatus.Text = string.Format(CultureInfo.InvariantCulture, "{0}: {1:N0} of {2:N0} blocks", + progress.Stage, progress.BlocksDone, progress.BlocksTotal); } - private class SupportedMaps + private void OnWorkerDoWork(object sender, DoWorkEventArgs e) { - public int Id { get; } - private string Name { get; } - public int Height { get; } - public int Width { get; } + e.Result = MapRegionCopier.Run((MapRegionCopyOptions)e.Argument); + } - protected SupportedMaps(int id, string name, int width, int height) + private void OnWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) + { + SetRunning(false); + + if (e.Error is OperationCanceledException) { - Id = id; - Name = name; - Width = width; - Height = height; + progressBar1.Value = 0; + labelStatus.Text = "Cancelled. Nothing was written."; + + return; } - public override string ToString() + if (e.Error != null) { - return $"{Id} - {Name} : {Width}x{Height}"; + progressBar1.Value = 0; + labelStatus.Text = "Failed."; + ShowError("Map and Statics Copy", e.Error); + + return; } + + var result = (MapRegionCopyResult)e.Result; + + progressBar1.Value = 100; + labelStatus.Text = "Done."; + + using (var form = new MapRegionCopyResultForm(result)) + { + form.ShowDialog(this); + } + } + + /// + /// Shows what actually went wrong. A bare "Object reference not set to an instance of an + /// object" tells a user nothing and tells whoever gets the bug report even less, so the + /// exception type and the place it came from go in the dialog and the whole thing goes to + /// the log. + /// + private void ShowError(string title, Exception error) + { + AppLog.For(GetType()).LogError(error, "{Title} failed.", title); + + var sb = new StringBuilder(); + + for (Exception current = error; current != null; current = current.InnerException) + { + sb.AppendLine(current.Message); + + if (current.InnerException != null) + { + sb.AppendLine(); + } + } + + sb.AppendLine(); + sb.AppendLine(error.GetType().FullName); + + string where = error.StackTrace?.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim(); + + if (!string.IsNullOrEmpty(where)) + { + sb.AppendLine(where); + } + + MessageBox.Show(this, sb.ToString(), title, MessageBoxButtons.OK, MessageBoxIcon.Error); } - private class RFeluccaOld : SupportedMaps + private void OnClickCancel(object sender, EventArgs e) { - public RFeluccaOld() : base(0, Options.MapNames[0] + "Old", 6144, 4096) { } + _cancellation?.Cancel(); + labelStatus.Text = "Cancelling..."; } - private class RFelucca : SupportedMaps + private void OnClickClose(object sender, EventArgs e) { - public RFelucca() : base(0, Options.MapNames[0], 7168, 4096) { } + Close(); } - private class RTrammel : SupportedMaps + private void SetRunning(bool running) { - public RTrammel() : base(1, Options.MapNames[1], 7168, 4096) { } + buttonCopy.Enabled = !running; + buttonCancel.Enabled = running; + buttonClose.Enabled = !running; + groupBoxSource.Enabled = !running; + groupBoxWhat.Enabled = !running; + groupBoxFrom.Enabled = !running; + groupBoxTo.Enabled = !running; } - private class RIlshenar : SupportedMaps + private void Fail(string message) { - public RIlshenar() : base(2, Options.MapNames[2], 2304, 1600) { } + MessageBox.Show(this, message, "Map and Statics Copy", MessageBoxButtons.OK, MessageBoxIcon.Error); } - private class RMalas : SupportedMaps + protected override void OnFormClosing(FormClosingEventArgs e) { - public RMalas() : base(3, Options.MapNames[3], 2560, 2048) { } + if (worker.IsBusy) + { + _cancellation?.Cancel(); + e.Cancel = true; + + return; + } + + base.OnFormClosing(e); } - private class RTokuno : SupportedMaps + protected override void OnFormClosed(FormClosedEventArgs e) { - public RTokuno() : base(4, Options.MapNames[4], 1448, 1448) { } + _cancellation?.Dispose(); + _cancellation = null; + + _zDebounce.Stop(); + _zDebounce.Dispose(); + _zCancellation?.Cancel(); + _zCancellation?.Dispose(); + _zCancellation = null; + _zWorker.Dispose(); + + _sourceMap?.Tiles.CloseStreams(); + _sourceMap = null; + + base.OnFormClosed(e); } - private class RTerMur : SupportedMaps + /// + /// One entry of the source-map dropdown. The sizes are the shapes a facet is known to ship + /// in; what the chosen folder actually holds is measured separately and has to agree. + /// + private sealed class SupportedMap { - public RTerMur() : base(5, Options.MapNames[5], 1280, 4096) { } + public SupportedMap(int id, string name, int width, int height) + { + Id = id; + Name = name; + Width = width; + Height = height; + } + + public int Id { get; } + + private string Name { get; } + + public int Width { get; } + + public int Height { get; } + + public override string ToString() + { + return $"{Id} - {Name} : {Width}x{Height}"; + } } } -} +} \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/SaveFormatDialog.Designer.cs b/UoFiddler.Controls/Forms/SaveFormatDialog.Designer.cs new file mode 100644 index 00000000..51246381 --- /dev/null +++ b/UoFiddler.Controls/Forms/SaveFormatDialog.Designer.cs @@ -0,0 +1,249 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +namespace UoFiddler.Controls.Forms +{ + partial class SaveFormatDialog + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + mainLayoutPanel = new System.Windows.Forms.TableLayoutPanel(); + iconPictureBox = new System.Windows.Forms.PictureBox(); + contentLayoutPanel = new System.Windows.Forms.TableLayoutPanel(); + toLabel = new System.Windows.Forms.Label(); + pathLabel = new System.Windows.Forms.Label(); + formatLabel = new System.Windows.Forms.Label(); + comboBoxFormat = new System.Windows.Forms.ComboBox(); + willCreateLabel = new System.Windows.Forms.Label(); + buttonsPanel = new System.Windows.Forms.TableLayoutPanel(); + buttonSave = new System.Windows.Forms.Button(); + buttonCancel = new System.Windows.Forms.Button(); + mainLayoutPanel.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)iconPictureBox).BeginInit(); + contentLayoutPanel.SuspendLayout(); + buttonsPanel.SuspendLayout(); + SuspendLayout(); + // + // mainLayoutPanel + // + mainLayoutPanel.AutoSize = true; + mainLayoutPanel.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink; + mainLayoutPanel.ColumnCount = 2; + mainLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + mainLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); + mainLayoutPanel.Controls.Add(iconPictureBox, 0, 0); + mainLayoutPanel.Controls.Add(contentLayoutPanel, 1, 0); + mainLayoutPanel.Dock = System.Windows.Forms.DockStyle.Fill; + mainLayoutPanel.Location = new System.Drawing.Point(12, 12); + mainLayoutPanel.Margin = new System.Windows.Forms.Padding(0); + mainLayoutPanel.Name = "mainLayoutPanel"; + mainLayoutPanel.RowCount = 1; + mainLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle()); + mainLayoutPanel.Size = new System.Drawing.Size(516, 160); + mainLayoutPanel.TabIndex = 0; + // + // iconPictureBox + // + iconPictureBox.Location = new System.Drawing.Point(0, 0); + iconPictureBox.Margin = new System.Windows.Forms.Padding(0, 0, 12, 0); + iconPictureBox.Name = "iconPictureBox"; + iconPictureBox.Size = new System.Drawing.Size(32, 32); + iconPictureBox.SizeMode = System.Windows.Forms.PictureBoxSizeMode.CenterImage; + iconPictureBox.TabIndex = 0; + iconPictureBox.TabStop = false; + // + // contentLayoutPanel + // + contentLayoutPanel.AutoSize = true; + contentLayoutPanel.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink; + contentLayoutPanel.ColumnCount = 1; + contentLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); + contentLayoutPanel.Controls.Add(toLabel, 0, 0); + contentLayoutPanel.Controls.Add(pathLabel, 0, 1); + contentLayoutPanel.Controls.Add(formatLabel, 0, 2); + contentLayoutPanel.Controls.Add(comboBoxFormat, 0, 3); + contentLayoutPanel.Controls.Add(willCreateLabel, 0, 4); + contentLayoutPanel.Controls.Add(buttonsPanel, 0, 5); + contentLayoutPanel.Dock = System.Windows.Forms.DockStyle.Fill; + contentLayoutPanel.Location = new System.Drawing.Point(44, 0); + contentLayoutPanel.Margin = new System.Windows.Forms.Padding(0); + contentLayoutPanel.Name = "contentLayoutPanel"; + contentLayoutPanel.RowCount = 6; + contentLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle()); + contentLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle()); + contentLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle()); + contentLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle()); + contentLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle()); + contentLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle()); + contentLayoutPanel.Size = new System.Drawing.Size(472, 160); + contentLayoutPanel.TabIndex = 1; + // + // toLabel + // + toLabel.AutoSize = true; + toLabel.Location = new System.Drawing.Point(0, 0); + toLabel.Margin = new System.Windows.Forms.Padding(0, 0, 0, 2); + toLabel.Name = "toLabel"; + toLabel.Size = new System.Drawing.Size(70, 15); + toLabel.TabIndex = 0; + toLabel.Text = "Saving to:"; + // + // pathLabel + // + pathLabel.AutoSize = true; + pathLabel.Location = new System.Drawing.Point(0, 17); + pathLabel.Margin = new System.Windows.Forms.Padding(0, 0, 0, 12); + pathLabel.MaximumSize = new System.Drawing.Size(472, 0); + pathLabel.Name = "pathLabel"; + pathLabel.Size = new System.Drawing.Size(0, 15); + pathLabel.TabIndex = 1; + pathLabel.UseMnemonic = false; + // + // formatLabel + // + formatLabel.AutoSize = true; + formatLabel.Location = new System.Drawing.Point(0, 44); + formatLabel.Margin = new System.Windows.Forms.Padding(0, 0, 0, 2); + formatLabel.Name = "formatLabel"; + formatLabel.Size = new System.Drawing.Size(60, 15); + formatLabel.TabIndex = 2; + formatLabel.Text = "Write as:"; + // + // comboBoxFormat + // + comboBoxFormat.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + comboBoxFormat.Location = new System.Drawing.Point(0, 61); + comboBoxFormat.Margin = new System.Windows.Forms.Padding(0, 0, 0, 8); + comboBoxFormat.Name = "comboBoxFormat"; + comboBoxFormat.Size = new System.Drawing.Size(472, 23); + comboBoxFormat.TabIndex = 3; + comboBoxFormat.SelectedIndexChanged += OnFormatChanged; + // + // willCreateLabel + // + willCreateLabel.AutoSize = true; + willCreateLabel.Location = new System.Drawing.Point(0, 92); + willCreateLabel.Margin = new System.Windows.Forms.Padding(0, 0, 0, 12); + willCreateLabel.MaximumSize = new System.Drawing.Size(472, 0); + willCreateLabel.Name = "willCreateLabel"; + willCreateLabel.Size = new System.Drawing.Size(0, 15); + willCreateLabel.TabIndex = 4; + willCreateLabel.UseMnemonic = false; + // + // buttonsPanel + // + buttonsPanel.Anchor = System.Windows.Forms.AnchorStyles.Right; + buttonsPanel.AutoSize = true; + buttonsPanel.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink; + buttonsPanel.ColumnCount = 2; + buttonsPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + buttonsPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + buttonsPanel.Controls.Add(buttonSave, 0, 0); + buttonsPanel.Controls.Add(buttonCancel, 1, 0); + buttonsPanel.Location = new System.Drawing.Point(216, 119); + buttonsPanel.Margin = new System.Windows.Forms.Padding(0); + buttonsPanel.Name = "buttonsPanel"; + buttonsPanel.RowCount = 1; + buttonsPanel.RowStyles.Add(new System.Windows.Forms.RowStyle()); + buttonsPanel.Size = new System.Drawing.Size(256, 36); + buttonsPanel.TabIndex = 5; + // + // buttonSave + // + buttonSave.AutoSize = false; + buttonSave.DialogResult = System.Windows.Forms.DialogResult.OK; + buttonSave.Location = new System.Drawing.Point(0, 0); + buttonSave.Margin = new System.Windows.Forms.Padding(0, 0, 8, 0); + buttonSave.Name = "buttonSave"; + buttonSave.Size = new System.Drawing.Size(120, 32); + buttonSave.TabIndex = 0; + buttonSave.Text = "Save"; + buttonSave.UseVisualStyleBackColor = true; + // + // buttonCancel + // + buttonCancel.AutoSize = false; + buttonCancel.DialogResult = System.Windows.Forms.DialogResult.Cancel; + buttonCancel.Location = new System.Drawing.Point(128, 0); + buttonCancel.Margin = new System.Windows.Forms.Padding(0); + buttonCancel.Name = "buttonCancel"; + buttonCancel.Size = new System.Drawing.Size(120, 32); + buttonCancel.TabIndex = 1; + buttonCancel.Text = "Cancel"; + buttonCancel.UseVisualStyleBackColor = true; + // + // SaveFormatDialog + // + AcceptButton = buttonSave; + AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F); + AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi; + AutoSize = true; + AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink; + CancelButton = buttonCancel; + ClientSize = new System.Drawing.Size(540, 184); + Controls.Add(mainLayoutPanel); + FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + Name = "SaveFormatDialog"; + Padding = new System.Windows.Forms.Padding(12); + ShowInTaskbar = false; + StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + Text = "Save"; + mainLayoutPanel.ResumeLayout(false); + mainLayoutPanel.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)iconPictureBox).EndInit(); + contentLayoutPanel.ResumeLayout(false); + contentLayoutPanel.PerformLayout(); + buttonsPanel.ResumeLayout(false); + ResumeLayout(false); + PerformLayout(); + } + + #endregion + + private System.Windows.Forms.TableLayoutPanel mainLayoutPanel; + private System.Windows.Forms.PictureBox iconPictureBox; + private System.Windows.Forms.TableLayoutPanel contentLayoutPanel; + private System.Windows.Forms.Label toLabel; + private System.Windows.Forms.Label pathLabel; + private System.Windows.Forms.Label formatLabel; + private System.Windows.Forms.ComboBox comboBoxFormat; + private System.Windows.Forms.Label willCreateLabel; + private System.Windows.Forms.TableLayoutPanel buttonsPanel; + private System.Windows.Forms.Button buttonSave; + private System.Windows.Forms.Button buttonCancel; + } +} \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/SaveFormatDialog.cs b/UoFiddler.Controls/Forms/SaveFormatDialog.cs new file mode 100644 index 00000000..c802bf76 --- /dev/null +++ b/UoFiddler.Controls/Forms/SaveFormatDialog.cs @@ -0,0 +1,78 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Drawing; +using System.Windows.Forms; +using Ultima.Uop; + +namespace UoFiddler.Controls.Forms +{ + /// + /// Asks which container a save should write, for the file types the client ships in either. + /// Shown only when the save format option is set to ask every time. + /// + public sealed partial class SaveFormatDialog : Form + { + private readonly FileType _type; + private readonly int _mapIndex; + private readonly string _outputDirectory; + + public SaveFormatDialog(FileType type, string outputDirectory, ContainerFormat suggested, int mapIndex = 0) + { + _type = type; + _mapIndex = mapIndex; + _outputDirectory = outputDirectory ?? string.Empty; + + InitializeComponent(); + + iconPictureBox.Image = SystemIcons.Question.ToBitmap(); + + var (mulName, idxName, uopName) = UopFileNames.For(type, mapIndex); + bool clientUsesUop = ClientFileSaver.ClientUsesUop(type, mapIndex); + + string mulItem = idxName == null ? mulName : $"{mulName} + {idxName}"; + + comboBoxFormat.Items.Add(Describe(mulItem, !clientUsesUop)); + comboBoxFormat.Items.Add(Describe(uopName, clientUsesUop)); + comboBoxFormat.SelectedIndex = suggested == ContainerFormat.Uop ? 1 : 0; + + pathLabel.Text = _outputDirectory; + + UpdateWillCreate(); + } + + /// The container the user picked. Only meaningful on . + public ContainerFormat SelectedFormat => + comboBoxFormat.SelectedIndex == 1 ? ContainerFormat.Uop : ContainerFormat.Mul; + + private static string Describe(string files, bool isWhatTheClientUses) + { + return isWhatTheClientUses ? $"{files} (the same format as this client)" : files; + } + + private void OnFormatChanged(object sender, EventArgs e) + { + UpdateWillCreate(); + } + + private void UpdateWillCreate() + { + var (mulName, idxName, uopName) = UopFileNames.For(_type, _mapIndex); + + string files = SelectedFormat == ContainerFormat.Uop + ? uopName + : idxName == null ? mulName : $"{mulName}, {idxName}"; + + willCreateLabel.Text = "Will create: " + files; + } + } +} \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/SaveFormatDialog.resx b/UoFiddler.Controls/Forms/SaveFormatDialog.resx new file mode 100644 index 00000000..1af7de15 --- /dev/null +++ b/UoFiddler.Controls/Forms/SaveFormatDialog.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/TileDataHelpForm.cs b/UoFiddler.Controls/Forms/TileDataHelpForm.cs index 90f7981e..dc4bbf60 100644 --- a/UoFiddler.Controls/Forms/TileDataHelpForm.cs +++ b/UoFiddler.Controls/Forms/TileDataHelpForm.cs @@ -26,6 +26,31 @@ public TileDataHelpForm() private void PopulateFields() { + AddHeader("Editing many entries"); + Add("Multi-select", + "Ctrl+click and Shift+click select several entries at once. The right hand pane keeps showing the entry " + + "you picked last, and 'Save Changes' writes to every selected entry.", + "multi"); + Add("Empty boxes", + "With more than one entry selected, a box the entries disagree on comes up empty, and an empty box is " + + "left alone when you save - the entries keep their own values. Fill it in to give them all the same " + + "value. Because empty means 'leave alone', a name cannot be cleared across a multi-selection; select " + + "the entry on its own to do that.", + "multi"); + Add("Greyed flags", + "A flag that is set on some of the selected entries but not all shows greyed, and a greyed flag is left " + + "alone when you save. Clicking it cycles leave alone -> set on all -> clear on all. A flag they all " + + "already agree on just toggles.", + "multi"); + Add("Copy / Paste special", + "Right-click an entry and choose 'Copy tile data' to remember it, then select any number of entries and " + + "choose 'Paste special...' to pick which of the copied fields and flags to write onto them.", + "multi"); + Add("Undo", + "Misc -> 'Undo last bulk apply' puts back the values the entries had before the last apply. Only the " + + "most recent apply is kept, and it is forgotten when tiledata is reloaded.", + "multi"); + AddHeader("Items"); Add("Name", "This field is for the name of the item, which can be a maximum of 20 characters.", "items"); Add("Animation", "This field is for the animation ID associated with the item.", "items"); diff --git a/UoFiddler.Controls/Forms/TileDataPasteSpecialForm.Designer.cs b/UoFiddler.Controls/Forms/TileDataPasteSpecialForm.Designer.cs new file mode 100644 index 00000000..fe58bc57 --- /dev/null +++ b/UoFiddler.Controls/Forms/TileDataPasteSpecialForm.Designer.cs @@ -0,0 +1,371 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +namespace UoFiddler.Controls.Forms +{ + partial class TileDataPasteSpecialForm + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + mainTableLayoutPanel = new System.Windows.Forms.TableLayoutPanel(); + headerLabel = new System.Windows.Forms.Label(); + contentSplitContainer = new System.Windows.Forms.SplitContainer(); + fieldsTableLayoutPanel = new System.Windows.Forms.TableLayoutPanel(); + fieldsLabel = new System.Windows.Forms.Label(); + fieldsCheckedListBox = new System.Windows.Forms.CheckedListBox(); + flagsGroupBox = new System.Windows.Forms.GroupBox(); + flagsTableLayoutPanel = new System.Windows.Forms.TableLayoutPanel(); + flagsModeFlowLayoutPanel = new System.Windows.Forms.FlowLayoutPanel(); + flagsLeaveRadioButton = new System.Windows.Forms.RadioButton(); + flagsReplaceRadioButton = new System.Windows.Forms.RadioButton(); + flagsSetCheckedRadioButton = new System.Windows.Forms.RadioButton(); + flagsClearCheckedRadioButton = new System.Windows.Forms.RadioButton(); + flagsCheckedListBox = new System.Windows.Forms.CheckedListBox(); + buttonsFlowLayoutPanel = new System.Windows.Forms.FlowLayoutPanel(); + cancelButton = new System.Windows.Forms.Button(); + applyButton = new System.Windows.Forms.Button(); + uncheckAllButton = new System.Windows.Forms.Button(); + checkAllButton = new System.Windows.Forms.Button(); + mainTableLayoutPanel.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)contentSplitContainer).BeginInit(); + contentSplitContainer.Panel1.SuspendLayout(); + contentSplitContainer.Panel2.SuspendLayout(); + contentSplitContainer.SuspendLayout(); + fieldsTableLayoutPanel.SuspendLayout(); + flagsGroupBox.SuspendLayout(); + flagsTableLayoutPanel.SuspendLayout(); + flagsModeFlowLayoutPanel.SuspendLayout(); + buttonsFlowLayoutPanel.SuspendLayout(); + SuspendLayout(); + // + // mainTableLayoutPanel + // + mainTableLayoutPanel.ColumnCount = 1; + mainTableLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); + mainTableLayoutPanel.Controls.Add(headerLabel, 0, 0); + mainTableLayoutPanel.Controls.Add(contentSplitContainer, 0, 1); + mainTableLayoutPanel.Controls.Add(buttonsFlowLayoutPanel, 0, 2); + mainTableLayoutPanel.Dock = System.Windows.Forms.DockStyle.Fill; + mainTableLayoutPanel.Location = new System.Drawing.Point(0, 0); + mainTableLayoutPanel.Name = "mainTableLayoutPanel"; + mainTableLayoutPanel.Padding = new System.Windows.Forms.Padding(8); + mainTableLayoutPanel.RowCount = 3; + mainTableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle()); + mainTableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F)); + mainTableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle()); + mainTableLayoutPanel.Size = new System.Drawing.Size(624, 441); + mainTableLayoutPanel.TabIndex = 0; + // + // headerLabel + // + headerLabel.AutoSize = true; + headerLabel.Dock = System.Windows.Forms.DockStyle.Fill; + headerLabel.Location = new System.Drawing.Point(11, 8); + headerLabel.Name = "headerLabel"; + headerLabel.Padding = new System.Windows.Forms.Padding(0, 0, 0, 6); + headerLabel.Size = new System.Drawing.Size(602, 21); + headerLabel.TabIndex = 0; + headerLabel.Text = "Paste special"; + // + // contentSplitContainer + // + contentSplitContainer.Dock = System.Windows.Forms.DockStyle.Fill; + contentSplitContainer.Location = new System.Drawing.Point(11, 32); + contentSplitContainer.Name = "contentSplitContainer"; + // + // contentSplitContainer.Panel1 + // + contentSplitContainer.Panel1.Controls.Add(fieldsTableLayoutPanel); + contentSplitContainer.Panel1MinSize = 160; + // + // contentSplitContainer.Panel2 + // + contentSplitContainer.Panel2.Controls.Add(flagsGroupBox); + contentSplitContainer.Panel2MinSize = 200; + contentSplitContainer.Size = new System.Drawing.Size(602, 359); + contentSplitContainer.SplitterDistance = 240; + contentSplitContainer.TabIndex = 1; + // + // fieldsTableLayoutPanel + // + fieldsTableLayoutPanel.ColumnCount = 1; + fieldsTableLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); + fieldsTableLayoutPanel.Controls.Add(fieldsLabel, 0, 0); + fieldsTableLayoutPanel.Controls.Add(fieldsCheckedListBox, 0, 1); + fieldsTableLayoutPanel.Dock = System.Windows.Forms.DockStyle.Fill; + fieldsTableLayoutPanel.Location = new System.Drawing.Point(0, 0); + fieldsTableLayoutPanel.Name = "fieldsTableLayoutPanel"; + fieldsTableLayoutPanel.RowCount = 2; + fieldsTableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle()); + fieldsTableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F)); + fieldsTableLayoutPanel.Size = new System.Drawing.Size(240, 359); + fieldsTableLayoutPanel.TabIndex = 0; + // + // fieldsLabel + // + fieldsLabel.AutoSize = true; + fieldsLabel.Dock = System.Windows.Forms.DockStyle.Fill; + fieldsLabel.Location = new System.Drawing.Point(3, 0); + fieldsLabel.Name = "fieldsLabel"; + fieldsLabel.Padding = new System.Windows.Forms.Padding(0, 0, 0, 4); + fieldsLabel.Size = new System.Drawing.Size(234, 19); + fieldsLabel.TabIndex = 0; + fieldsLabel.Text = "Fields to paste"; + // + // fieldsCheckedListBox + // + fieldsCheckedListBox.CheckOnClick = true; + fieldsCheckedListBox.Dock = System.Windows.Forms.DockStyle.Fill; + fieldsCheckedListBox.FormattingEnabled = true; + fieldsCheckedListBox.IntegralHeight = false; + fieldsCheckedListBox.Location = new System.Drawing.Point(3, 22); + fieldsCheckedListBox.Name = "fieldsCheckedListBox"; + fieldsCheckedListBox.Size = new System.Drawing.Size(234, 334); + fieldsCheckedListBox.TabIndex = 1; + // + // flagsGroupBox + // + flagsGroupBox.Controls.Add(flagsTableLayoutPanel); + flagsGroupBox.Dock = System.Windows.Forms.DockStyle.Fill; + flagsGroupBox.Location = new System.Drawing.Point(0, 0); + flagsGroupBox.Name = "flagsGroupBox"; + flagsGroupBox.Padding = new System.Windows.Forms.Padding(6); + flagsGroupBox.Size = new System.Drawing.Size(358, 359); + flagsGroupBox.TabIndex = 0; + flagsGroupBox.TabStop = false; + flagsGroupBox.Text = "Flags"; + // + // flagsTableLayoutPanel + // + flagsTableLayoutPanel.ColumnCount = 1; + flagsTableLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); + flagsTableLayoutPanel.Controls.Add(flagsModeFlowLayoutPanel, 0, 0); + flagsTableLayoutPanel.Controls.Add(flagsCheckedListBox, 0, 1); + flagsTableLayoutPanel.Dock = System.Windows.Forms.DockStyle.Fill; + flagsTableLayoutPanel.Location = new System.Drawing.Point(6, 22); + flagsTableLayoutPanel.Name = "flagsTableLayoutPanel"; + flagsTableLayoutPanel.RowCount = 2; + flagsTableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle()); + flagsTableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F)); + flagsTableLayoutPanel.Size = new System.Drawing.Size(346, 331); + flagsTableLayoutPanel.TabIndex = 0; + // + // flagsModeFlowLayoutPanel + // + flagsModeFlowLayoutPanel.AutoSize = true; + flagsModeFlowLayoutPanel.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink; + flagsModeFlowLayoutPanel.Controls.Add(flagsLeaveRadioButton); + flagsModeFlowLayoutPanel.Controls.Add(flagsReplaceRadioButton); + flagsModeFlowLayoutPanel.Controls.Add(flagsSetCheckedRadioButton); + flagsModeFlowLayoutPanel.Controls.Add(flagsClearCheckedRadioButton); + flagsModeFlowLayoutPanel.Dock = System.Windows.Forms.DockStyle.Fill; + flagsModeFlowLayoutPanel.FlowDirection = System.Windows.Forms.FlowDirection.TopDown; + flagsModeFlowLayoutPanel.Location = new System.Drawing.Point(3, 3); + flagsModeFlowLayoutPanel.Name = "flagsModeFlowLayoutPanel"; + flagsModeFlowLayoutPanel.Size = new System.Drawing.Size(340, 92); + flagsModeFlowLayoutPanel.TabIndex = 0; + flagsModeFlowLayoutPanel.WrapContents = false; + // + // flagsLeaveRadioButton + // + flagsLeaveRadioButton.AutoSize = true; + flagsLeaveRadioButton.Location = new System.Drawing.Point(3, 3); + flagsLeaveRadioButton.Name = "flagsLeaveRadioButton"; + flagsLeaveRadioButton.Size = new System.Drawing.Size(122, 19); + flagsLeaveRadioButton.TabIndex = 0; + flagsLeaveRadioButton.Text = "Don't change flags"; + flagsLeaveRadioButton.UseVisualStyleBackColor = true; + flagsLeaveRadioButton.CheckedChanged += OnFlagModeChanged; + // + // flagsReplaceRadioButton + // + flagsReplaceRadioButton.AutoSize = true; + flagsReplaceRadioButton.Checked = true; + flagsReplaceRadioButton.Location = new System.Drawing.Point(3, 28); + flagsReplaceRadioButton.Name = "flagsReplaceRadioButton"; + flagsReplaceRadioButton.Size = new System.Drawing.Size(178, 19); + flagsReplaceRadioButton.TabIndex = 1; + flagsReplaceRadioButton.TabStop = true; + flagsReplaceRadioButton.Text = "Replace all flags with source"; + flagsReplaceRadioButton.UseVisualStyleBackColor = true; + flagsReplaceRadioButton.CheckedChanged += OnFlagModeChanged; + // + // flagsSetCheckedRadioButton + // + flagsSetCheckedRadioButton.AutoSize = true; + flagsSetCheckedRadioButton.Location = new System.Drawing.Point(3, 53); + flagsSetCheckedRadioButton.Name = "flagsSetCheckedRadioButton"; + flagsSetCheckedRadioButton.Size = new System.Drawing.Size(148, 19); + flagsSetCheckedRadioButton.TabIndex = 2; + flagsSetCheckedRadioButton.Text = "Set only the checked flags"; + flagsSetCheckedRadioButton.UseVisualStyleBackColor = true; + flagsSetCheckedRadioButton.CheckedChanged += OnFlagModeChanged; + // + // flagsClearCheckedRadioButton + // + flagsClearCheckedRadioButton.AutoSize = true; + flagsClearCheckedRadioButton.Location = new System.Drawing.Point(3, 78); + flagsClearCheckedRadioButton.Name = "flagsClearCheckedRadioButton"; + flagsClearCheckedRadioButton.Size = new System.Drawing.Size(163, 19); + flagsClearCheckedRadioButton.TabIndex = 3; + flagsClearCheckedRadioButton.Text = "Clear only the checked flags"; + flagsClearCheckedRadioButton.UseVisualStyleBackColor = true; + flagsClearCheckedRadioButton.CheckedChanged += OnFlagModeChanged; + // + // flagsCheckedListBox + // + flagsCheckedListBox.CheckOnClick = true; + flagsCheckedListBox.Dock = System.Windows.Forms.DockStyle.Fill; + flagsCheckedListBox.FormattingEnabled = true; + flagsCheckedListBox.IntegralHeight = false; + flagsCheckedListBox.Location = new System.Drawing.Point(3, 101); + flagsCheckedListBox.MultiColumn = true; + flagsCheckedListBox.Name = "flagsCheckedListBox"; + flagsCheckedListBox.Size = new System.Drawing.Size(340, 227); + flagsCheckedListBox.TabIndex = 1; + // + // buttonsFlowLayoutPanel + // + buttonsFlowLayoutPanel.AutoSize = true; + buttonsFlowLayoutPanel.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink; + buttonsFlowLayoutPanel.Controls.Add(cancelButton); + buttonsFlowLayoutPanel.Controls.Add(applyButton); + buttonsFlowLayoutPanel.Controls.Add(uncheckAllButton); + buttonsFlowLayoutPanel.Controls.Add(checkAllButton); + buttonsFlowLayoutPanel.Dock = System.Windows.Forms.DockStyle.Fill; + buttonsFlowLayoutPanel.FlowDirection = System.Windows.Forms.FlowDirection.RightToLeft; + buttonsFlowLayoutPanel.Location = new System.Drawing.Point(11, 397); + buttonsFlowLayoutPanel.Name = "buttonsFlowLayoutPanel"; + buttonsFlowLayoutPanel.Padding = new System.Windows.Forms.Padding(0, 6, 0, 0); + buttonsFlowLayoutPanel.Size = new System.Drawing.Size(602, 35); + buttonsFlowLayoutPanel.TabIndex = 2; + buttonsFlowLayoutPanel.WrapContents = false; + // + // cancelButton + // + cancelButton.DialogResult = System.Windows.Forms.DialogResult.Cancel; + cancelButton.Location = new System.Drawing.Point(524, 9); + cancelButton.Name = "cancelButton"; + cancelButton.Size = new System.Drawing.Size(75, 23); + cancelButton.TabIndex = 0; + cancelButton.Text = "Cancel"; + cancelButton.UseVisualStyleBackColor = true; + // + // applyButton + // + applyButton.Location = new System.Drawing.Point(443, 9); + applyButton.Name = "applyButton"; + applyButton.Size = new System.Drawing.Size(75, 23); + applyButton.TabIndex = 1; + applyButton.Text = "Apply"; + applyButton.UseVisualStyleBackColor = true; + applyButton.Click += OnClickApply; + // + // uncheckAllButton + // + uncheckAllButton.Location = new System.Drawing.Point(348, 9); + uncheckAllButton.Name = "uncheckAllButton"; + uncheckAllButton.Size = new System.Drawing.Size(89, 23); + uncheckAllButton.TabIndex = 2; + uncheckAllButton.Text = "Uncheck all"; + uncheckAllButton.UseVisualStyleBackColor = true; + uncheckAllButton.Click += OnClickUncheckAll; + // + // checkAllButton + // + checkAllButton.Location = new System.Drawing.Point(253, 9); + checkAllButton.Name = "checkAllButton"; + checkAllButton.Size = new System.Drawing.Size(89, 23); + checkAllButton.TabIndex = 3; + checkAllButton.Text = "Check all"; + checkAllButton.UseVisualStyleBackColor = true; + checkAllButton.Click += OnClickCheckAll; + // + // TileDataPasteSpecialForm + // + AcceptButton = applyButton; + AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + CancelButton = cancelButton; + ClientSize = new System.Drawing.Size(624, 441); + Controls.Add(mainTableLayoutPanel); + MinimizeBox = false; + MaximizeBox = false; + MinimumSize = new System.Drawing.Size(560, 400); + Name = "TileDataPasteSpecialForm"; + ShowIcon = false; + ShowInTaskbar = false; + StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + Text = "Paste special"; + mainTableLayoutPanel.ResumeLayout(false); + mainTableLayoutPanel.PerformLayout(); + contentSplitContainer.Panel1.ResumeLayout(false); + contentSplitContainer.Panel2.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)contentSplitContainer).EndInit(); + contentSplitContainer.ResumeLayout(false); + fieldsTableLayoutPanel.ResumeLayout(false); + fieldsTableLayoutPanel.PerformLayout(); + flagsGroupBox.ResumeLayout(false); + flagsTableLayoutPanel.ResumeLayout(false); + flagsTableLayoutPanel.PerformLayout(); + flagsModeFlowLayoutPanel.ResumeLayout(false); + flagsModeFlowLayoutPanel.PerformLayout(); + buttonsFlowLayoutPanel.ResumeLayout(false); + ResumeLayout(false); + } + + #endregion + + private System.Windows.Forms.TableLayoutPanel mainTableLayoutPanel; + private System.Windows.Forms.Label headerLabel; + private System.Windows.Forms.SplitContainer contentSplitContainer; + private System.Windows.Forms.TableLayoutPanel fieldsTableLayoutPanel; + private System.Windows.Forms.Label fieldsLabel; + private System.Windows.Forms.CheckedListBox fieldsCheckedListBox; + private System.Windows.Forms.GroupBox flagsGroupBox; + private System.Windows.Forms.TableLayoutPanel flagsTableLayoutPanel; + private System.Windows.Forms.FlowLayoutPanel flagsModeFlowLayoutPanel; + private System.Windows.Forms.RadioButton flagsLeaveRadioButton; + private System.Windows.Forms.RadioButton flagsReplaceRadioButton; + private System.Windows.Forms.RadioButton flagsSetCheckedRadioButton; + private System.Windows.Forms.RadioButton flagsClearCheckedRadioButton; + private System.Windows.Forms.CheckedListBox flagsCheckedListBox; + private System.Windows.Forms.FlowLayoutPanel buttonsFlowLayoutPanel; + private System.Windows.Forms.Button cancelButton; + private System.Windows.Forms.Button applyButton; + private System.Windows.Forms.Button uncheckAllButton; + private System.Windows.Forms.Button checkAllButton; + } +} diff --git a/UoFiddler.Controls/Forms/TileDataPasteSpecialForm.cs b/UoFiddler.Controls/Forms/TileDataPasteSpecialForm.cs new file mode 100644 index 00000000..6c42fec6 --- /dev/null +++ b/UoFiddler.Controls/Forms/TileDataPasteSpecialForm.cs @@ -0,0 +1,428 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Windows.Forms; +using Ultima; +using UoFiddler.Controls.Classes; + +namespace UoFiddler.Controls.Forms +{ + /// + /// Picks which parts of a copied tiledata entry get pasted onto the selected + /// entries. Produces an / , + /// so the paste lands through the same apply path as a multi-selection + /// "Save Changes". + /// + public partial class TileDataPasteSpecialForm : Form + { + private enum FlagMode + { + Leave, + Replace, + SetChecked, + ClearChecked + } + + private enum ItemField + { + Name, + Animation, + Weight, + Quality, + Quantity, + Hue, + StackingOffset, + Value, + Height, + MiscData, + Unk2, + Unk3 + } + + private enum LandField + { + Name, + TextureId + } + + // Remembered for the session so pasting the same subset onto batch after batch + // does not mean re-ticking the same boxes every time. + private static readonly HashSet _rememberedItemFields = new HashSet(); + private static readonly HashSet _rememberedLandFields = new HashSet(); + private static bool _hasRememberedItemFields; + private static bool _hasRememberedLandFields; + private static FlagMode _rememberedFlagMode = FlagMode.Replace; + + private readonly bool _land; + private readonly ItemData _sourceItem; + private readonly LandData _sourceLand; + private readonly List _fieldKeys = new List(); + private readonly List _flagValues = new List(); + + public TileDataPasteSpecialForm(ItemData source, int sourceGraphic, int targetCount) + : this(false, sourceGraphic, targetCount) + { + _sourceItem = source; + BuildItemFields(); + BuildFlagList(source.Flags); + RestoreRemembered(_rememberedItemFields, _hasRememberedItemFields); + } + + public TileDataPasteSpecialForm(LandData source, int sourceGraphic, int targetCount) + : this(true, sourceGraphic, targetCount) + { + _sourceLand = source; + BuildLandFields(); + BuildFlagList(source.Flags); + RestoreRemembered(_rememberedLandFields, _hasRememberedLandFields); + } + + private TileDataPasteSpecialForm(bool land, int sourceGraphic, int targetCount) + { + InitializeComponent(); + + _land = land; + + string sourceName = land + ? TileData.LandTable[sourceGraphic].Name + : TileData.ItemTable[sourceGraphic].Name; + + headerLabel.Text = targetCount == 1 + ? $"Paste from 0x{sourceGraphic:X4} ({sourceGraphic}) \"{sourceName}\" onto 1 selected entry." + : $"Paste from 0x{sourceGraphic:X4} ({sourceGraphic}) \"{sourceName}\" onto {targetCount} selected entries."; + + switch (_rememberedFlagMode) + { + case FlagMode.Leave: + flagsLeaveRadioButton.Checked = true; + break; + + case FlagMode.SetChecked: + flagsSetCheckedRadioButton.Checked = true; + break; + + case FlagMode.ClearChecked: + flagsClearCheckedRadioButton.Checked = true; + break; + + default: + flagsReplaceRadioButton.Checked = true; + break; + } + + UpdateFlagListEnabled(); + } + + private void BuildItemFields() + { + AddField((int)ItemField.Name, "Name", _sourceItem.Name); + AddField((int)ItemField.Animation, "Anim", _sourceItem.Animation.ToString()); + AddField((int)ItemField.Weight, "Weight", _sourceItem.Weight.ToString()); + AddField((int)ItemField.Quality, "Layer", _sourceItem.Quality.ToString()); + AddField((int)ItemField.Quantity, "Quantity", _sourceItem.Quantity.ToString()); + AddField((int)ItemField.Hue, "Hue", _sourceItem.Hue.ToString()); + AddField((int)ItemField.StackingOffset, "StackOff", _sourceItem.StackingOffset.ToString()); + AddField((int)ItemField.Value, "Value", _sourceItem.Value.ToString()); + AddField((int)ItemField.Height, "Height", _sourceItem.Height.ToString()); + AddField((int)ItemField.MiscData, "MiscData", _sourceItem.MiscData.ToString()); + AddField((int)ItemField.Unk2, "Unk2", _sourceItem.Unk2.ToString()); + AddField((int)ItemField.Unk3, "Unk3", _sourceItem.Unk3.ToString()); + } + + private void BuildLandFields() + { + AddField((int)LandField.Name, "Name", _sourceLand.Name); + AddField((int)LandField.TextureId, "TexID", _sourceLand.TextureId.ToString()); + } + + private void AddField(int key, string label, string value) + { + _fieldKeys.Add(key); + fieldsCheckedListBox.Items.Add($"{label} = {value}", true); + } + + private void BuildFlagList(TileFlag sourceFlags) + { + string[] enumNames = Enum.GetNames(typeof(TileFlag)); + Array enumValues = Enum.GetValues(typeof(TileFlag)); + + // Same "which half of the enum is valid for this client" rule the tiledata + // editor and the CSV export use. + int maxLength = Art.IsUOAHS() ? enumNames.Length : (enumNames.Length / 2) + 1; + + flagsCheckedListBox.BeginUpdate(); + try + { + for (int i = 1; i < maxLength; ++i) + { + var flag = (TileFlag)enumValues.GetValue(i); + _flagValues.Add(flag); + flagsCheckedListBox.Items.Add(enumNames[i], (sourceFlags & flag) != 0); + } + } + finally + { + flagsCheckedListBox.EndUpdate(); + } + } + + private void RestoreRemembered(HashSet remembered, bool hasRemembered) + { + if (!hasRemembered) + { + return; + } + + for (int i = 0; i < _fieldKeys.Count; ++i) + { + fieldsCheckedListBox.SetItemChecked(i, remembered.Contains(_fieldKeys[i])); + } + } + + private FlagMode SelectedFlagMode + { + get + { + if (flagsLeaveRadioButton.Checked) + { + return FlagMode.Leave; + } + + if (flagsSetCheckedRadioButton.Checked) + { + return FlagMode.SetChecked; + } + + if (flagsClearCheckedRadioButton.Checked) + { + return FlagMode.ClearChecked; + } + + return FlagMode.Replace; + } + } + + /// The item edit the chosen boxes describe. Only valid for an item paste. + public ItemDataEdit BuildItemEdit() + { + var edit = new ItemDataEdit(); + + for (int i = 0; i < _fieldKeys.Count; ++i) + { + if (!fieldsCheckedListBox.GetItemChecked(i)) + { + continue; + } + + switch ((ItemField)_fieldKeys[i]) + { + case ItemField.Name: + edit.Name = _sourceItem.Name ?? string.Empty; + break; + + case ItemField.Animation: + edit.Animation = _sourceItem.Animation; + break; + + case ItemField.Weight: + edit.Weight = _sourceItem.Weight; + break; + + case ItemField.Quality: + edit.Quality = _sourceItem.Quality; + break; + + case ItemField.Quantity: + edit.Quantity = _sourceItem.Quantity; + break; + + case ItemField.Hue: + edit.Hue = _sourceItem.Hue; + break; + + case ItemField.StackingOffset: + edit.StackingOffset = _sourceItem.StackingOffset; + break; + + case ItemField.Value: + edit.Value = _sourceItem.Value; + break; + + case ItemField.Height: + edit.Height = _sourceItem.Height; + break; + + case ItemField.MiscData: + edit.MiscData = _sourceItem.MiscData; + break; + + case ItemField.Unk2: + edit.Unk2 = _sourceItem.Unk2; + break; + + case ItemField.Unk3: + edit.Unk3 = _sourceItem.Unk3; + break; + } + } + + ApplyFlagMode(_sourceItem.Flags, out TileFlag setFlags, out TileFlag clearFlags); + edit.SetFlags = setFlags; + edit.ClearFlags = clearFlags; + + return edit; + } + + /// The land edit the chosen boxes describe. Only valid for a land paste. + public LandDataEdit BuildLandEdit() + { + var edit = new LandDataEdit(); + + for (int i = 0; i < _fieldKeys.Count; ++i) + { + if (!fieldsCheckedListBox.GetItemChecked(i)) + { + continue; + } + + switch ((LandField)_fieldKeys[i]) + { + case LandField.Name: + edit.Name = _sourceLand.Name ?? string.Empty; + break; + + case LandField.TextureId: + edit.TextureId = _sourceLand.TextureId; + break; + } + } + + ApplyFlagMode(_sourceLand.Flags, out TileFlag setFlags, out TileFlag clearFlags); + edit.SetFlags = setFlags; + edit.ClearFlags = clearFlags; + + return edit; + } + + private void ApplyFlagMode(TileFlag sourceFlags, out TileFlag setFlags, out TileFlag clearFlags) + { + setFlags = TileFlag.None; + clearFlags = TileFlag.None; + + switch (SelectedFlagMode) + { + case FlagMode.Leave: + return; + + case FlagMode.Replace: + // Set what the source has and clear every other flag this client + // knows about, so the target ends up with exactly the source flags. + foreach (TileFlag flag in _flagValues) + { + if ((sourceFlags & flag) != 0) + { + setFlags |= flag; + } + else + { + clearFlags |= flag; + } + } + + return; + + case FlagMode.SetChecked: + setFlags = GetCheckedFlags(); + return; + + case FlagMode.ClearChecked: + clearFlags = GetCheckedFlags(); + return; + } + } + + private TileFlag GetCheckedFlags() + { + TileFlag flags = TileFlag.None; + for (int i = 0; i < _flagValues.Count; ++i) + { + if (flagsCheckedListBox.GetItemChecked(i)) + { + flags |= _flagValues[i]; + } + } + + return flags; + } + + private void UpdateFlagListEnabled() + { + FlagMode mode = SelectedFlagMode; + flagsCheckedListBox.Enabled = mode == FlagMode.SetChecked || mode == FlagMode.ClearChecked; + } + + private void OnFlagModeChanged(object sender, EventArgs e) + { + UpdateFlagListEnabled(); + } + + private void OnClickCheckAll(object sender, EventArgs e) + { + SetAllFieldsChecked(true); + } + + private void OnClickUncheckAll(object sender, EventArgs e) + { + SetAllFieldsChecked(false); + } + + private void SetAllFieldsChecked(bool value) + { + for (int i = 0; i < fieldsCheckedListBox.Items.Count; ++i) + { + fieldsCheckedListBox.SetItemChecked(i, value); + } + } + + private void OnClickApply(object sender, EventArgs e) + { + Remember(); + DialogResult = DialogResult.OK; + Close(); + } + + private void Remember() + { + _rememberedFlagMode = SelectedFlagMode; + + HashSet remembered = _land ? _rememberedLandFields : _rememberedItemFields; + remembered.Clear(); + for (int i = 0; i < _fieldKeys.Count; ++i) + { + if (fieldsCheckedListBox.GetItemChecked(i)) + { + remembered.Add(_fieldKeys[i]); + } + } + + if (_land) + { + _hasRememberedLandFields = true; + } + else + { + _hasRememberedItemFields = true; + } + } + } +} diff --git a/UoFiddler.Controls/Plugin/PluginBase.cs b/UoFiddler.Controls/Plugin/PluginBase.cs index 44321854..d653ed01 100644 --- a/UoFiddler.Controls/Plugin/PluginBase.cs +++ b/UoFiddler.Controls/Plugin/PluginBase.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Logging; using UoFiddler.Controls.Classes; using UoFiddler.Controls.Plugin.Interfaces; +using Ultima.Helpers; namespace UoFiddler.Controls.Plugin { @@ -47,4 +48,3 @@ public virtual void ModifyPluginToolStrip(ToolStripDropDownButton toolStrip) { } public virtual void ModifyTabPages(TabControl tabControl) { } } } - diff --git a/UoFiddler.Controls/Plugin/PluginServices.cs b/UoFiddler.Controls/Plugin/PluginServices.cs index d21c4199..c42178d1 100644 --- a/UoFiddler.Controls/Plugin/PluginServices.cs +++ b/UoFiddler.Controls/Plugin/PluginServices.cs @@ -17,6 +17,7 @@ using UoFiddler.Controls.Plugin.Interfaces; using UoFiddler.Controls.UserControls; using UoFiddler.Controls.UserControls.TileView; +using Ultima.Helpers; namespace UoFiddler.Controls.Plugin { diff --git a/UoFiddler.Controls/UserControls/GumpControl.Designer.cs b/UoFiddler.Controls/UserControls/GumpControl.Designer.cs index b99d5b44..37afc50d 100644 --- a/UoFiddler.Controls/UserControls/GumpControl.Designer.cs +++ b/UoFiddler.Controls/UserControls/GumpControl.Designer.cs @@ -57,6 +57,9 @@ private void InitializeComponent() jumpToMaleFemale = new System.Windows.Forms.ToolStripMenuItem(); toolStripSeparator5 = new System.Windows.Forms.ToolStripSeparator(); replaceGumpToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + copyImageToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + pasteImageToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + clipboardToolStripSeparator = new System.Windows.Forms.ToolStripSeparator(); insertToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); InsertText = new System.Windows.Forms.ToolStripTextBox(); toolStripMenuItem1 = new System.Windows.Forms.ToolStripMenuItem(); @@ -148,8 +151,9 @@ private void InitializeComponent() // // contextMenuStrip // - contextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { showFreeSlotsToolStripMenuItem, findNextFreeSlotToolStripMenuItem, changeBackgroundColorToolStripMenuItem, toolStripSeparator2, extractImageToolStripMenuItem, toolStripSeparator6, jumpToMaleFemale, toolStripSeparator5, replaceGumpToolStripMenuItem, insertToolStripMenuItem, toolStripMenuItem1, removeToolStripMenuItem, toolStripSeparator1, saveToolStripMenuItem }); + contextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { showFreeSlotsToolStripMenuItem, findNextFreeSlotToolStripMenuItem, changeBackgroundColorToolStripMenuItem, toolStripSeparator2, extractImageToolStripMenuItem, toolStripSeparator6, jumpToMaleFemale, toolStripSeparator5, copyImageToolStripMenuItem, pasteImageToolStripMenuItem, clipboardToolStripSeparator, replaceGumpToolStripMenuItem, insertToolStripMenuItem, toolStripMenuItem1, removeToolStripMenuItem, toolStripSeparator1, saveToolStripMenuItem }); contextMenuStrip.Name = "contextMenuStrip1"; + contextMenuStrip.Opening += ContextMenuStrip_Opening; contextMenuStrip.Size = new System.Drawing.Size(190, 226); // // showFreeSlotsToolStripMenuItem @@ -230,6 +234,27 @@ private void InitializeComponent() toolStripSeparator5.Name = "toolStripSeparator5"; toolStripSeparator5.Size = new System.Drawing.Size(186, 6); // + // copyImageToolStripMenuItem + // + copyImageToolStripMenuItem.Name = "copyImageToolStripMenuItem"; + copyImageToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+C"; + copyImageToolStripMenuItem.Size = new System.Drawing.Size(189, 22); + copyImageToolStripMenuItem.Text = "Copy Image"; + copyImageToolStripMenuItem.Click += OnClickCopyImage; + // + // pasteImageToolStripMenuItem + // + pasteImageToolStripMenuItem.Name = "pasteImageToolStripMenuItem"; + pasteImageToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+V"; + pasteImageToolStripMenuItem.Size = new System.Drawing.Size(189, 22); + pasteImageToolStripMenuItem.Text = "Paste Image"; + pasteImageToolStripMenuItem.Click += OnClickPasteImage; + // + // clipboardToolStripSeparator + // + clipboardToolStripSeparator.Name = "clipboardToolStripSeparator"; + clipboardToolStripSeparator.Size = new System.Drawing.Size(186, 6); + // // replaceGumpToolStripMenuItem // replaceGumpToolStripMenuItem.Name = "replaceGumpToolStripMenuItem"; @@ -541,6 +566,9 @@ private void InitializeComponent() private System.Windows.Forms.ToolStripProgressBar ProgressBar; private System.Windows.Forms.ToolStripMenuItem removeToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem replaceGumpToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem copyImageToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem pasteImageToolStripMenuItem; + private System.Windows.Forms.ToolStripSeparator clipboardToolStripSeparator; private System.Windows.Forms.ToolStripMenuItem saveToolStripMenuItem; private System.Windows.Forms.ToolStripLabel SizeLabel; private System.Windows.Forms.SplitContainer splitContainer1; diff --git a/UoFiddler.Controls/UserControls/GumpControl.cs b/UoFiddler.Controls/UserControls/GumpControl.cs index 8cd8f5c5..64b76262 100644 --- a/UoFiddler.Controls/UserControls/GumpControl.cs +++ b/UoFiddler.Controls/UserControls/GumpControl.cs @@ -19,6 +19,7 @@ using System.Windows.Forms; using System.Xml; using Ultima; +using Ultima.Uop; using UoFiddler.Controls.Classes; using UoFiddler.Controls.Forms; using UoFiddler.Controls.Helpers; @@ -497,6 +498,11 @@ private void ListView_DrawItem(object sender, DrawListViewItemEventArgs e) e.Graphics.FillRectangle(Brushes.LightCoral, e.Bounds.X, e.Bounds.Y, 105, e.Bounds.Height); } e.Graphics.DrawImage(bmp, new Rectangle(e.Bounds.X + 3, e.Bounds.Y + 3, width, height)); + + if (Gumps.IsModified(i)) + { + ModifiedMarker.Draw(e.Graphics, new Rectangle(e.Bounds.X, e.Bounds.Y, 105, e.Bounds.Height)); + } } else { @@ -601,6 +607,82 @@ private void JumpToMaleFemaleInvalidate() } } + private void ContextMenuStrip_Opening(object sender, System.ComponentModel.CancelEventArgs e) + { + int id = SelectedGumpId; + copyImageToolStripMenuItem.Enabled = id >= 0 && Gumps.IsValidIndex(id); + pasteImageToolStripMenuItem.Enabled = id >= 0 && ImageClipboard.ContainsImage(); + } + + private void OnClickCopyImage(object sender, EventArgs e) + { + int id = SelectedGumpId; + if (id < 0 || !Gumps.IsValidIndex(id)) + { + return; + } + + if (!ImageClipboard.TryCopy(Gumps.GetGump(id), out string error)) + { + MessageBox.Show(error, "Copy Image", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + private void OnClickPasteImage(object sender, EventArgs e) + { + int id = SelectedGumpId; + if (id < 0) + { + return; + } + + using Bitmap pasted = ImageClipboard.TryPaste(out string error); + if (pasted == null) + { + MessageBox.Show(error, "Paste Image", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + // Gumps have no fixed size, but the idx packs width and height into one int and the + // decoder refuses anything past 0xFFFF on either axis. + if (pasted.Width == 0 || pasted.Height == 0 || pasted.Width > 0xFFFF || pasted.Height > 0xFFFF) + { + MessageBox.Show( + $"Invalid gump dimensions!\n\n" + + $"Clipboard image: {pasted.Width}x{pasted.Height}\n" + + $"Gumps may be up to 65535x65535 pixels.\n\n" + + "No changes made.", + "Invalid Size", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + Gumps.ReplaceGump(id, Utils.ToUoBitmap(pasted)); + ControlEvents.FireGumpChangeEvent(this, id); + listView.Invalidate(); + ListView_SelectedIndexChanged(this, EventArgs.Empty); + Options.ChangedUltimaClass["Gumps"] = true; + } + + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) + { + // Copy/paste is handled here rather than as menu ShortcutKeys: a shortcut on a + // ContextMenuStrip is processed for the whole form, which would swallow Ctrl+C/Ctrl+V in + // every text box on every tab. + if (keyData == (Keys.Control | Keys.C) && listView.Focused) + { + OnClickCopyImage(this, EventArgs.Empty); + return true; + } + + if (keyData == (Keys.Control | Keys.V) && listView.Focused) + { + OnClickPasteImage(this, EventArgs.Empty); + return true; + } + + return base.ProcessCmdKey(ref msg, keyData); + } + private void OnClickReplace(object sender, EventArgs e) { if (SelectedGumpId < 0) @@ -651,15 +733,8 @@ private void OnClickSave(object sender, EventArgs e) return; } - using (new WaitCursorScope(this)) - { - ProgressBarDialog barDialog = new ProgressBarDialog(Gumps.GetCount(), "Save"); - Gumps.Save(Options.OutputPath); - barDialog.Dispose(); - } - - Options.ChangedUltimaClass["Gumps"] = false; - FileSavedDialog.Show(FindForm(), Options.OutputPath, "Files saved successfully."); + ClientFileSaveCommand.Run(this, FileType.GumpartLegacyMul, Gumps.Save, "Gumps", + createProgress: () => new ProgressBarDialog(Gumps.GetCount(), "Save")); } private void OnClickRemove(object sender, EventArgs e) diff --git a/UoFiddler.Controls/UserControls/ItemsControl.Designer.cs b/UoFiddler.Controls/UserControls/ItemsControl.Designer.cs index 4773dcfc..de21dc8a 100644 --- a/UoFiddler.Controls/UserControls/ItemsControl.Designer.cs +++ b/UoFiddler.Controls/UserControls/ItemsControl.Designer.cs @@ -67,6 +67,12 @@ private void InitializeComponent() selectInGumpsTabFemaleToolStripMenuItem = new ToolStripMenuItem(); toolStripSeparator2 = new ToolStripSeparator(); replaceToolStripMenuItem = new ToolStripMenuItem(); + copyImageToolStripMenuItem = new ToolStripMenuItem(); + pasteImageToolStripMenuItem = new ToolStripMenuItem(); + clipboardToolStripSeparator = new ToolStripSeparator(); + copyImageToolStripMenuItemDetail = new ToolStripMenuItem(); + pasteImageToolStripMenuItemDetail = new ToolStripMenuItem(); + clipboardToolStripSeparatorDetail = new ToolStripSeparator(); replaceStartingFromToolStripMenuItem = new ToolStripMenuItem(); ReplaceStartingFromText = new ToolStripTextBox(); replaceFromFolderToolStripMenuItem = new ToolStripMenuItem(); @@ -146,8 +152,9 @@ private void InitializeComponent() // // DetailPictureBoxContextMenuStrip // - DetailPictureBoxContextMenuStrip.Items.AddRange(new ToolStripItem[] { changeBackgroundColorToolStripMenuItemDetail }); + DetailPictureBoxContextMenuStrip.Items.AddRange(new ToolStripItem[] { copyImageToolStripMenuItemDetail, pasteImageToolStripMenuItemDetail, clipboardToolStripSeparatorDetail, changeBackgroundColorToolStripMenuItemDetail }); DetailPictureBoxContextMenuStrip.Name = "contextMenuStrip2"; + DetailPictureBoxContextMenuStrip.Opening += DetailPictureBoxContextMenuStrip_Opening; DetailPictureBoxContextMenuStrip.Size = new System.Drawing.Size(213, 26); // // changeBackgroundColorToolStripMenuItemDetail @@ -218,7 +225,7 @@ private void InitializeComponent() // // TileViewContextMenuStrip // - TileViewContextMenuStrip.Items.AddRange(new ToolStripItem[] { showFreeSlotsToolStripMenuItem, findNextFreeSlotToolStripMenuItem, ChangeBackgroundColorToolStripMenuItem, toolStripSeparator3, extractToolStripMenuItem, toolStripSeparator7, selectInTileDataTabToolStripMenuItem, selectInRadarColorTabToolStripMenuItem, selectInGumpsTabMaleToolStripMenuItem, selectInGumpsTabFemaleToolStripMenuItem, toolStripSeparator2, replaceToolStripMenuItem, replaceStartingFromToolStripMenuItem, replaceFromFolderToolStripMenuItem, insertAtToolStripMenuItem, removeToolStripMenuItem, toolStripSeparator1, saveToolStripMenuItem }); + TileViewContextMenuStrip.Items.AddRange(new ToolStripItem[] { showFreeSlotsToolStripMenuItem, findNextFreeSlotToolStripMenuItem, ChangeBackgroundColorToolStripMenuItem, toolStripSeparator3, extractToolStripMenuItem, toolStripSeparator7, selectInTileDataTabToolStripMenuItem, selectInRadarColorTabToolStripMenuItem, selectInGumpsTabMaleToolStripMenuItem, selectInGumpsTabFemaleToolStripMenuItem, toolStripSeparator2, copyImageToolStripMenuItem, pasteImageToolStripMenuItem, clipboardToolStripSeparator, replaceToolStripMenuItem, replaceStartingFromToolStripMenuItem, replaceFromFolderToolStripMenuItem, insertAtToolStripMenuItem, removeToolStripMenuItem, toolStripSeparator1, saveToolStripMenuItem }); TileViewContextMenuStrip.Name = "contextMenuStrip1"; TileViewContextMenuStrip.Size = new System.Drawing.Size(213, 314); TileViewContextMenuStrip.Opening += TileViewContextMenuStrip_Opening; @@ -325,6 +332,48 @@ private void InitializeComponent() toolStripSeparator2.Name = "toolStripSeparator2"; toolStripSeparator2.Size = new System.Drawing.Size(209, 6); // + // copyImageToolStripMenuItem + // + copyImageToolStripMenuItem.Name = "copyImageToolStripMenuItem"; + copyImageToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+C"; + copyImageToolStripMenuItem.Size = new System.Drawing.Size(212, 22); + copyImageToolStripMenuItem.Text = "Copy Image"; + copyImageToolStripMenuItem.Click += OnClickCopyImage; + // + // pasteImageToolStripMenuItem + // + pasteImageToolStripMenuItem.Name = "pasteImageToolStripMenuItem"; + pasteImageToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+V"; + pasteImageToolStripMenuItem.Size = new System.Drawing.Size(212, 22); + pasteImageToolStripMenuItem.Text = "Paste Image"; + pasteImageToolStripMenuItem.Click += OnClickPasteImage; + // + // clipboardToolStripSeparator + // + clipboardToolStripSeparator.Name = "clipboardToolStripSeparator"; + clipboardToolStripSeparator.Size = new System.Drawing.Size(209, 6); + // + // copyImageToolStripMenuItemDetail + // + copyImageToolStripMenuItemDetail.Name = "copyImageToolStripMenuItemDetail"; + copyImageToolStripMenuItemDetail.ShortcutKeyDisplayString = "Ctrl+C"; + copyImageToolStripMenuItemDetail.Size = new System.Drawing.Size(212, 22); + copyImageToolStripMenuItemDetail.Text = "Copy Image"; + copyImageToolStripMenuItemDetail.Click += OnClickCopyImage; + // + // pasteImageToolStripMenuItemDetail + // + pasteImageToolStripMenuItemDetail.Name = "pasteImageToolStripMenuItemDetail"; + pasteImageToolStripMenuItemDetail.ShortcutKeyDisplayString = "Ctrl+V"; + pasteImageToolStripMenuItemDetail.Size = new System.Drawing.Size(212, 22); + pasteImageToolStripMenuItemDetail.Text = "Paste Image"; + pasteImageToolStripMenuItemDetail.Click += OnClickPasteImage; + // + // clipboardToolStripSeparatorDetail + // + clipboardToolStripSeparatorDetail.Name = "clipboardToolStripSeparatorDetail"; + clipboardToolStripSeparatorDetail.Size = new System.Drawing.Size(209, 6); + // // replaceToolStripMenuItem // replaceToolStripMenuItem.Name = "replaceToolStripMenuItem"; @@ -619,6 +668,12 @@ private void InitializeComponent() private ToolStripProgressBar ProgressBar; private ToolStripMenuItem removeToolStripMenuItem; private ToolStripMenuItem replaceToolStripMenuItem; + private ToolStripMenuItem copyImageToolStripMenuItem; + private ToolStripMenuItem pasteImageToolStripMenuItem; + private ToolStripSeparator clipboardToolStripSeparator; + private ToolStripMenuItem copyImageToolStripMenuItemDetail; + private ToolStripMenuItem pasteImageToolStripMenuItemDetail; + private ToolStripSeparator clipboardToolStripSeparatorDetail; private ToolStripMenuItem saveToolStripMenuItem; private ToolStripMenuItem selectInRadarColorTabToolStripMenuItem; private ToolStripMenuItem selectInTileDataTabToolStripMenuItem; diff --git a/UoFiddler.Controls/UserControls/ItemsControl.cs b/UoFiddler.Controls/UserControls/ItemsControl.cs index b5a4069b..3f9b1cf7 100644 --- a/UoFiddler.Controls/UserControls/ItemsControl.cs +++ b/UoFiddler.Controls/UserControls/ItemsControl.cs @@ -20,6 +20,7 @@ using System.Text.RegularExpressions; using System.Windows.Forms; using Ultima; +using Ultima.Uop; using UoFiddler.Controls.Classes; using UoFiddler.Controls.Forms; using UoFiddler.Controls.Helpers; @@ -507,6 +508,93 @@ private void OnClickFindFree(object sender, EventArgs e) } } + private void OnClickCopyImage(object sender, EventArgs e) + { + // The clipboard holds one image, so a multi selection copies the focused tile. + int id = SelectedGraphicId; + if (id < 0 || !Art.IsValidStatic(id)) + { + return; + } + + if (!ImageClipboard.TryCopy(Art.GetStatic(id), out string error)) + { + MessageBox.Show(error, "Copy Image", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + private void OnClickPasteImage(object sender, EventArgs e) + { + var ids = GetSelectedGraphicIds(); + if (ids.Count == 0) + { + if (SelectedGraphicId < 0) + { + return; + } + + ids.Add(SelectedGraphicId); + } + + using Bitmap pasted = ImageClipboard.TryPaste(out string error); + if (pasted == null) + { + MessageBox.Show(error, "Paste Image", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + if (ids.Count > 1) + { + DialogResult confirm = MessageBox.Show( + $"Paste this {pasted.Width}x{pasted.Height} image into {ids.Count} selected items?", + "Paste Image", MessageBoxButtons.YesNo, MessageBoxIcon.Question, + MessageBoxDefaultButton.Button2); + + if (confirm != DialogResult.Yes) + { + return; + } + } + + Bitmap converted = Utils.ToUoBitmap(pasted); + + if (!Art.ValidateStaticSize(converted, out int estimatedSize)) + { + converted.Dispose(); + + MessageBox.Show( + $"Image is too large for MUL format!\n\n" + + $"Image dimensions: {pasted.Width}x{pasted.Height}\n" + + $"Encoded size: {estimatedSize:N0} ushorts\n" + + $"Maximum allowed: 65,535 ushorts\n\n" + + "Try a smaller image or one with more transparent pixels.", + "Image Too Large", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + foreach (int id in ids) + { + // Each slot needs its own bitmap: the SDK keeps the instance it is handed. + Art.ReplaceStatic(id, ids.Count == 1 ? converted : (Bitmap)converted.Clone()); + ControlEvents.FireItemChangeEvent(this, id); + } + + if (ids.Count > 1) + { + converted.Dispose(); + } + + ItemsTileView.Invalidate(); + + if (SelectedGraphicId >= 0) + { + UpdateToolStripLabels(SelectedGraphicId); + UpdateDetail(SelectedGraphicId); + } + + Options.ChangedUltimaClass["Art"] = true; + } + private void OnClickReplace(object sender, EventArgs e) { if (ItemsTileView.SelectedIndices.Count > 1) @@ -781,16 +869,8 @@ private void OnClickSave(object sender, EventArgs e) return; } - using (new WaitCursorScope(this)) - { - ProgressBarDialog barDialog = new ProgressBarDialog(Art.GetIdxLength(), "Save"); - Art.Save(Options.OutputPath); - barDialog.Dispose(); - } - - Options.ChangedUltimaClass["Art"] = false; - - FileSavedDialog.Show(FindForm(), Options.OutputPath, "Files saved successfully."); + ClientFileSaveCommand.Run(this, FileType.ArtLegacyMul, Art.Save, "Art", + createProgress: () => new ProgressBarDialog(Art.GetIdxLength(), "Save")); } private void OnClickShowFreeSlots(object sender, EventArgs e) @@ -910,7 +990,14 @@ private static void ExportItemImage(int index, ImageFormat imageFormat) private void OnClickSelectTiledata(object sender, EventArgs e) { - if (_selectedGraphicId >= 0) + // Carry the whole selection over, so a set of tiles picked here can be + // edited in one go on the TileData tab. + List ids = GetSelectedGraphicIds(); + if (ids.Count > 0) + { + TileDataControl.Select(ids, false); + } + else if (_selectedGraphicId >= 0) { TileDataControl.Select(_selectedGraphicId, false); } @@ -1099,6 +1186,11 @@ private void ItemsTileView_DrawItem(object sender, TileViewControl.DrawTileListI e.Graphics.DrawImage(bitmap, new Rectangle(itemPoint, new Size(width, height))); } + if (Art.IsStaticModified(_itemList[e.Index])) + { + ModifiedMarker.Draw(e.Graphics, rect); + } + e.Graphics.Clip = previousClip; } } @@ -1221,12 +1313,24 @@ private void SelectInGumpsTabFemaleToolStripMenuItem_Click(object sender, EventA SelectInGumpsTab(SelectedGraphicId, true); } + private void DetailPictureBoxContextMenuStrip_Opening(object sender, CancelEventArgs e) + { + copyImageToolStripMenuItemDetail.Enabled = SelectedGraphicId >= 0 && Art.IsValidStatic(SelectedGraphicId); + pasteImageToolStripMenuItemDetail.Enabled = SelectedGraphicId >= 0 && ImageClipboard.ContainsImage(); + } + private void TileViewContextMenuStrip_Opening(object sender, CancelEventArgs e) { int selectedCount = ItemsTileView.SelectedIndices.Count; + copyImageToolStripMenuItem.Enabled = SelectedGraphicId >= 0 && Art.IsValidStatic(SelectedGraphicId); + pasteImageToolStripMenuItem.Enabled = selectedCount > 0 && ImageClipboard.ContainsImage(); + pasteImageToolStripMenuItem.Text = selectedCount > 1 ? $"Paste Image into {selectedCount}" : "Paste Image"; removeToolStripMenuItem.Text = selectedCount > 1 ? $"Remove {selectedCount}" : "Remove"; extractToolStripMenuItem.Text = selectedCount > 1 ? $"Export {selectedCount} Images..." : "Export Image.."; replaceToolStripMenuItem.Text = selectedCount > 1 ? $"Replace {selectedCount}..." : "Replace..."; + selectInTileDataTabToolStripMenuItem.Text = selectedCount > 1 + ? $"Select {selectedCount} in TileData tab" + : "Select in TileData tab"; if (SelectedGraphicId <= 0) { @@ -1485,6 +1589,21 @@ private void SearchByIdToolStripTextBox_KeyUp(object sender, KeyEventArgs e) protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { + // Copy/paste is handled here rather than as menu ShortcutKeys: a shortcut on a + // ContextMenuStrip is processed for the whole form, which would swallow Ctrl+C/Ctrl+V in + // every text box on every tab. + if (keyData == (Keys.Control | Keys.C) && ItemsTileView.Focused) + { + OnClickCopyImage(this, EventArgs.Empty); + return true; + } + + if (keyData == (Keys.Control | Keys.V) && ItemsTileView.Focused) + { + OnClickPasteImage(this, EventArgs.Empty); + return true; + } + if (keyData == Keys.F3 || keyData == (Keys.F3 | Keys.Shift)) { if (searchByNameToolStripTextBox.TextBox.Focused) diff --git a/UoFiddler.Controls/UserControls/LandTilesControl.Designer.cs b/UoFiddler.Controls/UserControls/LandTilesControl.Designer.cs index 2dbea649..d70f5354 100644 --- a/UoFiddler.Controls/UserControls/LandTilesControl.Designer.cs +++ b/UoFiddler.Controls/UserControls/LandTilesControl.Designer.cs @@ -60,6 +60,9 @@ private void InitializeComponent() selectInTexturesTabToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator(); replaceToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + copyImageToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + pasteImageToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + clipboardToolStripSeparator = new System.Windows.Forms.ToolStripSeparator(); replaceStartingFromToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); ReplaceStartingFromTb = new System.Windows.Forms.ToolStripTextBox(); replaceFromFolderToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); @@ -98,7 +101,7 @@ private void InitializeComponent() // // LandTilesContextMenuStrip // - LandTilesContextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { showFreeSlotsToolStripMenuItem, findNextFreeSlotToolStripMenuItem, changeBackgroundColorToolStripMenuItem, toolStripSeparator6, exportImageToolStripMenuItem, toolStripSeparator3, selectInTileDataTabToolStripMenuItem, selectInRadarColorTabToolStripMenuItem, selectInTexturesTabToolStripMenuItem, toolStripSeparator2, replaceToolStripMenuItem, replaceStartingFromToolStripMenuItem, replaceFromFolderToolStripMenuItem, insertAtToolStripMenuItem, removeToolStripMenuItem, toolStripSeparator1, saveToolStripMenuItem }); + LandTilesContextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { showFreeSlotsToolStripMenuItem, findNextFreeSlotToolStripMenuItem, changeBackgroundColorToolStripMenuItem, toolStripSeparator6, exportImageToolStripMenuItem, toolStripSeparator3, selectInTileDataTabToolStripMenuItem, selectInRadarColorTabToolStripMenuItem, selectInTexturesTabToolStripMenuItem, toolStripSeparator2, copyImageToolStripMenuItem, pasteImageToolStripMenuItem, clipboardToolStripSeparator, replaceToolStripMenuItem, replaceStartingFromToolStripMenuItem, replaceFromFolderToolStripMenuItem, insertAtToolStripMenuItem, removeToolStripMenuItem, toolStripSeparator1, saveToolStripMenuItem }); LandTilesContextMenuStrip.Name = "contextMenuStrip1"; LandTilesContextMenuStrip.Size = new System.Drawing.Size(201, 270); LandTilesContextMenuStrip.Opening += LandTilesContextMenuStrip_Opening; @@ -196,6 +199,27 @@ private void InitializeComponent() toolStripSeparator2.Name = "toolStripSeparator2"; toolStripSeparator2.Size = new System.Drawing.Size(197, 6); // + // copyImageToolStripMenuItem + // + copyImageToolStripMenuItem.Name = "copyImageToolStripMenuItem"; + copyImageToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+C"; + copyImageToolStripMenuItem.Size = new System.Drawing.Size(200, 22); + copyImageToolStripMenuItem.Text = "Copy Image"; + copyImageToolStripMenuItem.Click += OnClickCopyImage; + // + // pasteImageToolStripMenuItem + // + pasteImageToolStripMenuItem.Name = "pasteImageToolStripMenuItem"; + pasteImageToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+V"; + pasteImageToolStripMenuItem.Size = new System.Drawing.Size(200, 22); + pasteImageToolStripMenuItem.Text = "Paste Image"; + pasteImageToolStripMenuItem.Click += OnClickPasteImage; + // + // clipboardToolStripSeparator + // + clipboardToolStripSeparator.Name = "clipboardToolStripSeparator"; + clipboardToolStripSeparator.Size = new System.Drawing.Size(197, 6); + // // replaceToolStripMenuItem // replaceToolStripMenuItem.Name = "replaceToolStripMenuItem"; @@ -473,6 +497,9 @@ private void InitializeComponent() private System.Windows.Forms.ToolStripTextBox InsertText; private System.Windows.Forms.ToolStripMenuItem removeToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem replaceToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem copyImageToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem pasteImageToolStripMenuItem; + private System.Windows.Forms.ToolStripSeparator clipboardToolStripSeparator; private System.Windows.Forms.ToolStripButton SaveButton; private System.Windows.Forms.ToolStripMenuItem selectInRadarColorTabToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem selectInTexturesTabToolStripMenuItem; diff --git a/UoFiddler.Controls/UserControls/LandTilesControl.cs b/UoFiddler.Controls/UserControls/LandTilesControl.cs index 8a16c8aa..db81f395 100644 --- a/UoFiddler.Controls/UserControls/LandTilesControl.cs +++ b/UoFiddler.Controls/UserControls/LandTilesControl.cs @@ -20,6 +20,7 @@ using System.Text.RegularExpressions; using System.Windows.Forms; using Ultima; +using Ultima.Uop; using UoFiddler.Controls.Classes; using UoFiddler.Controls.Forms; using UoFiddler.Controls.Helpers; @@ -43,6 +44,11 @@ public LandTilesControl() private const int _landTileMax = 0x4000; + /// + /// Land art is stored as a fixed size raw block, so every land bitmap is this wide and tall. + /// + private const int LandTileSize = 44; + private static LandTilesControl _refMarker; private int _selectedGraphicId = -1; private readonly List _tileList = new List(); @@ -383,6 +389,90 @@ private void OnClickRemove(object sender, EventArgs e) Options.ChangedUltimaClass["Art"] = true; } + private void OnClickCopyImage(object sender, EventArgs e) + { + // The clipboard holds one image, so a multi selection copies the focused tile. + int id = _selectedGraphicId; + if (id < 0 || !Art.IsValidLand(id)) + { + return; + } + + if (!ImageClipboard.TryCopy(Art.GetLand(id), out string error)) + { + MessageBox.Show(error, "Copy Image", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + private void OnClickPasteImage(object sender, EventArgs e) + { + var ids = GetSelectedGraphicIds(); + if (ids.Count == 0) + { + if (_selectedGraphicId < 0) + { + return; + } + + ids.Add(_selectedGraphicId); + } + + using Bitmap pasted = ImageClipboard.TryPaste(out string error); + if (pasted == null) + { + MessageBox.Show(error, "Paste Image", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + // Land art is a fixed 44x44 raw block - anything else would be written out as garbage. + if (pasted.Width != LandTileSize || pasted.Height != LandTileSize) + { + MessageBox.Show( + $"Invalid land tile dimensions!\n\n" + + $"Clipboard image: {pasted.Width}x{pasted.Height}\n" + + $"Land tiles must be {LandTileSize}x{LandTileSize} pixels.\n\n" + + "No changes made.", + "Invalid Size", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + if (ids.Count > 1) + { + DialogResult confirm = MessageBox.Show( + $"Paste this image into {ids.Count} selected land tiles?", + "Paste Image", MessageBoxButtons.YesNo, MessageBoxIcon.Question, + MessageBoxDefaultButton.Button2); + + if (confirm != DialogResult.Yes) + { + return; + } + } + + Bitmap converted = Utils.ToUoBitmap(pasted); + + foreach (int id in ids) + { + // Each slot needs its own bitmap: the SDK keeps the instance it is handed. + Art.ReplaceLand(id, ids.Count == 1 ? converted : (Bitmap)converted.Clone()); + ControlEvents.FireLandTileChangeEvent(this, id); + } + + if (ids.Count > 1) + { + converted.Dispose(); + } + + LandTilesTileView.Invalidate(); + + if (_selectedGraphicId >= 0) + { + UpdateToolStripLabels(_selectedGraphicId); + } + + Options.ChangedUltimaClass["Art"] = true; + } + private void OnClickReplace(object sender, EventArgs e) { if (LandTilesTileView.SelectedIndices.Count > 1) @@ -636,12 +726,7 @@ private void OnClickSave(object sender, EventArgs e) return; } - using (new WaitCursorScope(this)) - { - Art.Save(Options.OutputPath); - } - Options.ChangedUltimaClass["Art"] = false; - FileSavedDialog.Show(FindForm(), Options.OutputPath, "Files saved successfully."); + ClientFileSaveCommand.Run(this, FileType.ArtLegacyMul, Art.Save, "Art"); } private void OnClickExportBmp(object sender, EventArgs e) @@ -724,7 +809,14 @@ private static void ExportLandTileImage(int index, ImageFormat imageFormat) private void OnClickSelectTiledata(object sender, EventArgs e) { - if (_selectedGraphicId >= 0) + // Carry the whole selection over, so a set of tiles picked here can be + // edited in one go on the TileData tab. + List ids = GetSelectedGraphicIds(); + if (ids.Count > 0) + { + TileDataControl.Select(ids, true); + } + else if (_selectedGraphicId >= 0) { TileDataControl.Select(_selectedGraphicId, true); } @@ -755,9 +847,15 @@ private void OnClickSelectTexture(object sender, EventArgs e) private void LandTilesContextMenuStrip_Opening(object sender, System.ComponentModel.CancelEventArgs e) { int selectedCount = LandTilesTileView.SelectedIndices.Count; + copyImageToolStripMenuItem.Enabled = _selectedGraphicId >= 0 && Art.IsValidLand(_selectedGraphicId); + pasteImageToolStripMenuItem.Enabled = selectedCount > 0 && ImageClipboard.ContainsImage(); + pasteImageToolStripMenuItem.Text = selectedCount > 1 ? $"Paste Image into {selectedCount}" : "Paste Image"; removeToolStripMenuItem.Text = selectedCount > 1 ? $"Remove {selectedCount}" : "Remove"; exportImageToolStripMenuItem.Text = selectedCount > 1 ? $"Export {selectedCount} Images..." : "Export Image.."; replaceToolStripMenuItem.Text = selectedCount > 1 ? $"Replace {selectedCount}" : "Replace"; + selectInTileDataTabToolStripMenuItem.Text = selectedCount > 1 + ? $"Select {selectedCount} in TileData tab" + : "Select in TileData tab"; bool hasTexture = _selectedGraphicId >= 0 && TileData.LandTable[_selectedGraphicId].TextureId != 0 @@ -844,8 +942,7 @@ private void LandTilesTileView_DrawItem(object sender, TileView.TileViewControl. } Point itemPoint = new Point(e.Bounds.X + LandTilesTileView.TilePadding.Left, e.Bounds.Y + LandTilesTileView.TilePadding.Top); - const int fixedTileSize = 44; - Size itemSize = new Size(fixedTileSize, fixedTileSize); + Size itemSize = new Size(LandTileSize, LandTileSize); Rectangle itemRec = new Rectangle(itemPoint, itemSize); using var previousClip = e.Graphics.Clip; @@ -882,6 +979,11 @@ private void LandTilesTileView_DrawItem(object sender, TileView.TileViewControl. e.Graphics.DrawImage(bitmap, itemRec); + if (Art.IsLandModified(_tileList[e.Index])) + { + ModifiedMarker.Draw(e.Graphics, itemRec); + } + e.Graphics.Clip = previousClip; } } @@ -1194,6 +1296,21 @@ private void SearchByIdToolStripTextBox_KeyUp(object sender, KeyEventArgs e) protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { + // Copy/paste is handled here rather than as menu ShortcutKeys: a shortcut on a + // ContextMenuStrip is processed for the whole form, which would swallow Ctrl+C/Ctrl+V in + // every text box on every tab. + if (keyData == (Keys.Control | Keys.C) && LandTilesTileView.Focused) + { + OnClickCopyImage(this, EventArgs.Empty); + return true; + } + + if (keyData == (Keys.Control | Keys.V) && LandTilesTileView.Focused) + { + OnClickPasteImage(this, EventArgs.Empty); + return true; + } + if (keyData == Keys.F3 || keyData == (Keys.F3 | Keys.Shift)) { if (searchByNameToolStripTextBox.TextBox.Focused) diff --git a/UoFiddler.Controls/UserControls/MapControl.Designer.cs b/UoFiddler.Controls/UserControls/MapControl.Designer.cs index 88379e20..6f23940e 100644 --- a/UoFiddler.Controls/UserControls/MapControl.Designer.cs +++ b/UoFiddler.Controls/UserControls/MapControl.Designer.cs @@ -108,7 +108,6 @@ private void InitializeComponent() this.PreloadMap = new System.Windows.Forms.ToolStripButton(); this.toolStripDropDownButton3 = new System.Windows.Forms.ToolStripDropDownButton(); this.defragStaticsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.defragAndRemoveDuplicatesStToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.importStaticsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.meltStaticsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.clearStaticsinMemoryToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); @@ -612,7 +611,6 @@ private void InitializeComponent() this.toolStripDropDownButton3.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; this.toolStripDropDownButton3.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { this.defragStaticsToolStripMenuItem, - this.defragAndRemoveDuplicatesStToolStripMenuItem, this.importStaticsToolStripMenuItem, this.meltStaticsToolStripMenuItem, this.clearStaticsinMemoryToolStripMenuItem, @@ -635,16 +633,11 @@ private void InitializeComponent() // this.defragStaticsToolStripMenuItem.Name = "defragStaticsToolStripMenuItem"; this.defragStaticsToolStripMenuItem.Size = new System.Drawing.Size(308, 22); - this.defragStaticsToolStripMenuItem.Text = "Defrag Statics"; + this.defragStaticsToolStripMenuItem.Text = "Defrag Statics..."; + this.defragStaticsToolStripMenuItem.ToolTipText = "Rewrites staidx/statics for this map, compacting it and optionally filtering out " + + "statics the client cannot draw correctly. Reports what it removed."; this.defragStaticsToolStripMenuItem.Click += new System.EventHandler(this.OnClickDefragStatics); // - // defragAndRemoveDuplicatesStToolStripMenuItem - // - this.defragAndRemoveDuplicatesStToolStripMenuItem.Name = "defragAndRemoveDuplicatesStToolStripMenuItem"; - this.defragAndRemoveDuplicatesStToolStripMenuItem.Size = new System.Drawing.Size(308, 22); - this.defragAndRemoveDuplicatesStToolStripMenuItem.Text = "Defrag and Remove Duplicates Statics"; - this.defragAndRemoveDuplicatesStToolStripMenuItem.Click += new System.EventHandler(this.OnClickDefragRemoveStatics); - // // importStaticsToolStripMenuItem // this.importStaticsToolStripMenuItem.Name = "importStaticsToolStripMenuItem"; @@ -811,7 +804,6 @@ private void InitializeComponent() private System.Windows.Forms.ContextMenuStrip contextMenuStrip2; private System.Windows.Forms.ToolStripStatusLabel CoordsLabel; private System.Windows.Forms.ToolStripMenuItem copyToolStripMenuItem; - private System.Windows.Forms.ToolStripMenuItem defragAndRemoveDuplicatesStToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem defragStaticsToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem extractMapToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem feluccaToolStripMenuItem; diff --git a/UoFiddler.Controls/UserControls/MapControl.cs b/UoFiddler.Controls/UserControls/MapControl.cs index 18d1fca6..563a5b54 100644 --- a/UoFiddler.Controls/UserControls/MapControl.cs +++ b/UoFiddler.Controls/UserControls/MapControl.cs @@ -1304,23 +1304,10 @@ private void OnChangeView(object sender, EventArgs e) private void OnClickDefragStatics(object sender, EventArgs e) { - using (new WaitCursorScope(this)) - { - Map.DefragStatics(Options.OutputPath, - CurrentMap, CurrentMap.Width, CurrentMap.Height, false); - } - - FileSavedDialog.Show(FindForm(), Options.OutputPath, "Statics saved successfully."); - } - - private void OnClickDefragRemoveStatics(object sender, EventArgs e) - { - using (new WaitCursorScope(this)) + using (var form = new MapDefragStaticsForm(CurrentMap, Options.OutputPath)) { - Map.DefragStatics(Options.OutputPath, - CurrentMap, CurrentMap.Width, CurrentMap.Height, true); + form.ShowDialog(FindForm()); } - FileSavedDialog.Show(FindForm(), Options.OutputPath, "Statics saved successfully."); } private void OnResizeMap(object sender, EventArgs e) diff --git a/UoFiddler.Controls/UserControls/MapRegionPreview.Designer.cs b/UoFiddler.Controls/UserControls/MapRegionPreview.Designer.cs new file mode 100644 index 00000000..5e29d495 --- /dev/null +++ b/UoFiddler.Controls/UserControls/MapRegionPreview.Designer.cs @@ -0,0 +1,43 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +namespace UoFiddler.Controls.UserControls +{ + partial class MapRegionPreview + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + #region Component Designer generated code + + /// + /// The control paints itself, so it owns no child controls. + /// + private void InitializeComponent() + { + this.SuspendLayout(); + // + // MapRegionPreview + // + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.DoubleBuffered = true; + this.Name = "MapRegionPreview"; + this.Size = new System.Drawing.Size(320, 240); + this.ResumeLayout(false); + } + + #endregion + } +} \ No newline at end of file diff --git a/UoFiddler.Controls/UserControls/MapRegionPreview.cs b/UoFiddler.Controls/UserControls/MapRegionPreview.cs new file mode 100644 index 00000000..29f06f91 --- /dev/null +++ b/UoFiddler.Controls/UserControls/MapRegionPreview.cs @@ -0,0 +1,895 @@ +/*************************************************************************** + * + * $Author: Turley + * + * "THE BEER-WARE LICENSE" + * As long as you retain this notice you can do whatever you want with + * this stuff. If we meet some day, and you think this stuff is worth it, + * you can buy me a beer in return. + * + ***************************************************************************/ + +using System; +using System.ComponentModel; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.Windows.Forms; +using Ultima; +using Ultima.Maps; +using UoFiddler.Controls.Classes; + +namespace UoFiddler.Controls.UserControls +{ + public enum MapPreviewMode + { + /// Shows the selection but does not let it be changed. + ReadOnly, + + /// Dragging with the left button draws a new selection rectangle. + Rectangle, + + /// Dragging with the left button moves the selection without changing its size. + MoveFixedSize + } + + /// + /// Draws a window of a map facet with a block-aligned selection rectangle over it, so a region + /// can be seen and placed rather than only typed. The left button sets the region, the right + /// button pans, the wheel zooms and a double click re-fits. + /// + /// + /// Rendering reuses and its half and + /// quarter resolution siblings, which fill a caller-supplied bitmap from a block rectangle. The + /// rendered window is cached and rebuilt on a worker behind a short debounce, while painting maps + /// blocks to the control every frame - so a pan slides the cached bitmap straight away and the + /// fresh render simply replaces it when it lands. + /// + public sealed partial class MapRegionPreview : UserControl + { + /// Blocks of context left around the selection when the view is framed on it. + private const int DefaultMarginBlocks = 6; + + /// How far in the wheel can go. Eight blocks across a panel is 64 tiles. + private const int MinViewBlocks = 8; + + /// + /// Map rendering walks per-instance caches and file streams that are not safe to use from + /// two threads, and the source map is shared between a source panel and the overlay of a + /// destination panel, so every render in the process takes this. + /// + private static readonly object _renderLock = new object(); + + private readonly System.Windows.Forms.Timer _debounce = new System.Windows.Forms.Timer { Interval = 120 }; + private readonly BackgroundWorker _worker = new BackgroundWorker(); + + private Bitmap _rendered; + private Bitmap _overlayRendered; + private RenderRequest _renderedRequest; + private RenderRequest _pending; + private bool _hasPending; + + private Map _map; + private MapSize _mapSize; + private bool _showStatics = true; + private BlockRectangle _selection; + private Map _overlayMap; + private BlockRectangle _overlaySelection; + private string _message; + + /// + /// The blocks on screen. Deliberately not derived from the selection: drawing a region must + /// not move the map out from under the hand drawing it. + /// + private int _viewX; + private int _viewY; + private int _viewWidth; + private int _viewHeight; + private bool _viewFramed; + + private bool _dragging; + private Point _dragStartBlock; + private BlockRectangle _dragOrigin; + + private bool _panning; + private Point _panStartMouse; + private int _panStartX; + private int _panStartY; + + public MapRegionPreview() + { + InitializeComponent(); + + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | + ControlStyles.UserPaint | ControlStyles.ResizeRedraw, true); + + BackColor = Options.PreviewBackgroundColor; + + _debounce.Tick += OnDebounceTick; + _worker.DoWork += OnWorkerDoWork; + _worker.RunWorkerCompleted += OnWorkerCompleted; + } + + /// Raised when a drag changed . + public event EventHandler SelectionChanged; + + /// The facet to draw. May be built on a directory other than the loaded client. + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public Map Map + { + get => _map; + set + { + _map = value; + ResetView(); + } + } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public MapSize MapSize + { + get => _mapSize; + set + { + _mapSize = value; + ResetView(); + } + } + + [DefaultValue(true)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool ShowStatics + { + get => _showStatics; + set + { + if (_showStatics == value) + { + return; + } + + _showStatics = value; + Rebuild(); + } + } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public MapPreviewMode Mode { get; set; } = MapPreviewMode.ReadOnly; + + /// The block rectangle outlined on top of the map. + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public BlockRectangle Selection + { + get => _selection; + set + { + bool wasVisible = _viewFramed && Intersects(CurrentWindow(), value); + + _selection = value; + + // A selection that lands somewhere else entirely brings the view with it; one that is + // still on screen leaves the view where the user put it. + if (!wasVisible) + { + _viewFramed = false; + } + + Rebuild(); + } + } + + /// + /// Optional second map whose is drawn inside + /// , so a destination panel can show the piece that will land there + /// rather than an empty outline. + /// + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public Map OverlayMap + { + get => _overlayMap; + set + { + _overlayMap = value; + Rebuild(); + } + } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public BlockRectangle OverlaySelection + { + get => _overlaySelection; + set + { + _overlaySelection = value; + Rebuild(); + } + } + + /// Tints the blocks it returns true for. Used to mark blocks a diff covers. + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public Func BlockHighlight { get; set; } + + /// Shown instead of a render, for "choose a folder first" and the like. + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public string Message + { + get => _message; + set + { + _message = value; + Invalidate(); + } + } + + /// True when part of the selection lies outside the blocks on screen. + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool SelectionTruncated { get; private set; } + + /// Queues a rebuild of the rendered window. + public void Rebuild() + { + _debounce.Stop(); + _debounce.Start(); + Invalidate(); + } + + /// The blocks the panel is showing, selection plus margin, panned and clamped. + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public BlockRectangle Window => CurrentWindow(); + + /// Drops the pan and zoom back to framing the selection. + public void ResetView() + { + _viewFramed = false; + + Rebuild(); + } + + /// + /// The blocks currently on screen. Pure arithmetic, so painting can call it every frame. + /// + private BlockRectangle CurrentWindow() + { + if (_mapSize.IsEmpty) + { + SelectionTruncated = false; + + return default; + } + + if (!_viewFramed) + { + FrameSelection(); + } + + ClampView(); + + var window = new BlockRectangle(_viewX, _viewY, + _viewX + _viewWidth - 1, _viewY + _viewHeight - 1); + + SelectionTruncated = + _selection.BlockX1 < window.BlockX1 || _selection.BlockX2 > window.BlockX2 || + _selection.BlockY1 < window.BlockY1 || _selection.BlockY2 > window.BlockY2; + + return window; + } + + /// Puts the selection back on screen with a margin of context around it. + private void FrameSelection() + { + int width = Math.Max(1, _selection.BlockWidth) + (DefaultMarginBlocks * 2); + int height = Math.Max(1, _selection.BlockHeight) + (DefaultMarginBlocks * 2); + + SetView(_selection.BlockX1 - DefaultMarginBlocks, _selection.BlockY1 - DefaultMarginBlocks, + width, height); + + _viewFramed = true; + } + + /// + /// Stores a view, grown to the shape of the panel so the map fills it rather than sitting in + /// a letterbox, then clamped to the facet. The growth is centred on what was asked for. + /// + private void SetView(int x, int y, int width, int height) + { + if (_mapSize.IsEmpty) + { + return; + } + + int wantedWidth = Math.Clamp(width, MinViewBlocks, _mapSize.BlockWidth); + int wantedHeight = Math.Clamp(height, MinViewBlocks, _mapSize.BlockHeight); + + width = wantedWidth; + height = wantedHeight; + + if (ClientSize.Width > 0 && ClientSize.Height > 0) + { + double aspect = (double)ClientSize.Width / ClientSize.Height; + + int neededWidth = (int)Math.Round(height * aspect); + int neededHeight = (int)Math.Round(width / aspect); + + // Only ever grow, and only when a whole block is missing, so putting a view that + // already has the panel's shape back through here leaves it alone. Rounding up on + // every pass would inflate the view a block at a time. + if (neededWidth > width) + { + width = neededWidth; + } + else if (neededHeight > height) + { + height = neededHeight; + } + } + + _viewWidth = width; + _viewHeight = height; + + // Growing one axis must not shove the view sideways off what was asked for. + _viewX = x - ((_viewWidth - wantedWidth) / 2); + _viewY = y - ((_viewHeight - wantedHeight) / 2); + + ClampView(); + } + + /// Pulls the stored view back onto the facet without reshaping it. + private void ClampView() + { + _viewWidth = Math.Clamp(_viewWidth, 1, _mapSize.BlockWidth); + _viewHeight = Math.Clamp(_viewHeight, 1, _mapSize.BlockHeight); + + _viewX = Math.Clamp(_viewX, 0, Math.Max(0, _mapSize.BlockWidth - _viewWidth)); + _viewY = Math.Clamp(_viewY, 0, Math.Max(0, _mapSize.BlockHeight - _viewHeight)); + } + + /// + /// The largest of the three native scales whose bitmap still fits a couple of panels worth + /// of pixels. Anything finer would be thrown away by the scale down to the panel. + /// + private int ChoosePixelsPerBlock(BlockRectangle window) + { + int budgetX = Math.Max(64, ClientSize.Width * 2); + int budgetY = Math.Max(64, ClientSize.Height * 2); + + foreach (int candidate in new[] { 8, 4, 2 }) + { + if (window.BlockWidth * candidate <= budgetX && window.BlockHeight * candidate <= budgetY) + { + return candidate; + } + } + + return 2; + } + + /// Pixels per block on screen for the window currently framed. + private float ScreenScale(BlockRectangle window) + { + if (window.BlockWidth <= 0 || window.BlockHeight <= 0) + { + return 0f; + } + + return Math.Min((float)ClientSize.Width / window.BlockWidth, (float)ClientSize.Height / window.BlockHeight); + } + + /// Top left of the framed window in control pixels, centred. + private PointF ScreenOrigin(BlockRectangle window, float scale) + { + return new PointF( + (ClientSize.Width - (window.BlockWidth * scale)) / 2f, + (ClientSize.Height - (window.BlockHeight * scale)) / 2f); + } + + private static bool Intersects(BlockRectangle window, BlockRectangle selection) + { + return selection.BlockX2 >= window.BlockX1 && selection.BlockX1 <= window.BlockX2 && + selection.BlockY2 >= window.BlockY1 && selection.BlockY1 <= window.BlockY2; + } + + private void OnDebounceTick(object sender, EventArgs e) + { + _debounce.Stop(); + + if (_map == null || _mapSize.IsEmpty || ClientSize.Width < 8 || ClientSize.Height < 8) + { + return; + } + + BlockRectangle window = CurrentWindow(); + + var request = new RenderRequest + { + Map = _map, + Window = window, + PixelsPerBlock = ChoosePixelsPerBlock(window), + Statics = _showStatics, + OverlayMap = _overlayMap, + OverlayWindow = _overlaySelection + }; + + if (request.Equals(_renderedRequest)) + { + Invalidate(); + + return; + } + + _pending = request; + _hasPending = true; + + if (_worker.IsBusy) + { + return; + } + + _hasPending = false; + _worker.RunWorkerAsync(request); + } + + private void OnWorkerDoWork(object sender, DoWorkEventArgs e) + { + var request = (RenderRequest)e.Argument; + + lock (_renderLock) + { + Bitmap map = Render(request.Map, request.Window, request.PixelsPerBlock, request.Statics); + Bitmap overlay = request.OverlayMap == null + ? null + : Render(request.OverlayMap, request.OverlayWindow, request.PixelsPerBlock, request.Statics); + + e.Result = new RenderResult { Request = request, Map = map, Overlay = overlay }; + } + } + + private static Bitmap Render(Map map, BlockRectangle window, int pixelsPerBlock, bool statics) + { + if (map == null || window.BlockWidth <= 0 || window.BlockHeight <= 0) + { + return null; + } + + var bitmap = new Bitmap(window.BlockWidth * pixelsPerBlock, window.BlockHeight * pixelsPerBlock, + PixelFormat.Format16bppRgb555); + + switch (pixelsPerBlock) + { + case 8: + map.GetImage(window.BlockX1, window.BlockY1, window.BlockWidth, window.BlockHeight, bitmap, statics); + break; + + case 4: + map.GetImageHalf(window.BlockX1, window.BlockY1, window.BlockWidth, window.BlockHeight, bitmap, statics); + break; + + default: + map.GetImageQuarter(window.BlockX1, window.BlockY1, window.BlockWidth, window.BlockHeight, bitmap, statics); + break; + } + + return bitmap; + } + + private void OnWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) + { + if (e.Error == null && e.Result is RenderResult result) + { + _rendered?.Dispose(); + _overlayRendered?.Dispose(); + + _rendered = result.Map; + _overlayRendered = result.Overlay; + _renderedRequest = result.Request; + + Invalidate(); + } + + if (!_hasPending || _map == null || IsDisposed) + { + return; + } + + // Something changed while that render was in flight. + _hasPending = false; + _worker.RunWorkerAsync(_pending); + } + + protected override void OnPaint(PaintEventArgs e) + { + e.Graphics.Clear(BackColor); + + if (!string.IsNullOrEmpty(_message)) + { + DrawCentredText(e.Graphics, _message); + + return; + } + + BlockRectangle window = CurrentWindow(); + float scale = ScreenScale(window); + + if (_rendered == null || scale <= 0) + { + DrawCentredText(e.Graphics, "Rendering..."); + + return; + } + + PointF origin = ScreenOrigin(window, scale); + + e.Graphics.InterpolationMode = InterpolationMode.NearestNeighbor; + e.Graphics.PixelOffsetMode = PixelOffsetMode.Half; + + // The cached bitmap covers whatever window it was rendered for, which is not necessarily + // the one framed now - during a pan it lags by the distance dragged. Drawing it at its + // own block position makes the stale image slide with the pan instead of sitting still. + e.Graphics.DrawImage(_rendered, BlockRect(origin, window, scale, _renderedRequest.Window)); + + DrawHighlights(e.Graphics, origin, window, scale); + DrawOverlay(e.Graphics, origin, window, scale); + DrawSelection(e.Graphics, origin, window, scale); + + if (SelectionTruncated) + { + DrawNote(e.Graphics, "the region runs past the view - zoom out with the wheel to see all of it"); + } + else if (_debounce.Enabled || _worker.IsBusy) + { + DrawNote(e.Graphics, "rendering..."); + } + } + + /// Where a block rectangle sits on screen, given the window currently framed. + private static RectangleF BlockRect(PointF origin, BlockRectangle window, float scale, BlockRectangle blocks) + { + return new RectangleF( + origin.X + ((blocks.BlockX1 - window.BlockX1) * scale), + origin.Y + ((blocks.BlockY1 - window.BlockY1) * scale), + Math.Max(1f, blocks.BlockWidth * scale), + Math.Max(1f, blocks.BlockHeight * scale)); + } + + private void DrawHighlights(Graphics g, PointF origin, BlockRectangle window, float scale) + { + if (BlockHighlight == null) + { + return; + } + + using (var brush = new SolidBrush(Color.FromArgb(90, Color.Gold))) + { + for (int x = window.BlockX1; x <= window.BlockX2; ++x) + { + for (int y = window.BlockY1; y <= window.BlockY2; ++y) + { + if (!BlockHighlight(x, y)) + { + continue; + } + + g.FillRectangle(brush, + origin.X + ((x - window.BlockX1) * scale), + origin.Y + ((y - window.BlockY1) * scale), + Math.Max(1f, scale), Math.Max(1f, scale)); + } + } + } + } + + private void DrawOverlay(Graphics g, PointF origin, BlockRectangle window, float scale) + { + if (_overlayRendered == null) + { + return; + } + + g.DrawImage(_overlayRendered, BlockRect(origin, window, scale, _selection)); + } + + private void DrawSelection(Graphics g, PointF origin, BlockRectangle window, float scale) + { + RectangleF rectangle = BlockRect(origin, window, scale, _selection); + + using (var shadow = new Pen(Color.FromArgb(160, Color.Black), 3f)) + using (var pen = new Pen(Options.DarkMode ? Color.OrangeRed : Color.Red, 1.5f)) + { + g.DrawRectangle(shadow, rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height); + g.DrawRectangle(pen, rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height); + } + } + + private void DrawCentredText(Graphics g, string text) + { + SizeF size = g.MeasureString(text, Font); + + g.DrawString(text, Font, SystemBrushes.ControlText, + (ClientSize.Width - size.Width) / 2f, (ClientSize.Height - size.Height) / 2f); + } + + private void DrawNote(Graphics g, string text) + { + SizeF size = g.MeasureString(text, Font); + + using (var brush = new SolidBrush(Color.FromArgb(170, Color.Black))) + { + g.FillRectangle(brush, 0, ClientSize.Height - size.Height, ClientSize.Width, size.Height); + } + + g.DrawString(text, Font, Brushes.White, 2, ClientSize.Height - size.Height); + } + + private bool TryBlockAt(Point location, out int blockX, out int blockY) + { + blockX = 0; + blockY = 0; + + if (_mapSize.IsEmpty) + { + return false; + } + + BlockRectangle window = CurrentWindow(); + float scale = ScreenScale(window); + + if (scale <= 0) + { + return false; + } + + PointF origin = ScreenOrigin(window, scale); + + blockX = window.BlockX1 + (int)Math.Floor((location.X - origin.X) / scale); + blockY = window.BlockY1 + (int)Math.Floor((location.Y - origin.Y) / scale); + + blockX = Math.Clamp(blockX, 0, _mapSize.BlockWidth - 1); + blockY = Math.Clamp(blockY, 0, _mapSize.BlockHeight - 1); + + return true; + } + + protected override void OnMouseDown(MouseEventArgs e) + { + base.OnMouseDown(e); + + if (e.Button == MouseButtons.Right) + { + _panning = true; + _panStartMouse = e.Location; + _panStartX = _viewX; + _panStartY = _viewY; + Cursor = Cursors.SizeAll; + + return; + } + + if (e.Button != MouseButtons.Left || Mode == MapPreviewMode.ReadOnly || + !TryBlockAt(e.Location, out int blockX, out int blockY)) + { + return; + } + + _dragging = true; + _dragStartBlock = new Point(blockX, blockY); + _dragOrigin = _selection; + + if (Mode == MapPreviewMode.Rectangle) + { + SetSelection(new BlockRectangle(blockX, blockY, blockX, blockY)); + } + else + { + MoveSelectionTo(blockX, blockY); + } + } + + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + + if (_panning) + { + Pan(e.Location); + + return; + } + + if (!_dragging || !TryBlockAt(e.Location, out int blockX, out int blockY)) + { + return; + } + + if (Mode == MapPreviewMode.Rectangle) + { + SetSelection(new BlockRectangle( + Math.Min(_dragStartBlock.X, blockX), Math.Min(_dragStartBlock.Y, blockY), + Math.Max(_dragStartBlock.X, blockX), Math.Max(_dragStartBlock.Y, blockY))); + } + else + { + MoveSelectionTo(blockX, blockY); + } + } + + protected override void OnMouseUp(MouseEventArgs e) + { + base.OnMouseUp(e); + + if (_panning && e.Button == MouseButtons.Right) + { + _panning = false; + Cursor = Cursors.Default; + } + + if (e.Button == MouseButtons.Left) + { + _dragging = false; + } + } + + /// Drags the view under the cursor, a block per block-sized step of the mouse. + private void Pan(Point location) + { + BlockRectangle window = CurrentWindow(); + float scale = ScreenScale(window); + + if (scale <= 0) + { + return; + } + + _viewX = _panStartX - (int)Math.Round((location.X - _panStartMouse.X) / scale); + _viewY = _panStartY - (int)Math.Round((location.Y - _panStartMouse.Y) / scale); + + ClampView(); + + Rebuild(); + } + + /// Centres the fixed-size selection on a block, clamped so it stays on the map. + private void MoveSelectionTo(int blockX, int blockY) + { + int width = _dragOrigin.BlockWidth; + int height = _dragOrigin.BlockHeight; + + int x1 = Math.Clamp(blockX - (width / 2), 0, Math.Max(0, _mapSize.BlockWidth - width)); + int y1 = Math.Clamp(blockY - (height / 2), 0, Math.Max(0, _mapSize.BlockHeight - height)); + + SetSelection(new BlockRectangle(x1, y1, x1 + width - 1, y1 + height - 1)); + } + + /// + /// Sets the selection from a drag. Unlike the property, this leaves the pan alone - the user + /// is working inside the view they arranged. + /// + private void SetSelection(BlockRectangle selection) + { + _selection = selection; + + Invalidate(); + SelectionChanged?.Invoke(this, EventArgs.Empty); + } + + protected override void OnMouseWheel(MouseEventArgs e) + { + base.OnMouseWheel(e); + + if (_mapSize.IsEmpty) + { + return; + } + + BlockRectangle window = CurrentWindow(); + float scale = ScreenScale(window); + + if (scale <= 0) + { + return; + } + + PointF origin = ScreenOrigin(window, scale); + + // Where the cursor sits in the window, as a fraction of it. Keeping that fraction over + // the same block is what makes the wheel zoom towards what is under the pointer. + double acrossX = Math.Clamp((e.Location.X - origin.X) / (window.BlockWidth * scale), 0d, 1d); + double acrossY = Math.Clamp((e.Location.Y - origin.Y) / (window.BlockHeight * scale), 0d, 1d); + + double anchorX = window.BlockX1 + (acrossX * window.BlockWidth); + double anchorY = window.BlockY1 + (acrossY * window.BlockHeight); + + double factor = e.Delta > 0 ? 1d / 1.3d : 1.3d; + + int width = (int)Math.Round(window.BlockWidth * factor); + int height = (int)Math.Round(window.BlockHeight * factor); + + SetView((int)Math.Round(anchorX - (acrossX * width)), + (int)Math.Round(anchorY - (acrossY * height)), width, height); + + Rebuild(); + } + + protected override void OnDoubleClick(EventArgs e) + { + base.OnDoubleClick(e); + + ResetView(); + } + + protected override void OnResize(EventArgs e) + { + base.OnResize(e); + + if (_viewFramed) + { + SetView(_viewX, _viewY, _viewWidth, _viewHeight); + } + + Rebuild(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _debounce.Stop(); + _debounce.Dispose(); + _worker.Dispose(); + _rendered?.Dispose(); + _overlayRendered?.Dispose(); + + components?.Dispose(); + } + + base.Dispose(disposing); + } + + private struct RenderRequest : IEquatable + { + public Map Map; + public BlockRectangle Window; + public int PixelsPerBlock; + public bool Statics; + public Map OverlayMap; + public BlockRectangle OverlayWindow; + + public bool Equals(RenderRequest other) + { + return ReferenceEquals(Map, other.Map) && + PixelsPerBlock == other.PixelsPerBlock && + Statics == other.Statics && + Same(Window, other.Window) && + ReferenceEquals(OverlayMap, other.OverlayMap) && + Same(OverlayWindow, other.OverlayWindow); + } + + public override bool Equals(object obj) => obj is RenderRequest other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(Map, Window.BlockX1, Window.BlockY1, + Window.BlockX2, Window.BlockY2, PixelsPerBlock, Statics, OverlayMap); + + private static bool Same(BlockRectangle a, BlockRectangle b) + { + return a.BlockX1 == b.BlockX1 && a.BlockY1 == b.BlockY1 && + a.BlockX2 == b.BlockX2 && a.BlockY2 == b.BlockY2; + } + } + + private sealed class RenderResult + { + public RenderRequest Request { get; init; } + + public Bitmap Map { get; init; } + + public Bitmap Overlay { get; init; } + } + } +} \ No newline at end of file diff --git a/UoFiddler.Controls/UserControls/MapRegionPreview.resx b/UoFiddler.Controls/UserControls/MapRegionPreview.resx new file mode 100644 index 00000000..6dae11dd --- /dev/null +++ b/UoFiddler.Controls/UserControls/MapRegionPreview.resx @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/UoFiddler.Controls/UserControls/MultisControl.cs b/UoFiddler.Controls/UserControls/MultisControl.cs index 3a26d859..8ca277cc 100644 --- a/UoFiddler.Controls/UserControls/MultisControl.cs +++ b/UoFiddler.Controls/UserControls/MultisControl.cs @@ -17,6 +17,7 @@ using System.Windows.Forms; using System.Xml; using Ultima; +using Ultima.Uop; using UoFiddler.Controls.Classes; using UoFiddler.Controls.Forms; using UoFiddler.Controls.Helpers; @@ -621,10 +622,7 @@ private void OnExportUOAFile(object sender, EventArgs e) private void OnClickSave(object sender, EventArgs e) { - Multis.Save(Options.OutputPath); - Options.ChangedUltimaClass["Multis"] = false; - - FileSavedDialog.Show(FindForm(), Options.OutputPath, "Files saved successfully."); + ClientFileSaveCommand.Run(this, FileType.MultiCollection, Multis.Save, "Multis"); } private void OnClickRemove(object sender, EventArgs e) diff --git a/UoFiddler.Controls/UserControls/SoundsControl.cs b/UoFiddler.Controls/UserControls/SoundsControl.cs index 612b3607..47555dc5 100644 --- a/UoFiddler.Controls/UserControls/SoundsControl.cs +++ b/UoFiddler.Controls/UserControls/SoundsControl.cs @@ -16,6 +16,7 @@ using System.Linq; using System.Windows.Forms; using Ultima; +using Ultima.Uop; using UoFiddler.Controls.Classes; using UoFiddler.Controls.Forms; using UoFiddler.Controls.Helpers; @@ -441,14 +442,7 @@ private void OnClickExtract(object sender, EventArgs e) private void OnClickSave(object sender, EventArgs e) { - using (new WaitCursorScope(this)) - { - string path = Options.OutputPath; - Sounds.Save(path); - Options.ChangedUltimaClass["Sound"] = false; - } - - FileSavedDialog.Show(FindForm(), Options.OutputPath, "Files saved successfully."); + ClientFileSaveCommand.Run(this, FileType.SoundLegacyMul, Sounds.Save, "Sound"); } private void OnClickRemove(object sender, EventArgs e) diff --git a/UoFiddler.Controls/UserControls/TexturesControl.Designer.cs b/UoFiddler.Controls/UserControls/TexturesControl.Designer.cs index 20a4a8b8..5917885b 100644 --- a/UoFiddler.Controls/UserControls/TexturesControl.Designer.cs +++ b/UoFiddler.Controls/UserControls/TexturesControl.Designer.cs @@ -52,6 +52,9 @@ private void InitializeComponent() selectInLandTilesTabToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator(); replaceToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + copyImageToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + pasteImageToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + clipboardToolStripSeparator = new System.Windows.Forms.ToolStripSeparator(); replaceStartingFromToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); ReplaceStartingFromTb = new System.Windows.Forms.ToolStripTextBox(); insertAtToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); @@ -84,7 +87,7 @@ private void InitializeComponent() // // contextMenuStrip // - contextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { showFreeSlotsToolStripMenuItem, findNextFreeSlotToolStripMenuItem, toolStripSeparator5, exportImageToolStripMenuItem, toolStripSeparator6, selectInLandTilesTabToolStripMenuItem, toolStripSeparator2, replaceToolStripMenuItem, replaceStartingFromToolStripMenuItem, insertAtToolStripMenuItem, removeToolStripMenuItem, toolStripSeparator3, saveToolStripMenuItem }); + contextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { showFreeSlotsToolStripMenuItem, findNextFreeSlotToolStripMenuItem, toolStripSeparator5, exportImageToolStripMenuItem, toolStripSeparator6, selectInLandTilesTabToolStripMenuItem, toolStripSeparator2, copyImageToolStripMenuItem, pasteImageToolStripMenuItem, clipboardToolStripSeparator, replaceToolStripMenuItem, replaceStartingFromToolStripMenuItem, insertAtToolStripMenuItem, removeToolStripMenuItem, toolStripSeparator3, saveToolStripMenuItem }); contextMenuStrip.Name = "contextMenuStrip1"; contextMenuStrip.Size = new System.Drawing.Size(194, 248); contextMenuStrip.Opening += contextMenuStrip_Opening; @@ -156,6 +159,27 @@ private void InitializeComponent() toolStripSeparator2.Name = "toolStripSeparator2"; toolStripSeparator2.Size = new System.Drawing.Size(190, 6); // + // copyImageToolStripMenuItem + // + copyImageToolStripMenuItem.Name = "copyImageToolStripMenuItem"; + copyImageToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+C"; + copyImageToolStripMenuItem.Size = new System.Drawing.Size(193, 22); + copyImageToolStripMenuItem.Text = "Copy Image"; + copyImageToolStripMenuItem.Click += OnClickCopyImage; + // + // pasteImageToolStripMenuItem + // + pasteImageToolStripMenuItem.Name = "pasteImageToolStripMenuItem"; + pasteImageToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+V"; + pasteImageToolStripMenuItem.Size = new System.Drawing.Size(193, 22); + pasteImageToolStripMenuItem.Text = "Paste Image"; + pasteImageToolStripMenuItem.Click += OnClickPasteImage; + // + // clipboardToolStripSeparator + // + clipboardToolStripSeparator.Name = "clipboardToolStripSeparator"; + clipboardToolStripSeparator.Size = new System.Drawing.Size(190, 6); + // // replaceToolStripMenuItem // replaceToolStripMenuItem.Name = "replaceToolStripMenuItem"; @@ -387,6 +411,9 @@ private void InitializeComponent() private System.Windows.Forms.ToolStripTextBox InsertText; private System.Windows.Forms.ToolStripMenuItem removeToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem replaceToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem copyImageToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem pasteImageToolStripMenuItem; + private System.Windows.Forms.ToolStripSeparator clipboardToolStripSeparator; private System.Windows.Forms.ToolStripButton SaveButton; private System.Windows.Forms.ToolStrip topMenuToolStrip; private System.Windows.Forms.ToolStripSeparator toolStripSeparator1; diff --git a/UoFiddler.Controls/UserControls/TexturesControl.cs b/UoFiddler.Controls/UserControls/TexturesControl.cs index 21ad3219..c02ba781 100644 --- a/UoFiddler.Controls/UserControls/TexturesControl.cs +++ b/UoFiddler.Controls/UserControls/TexturesControl.cs @@ -289,6 +289,93 @@ private void OnClickRemove(object sender, EventArgs e) Options.ChangedUltimaClass["Texture"] = true; } + private void OnClickCopyImage(object sender, EventArgs e) + { + // The clipboard holds one image, so a multi selection copies the focused texture. + int id = _selectedTextureId; + if (id < 0 || !Textures.TestTexture(id)) + { + return; + } + + if (!ImageClipboard.TryCopy(Textures.GetTexture(id), out string error)) + { + MessageBox.Show(error, "Copy Image", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + private void OnClickPasteImage(object sender, EventArgs e) + { + var ids = GetSelectedTextureIds(); + if (ids.Count == 0) + { + if (_selectedTextureId < 0) + { + return; + } + + ids.Add(_selectedTextureId); + } + + using Bitmap pasted = ImageClipboard.TryPaste(out string error); + if (pasted == null) + { + MessageBox.Show(error, "Paste Image", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + if (!IsValidTextureSize(pasted)) + { + MessageBox.Show( + $"Invalid texture dimensions!\n\n" + + $"Clipboard image: {pasted.Width}x{pasted.Height}\n" + + $"Textures must be 64x64 or 128x128 pixels.\n\n" + + "No changes made.", + "Invalid Size", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + if (ids.Count > 1) + { + DialogResult confirm = MessageBox.Show( + $"Paste this {pasted.Width}x{pasted.Height} image into {ids.Count} selected textures?", + "Paste Image", MessageBoxButtons.YesNo, MessageBoxIcon.Question, + MessageBoxDefaultButton.Button2); + + if (confirm != DialogResult.Yes) + { + return; + } + } + + Bitmap converted = Utils.ToUoBitmap(pasted); + + foreach (int id in ids) + { + // Each slot needs its own bitmap: the SDK keeps the instance it is handed. + Textures.Replace(id, ids.Count == 1 ? converted : (Bitmap)converted.Clone()); + ControlEvents.FireTextureChangeEvent(this, id); + } + + if (ids.Count > 1) + { + converted.Dispose(); + } + + TextureTileView.Invalidate(); + Options.ChangedUltimaClass["Texture"] = true; + } + + /// + /// Texidx stores no dimensions - the client derives them from the encoded length - so a + /// texture that is not 64x64 or 128x128 would be read back as the wrong size. + /// + private static bool IsValidTextureSize(Image image) + { + return (image.Width == 64 && image.Height == 64) + || (image.Width == 128 && image.Height == 128); + } + private void OnClickReplace(object sender, EventArgs e) { if (TextureTileView.SelectedIndices.Count > 1) @@ -692,6 +779,11 @@ private void TextureTileView_DrawItem(object sender, TileView.TileViewControl.Dr Rectangle textureRectangle = new Rectangle(itemPoint, new Size(bitmap.Width, bitmap.Height)); e.Graphics.DrawImage(bitmap, textureRectangle); + if (Textures.IsModified(_textureList[e.Index])) + { + ModifiedMarker.Draw(e.Graphics, tileRectangle); + } + e.Graphics.Clip = previousClip; } } @@ -932,9 +1024,32 @@ private void OnClickSelectInLandTiles(object sender, EventArgs e) } } + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) + { + // Copy/paste is handled here rather than as menu ShortcutKeys: a shortcut on a + // ContextMenuStrip is processed for the whole form, which would swallow Ctrl+C/Ctrl+V in + // every text box on every tab. + if (keyData == (Keys.Control | Keys.C) && TextureTileView.Focused) + { + OnClickCopyImage(this, EventArgs.Empty); + return true; + } + + if (keyData == (Keys.Control | Keys.V) && TextureTileView.Focused) + { + OnClickPasteImage(this, EventArgs.Empty); + return true; + } + + return base.ProcessCmdKey(ref msg, keyData); + } + private void contextMenuStrip_Opening(object sender, System.ComponentModel.CancelEventArgs e) { int selectedCount = TextureTileView.SelectedIndices.Count; + copyImageToolStripMenuItem.Enabled = _selectedTextureId >= 0 && Textures.TestTexture(_selectedTextureId); + pasteImageToolStripMenuItem.Enabled = selectedCount > 0 && ImageClipboard.ContainsImage(); + pasteImageToolStripMenuItem.Text = selectedCount > 1 ? $"Paste Image into {selectedCount}" : "Paste Image"; removeToolStripMenuItem.Text = selectedCount > 1 ? $"Remove {selectedCount}" : "Remove"; exportImageToolStripMenuItem.Text = selectedCount > 1 ? $"Export {selectedCount} Images..." : "Export Image.."; replaceToolStripMenuItem.Text = selectedCount > 1 ? $"Replace {selectedCount}" : "Replace"; diff --git a/UoFiddler.Controls/UserControls/TileDataControl.Designer.cs b/UoFiddler.Controls/UserControls/TileDataControl.Designer.cs index 27f1276f..f410b3c3 100644 --- a/UoFiddler.Controls/UserControls/TileDataControl.Designer.cs +++ b/UoFiddler.Controls/UserControls/TileDataControl.Designer.cs @@ -48,6 +48,9 @@ private void InitializeComponent() listViewItem = new System.Windows.Forms.ListView(); listViewItemColumn = new System.Windows.Forms.ColumnHeader(); ItemsContextMenuStrip = new System.Windows.Forms.ContextMenuStrip(components); + copyItemTileDataToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + pasteSpecialItemToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + toolStripSeparator7 = new System.Windows.Forms.ToolStripSeparator(); selectInItemsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); selectRadarColorToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator(); @@ -99,6 +102,9 @@ private void InitializeComponent() listViewLand = new System.Windows.Forms.ListView(); listViewLandColumn = new System.Windows.Forms.ColumnHeader(); LandTilesContextMenuStrip = new System.Windows.Forms.ContextMenuStrip(components); + copyLandTileDataToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + pasteSpecialLandToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + toolStripSeparator8 = new System.Windows.Forms.ToolStripSeparator(); selectInLandtilesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); selToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); pictureBoxLand = new System.Windows.Forms.PictureBox(); @@ -118,6 +124,10 @@ private void InitializeComponent() searchByNameToolStripButton = new System.Windows.Forms.ToolStripButton(); toolStripSeparator5 = new System.Windows.Forms.ToolStripSeparator(); toolStripDropDownButton1 = new System.Windows.Forms.ToolStripDropDownButton(); + toolStripSeparator9 = new System.Windows.Forms.ToolStripSeparator(); + undoBulkApplyToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + multiSelectItemInfoLabel = new System.Windows.Forms.Label(); + multiSelectLandInfoLabel = new System.Windows.Forms.Label(); memorySaveWarningToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); saveDirectlyOnChangesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); setFilterToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); @@ -247,23 +257,47 @@ private void InitializeComponent() listViewItem.View = System.Windows.Forms.View.Details; listViewItem.VirtualMode = true; listViewItem.FullRowSelect = true; - listViewItem.MultiSelect = false; + listViewItem.MultiSelect = true; listViewItem.HeaderStyle = System.Windows.Forms.ColumnHeaderStyle.None; listViewItemColumn.Text = "Item"; listViewItemColumn.Width = 240; listViewItem.Columns.Add(listViewItemColumn); listViewItem.RetrieveVirtualItem += OnRetrieveItemVirtualItem; listViewItem.SelectedIndexChanged += OnItemSelectedIndexChanged; + listViewItem.VirtualItemsSelectionRangeChanged += OnItemSelectionRangeChanged; // // ItemsContextMenuStrip // - ItemsContextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { selectInItemsToolStripMenuItem, selectRadarColorToolStripMenuItem, toolStripSeparator3, selectInGumpsTabMaleToolStripMenuItem, selectInGumpsTabFemaleToolStripMenuItem, selectInAnimDataTabToolStripMenuItem }); + ItemsContextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { copyItemTileDataToolStripMenuItem, pasteSpecialItemToolStripMenuItem, toolStripSeparator7, selectInItemsToolStripMenuItem, selectRadarColorToolStripMenuItem, toolStripSeparator3, selectInGumpsTabMaleToolStripMenuItem, selectInGumpsTabFemaleToolStripMenuItem, selectInAnimDataTabToolStripMenuItem }); ItemsContextMenuStrip.Name = "contextMenuStrip1"; ItemsContextMenuStrip.Size = new System.Drawing.Size(201, 98); ItemsContextMenuStrip.Opening += ItemsContextMenuStrip_Opening; - // + // + // copyItemTileDataToolStripMenuItem + // + copyItemTileDataToolStripMenuItem.Name = "copyItemTileDataToolStripMenuItem"; + copyItemTileDataToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+C"; + copyItemTileDataToolStripMenuItem.ShowShortcutKeys = true; + copyItemTileDataToolStripMenuItem.Size = new System.Drawing.Size(200, 22); + copyItemTileDataToolStripMenuItem.Text = "Copy tile data"; + copyItemTileDataToolStripMenuItem.Click += OnClickCopyItemTileData; + // + // pasteSpecialItemToolStripMenuItem + // + pasteSpecialItemToolStripMenuItem.Name = "pasteSpecialItemToolStripMenuItem"; + pasteSpecialItemToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+V"; + pasteSpecialItemToolStripMenuItem.ShowShortcutKeys = true; + pasteSpecialItemToolStripMenuItem.Size = new System.Drawing.Size(200, 22); + pasteSpecialItemToolStripMenuItem.Text = "Paste special..."; + pasteSpecialItemToolStripMenuItem.Click += OnClickPasteSpecialItem; + // + // toolStripSeparator7 + // + toolStripSeparator7.Name = "toolStripSeparator7"; + toolStripSeparator7.Size = new System.Drawing.Size(197, 6); + // // selectInItemsToolStripMenuItem - // + // selectInItemsToolStripMenuItem.Name = "selectInItemsToolStripMenuItem"; selectInItemsToolStripMenuItem.Size = new System.Drawing.Size(200, 22); selectInItemsToolStripMenuItem.Text = "Select In Items tab"; @@ -378,6 +412,7 @@ private void InitializeComponent() // splitContainer3.Panel2 // splitContainer3.Panel2.Controls.Add(checkedListBox1); + splitContainer3.Panel2.Controls.Add(multiSelectItemInfoLabel); splitContainer3.Size = new System.Drawing.Size(504, 341); splitContainer3.SplitterDistance = 157; splitContainer3.SplitterWidth = 2; @@ -743,6 +778,17 @@ private void InitializeComponent() checkedListBox1.Size = new System.Drawing.Size(504, 182); checkedListBox1.TabIndex = 0; checkedListBox1.ItemCheck += OnFlagItemCheckItems; + // + // multiSelectItemInfoLabel + // + multiSelectItemInfoLabel.AutoSize = false; + multiSelectItemInfoLabel.Dock = System.Windows.Forms.DockStyle.Top; + multiSelectItemInfoLabel.Location = new System.Drawing.Point(0, 0); + multiSelectItemInfoLabel.Name = "multiSelectItemInfoLabel"; + multiSelectItemInfoLabel.Padding = new System.Windows.Forms.Padding(3, 3, 3, 3); + multiSelectItemInfoLabel.Size = new System.Drawing.Size(504, 42); + multiSelectItemInfoLabel.TabIndex = 1; + multiSelectItemInfoLabel.Visible = false; // // tabPageLand // @@ -810,19 +856,44 @@ private void InitializeComponent() listViewLand.View = System.Windows.Forms.View.Details; listViewLand.VirtualMode = true; listViewLand.FullRowSelect = true; - listViewLand.MultiSelect = false; + listViewLand.MultiSelect = true; listViewLand.HeaderStyle = System.Windows.Forms.ColumnHeaderStyle.None; listViewLandColumn.Text = "Land"; listViewLandColumn.Width = 240; listViewLand.Columns.Add(listViewLandColumn); listViewLand.RetrieveVirtualItem += OnRetrieveLandVirtualItem; listViewLand.SelectedIndexChanged += OnLandSelectedIndexChanged; + listViewLand.VirtualItemsSelectionRangeChanged += OnLandSelectionRangeChanged; // // LandTilesContextMenuStrip // - LandTilesContextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { selectInLandtilesToolStripMenuItem, selToolStripMenuItem }); + LandTilesContextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { copyLandTileDataToolStripMenuItem, pasteSpecialLandToolStripMenuItem, toolStripSeparator8, selectInLandtilesToolStripMenuItem, selToolStripMenuItem }); LandTilesContextMenuStrip.Name = "contextMenuStrip2"; LandTilesContextMenuStrip.Size = new System.Drawing.Size(201, 48); + LandTilesContextMenuStrip.Opening += LandTilesContextMenuStrip_Opening; + // + // copyLandTileDataToolStripMenuItem + // + copyLandTileDataToolStripMenuItem.Name = "copyLandTileDataToolStripMenuItem"; + copyLandTileDataToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+C"; + copyLandTileDataToolStripMenuItem.ShowShortcutKeys = true; + copyLandTileDataToolStripMenuItem.Size = new System.Drawing.Size(200, 22); + copyLandTileDataToolStripMenuItem.Text = "Copy tile data"; + copyLandTileDataToolStripMenuItem.Click += OnClickCopyLandTileData; + // + // pasteSpecialLandToolStripMenuItem + // + pasteSpecialLandToolStripMenuItem.Name = "pasteSpecialLandToolStripMenuItem"; + pasteSpecialLandToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+V"; + pasteSpecialLandToolStripMenuItem.ShowShortcutKeys = true; + pasteSpecialLandToolStripMenuItem.Size = new System.Drawing.Size(200, 22); + pasteSpecialLandToolStripMenuItem.Text = "Paste special..."; + pasteSpecialLandToolStripMenuItem.Click += OnClickPasteSpecialLand; + // + // toolStripSeparator8 + // + toolStripSeparator8.Name = "toolStripSeparator8"; + toolStripSeparator8.Size = new System.Drawing.Size(197, 6); // // selectInLandtilesToolStripMenuItem // @@ -889,6 +960,7 @@ private void InitializeComponent() // splitContainer7.Panel2 // splitContainer7.Panel2.Controls.Add(checkedListBox2); + splitContainer7.Panel2.Controls.Add(multiSelectLandInfoLabel); splitContainer7.Size = new System.Drawing.Size(504, 341); splitContainer7.SplitterDistance = 27; splitContainer7.SplitterWidth = 2; @@ -945,6 +1017,17 @@ private void InitializeComponent() checkedListBox2.Size = new System.Drawing.Size(504, 312); checkedListBox2.TabIndex = 0; checkedListBox2.ItemCheck += OnFlagItemCheckLandTiles; + // + // multiSelectLandInfoLabel + // + multiSelectLandInfoLabel.AutoSize = false; + multiSelectLandInfoLabel.Dock = System.Windows.Forms.DockStyle.Top; + multiSelectLandInfoLabel.Location = new System.Drawing.Point(0, 0); + multiSelectLandInfoLabel.Name = "multiSelectLandInfoLabel"; + multiSelectLandInfoLabel.Padding = new System.Windows.Forms.Padding(3, 3, 3, 3); + multiSelectLandInfoLabel.Size = new System.Drawing.Size(504, 42); + multiSelectLandInfoLabel.TabIndex = 1; + multiSelectLandInfoLabel.Visible = false; // // MainToolStrip // @@ -999,11 +1082,25 @@ private void InitializeComponent() // toolStripDropDownButton1 // toolStripDropDownButton1.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; - toolStripDropDownButton1.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { memorySaveWarningToolStripMenuItem, saveDirectlyOnChangesToolStripMenuItem, setFilterToolStripMenuItem, toolStripSeparator4, setTextureOnDoubleClickToolStripMenuItem, setTexturesToolStripMenuItem }); + toolStripDropDownButton1.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { memorySaveWarningToolStripMenuItem, saveDirectlyOnChangesToolStripMenuItem, setFilterToolStripMenuItem, toolStripSeparator9, undoBulkApplyToolStripMenuItem, toolStripSeparator4, setTextureOnDoubleClickToolStripMenuItem, setTexturesToolStripMenuItem }); + toolStripDropDownButton1.DropDownOpening += MiscToolStripDropDownButton_DropDownOpening; toolStripDropDownButton1.ImageTransparentColor = System.Drawing.Color.Magenta; toolStripDropDownButton1.Name = "toolStripDropDownButton1"; toolStripDropDownButton1.Size = new System.Drawing.Size(45, 22); toolStripDropDownButton1.Text = "Misc"; + // + // toolStripSeparator9 + // + toolStripSeparator9.Name = "toolStripSeparator9"; + toolStripSeparator9.Size = new System.Drawing.Size(202, 6); + // + // undoBulkApplyToolStripMenuItem + // + undoBulkApplyToolStripMenuItem.Enabled = false; + undoBulkApplyToolStripMenuItem.Name = "undoBulkApplyToolStripMenuItem"; + undoBulkApplyToolStripMenuItem.Size = new System.Drawing.Size(205, 22); + undoBulkApplyToolStripMenuItem.Text = "Undo last bulk apply"; + undoBulkApplyToolStripMenuItem.Click += OnClickUndoBulkApply; // // memorySaveWarningToolStripMenuItem // @@ -1188,6 +1285,16 @@ private void InitializeComponent() private System.Windows.Forms.ToolStripMenuItem saveDirectlyOnChangesToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem selectInItemsToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem selectInLandtilesToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem copyItemTileDataToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem pasteSpecialItemToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem copyLandTileDataToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem pasteSpecialLandToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem undoBulkApplyToolStripMenuItem; + private System.Windows.Forms.ToolStripSeparator toolStripSeparator7; + private System.Windows.Forms.ToolStripSeparator toolStripSeparator8; + private System.Windows.Forms.ToolStripSeparator toolStripSeparator9; + private System.Windows.Forms.Label multiSelectItemInfoLabel; + private System.Windows.Forms.Label multiSelectLandInfoLabel; private System.Windows.Forms.ToolStripMenuItem selectRadarColorToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem selToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem setFilterToolStripMenuItem; diff --git a/UoFiddler.Controls/UserControls/TileDataControl.cs b/UoFiddler.Controls/UserControls/TileDataControl.cs index 11422788..dfbba2c5 100644 --- a/UoFiddler.Controls/UserControls/TileDataControl.cs +++ b/UoFiddler.Controls/UserControls/TileDataControl.cs @@ -106,18 +106,69 @@ private void InitItemsFlagsCheckBoxes() private static Color ModifiedColor => Options.DarkMode ? Color.OrangeRed : Color.Red; + // Snapshot of the entries the last bulk apply overwrote, so one misplaced + // "apply to everything selected" can be taken back. Only one level is kept. + private TileDataBulkUndo _lastBulkUndo; + private int GetSelectedItemGraphic() { - return listViewItem.SelectedIndices.Count > 0 - ? _itemIndices[listViewItem.SelectedIndices[0]] - : -1; + return GetPrimaryGraphic(listViewItem, _itemIndices); } private int GetSelectedLandGraphic() { - return listViewLand.SelectedIndices.Count > 0 - ? _landIndices[listViewLand.SelectedIndices[0]] - : -1; + return GetPrimaryGraphic(listViewLand, _landIndices); + } + + /// + /// The entry the editor pane shows - the focused row while it is part of the + /// selection, which is the one the user picked last, otherwise the first + /// selected row. + /// + private static int GetPrimaryGraphic(ListView listView, int[] indices) + { + if (listView.SelectedIndices.Count == 0) + { + return -1; + } + + int row = listView.FocusedItem?.Index ?? -1; + if (row < 0 || !listView.SelectedIndices.Contains(row)) + { + row = listView.SelectedIndices[0]; + } + + return (uint)row < (uint)indices.Length ? indices[row] : -1; + } + + /// + /// Every selected graphic id in ascending order. Rows are mapped through the + /// filter projection, so this is ids, not row positions. + /// + private static int[] GetSelectedGraphics(ListView listView, int[] indices) + { + ListView.SelectedIndexCollection selected = listView.SelectedIndices; + var graphics = new List(selected.Count); + foreach (int row in selected) + { + if ((uint)row < (uint)indices.Length) + { + graphics.Add(indices[row]); + } + } + + graphics.Sort(); + return graphics.ToArray(); + } + + private int[] GetSelectedItemGraphics() + { + return GetSelectedGraphics(listViewItem, _itemIndices); + } + + private int[] GetSelectedLandGraphics() + { + return GetSelectedGraphics(listViewLand, _landIndices); } private static string FormatItemRow(int graphic, string name) @@ -218,6 +269,52 @@ private void SelectLandRow(int rowPos) } } + /// + /// Selects several rows at once, focusing the last one so the editor pane + /// shows it. Batched - a virtual ListView raises a selection event per row. + /// + private static void SelectRows(ListView listView, IReadOnlyList rowPositions, int rowCount) + { + listView.BeginUpdate(); + try + { + listView.SelectedIndices.Clear(); + foreach (int rowPos in rowPositions) + { + if ((uint)rowPos < (uint)rowCount) + { + listView.SelectedIndices.Add(rowPos); + } + } + } + finally + { + listView.EndUpdate(); + } + + if (rowPositions.Count == 0) + { + return; + } + + int last = rowPositions[rowPositions.Count - 1]; + if ((uint)last < (uint)rowCount) + { + listView.EnsureVisible(last); + listView.FocusedItem = listView.Items[last]; + } + } + + private void SelectItemRows(IReadOnlyList rowPositions) + { + SelectRows(listViewItem, rowPositions, _itemIndices.Length); + } + + private void SelectLandRows(IReadOnlyList rowPositions) + { + SelectRows(listViewLand, rowPositions, _landIndices.Length); + } + private static int[] BuildIdentity(int length) { var array = new int[length]; @@ -295,6 +392,109 @@ public static bool SearchGraphic(int graphic, bool land) } } + /// + /// Cross-tab entry point carrying a whole selection over, e.g. from the Items + /// tab's "Select in TileData tab". See for why + /// the tab has to be activated before the selection is set. + /// + public static void Select(IReadOnlyList graphics, bool land) + { + if (_refMarker == null || graphics == null || graphics.Count == 0) + { + return; + } + + if (graphics.Count == 1) + { + Select(graphics[0], land); + return; + } + + TabPageNavigator.ActivateOwningTabPage(_refMarker); + + if (_refMarker.IsHandleCreated) + { + _refMarker.BeginInvoke(new Action(() => SearchGraphics(graphics, land))); + } + else + { + SearchGraphics(graphics, land); + } + } + + /// + /// Selects every given graphic. Returns false only when none of them exist at + /// all; ids the current filter hides are reached by resetting the view once, + /// the same way does for a single id. + /// + public static bool SearchGraphics(IReadOnlyList graphics, bool land) + { + if (_refMarker == null || graphics == null || graphics.Count == 0) + { + return false; + } + + int[] indices = land ? _refMarker._landIndices : _refMarker._itemIndices; + List rows = MapGraphicsToRows(graphics, indices); + + if (rows.Count < graphics.Count) + { + // At least one target is filtered out of the view - drop the filter so + // the navigation always lands on the full selection. + if (land) + { + _refMarker.ResetLandView(); + indices = _refMarker._landIndices; + } + else + { + _refMarker.ResetItemView(); + indices = _refMarker._itemIndices; + } + + rows = MapGraphicsToRows(graphics, indices); + } + + if (rows.Count == 0) + { + return false; + } + + if (land) + { + _refMarker.tabcontrol.SelectTab(1); + _refMarker.SelectLandRows(rows); + } + else + { + _refMarker.tabcontrol.SelectTab(0); + _refMarker.SelectItemRows(rows); + } + + return true; + } + + /// + /// Row lookup for a batch of ids. The projection arrays are always ascending - + /// identity, or filter matches appended in order - so this can binary search + /// instead of scanning the array once per id. + /// + private static List MapGraphicsToRows(IReadOnlyList graphics, int[] indices) + { + var rows = new List(graphics.Count); + foreach (int graphic in graphics) + { + int pos = Array.BinarySearch(indices, graphic); + if (pos >= 0) + { + rows.Add(pos); + } + } + + rows.Sort(); + return rows; + } + private void ResetItemView() { int total = TileData.ItemTable?.Length ?? 0; @@ -313,6 +513,38 @@ private void ResetLandView() protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { + // Copy/paste is handled here rather than as menu ShortcutKeys: a shortcut on + // a ContextMenuStrip is processed for the whole form, which would swallow + // Ctrl+C/Ctrl+V in every text box on every tab. + if (keyData == (Keys.Control | Keys.C)) + { + if (listViewItem.Focused) + { + OnClickCopyItemTileData(this, EventArgs.Empty); + return true; + } + + if (listViewLand.Focused) + { + OnClickCopyLandTileData(this, EventArgs.Empty); + return true; + } + } + else if (keyData == (Keys.Control | Keys.V)) + { + if (listViewItem.Focused && _copiedItem != null) + { + OnClickPasteSpecialItem(this, EventArgs.Empty); + return true; + } + + if (listViewLand.Focused && _copiedLand != null) + { + OnClickPasteSpecialLand(this, EventArgs.Empty); + return true; + } + } + if (keyData == Keys.F3 || keyData == (Keys.F3 | Keys.Shift)) { if (searchByNameToolStripTextBox.TextBox.Focused) @@ -560,6 +792,9 @@ public void OnLoad(object sender, EventArgs e) _modifiedItems.Clear(); _modifiedLand.Clear(); + // The snapshot refers to entries that have just been replaced wholesale. + _lastBulkUndo = null; + ResetItemView(); ResetLandView(); @@ -587,17 +822,8 @@ private void OnPreviewBackgroundColorChanged() pictureBoxItem.BackColor = Options.PreviewBackgroundColor; pictureBoxLand.BackColor = Options.PreviewBackgroundColor; - int itemGraphic = GetSelectedItemGraphic(); - if (itemGraphic >= 0) - { - UpdateSelectedItemPreview(itemGraphic); - } - - int landGraphic = GetSelectedLandGraphic(); - if (landGraphic >= 0) - { - UpdateSelectedLandPreview(landGraphic); - } + RefreshItemEditor(); + RefreshLandEditor(); } private void OnTileDataChangeEvent(object sender, int index) @@ -618,7 +844,7 @@ private void OnTileDataChangeEvent(object sender, int index) MarkItemModified(graphic); if (GetSelectedItemGraphic() == graphic) { - UpdateSelectedItemPreview(graphic); + QueueItemEditorRefresh(); } } else @@ -626,31 +852,309 @@ private void OnTileDataChangeEvent(object sender, int index) MarkLandModified(index); if (GetSelectedLandGraphic() == index) { - UpdateSelectedLandPreview(index); + QueueLandEditorRefresh(); } } } private void OnItemSelectedIndexChanged(object sender, EventArgs e) { - int graphic = GetSelectedItemGraphic(); - if (graphic < 0) + QueueItemEditorRefresh(); + } + + private void OnLandSelectedIndexChanged(object sender, EventArgs e) + { + QueueLandEditorRefresh(); + } + + private void OnItemSelectionRangeChanged(object sender, ListViewVirtualItemsSelectionRangeChangedEventArgs e) + { + QueueItemEditorRefresh(); + } + + private void OnLandSelectionRangeChanged(object sender, ListViewVirtualItemsSelectionRangeChangedEventArgs e) + { + QueueLandEditorRefresh(); + } + + // SelectedIndexChanged fires once per row, so rubber-banding a few thousand + // rows would otherwise repopulate the whole editor pane - art decode included - + // once per row. Coalesce everything raised in one message-loop turn. + private bool _itemRefreshPending; + private bool _landRefreshPending; + + private void QueueItemEditorRefresh() + { + if (_itemRefreshPending || !IsHandleCreated || IsDisposed) { return; } - UpdateSelectedItemPreview(graphic); + _itemRefreshPending = true; + BeginInvoke(new Action(() => + { + _itemRefreshPending = false; + if (!IsDisposed) + { + RefreshItemEditor(); + } + })); } - private void OnLandSelectedIndexChanged(object sender, EventArgs e) + private void QueueLandEditorRefresh() { - int graphic = GetSelectedLandGraphic(); - if (graphic < 0) + if (_landRefreshPending || !IsHandleCreated || IsDisposed) + { + return; + } + + _landRefreshPending = true; + BeginInvoke(new Action(() => + { + _landRefreshPending = false; + if (!IsDisposed) + { + RefreshLandEditor(); + } + })); + } + + private void RefreshItemEditor() + { + int[] selection = GetSelectedItemGraphics(); + UpdateMultiSelectInfoLabel(multiSelectItemInfoLabel, selection.Length); + + int primary = GetSelectedItemGraphic(); + if (primary < 0) + { + return; + } + + UpdateSelectedItemPreview(primary); + + if (selection.Length > 1) + { + ApplyItemMixedState(selection); + _itemMultiBaseline = BuildItemEditFromEditor(); + } + else + { + _itemMultiBaseline = null; + } + } + + private void RefreshLandEditor() + { + int[] selection = GetSelectedLandGraphics(); + UpdateMultiSelectInfoLabel(multiSelectLandInfoLabel, selection.Length); + + int primary = GetSelectedLandGraphic(); + if (primary < 0) + { + return; + } + + UpdateSelectedLandPreview(primary); + + if (selection.Length > 1) + { + ApplyLandMixedState(selection); + _landMultiBaseline = BuildLandEditFromEditor(); + } + else + { + _landMultiBaseline = null; + } + } + + private static void UpdateMultiSelectInfoLabel(Label label, int selectionCount) + { + if (selectionCount > 1) + { + label.Text = + $"{selectionCount} entries selected - 'Save Changes' writes what you edit to all of them." + + " Empty boxes and greyed flags are left unchanged."; + label.Visible = true; + } + else + { + label.Visible = false; + } + } + + /// + /// With more than one entry selected the pane shows the primary entry's values, + /// then this blanks every box the selection disagrees on and greys every flag + /// that is not uniformly set or clear. Blank and grey both read as "leave + /// alone" when the edit is applied. + /// + private void ApplyItemMixedState(int[] selection) + { + ref readonly ItemData first = ref TileData.ItemTable[selection[0]]; + + bool sameName = true; + bool sameAnim = true; + bool sameWeight = true; + bool sameQuality = true; + bool sameQuantity = true; + bool sameHue = true; + bool sameStackOff = true; + bool sameValue = true; + bool sameHeight = true; + bool sameMisc = true; + bool sameUnk2 = true; + bool sameUnk3 = true; + + TileFlag inAll = first.Flags; + TileFlag inAny = first.Flags; + + for (int i = 1; i < selection.Length; ++i) + { + ref readonly ItemData row = ref TileData.ItemTable[selection[i]]; + + sameName &= string.Equals(row.Name, first.Name, StringComparison.Ordinal); + sameAnim &= row.Animation == first.Animation; + sameWeight &= row.Weight == first.Weight; + sameQuality &= row.Quality == first.Quality; + sameQuantity &= row.Quantity == first.Quantity; + sameHue &= row.Hue == first.Hue; + sameStackOff &= row.StackingOffset == first.StackingOffset; + sameValue &= row.Value == first.Value; + sameHeight &= row.Height == first.Height; + sameMisc &= row.MiscData == first.MiscData; + sameUnk2 &= row.Unk2 == first.Unk2; + sameUnk3 &= row.Unk3 == first.Unk3; + + inAll &= row.Flags; + inAny |= row.Flags; + } + + _changingIndex = true; + try + { + BlankIfMixed(textBoxName, sameName); + BlankIfMixed(textBoxAnim, sameAnim); + BlankIfMixed(textBoxWeight, sameWeight); + BlankIfMixed(textBoxQuality, sameQuality); + BlankIfMixed(textBoxQuantity, sameQuantity); + BlankIfMixed(textBoxHue, sameHue); + BlankIfMixed(textBoxStackOff, sameStackOff); + BlankIfMixed(textBoxValue, sameValue); + BlankIfMixed(textBoxHeigth, sameHeight); + BlankIfMixed(textBoxUnk1, sameMisc); + BlankIfMixed(textBoxUnk2, sameUnk2); + BlankIfMixed(textBoxUnk3, sameUnk3); + + // Set somewhere but not everywhere. + MarkMixedFlags(checkedListBox1, inAny & ~inAll); + } + finally + { + _changingIndex = false; + } + } + + private void ApplyLandMixedState(int[] selection) + { + ref readonly LandData first = ref TileData.LandTable[selection[0]]; + + bool sameName = true; + bool sameTexture = true; + + TileFlag inAll = first.Flags; + TileFlag inAny = first.Flags; + + for (int i = 1; i < selection.Length; ++i) + { + ref readonly LandData row = ref TileData.LandTable[selection[i]]; + + sameName &= string.Equals(row.Name, first.Name, StringComparison.Ordinal); + sameTexture &= row.TextureId == first.TextureId; + + inAll &= row.Flags; + inAny |= row.Flags; + } + + _changingIndex = true; + try + { + BlankIfMixed(textBoxNameLand, sameName); + BlankIfMixed(textBoxTexID, sameTexture); + + MarkMixedFlags(checkedListBox2, inAny & ~inAll); + } + finally + { + _changingIndex = false; + } + } + + private static void BlankIfMixed(TextBox textBox, bool allAgree) + { + if (!allAgree) + { + textBox.Text = string.Empty; + } + } + + private static void MarkMixedFlags(CheckedListBox checkedListBox, TileFlag mixed) + { + if (mixed == TileFlag.None) + { + return; + } + + Array enumValues = Enum.GetValues(typeof(TileFlag)); + for (int i = 0; i < checkedListBox.Items.Count; ++i) + { + if ((mixed & (TileFlag)enumValues.GetValue(i + 1)) != 0) + { + checkedListBox.SetItemCheckState(i, CheckState.Indeterminate); + } + } + } + + /// + /// Multi-selection flag cycling, which CheckedListBox will not do on its own. + /// A flag the selection disagreed on cycles leave alone -> set on all -> clear + /// on all, so the "don't touch it" state stays reachable. A flag they all + /// already agreed on just toggles: leaving it at the value it came up with is + /// already a no-op, so it has no need of a third state. + /// + private static CheckState NextMultiSelectFlagState(CheckState current, bool wasMixed) + { + if (!wasMixed) + { + return current == CheckState.Checked ? CheckState.Unchecked : CheckState.Checked; + } + + switch (current) + { + case CheckState.Indeterminate: + return CheckState.Checked; + + case CheckState.Checked: + return CheckState.Unchecked; + + default: + return CheckState.Indeterminate; + } + } + + /// + /// True when the selection disagreed on this flag at the time the pane was + /// populated - i.e. the baseline left it out of both masks. + /// + private static bool FlagWasMixed(TileFlag baselineSet, TileFlag baselineClear, int flagIndex) + { + Array enumValues = Enum.GetValues(typeof(TileFlag)); + if ((uint)(flagIndex + 1) >= (uint)enumValues.Length) { - return; + return false; } - UpdateSelectedLandPreview(graphic); + var flag = (TileFlag)enumValues.GetValue(flagIndex + 1); + return (baselineSet & flag) == 0 && (baselineClear & flag) == 0; } private void UpdateSelectedItemPreview(int index) @@ -744,6 +1248,12 @@ private void OnClickSaveChanges(object sender, EventArgs e) { if (tabcontrol.SelectedIndex == 0) // items { + if (listViewItem.SelectedIndices.Count > 1) + { + ApplyItemEditToSelection(GetSelectedItemGraphics(), BuildItemEditForSelection()); + return; + } + int index = GetSelectedItemGraphic(); if (index < 0) { @@ -837,6 +1347,12 @@ private void OnClickSaveChanges(object sender, EventArgs e) } else // land { + if (listViewLand.SelectedIndices.Count > 1) + { + ApplyLandEditToSelection(GetSelectedLandGraphics(), BuildLandEditForSelection()); + return; + } + int index = GetSelectedLandGraphic(); if (index < 0) { @@ -880,14 +1396,338 @@ private void OnClickSaveChanges(object sender, EventArgs e) } } + /// + /// Reads the editor pane into a sparse edit. An empty or unparseable box and an + /// indeterminate flag are both left out, so applying it across a selection only + /// touches what the user actually filled in. + /// + private ItemDataEdit BuildItemEditFromEditor() + { + var edit = new ItemDataEdit + { + Name = string.IsNullOrEmpty(textBoxName.Text) ? null : TileDataBulkEdit.TruncateName(textBoxName.Text), + Animation = ParseShort(textBoxAnim.Text), + Weight = ParseByte(textBoxWeight.Text), + Quality = ParseByte(textBoxQuality.Text), + Quantity = ParseByte(textBoxQuantity.Text), + Hue = ParseByte(textBoxHue.Text), + StackingOffset = ParseByte(textBoxStackOff.Text), + Value = ParseByte(textBoxValue.Text), + Height = ParseByte(textBoxHeigth.Text), + MiscData = ParseShort(textBoxUnk1.Text), + Unk2 = ParseByte(textBoxUnk2.Text), + Unk3 = ParseByte(textBoxUnk3.Text) + }; + + ReadFlagMasks(checkedListBox1, out TileFlag setFlags, out TileFlag clearFlags); + edit.SetFlags = setFlags; + edit.ClearFlags = clearFlags; + + return edit; + } + + private LandDataEdit BuildLandEditFromEditor() + { + var edit = new LandDataEdit + { + Name = string.IsNullOrEmpty(textBoxNameLand.Text) + ? null + : TileDataBulkEdit.TruncateName(textBoxNameLand.Text), + TextureId = ushort.TryParse(textBoxTexID.Text, out ushort textureId) ? textureId : (ushort?)null + }; + + ReadFlagMasks(checkedListBox2, out TileFlag setFlags, out TileFlag clearFlags); + edit.SetFlags = setFlags; + edit.ClearFlags = clearFlags; + + return edit; + } + + private static void ReadFlagMasks(CheckedListBox checkedListBox, out TileFlag setFlags, out TileFlag clearFlags) + { + setFlags = TileFlag.None; + clearFlags = TileFlag.None; + + Array enumValues = Enum.GetValues(typeof(TileFlag)); + for (int i = 0; i < checkedListBox.Items.Count; ++i) + { + CheckState state = checkedListBox.GetItemCheckState(i); + if (state == CheckState.Indeterminate) + { + continue; + } + + var flag = (TileFlag)enumValues.GetValue(i + 1); + if (state == CheckState.Checked) + { + setFlags |= flag; + } + else + { + clearFlags |= flag; + } + } + } + + private static byte? ParseByte(string text) + { + return byte.TryParse(text, out byte value) ? value : (byte?)null; + } + + private static short? ParseShort(string text) + { + return short.TryParse(text, out short value) ? value : (short?)null; + } + + // The pane as it stood when it was populated for the current multi-selection. + // A bulk apply writes the difference against this, so boxes and flags the user + // left alone are neither listed in the confirmation nor written back over + // entries that already agree. + private ItemDataEdit _itemMultiBaseline; + private LandDataEdit _landMultiBaseline; + + /// + /// What the user actually changed in the pane since it was populated for this + /// selection. + /// + private ItemDataEdit BuildItemEditForSelection() + { + ItemDataEdit current = BuildItemEditFromEditor(); + ItemDataEdit baseline = _itemMultiBaseline; + if (baseline == null) + { + return current; + } + + return new ItemDataEdit + { + Name = OnlyIfChanged(current.Name, baseline.Name), + Animation = OnlyIfChanged(current.Animation, baseline.Animation), + Weight = OnlyIfChanged(current.Weight, baseline.Weight), + Quality = OnlyIfChanged(current.Quality, baseline.Quality), + Quantity = OnlyIfChanged(current.Quantity, baseline.Quantity), + Hue = OnlyIfChanged(current.Hue, baseline.Hue), + StackingOffset = OnlyIfChanged(current.StackingOffset, baseline.StackingOffset), + Value = OnlyIfChanged(current.Value, baseline.Value), + Height = OnlyIfChanged(current.Height, baseline.Height), + MiscData = OnlyIfChanged(current.MiscData, baseline.MiscData), + Unk2 = OnlyIfChanged(current.Unk2, baseline.Unk2), + Unk3 = OnlyIfChanged(current.Unk3, baseline.Unk3), + + // Only flags the user moved to checked / unchecked from something else. + SetFlags = current.SetFlags & ~baseline.SetFlags, + ClearFlags = current.ClearFlags & ~baseline.ClearFlags + }; + } + + private LandDataEdit BuildLandEditForSelection() + { + LandDataEdit current = BuildLandEditFromEditor(); + LandDataEdit baseline = _landMultiBaseline; + if (baseline == null) + { + return current; + } + + return new LandDataEdit + { + Name = OnlyIfChanged(current.Name, baseline.Name), + TextureId = OnlyIfChanged(current.TextureId, baseline.TextureId), + SetFlags = current.SetFlags & ~baseline.SetFlags, + ClearFlags = current.ClearFlags & ~baseline.ClearFlags + }; + } + + private static T? OnlyIfChanged(T? current, T? baseline) where T : struct + { + return current.HasValue && !EqualityComparer.Default.Equals(current, baseline) + ? current + : null; + } + + private static string OnlyIfChanged(string current, string baseline) + { + return current != null && !string.Equals(current, baseline, StringComparison.Ordinal) + ? current + : null; + } + + /// + /// Writes one sparse edit to every selected item entry, after confirming what is + /// about to change and snapshotting the old values for a single level of undo. + /// + private void ApplyItemEditToSelection(int[] graphics, ItemDataEdit edit) + { + if (graphics.Length == 0) + { + return; + } + + string what = TileDataBulkEdit.Describe(edit); + if (!ConfirmBulkApply(what, graphics.Length)) + { + return; + } + + _lastBulkUndo = TileDataBulkUndo.ForItems(graphics, what); + + using (new WaitCursorScope(this)) + { + foreach (int graphic in graphics) + { + TileData.ItemTable[graphic] = TileDataBulkEdit.Apply(TileData.ItemTable[graphic], edit); + + // Mark without RedrawItemRow - that scans the projection array per + // call, which would be quadratic over a large selection. One + // Invalidate below repaints the lot. + _modifiedItems.Add(graphic); + ControlEvents.FireTileDataChangeEvent(this, graphic + 0x4000); + } + } + + Options.ChangedUltimaClass["TileData"] = true; + listViewItem.Invalidate(); + QueueItemEditorRefresh(); + + ReportBulkApply(what, graphics.Length); + } + + private void ApplyLandEditToSelection(int[] graphics, LandDataEdit edit) + { + if (graphics.Length == 0) + { + return; + } + + string what = TileDataBulkEdit.Describe(edit); + if (!ConfirmBulkApply(what, graphics.Length)) + { + return; + } + + _lastBulkUndo = TileDataBulkUndo.ForLand(graphics, what); + + using (new WaitCursorScope(this)) + { + foreach (int graphic in graphics) + { + TileData.LandTable[graphic] = TileDataBulkEdit.Apply(TileData.LandTable[graphic], edit); + _modifiedLand.Add(graphic); + ControlEvents.FireTileDataChangeEvent(this, graphic); + } + } + + Options.ChangedUltimaClass["TileData"] = true; + listViewLand.Invalidate(); + QueueLandEditorRefresh(); + + ReportBulkApply(what, graphics.Length); + } + + private bool ConfirmBulkApply(string what, int count) + { + if (string.IsNullOrEmpty(what)) + { + MessageBox.Show( + "Nothing to apply - every box is empty and every flag is left unchanged.", + "Apply to selection", MessageBoxButtons.OK, MessageBoxIcon.Information); + return false; + } + + return MessageBox.Show( + $"Apply {what} to {count} entries?", + "Apply to selection", MessageBoxButtons.YesNo, MessageBoxIcon.Question, + MessageBoxDefaultButton.Button2) == DialogResult.Yes; + } + + private void ReportBulkApply(string what, int count) + { + if (!memorySaveWarningToolStripMenuItem.Checked) + { + return; + } + + MessageBox.Show( + $"Applied {what} to {count} entries in memory.\r\n\r\nClick 'Save Tiledata' to write to file.", + "Saved", MessageBoxButtons.OK, MessageBoxIcon.Information, MessageBoxDefaultButton.Button1); + } + + private void MiscToolStripDropDownButton_DropDownOpening(object sender, EventArgs e) + { + undoBulkApplyToolStripMenuItem.Enabled = _lastBulkUndo != null; + undoBulkApplyToolStripMenuItem.Text = _lastBulkUndo == null + ? "Undo last bulk apply" + : $"Undo last bulk apply ({_lastBulkUndo.Count} entries)"; + } + + private void OnClickUndoBulkApply(object sender, EventArgs e) + { + TileDataBulkUndo undo = _lastBulkUndo; + if (undo == null) + { + return; + } + + if (MessageBox.Show( + $"Restore {undo.Count} entries to the values they had before '{undo.Description}' was applied?", + "Undo bulk apply", MessageBoxButtons.YesNo, MessageBoxIcon.Question, + MessageBoxDefaultButton.Button1) != DialogResult.Yes) + { + return; + } + + using (new WaitCursorScope(this)) + { + for (int i = 0; i < undo.Ids.Length; ++i) + { + int graphic = undo.Ids[i]; + if (undo.Land) + { + TileData.LandTable[graphic] = undo.Lands[i]; + ControlEvents.FireTileDataChangeEvent(this, graphic); + } + else + { + TileData.ItemTable[graphic] = undo.Items[i]; + ControlEvents.FireTileDataChangeEvent(this, graphic + 0x4000); + } + } + } + + // The entries keep their modified marker on purpose: undoing restores what + // was in memory before this apply, which is not necessarily what is on disk. + _lastBulkUndo = null; + + if (undo.Land) + { + listViewLand.Invalidate(); + QueueLandEditorRefresh(); + } + else + { + listViewItem.Invalidate(); + QueueItemEditorRefresh(); + } + } + private void SaveDirectlyOnChangesToolStripMenuItemOnCheckedChanged(object sender, EventArgs eventArgs) { Options.TileDataDirectlySaveOnChange = saveDirectlyOnChangesToolStripMenuItem.Checked; } + // "Save directly on changes" writes on every keystroke, which has no sensible + // meaning across a multi-selection - half-typed values would land on every + // selected entry. Bulk edits go through Save Changes instead, so the + // per-keystroke path only runs while exactly one entry is selected. + private bool DirectSaveItemEnabled => + saveDirectlyOnChangesToolStripMenuItem.Checked && listViewItem.SelectedIndices.Count == 1; + + private bool DirectSaveLandEnabled => + saveDirectlyOnChangesToolStripMenuItem.Checked && listViewLand.SelectedIndices.Count == 1; + private void OnTextChangedItemAnim(object sender, EventArgs e) { - if (!saveDirectlyOnChangesToolStripMenuItem.Checked) + if (!DirectSaveItemEnabled) { return; } @@ -918,7 +1758,7 @@ private void OnTextChangedItemAnim(object sender, EventArgs e) private void OnTextChangedItemName(object sender, EventArgs e) { - if (!saveDirectlyOnChangesToolStripMenuItem.Checked) + if (!DirectSaveItemEnabled) { return; } @@ -956,7 +1796,7 @@ private void OnTextChangedItemName(object sender, EventArgs e) private void OnTextChangedItemWeight(object sender, EventArgs e) { - if (!saveDirectlyOnChangesToolStripMenuItem.Checked) + if (!DirectSaveItemEnabled) { return; } @@ -987,7 +1827,7 @@ private void OnTextChangedItemWeight(object sender, EventArgs e) private void OnTextChangedItemQuality(object sender, EventArgs e) { - if (!saveDirectlyOnChangesToolStripMenuItem.Checked) + if (!DirectSaveItemEnabled) { return; } @@ -1018,7 +1858,7 @@ private void OnTextChangedItemQuality(object sender, EventArgs e) private void OnTextChangedItemQuantity(object sender, EventArgs e) { - if (!saveDirectlyOnChangesToolStripMenuItem.Checked) + if (!DirectSaveItemEnabled) { return; } @@ -1049,7 +1889,7 @@ private void OnTextChangedItemQuantity(object sender, EventArgs e) private void OnTextChangedItemHue(object sender, EventArgs e) { - if (!saveDirectlyOnChangesToolStripMenuItem.Checked) + if (!DirectSaveItemEnabled) { return; } @@ -1080,7 +1920,7 @@ private void OnTextChangedItemHue(object sender, EventArgs e) private void OnTextChangedItemStackOff(object sender, EventArgs e) { - if (!saveDirectlyOnChangesToolStripMenuItem.Checked) + if (!DirectSaveItemEnabled) { return; } @@ -1111,7 +1951,7 @@ private void OnTextChangedItemStackOff(object sender, EventArgs e) private void OnTextChangedItemValue(object sender, EventArgs e) { - if (!saveDirectlyOnChangesToolStripMenuItem.Checked) + if (!DirectSaveItemEnabled) { return; } @@ -1142,7 +1982,7 @@ private void OnTextChangedItemValue(object sender, EventArgs e) private void OnTextChangedItemHeight(object sender, EventArgs e) { - if (!saveDirectlyOnChangesToolStripMenuItem.Checked) + if (!DirectSaveItemEnabled) { return; } @@ -1173,7 +2013,7 @@ private void OnTextChangedItemHeight(object sender, EventArgs e) private void OnTextChangedItemMiscData(object sender, EventArgs e) { - if (!saveDirectlyOnChangesToolStripMenuItem.Checked) + if (!DirectSaveItemEnabled) { return; } @@ -1204,7 +2044,7 @@ private void OnTextChangedItemMiscData(object sender, EventArgs e) private void OnTextChangedItemUnk2(object sender, EventArgs e) { - if (!saveDirectlyOnChangesToolStripMenuItem.Checked) + if (!DirectSaveItemEnabled) { return; } @@ -1235,7 +2075,7 @@ private void OnTextChangedItemUnk2(object sender, EventArgs e) private void OnTextChangedItemUnk3(object sender, EventArgs e) { - if (!saveDirectlyOnChangesToolStripMenuItem.Checked) + if (!DirectSaveItemEnabled) { return; } @@ -1266,7 +2106,7 @@ private void OnTextChangedItemUnk3(object sender, EventArgs e) private void OnTextChangedLandName(object sender, EventArgs e) { - if (!saveDirectlyOnChangesToolStripMenuItem.Checked) + if (!DirectSaveLandEnabled) { return; } @@ -1303,7 +2143,7 @@ private void OnTextChangedLandName(object sender, EventArgs e) private void OnTextChangedLandTexID(object sender, EventArgs e) { - if (!saveDirectlyOnChangesToolStripMenuItem.Checked) + if (!DirectSaveLandEnabled) { return; } @@ -1334,12 +2174,20 @@ private void OnTextChangedLandTexID(object sender, EventArgs e) private void OnFlagItemCheckItems(object sender, ItemCheckEventArgs e) { - if (!saveDirectlyOnChangesToolStripMenuItem.Checked) + if (_changingIndex) { return; } - if (_changingIndex) + if (listViewItem.SelectedIndices.Count > 1) + { + bool wasMixed = _itemMultiBaseline != null + && FlagWasMixed(_itemMultiBaseline.SetFlags, _itemMultiBaseline.ClearFlags, e.Index); + e.NewValue = NextMultiSelectFlagState(e.CurrentValue, wasMixed); + return; + } + + if (!saveDirectlyOnChangesToolStripMenuItem.Checked) { return; } @@ -1390,12 +2238,20 @@ private void OnFlagItemCheckItems(object sender, ItemCheckEventArgs e) private void OnFlagItemCheckLandTiles(object sender, ItemCheckEventArgs e) { - if (!saveDirectlyOnChangesToolStripMenuItem.Checked) + if (_changingIndex) { return; } - if (_changingIndex) + if (listViewLand.SelectedIndices.Count > 1) + { + bool wasMixed = _landMultiBaseline != null + && FlagWasMixed(_landMultiBaseline.SetFlags, _landMultiBaseline.ClearFlags, e.Index); + e.NewValue = NextMultiSelectFlagState(e.CurrentValue, wasMixed); + return; + } + + if (!saveDirectlyOnChangesToolStripMenuItem.Checked) { return; } @@ -1412,33 +2268,13 @@ private void OnFlagItemCheckLandTiles(object sender, ItemCheckEventArgs e) } LandData land = TileData.LandTable[index]; - TileFlag changeFlag; - switch (e.Index) - { - case 0: - changeFlag = TileFlag.Damaging; - break; - - case 1: - changeFlag = TileFlag.Wet; - break; - - case 2: - changeFlag = TileFlag.Impassable; - break; - case 3: - changeFlag = TileFlag.Wall; - break; - - case 4: - changeFlag = TileFlag.NoDiagonal; - break; - - default: - changeFlag = TileFlag.None; - break; - } + // The list holds every TileFlag in enum order (index 0 is None and is not + // listed), the same mapping OnClickSaveChanges uses. It used to be a + // hardcoded switch over five flags, which toggled the wrong bit for the + // first five entries and did nothing at all past them. + Array enumValues = Enum.GetValues(typeof(TileFlag)); + var changeFlag = (TileFlag)enumValues.GetValue(e.Index + 1); if ((land.Flags & changeFlag) != 0) { @@ -1616,8 +2452,107 @@ private void SelectInGumpsTabFemaleToolStripMenuItem_Click(object sender, EventA SelectInGumpsTab(graphic, true); } + // In-process clipboard for tiledata entries, one slot per table so item data can + // never land on a land tile. Not the Windows clipboard - there is no sensible + // text form of a tiledata entry to hand to other applications. + private static ItemData? _copiedItem; + private static int _copiedItemGraphic = -1; + private static LandData? _copiedLand; + private static int _copiedLandGraphic = -1; + + private void OnClickCopyItemTileData(object sender, EventArgs e) + { + int graphic = GetSelectedItemGraphic(); + if (graphic < 0) + { + return; + } + + _copiedItem = TileData.ItemTable[graphic]; + _copiedItemGraphic = graphic; + } + + private void OnClickCopyLandTileData(object sender, EventArgs e) + { + int graphic = GetSelectedLandGraphic(); + if (graphic < 0) + { + return; + } + + _copiedLand = TileData.LandTable[graphic]; + _copiedLandGraphic = graphic; + } + + private void OnClickPasteSpecialItem(object sender, EventArgs e) + { + if (_copiedItem == null) + { + return; + } + + int[] graphics = GetSelectedItemGraphics(); + if (graphics.Length == 0) + { + return; + } + + using (var dialog = new TileDataPasteSpecialForm(_copiedItem.Value, _copiedItemGraphic, graphics.Length)) + { + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + ApplyItemEditToSelection(graphics, dialog.BuildItemEdit()); + } + } + + private void OnClickPasteSpecialLand(object sender, EventArgs e) + { + if (_copiedLand == null) + { + return; + } + + int[] graphics = GetSelectedLandGraphics(); + if (graphics.Length == 0) + { + return; + } + + using (var dialog = new TileDataPasteSpecialForm(_copiedLand.Value, _copiedLandGraphic, graphics.Length)) + { + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + ApplyLandEditToSelection(graphics, dialog.BuildLandEdit()); + } + } + + private void LandTilesContextMenuStrip_Opening(object sender, System.ComponentModel.CancelEventArgs e) + { + int selectedCount = listViewLand.SelectedIndices.Count; + + copyLandTileDataToolStripMenuItem.Enabled = selectedCount == 1; + pasteSpecialLandToolStripMenuItem.Enabled = _copiedLand != null && selectedCount > 0; + pasteSpecialLandToolStripMenuItem.Text = selectedCount > 1 + ? $"Paste special onto {selectedCount}..." + : "Paste special..."; + } + private void ItemsContextMenuStrip_Opening(object sender, System.ComponentModel.CancelEventArgs e) { + int selectedCount = listViewItem.SelectedIndices.Count; + + copyItemTileDataToolStripMenuItem.Enabled = selectedCount == 1; + pasteSpecialItemToolStripMenuItem.Enabled = _copiedItem != null && selectedCount > 0; + pasteSpecialItemToolStripMenuItem.Text = selectedCount > 1 + ? $"Paste special onto {selectedCount}..." + : "Paste special..."; + int graphic = GetSelectedItemGraphic(); if (graphic <= 0) { diff --git a/UoFiddler.Plugin.MassImport/Forms/MassImportForm.cs b/UoFiddler.Plugin.MassImport/Forms/MassImportForm.cs index 951a7b6f..4176accf 100644 --- a/UoFiddler.Plugin.MassImport/Forms/MassImportForm.cs +++ b/UoFiddler.Plugin.MassImport/Forms/MassImportForm.cs @@ -15,6 +15,7 @@ using System.IO; using System.Windows.Forms; using System.Xml; +using Ultima.Uop; using UoFiddler.Controls.Classes; using UoFiddler.Controls.Forms; using UoFiddler.Plugin.MassImport.Imports; @@ -322,7 +323,7 @@ private void StartOnClick(object sender, EventArgs e) if (changedUltimaClass["Art"]) { OutputBox.AppendText($"Saving Items/LandTiles..{Environment.NewLine}"); - Ultima.Art.Save(Options.OutputPath); + SaveInChosenFormat(FileType.ArtLegacyMul, Ultima.Art.Save); } if (changedUltimaClass["Texture"]) @@ -334,7 +335,7 @@ private void StartOnClick(object sender, EventArgs e) if (changedUltimaClass["Gumps"]) { OutputBox.AppendText($"Saving Gumps..{Environment.NewLine}"); - Ultima.Gumps.Save(Options.OutputPath); + SaveInChosenFormat(FileType.GumpartLegacyMul, Ultima.Gumps.Save); } if (changedUltimaClass["TileData"]) @@ -352,12 +353,41 @@ private void StartOnClick(object sender, EventArgs e) if (changedUltimaClass["Multis"]) { OutputBox.AppendText($"Saving Multis..{Environment.NewLine}"); - Ultima.Multis.Save(Options.OutputPath); + SaveInChosenFormat(FileType.MultiCollection, Ultima.Multis.Save); } OutputBox.AppendText($"Done{Environment.NewLine}"); } } } + + /// + /// Writes one file type in whatever container the save format option asks for. A batch cannot + /// stop on a dialog, so "ask every time" is taken here to mean the format the client already uses. + /// + private void SaveInChosenFormat(FileType type, Action writeMul) + { + ContainerFormat format = SaveFormatResolver.Resolve(type); + + try + { + foreach (string concern in ClientFileSaver.Preflight(type, format)) + { + OutputBox.AppendText($"{concern}{Environment.NewLine}"); + } + + ClientFileSaveResult result = ClientFileSaver.Save(type, Options.OutputPath, format, writeMul); + + foreach (string warning in result.Warnings) + { + OutputBox.AppendText($"{warning}{Environment.NewLine}"); + } + } + catch (Exception ex) + { + // Losing one type's output should not abandon the rest of the batch. + OutputBox.AppendText($"Could not save {type}: {ex.Message}{Environment.NewLine}"); + } + } } } \ No newline at end of file diff --git a/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs b/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs index 2c568e39..111783be 100644 --- a/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs +++ b/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs @@ -18,7 +18,8 @@ using Ultima; using UoFiddler.Controls.Classes; using UoFiddler.Controls.Forms; -using UoFiddler.Plugin.UopPacker.Classes; +using Ultima.Uop; +using Ultima.Helpers; namespace UoFiddler.Plugin.UopPacker.UserControls { @@ -116,19 +117,6 @@ private void UpdatePackAllCompressionVisibility() private const string _batchMultiIdxName = "multi.idx"; - private static (string mul, string idx, string uop) GetConventionalNames(FileType type, int mapIndex) - { - return type switch - { - FileType.ArtLegacyMul => ("art.mul", "artidx.mul", "artLegacyMUL.uop"), - FileType.GumpartLegacyMul => ("gumpart.mul", "gumpidx.mul", "gumpartLegacyMUL.uop"), - FileType.MapLegacyMul => ($"map{mapIndex}.mul", null, $"map{mapIndex}LegacyMUL.uop"), - FileType.SoundLegacyMul => ("sound.mul", "soundidx.mul", "soundLegacyMUL.uop"), - FileType.MultiCollection => ("multi.mul", "multi.idx", "MultiCollection.uop"), - _ => ("", "", "") - }; - } - private void OnMulTypeChanged(object sender, EventArgs e) => RefreshMulTypeUi(); private void OnUopTypeChanged(object sender, EventArgs e) => RefreshUopTypeUi(); @@ -146,21 +134,15 @@ private void RefreshMulTypeUi() inidx.Enabled = inidxbtn.Enabled = !isMap; mulMapIndex.Enabled = isMap; - // Every entry of every shipped MultiCollection.uop is zlib compressed. Packing it uncompressed - // produces a file several times larger than the original, and Mythic is not a valid compression - // for this type at all, so the choice is fixed rather than merely defaulted. - if (isMulti) - { - compressionBox.SelectedItem = nameof(CompressionFlag.Zlib); - } - else if (type == FileType.ArtLegacyMul || type == FileType.MapLegacyMul || type == FileType.SoundLegacyMul) + // What the shipped files use, and whether the type has any other valid choice, is recorded + // once in UopFileNames. Gumpart is the one type the client also accepts zlib and Mythic for, + // so its selection is left wherever the user put it rather than reset on every type change. + if (type != FileType.GumpartLegacyMul) { - // Every art, map and sound entry of every shipped client is stored uncompressed, and - // UOFiddler's own map reader can only address stored entries. Default accordingly. - compressionBox.SelectedItem = nameof(CompressionFlag.None); + compressionBox.SelectedItem = UopFileNames.DefaultCompression(type).ToString(); } - compressionBox.Enabled = !isMulti; + compressionBox.Enabled = !UopFileNames.IsCompressionFixed(type); inhousingbin.Visible = inhousingbinbtn.Visible = labelHousingBin.Visible = isMulti; @@ -170,7 +152,7 @@ private void RefreshMulTypeUi() inidx.Text = string.Empty; inhousingbin.Text = string.Empty; - var (mulName, idxName, uopName) = GetConventionalNames(type, (int)mulMapIndex.Value); + var (mulName, idxName, uopName) = UopFileNames.For(type, (int)mulMapIndex.Value); inmul.PlaceholderText = mulName; inidx.PlaceholderText = idxName ?? string.Empty; inhousingbin.PlaceholderText = "housing.bin"; @@ -192,7 +174,7 @@ private void RefreshUopTypeUi() inuop.Text = string.Empty; - var (mulName, idxName, uopName) = GetConventionalNames(type, (int)uopMapIndex.Value); + var (mulName, idxName, uopName) = UopFileNames.For(type, (int)uopMapIndex.Value); inuop.PlaceholderText = uopName; // Preview what will be written under the output folder. @@ -304,7 +286,7 @@ private async void ToUop(object sender, EventArgs e) } } - var (_, _, uopName) = GetConventionalNames(fileType, (int)mulMapIndex.Value); + var (_, _, uopName) = UopFileNames.For(fileType, (int)mulMapIndex.Value); string outUopPath = Path.Combine(outuopfolder.Text, uopName); string inIdxPath = fileType == FileType.MapLegacyMul ? null : inidx.Text; @@ -449,7 +431,7 @@ private async void ToMul(object sender, EventArgs e) } int mapIdx = (int)uopMapIndex.Value; - var (mulName, idxName, _) = GetConventionalNames(fileType, mapIdx); + var (mulName, idxName, _) = UopFileNames.For(fileType, mapIdx); string outMulPath = Path.Combine(outfolder.Text, mulName); string outIdxPath = idxName != null ? Path.Combine(outfolder.Text, idxName) : null; diff --git a/UoFiddler/Classes/FiddlerOptions.cs b/UoFiddler/Classes/FiddlerOptions.cs index ee361531..7ac36bec 100644 --- a/UoFiddler/Classes/FiddlerOptions.cs +++ b/UoFiddler/Classes/FiddlerOptions.cs @@ -131,6 +131,12 @@ public static void SaveProfile() elem = dom.CreateElement("CacheData"); elem.SetAttribute("active", Files.CacheData.ToString()); sr.AppendChild(elem); + comment = dom.CreateComment( + "SaveFormat for art, gumpart, sound, multis and maps: FollowSource, Mul, Uop or Ask"); + sr.AppendChild(comment); + elem = dom.CreateElement("SaveFormat"); + elem.SetAttribute("value", Options.SaveFormat.ToString()); + sr.AppendChild(elem); // + Colors comment = dom.CreateComment("Focus tile color for tile views"); sr.AppendChild(comment); @@ -324,6 +330,12 @@ public static void LoadProfile(string filename) Options.ArtItemClip = bool.Parse(elem.GetAttribute("active")); } + elem = (XmlElement)xOptions.SelectSingleNode("SaveFormat"); + if (elem != null && Enum.TryParse(elem.GetAttribute("value"), out ClientFileSaveFormat saveFormat)) + { + Options.SaveFormat = saveFormat; + } + elem = (XmlElement)xOptions.SelectSingleNode("CacheData"); if (elem != null) { diff --git a/UoFiddler/Classes/UpdateRunner.cs b/UoFiddler/Classes/UpdateRunner.cs index 7abb810a..5973a1a5 100644 --- a/UoFiddler/Classes/UpdateRunner.cs +++ b/UoFiddler/Classes/UpdateRunner.cs @@ -4,6 +4,7 @@ using System.Windows.Forms; using Microsoft.Extensions.Logging; using UoFiddler.Controls.Classes; +using Ultima.Helpers; namespace UoFiddler.Classes { diff --git a/UoFiddler/FiddlerAppContext.cs b/UoFiddler/FiddlerAppContext.cs index dc62d5c8..0a8309dd 100644 --- a/UoFiddler/FiddlerAppContext.cs +++ b/UoFiddler/FiddlerAppContext.cs @@ -17,6 +17,7 @@ using UoFiddler.Controls.Classes; using UoFiddler.Controls.UserControls; using UoFiddler.Forms; +using Ultima.Helpers; namespace UoFiddler { diff --git a/UoFiddler/Forms/AboutBoxForm.resx b/UoFiddler/Forms/AboutBoxForm.resx index 3cf41a32..dbab37ce 100644 --- a/UoFiddler/Forms/AboutBoxForm.resx +++ b/UoFiddler/Forms/AboutBoxForm.resx @@ -118,7 +118,21 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Version 4.23.0 + Version 4.24.0 +- Statics defrag rewritten with explicit filters (item id ceiling, out-of-block offsets, invalid z, duplicates, stacked statics), a report naming everything removed, and a Verify button that reads the result back and reconciles it against the source +- Defrag now runs on a background worker with a working Cancel, and no longer reports success when the source files were missing and nothing was written +- Map region copy and map diff insert work on UOP-only clients, copy land blocks byte-exactly, and can write either `map{N}.mul` or `map{N}LegacyMUL.uop` +- Both map dialogs gain a draggable region preview with pan and zoom, so a region is picked on the map instead of typed as numbers +- A copied region can be shifted in z, with the dialog saying up front how far it can actually move before the signed byte runs out +- New per-profile save format option: art, gumpart, sound, MultiCollection and maps can be written as mul, as uop, following the loaded client, or chosen at save time +- Fixed the land block sentinel size, diff patch bounds on TerMur, and facet 5 diff files never being found +- Fixed textures on older clients throwing from a paint handler: unused idx rows were being read as verdata patches +- Fixed hued gumps taking the process down on UOP clients +- Fixed AnimationSequence group records being skipped or walked at the wrong stride, so bodies such as 666 and 1253 now resolve the action aliases the file declares +- TileData can be edited for many entries at once, with Copy tile data / Paste special and a one-level undo of the last bulk apply +- Items, Land Tiles, Gumps and Textures copy and paste images through the clipboard, and mark the entries edited since load + +Version 4.23.0 - Multi tile flags survive a UOP <> mul round trip: the visibility word maps onto both `multi.mul` int32s instead of one boolean (8207 of 186695 shipped tiles used to lose a bit) - Per-tile component ids are kept in a `multi-components.txt` sidecar, so repacking no longer strips a boat's tiller man, hatch and planks or a house's doors - Loading a multi no longer deletes invisible tiles or reorders the tile list (this dropped 122 tiles and reshuffled 119 multis on shipped data) diff --git a/UoFiddler/Forms/LoadProfileForm.cs b/UoFiddler/Forms/LoadProfileForm.cs index 295a490f..38e74810 100644 --- a/UoFiddler/Forms/LoadProfileForm.cs +++ b/UoFiddler/Forms/LoadProfileForm.cs @@ -15,6 +15,7 @@ using Microsoft.Extensions.Logging; using UoFiddler.Controls.Classes; using UoFiddler.Classes; +using Ultima.Helpers; namespace UoFiddler.Forms { diff --git a/UoFiddler/Forms/ManagePluginsForm.cs b/UoFiddler/Forms/ManagePluginsForm.cs index a1d2cb39..7738ddea 100644 --- a/UoFiddler/Forms/ManagePluginsForm.cs +++ b/UoFiddler/Forms/ManagePluginsForm.cs @@ -15,6 +15,7 @@ using Microsoft.Extensions.Logging; using UoFiddler.Controls.Classes; using UoFiddler.Controls.Plugin; +using Ultima.Helpers; namespace UoFiddler.Forms { diff --git a/UoFiddler/Forms/OptionsForm.Designer.cs b/UoFiddler/Forms/OptionsForm.Designer.cs index 093a2e35..d5212629 100644 --- a/UoFiddler/Forms/OptionsForm.Designer.cs +++ b/UoFiddler/Forms/OptionsForm.Designer.cs @@ -78,6 +78,8 @@ private void InitializeComponent() button2 = new System.Windows.Forms.Button(); textBoxOutputPath = new System.Windows.Forms.TextBox(); label10 = new System.Windows.Forms.Label(); + comboBoxSaveFormat = new System.Windows.Forms.ComboBox(); + labelSaveFormat = new System.Windows.Forms.Label(); ColorsGroupBox = new System.Windows.Forms.GroupBox(); checkboxRemoveTileBorder = new System.Windows.Forms.CheckBox(); RestoreDefaultsButton = new System.Windows.Forms.Button(); @@ -224,7 +226,7 @@ private void InitializeComponent() // // buttonApply // - buttonApply.Location = new System.Drawing.Point(318, 551); + buttonApply.Location = new System.Drawing.Point(318, 586); buttonApply.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); buttonApply.Name = "buttonApply"; buttonApply.Size = new System.Drawing.Size(88, 27); @@ -478,14 +480,16 @@ private void InitializeComponent() groupBox4.Controls.Add(button2); groupBox4.Controls.Add(textBoxOutputPath); groupBox4.Controls.Add(label10); + groupBox4.Controls.Add(comboBoxSaveFormat); + groupBox4.Controls.Add(labelSaveFormat); groupBox4.Location = new System.Drawing.Point(16, 419); groupBox4.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); groupBox4.Name = "groupBox4"; groupBox4.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3); - groupBox4.Size = new System.Drawing.Size(486, 51); + groupBox4.Size = new System.Drawing.Size(486, 86); groupBox4.TabIndex = 6; groupBox4.TabStop = false; - groupBox4.Text = "Path"; + groupBox4.Text = "Output"; // // button2 // @@ -516,6 +520,26 @@ private void InitializeComponent() label10.TabIndex = 0; label10.Text = "Output Path"; // + // comboBoxSaveFormat + // + comboBoxSaveFormat.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + comboBoxSaveFormat.Location = new System.Drawing.Point(87, 51); + comboBoxSaveFormat.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + comboBoxSaveFormat.Name = "comboBoxSaveFormat"; + comboBoxSaveFormat.Size = new System.Drawing.Size(383, 23); + comboBoxSaveFormat.TabIndex = 4; + toolTip1.SetToolTip(comboBoxSaveFormat, "Which container a save writes for the files the client ships as either. Affects art, gumpart, sound, multis and maps; everything else has only one format."); + // + // labelSaveFormat + // + labelSaveFormat.AutoSize = true; + labelSaveFormat.Location = new System.Drawing.Point(7, 55); + labelSaveFormat.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + labelSaveFormat.Name = "labelSaveFormat"; + labelSaveFormat.Size = new System.Drawing.Size(72, 15); + labelSaveFormat.TabIndex = 3; + labelSaveFormat.Text = "Save Format"; + // // ColorsGroupBox // ColorsGroupBox.Controls.Add(checkboxRemoveTileBorder); @@ -598,7 +622,7 @@ private void InitializeComponent() // // buttonClose // - buttonClose.Location = new System.Drawing.Point(414, 551); + buttonClose.Location = new System.Drawing.Point(414, 586); buttonClose.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); buttonClose.Name = "buttonClose"; buttonClose.Size = new System.Drawing.Size(88, 27); @@ -612,7 +636,7 @@ private void InitializeComponent() ExportFilenamesGroupBox.Controls.Add(radioExportFilenameHex); ExportFilenamesGroupBox.Controls.Add(radioExportFilenameDec); ExportFilenamesGroupBox.Controls.Add(checkBoxExportFilenameDecPad); - ExportFilenamesGroupBox.Location = new System.Drawing.Point(16, 476); + ExportFilenamesGroupBox.Location = new System.Drawing.Point(16, 511); ExportFilenamesGroupBox.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); ExportFilenamesGroupBox.Name = "ExportFilenamesGroupBox"; ExportFilenamesGroupBox.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3); @@ -625,7 +649,7 @@ private void InitializeComponent() // AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - ClientSize = new System.Drawing.Size(518, 590); + ClientSize = new System.Drawing.Size(518, 625); Controls.Add(buttonClose); Controls.Add(ExportFilenamesGroupBox); Controls.Add(ColorsGroupBox); @@ -672,6 +696,8 @@ private void InitializeComponent() private System.Windows.Forms.GroupBox groupBox2; private System.Windows.Forms.GroupBox groupBox3; private System.Windows.Forms.GroupBox groupBox4; + private System.Windows.Forms.ComboBox comboBoxSaveFormat; + private System.Windows.Forms.Label labelSaveFormat; private System.Windows.Forms.Label label1; private System.Windows.Forms.Label label10; private System.Windows.Forms.Label label2; diff --git a/UoFiddler/Forms/OptionsForm.cs b/UoFiddler/Forms/OptionsForm.cs index 6c4edb4f..44a68aa2 100644 --- a/UoFiddler/Forms/OptionsForm.cs +++ b/UoFiddler/Forms/OptionsForm.cs @@ -90,6 +90,13 @@ public OptionsForm(Action updateAllTileViewsAction, cmdtext.Text = Options.MapCmd; argstext.Text = Options.MapArgs; textBoxOutputPath.Text = Options.OutputPath; + + foreach (ClientFileSaveFormat format in ClientFileSaveFormats.All) + { + comboBoxSaveFormat.Items.Add(ClientFileSaveFormats.DisplayName(format)); + } + + comboBoxSaveFormat.SelectedIndex = ClientFileSaveFormats.IndexOf(Options.SaveFormat); } private void OnClickApply(object sender, EventArgs e) @@ -192,6 +199,11 @@ private void OnClickApply(object sender, EventArgs e) Options.OutputPath = textBoxOutputPath.Text; } + if (comboBoxSaveFormat.SelectedIndex >= 0) + { + Options.SaveFormat = ClientFileSaveFormats.All[comboBoxSaveFormat.SelectedIndex]; + } + bool newHex = radioExportFilenameHex.Checked; bool newPad = checkBoxExportFilenameDecPad.Checked; if (newHex != AppSettings.ExportFilenameInHex || newPad != AppSettings.ExportFilenameDecimalPadded) diff --git a/UoFiddler/Options_default.xml b/UoFiddler/Options_default.xml index 571bfa4d..f689a8b3 100644 --- a/UoFiddler/Options_default.xml +++ b/UoFiddler/Options_default.xml @@ -6,6 +6,8 @@ + + diff --git a/UoFiddler/UoFiddler.csproj b/UoFiddler/UoFiddler.csproj index ad555456..01b63757 100644 --- a/UoFiddler/UoFiddler.csproj +++ b/UoFiddler/UoFiddler.csproj @@ -9,9 +9,9 @@ UoFiddler UoFiddler Copyright © 2026 - 4.23.0 - 4.23.0 - 4.23.0 + 4.24.0 + 4.24.0 + 4.24.0 true