From ef9e315a5a5103599c1acd5e237b95544ed71947 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:12:52 +0200 Subject: [PATCH 01/15] Rewrite statics defrag as a reportable engine Map.DefragStatics wrapped the rewrite in a bare catch that, on any error, mutated its loop variables and zero-filled every remaining block. It never checked that a block length was a whole number of records or that its lookup was inside the file, kept out-of-block offsets and z == -128, and keyed duplicates on hue so stacked water survived. Ultima/Statics replaces it: every filter explicit, every anomaly a counter on the result rather than control flow. StaticsComparer reads a run back and compares each block as a multiset, so an unfiltered run must come back identical and a filtered one may only be missing what the counters name. Map.DefragStatics stays as an obsolete shim so plugins keep compiling. --- Ultima/Map.cs | 276 +------- Ultima/Statics/StaticsComparer.cs | 344 +++++++++ Ultima/Statics/StaticsDefragException.cs | 30 + Ultima/Statics/StaticsDefragOptions.cs | 180 +++++ Ultima/Statics/StaticsDefragResult.cs | 238 +++++++ Ultima/Statics/StaticsDefragmenter.cs | 856 +++++++++++++++++++++++ 6 files changed, 1659 insertions(+), 265 deletions(-) create mode 100644 Ultima/Statics/StaticsComparer.cs create mode 100644 Ultima/Statics/StaticsDefragException.cs create mode 100644 Ultima/Statics/StaticsDefragOptions.cs create mode 100644 Ultima/Statics/StaticsDefragResult.cs create mode 100644 Ultima/Statics/StaticsDefragmenter.cs 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/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..613630e3 --- /dev/null +++ b/Ultima/Statics/StaticsDefragResult.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.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 + { + 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; + + 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..ee0255c9 --- /dev/null +++ b/Ultima/Statics/StaticsDefragmenter.cs @@ -0,0 +1,856 @@ +// /*************************************************************************** +// * +// * $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 format is documented in the client decompilation notes: 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 _duplicateIndex = new Dictionary(); + private readonly Dictionary _stackIndex = new Dictionary(); + + private byte[] _buffer = Array.Empty(); + 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; + + Entry3D[] entries = ReadIndex(index, blockCount, result); + CheckForSurplusBlocks(entries, blockCount, statics.Length, result); + + if ((long)blockCount * IndexRecordSize > int.MaxValue) + { + throw new StaticsDefragException("The configured map size needs an index larger than 2 GB."); + } + + TileMatrix tiles = _options.Map?.Tiles; + var outIndexBytes = new byte[blockCount * IndexRecordSize]; + + long staticsPosition = 0; + + for (int bx = 0; bx < blockWidth; ++bx) + { + for (int by = 0; by < blockHeight; ++by) + { + int blockId = (bx * blockHeight) + by; + + _tiles.Clear(); + + Entry3D entry = entries[blockId]; + bool removed = tiles != null && tiles.IsStaticBlockRemoved(bx, by); + + if (removed) + { + ++result.BlocksClearedByRemove; + } + else + { + if (entry.Lookup >= 0 && entry.Length > 0) + { + ++result.SourceBlocksWithStatics; + ReadBlock(statics, entry, bx, by, result); + } + + CollectPendingStatics(tiles, bx, by, result); + ApplyBlockFilters(bx, by, result); + } + + WriteBlock(outIndexBytes, blockId, entry, ref staticsPosition, outStatics, result); + + ++result.BlocksProcessed; + + if ((result.BlocksProcessed & (ProgressInterval - 1)) == 0) + { + _options.CancellationToken.ThrowIfCancellationRequested(); + ReportProgress(result, blockCount); + } + } + } + + outIndex.Write(outIndexBytes, 0, outIndexBytes.Length); + outIndex.Flush(); + outStatics.Flush(); + + result.OutputStaticsBytes = staticsPosition; + result.Elapsed = stopwatch.Elapsed; + ReportProgress(result, blockCount); + + return result; + } + + private void ReportProgress(StaticsDefragResult result, int blockCount) + { + _options.Progress?.Report(new StaticsDefragProgress + { + BlocksDone = result.BlocksProcessed, + BlocksTotal = blockCount, + TilesWritten = result.TilesWritten + }); + } + + // ---- geometry ------------------------------------------------------------------------- + + 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 Entry3D[] ReadIndex(Stream index, int blockCount, StaticsDefragResult result) + { + int entriesInFile = (int)Math.Min(index.Length / IndexRecordSize, int.MaxValue / IndexRecordSize); + var 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. The old routine reached this through an exception handler that then + // zero-filled the rest of the map. + for (int i = entriesInFile; i < entries.Length; ++i) + { + entries[i].Lookup = -1; + entries[i].Length = -1; + entries[i].Extra = -1; + } + + if (entriesInFile < blockCount) + { + result.Warnings.Add(string.Format(CultureInfo.InvariantCulture, + "staidx{0}.mul only covers {1:N0} of the {2:N0} blocks the configured map size needs. The remaining blocks were written empty.", + _options.FileIndex, entriesInFile, blockCount)); + } + + return entries; + } + + 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."); + } + + // ---- reading -------------------------------------------------------------------------- + + private void ReadBlock(Stream statics, Entry3D entry, int bx, int by, StaticsDefragResult result) + { + int length = entry.Length; + + if (entry.Lookup >= statics.Length) + { + ++result.BlocksWithBadLookup; + result.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.", + bx, by, entry.Lookup)); + return; + } + + long available = statics.Length - entry.Lookup; + if (length > available) + { + ++result.BlocksWithBadLookup; + result.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.", + bx, by, length, available)); + length = (int)(available - (available % TileRecordSize)); + } + + if (length % TileRecordSize != 0) + { + ++result.BlocksWithBadLength; + result.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.", + bx, by, length, TileRecordSize, length % TileRecordSize)); + 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)); + result.TilesRead += source.Length; + + Tile[] land = GetLandBlock(bx, by); + + for (int i = 0; i < source.Length; ++i) + { + StaticTile tile = source[i]; + + if (Accept(ref tile, bx, by, land, result)) + { + _tiles.Add(tile); + } + } + } + + 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; + } + + Tile[] land = GetLandBlock(bx, by); + + for (int i = 0; i < pending.Length; ++i) + { + StaticTile tile = pending[i]; + + if (!Accept(ref tile, bx, by, land, result)) + { + continue; + } + + _tiles.Add(tile); + ++result.PendingTilesAdded; + } + } + + private Tile[] GetLandBlock(int bx, int by) + { + if (!_options.DropBelowTerrain || _options.Map == null) + { + return null; + } + + Tile[] land = _options.Map.Tiles.GetLandBlock(bx, by); + + return land != null && land.Length >= 64 ? land : null; + } + + // ---- filtering ------------------------------------------------------------------------ + + private bool Accept(ref StaticTile tile, int bx, int by, Tile[] land, StaticsDefragResult result) + { + if (tile.Id > result.HighestItemIdSeen) + { + result.HighestItemIdSeen = tile.Id; + } + + if (_options.DropInvalidItemIds && tile.Id > _maxItemId) + { + ++result.DroppedInvalidItemId; + AddSample(result, bx, by, tile, RejectReason.InvalidItemId); + return false; + } + + if (tile.X > 7 || tile.Y > 7) + { + switch (_options.OutOfBlockTiles) + { + case OutOfBlockAction.Drop: + ++result.DroppedOutOfBlock; + AddSample(result, bx, by, tile, RejectReason.OutOfBlockOffset); + return false; + + case OutOfBlockAction.Mask: + tile.X &= 0x7; + tile.Y &= 0x7; + ++result.MaskedOutOfBlock; + break; + } + } + + if (_options.DropInvalidZ && tile.Z == sbyte.MinValue) + { + ++result.DroppedInvalidZ; + AddSample(result, bx, by, tile, RejectReason.InvalidZ); + return false; + } + + if (_options.NormalizeNegativeHue && tile.Hue < 0) + { + tile.Hue = 0; + ++result.HuesNormalized; + } + + if (land != null && IsBelowTerrain(tile, land)) + { + ++result.DroppedBelowTerrain; + AddSample(result, bx, by, tile, RejectReason.BelowTerrain); + return false; + } + + return true; + } + + /// + /// 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) + { + if (_options.RemoveDuplicates && _tiles.Count > 1) + { + RemoveDuplicates(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. + /// + private void RemoveDuplicates(int bx, int by, StaticsDefragResult result) + { + _duplicateIndex.Clear(); + + int 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)) + { + // Position of the first occurrence, payload of the last - what StaticFix does. + _tiles[existing] = tile; + ++result.DuplicatesRemoved; + AddSample(result, bx, by, 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 (_options.DuplicatesCompareHue) + { + key |= (ulong)(ushort)tile.Hue << 30; + } + + return key; + } + + /// + /// 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)) + { + ++result.StacksCollapsed; + AddSample(result, 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 void AddSample(StaticsDefragResult result, int bx, int by, StaticTile tile, RejectReason reason) + { + if (result.RejectSamples.Count < _options.RejectSampleLimit) + { + result.RejectSamples.Add(new RejectedStaticTile(bx, by, tile, reason)); + } + } + + // ---- writing -------------------------------------------------------------------------- + + private void WriteBlock(byte[] indexBytes, int blockId, Entry3D entry, ref long staticsPosition, + Stream outStatics, StaticsDefragResult result) + { + Span record = indexBytes.AsSpan(blockId * IndexRecordSize, IndexRecordSize); + + if (_tiles.Count == 0) + { + WriteEmptyEntry(record); + return; + } + + int length = _tiles.Count * TileRecordSize; + + if (staticsPosition + length > int.MaxValue) + { + throw new StaticsDefragException( + "The output statics file would pass 2 GB, which the 32-bit index lookup cannot address."); + } + + int extra = _options.PreserveExtra ? entry.Extra : 0; + if (extra == -1) + { + extra = 0; + } + + BinaryPrimitives.WriteInt32LittleEndian(record, (int)staticsPosition); + BinaryPrimitives.WriteInt32LittleEndian(record.Slice(4), length); + BinaryPrimitives.WriteInt32LittleEndian(record.Slice(8), extra); + + outStatics.Write(MemoryMarshal.AsBytes(CollectionsMarshal.AsSpan(_tiles))); + + staticsPosition += length; + result.TilesWritten += _tiles.Count; + ++result.OutputBlocksWithStatics; + } + + private void WriteEmptyEntry(Span record) + { + int empty = _options.EmptyBlocks == EmptyBlockStyle.Zero ? 0 : -1; + + BinaryPrimitives.WriteInt32LittleEndian(record, -1); + BinaryPrimitives.WriteInt32LittleEndian(record.Slice(4), empty); + BinaryPrimitives.WriteInt32LittleEndian(record.Slice(8), empty); + } + + // ---- file handling -------------------------------------------------------------------- + + 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 From 4891564e18372f48727c466b149cd9cca9b96769 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:12:53 +0200 Subject: [PATCH 02/15] Add a defrag options dialog and a verifiable report The two menu items ran on the UI thread under a wait cursor, offered no choice beyond duplicate removal, and always reported success - including when the source files were missing and nothing had been written. Both are replaced by one entry opening MapDefragStaticsForm: each filter a checkbox, the resolved item id ceiling shown, the block grid compared with the index actually on disk, and the run on a background worker with a working Cancel. The result form saves or copies the report, and its Verify button reads both files back and reconciles every static against the filters. --- .../Forms/MapDefragStaticsForm.Designer.cs | 476 ++++++++++++++++++ .../Forms/MapDefragStaticsForm.cs | 432 ++++++++++++++++ .../Forms/MapDefragStaticsForm.resx | 60 +++ .../MapDefragStaticsResultForm.Designer.cs | 151 ++++++ .../Forms/MapDefragStaticsResultForm.cs | 137 +++++ .../Forms/MapDefragStaticsResultForm.resx | 60 +++ .../UserControls/MapControl.Designer.cs | 14 +- UoFiddler.Controls/UserControls/MapControl.cs | 17 +- 8 files changed, 1321 insertions(+), 26 deletions(-) create mode 100644 UoFiddler.Controls/Forms/MapDefragStaticsForm.Designer.cs create mode 100644 UoFiddler.Controls/Forms/MapDefragStaticsForm.cs create mode 100644 UoFiddler.Controls/Forms/MapDefragStaticsForm.resx create mode 100644 UoFiddler.Controls/Forms/MapDefragStaticsResultForm.Designer.cs create mode 100644 UoFiddler.Controls/Forms/MapDefragStaticsResultForm.cs create mode 100644 UoFiddler.Controls/Forms/MapDefragStaticsResultForm.resx 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/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) From fc7962a7558a5eb6ecb4508e07d07b898a8ff9c7 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:12:53 +0200 Subject: [PATCH 03/15] Fix land block size, patch bounds and the missing facet 5 diffs TileMatrix allocated InvalidLandBlock as Tile[196]. 196 is the on-disk byte size of a block, not its 64 tiles, so a consumer that enumerates the block wrote 588 bytes instead of 192 and misaligned every block after it. TileMatrixPatch took _blockHeight from BlockWidth, so on TerMur - the only facet taller than it is wide - every patched block from y 160 up was reported unpatched and its diff data dropped. Files registered the mapdif and stadif families for indices 0 to 4 only, so facet 5 diffs could never be resolved at all. --- Ultima/Files.cs | 5 +++++ Ultima/TileMatrix.cs | 5 ++++- Ultima/TileMatrixPatch.cs | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) 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/TileMatrix.cs b/Ultima/TileMatrix.cs index 4c956978..8a51bb25 100644 --- a/Ultima/TileMatrix.cs +++ b/Ultima/TileMatrix.cs @@ -125,7 +125,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][][][][]; diff --git a/Ultima/TileMatrixPatch.cs b/Ultima/TileMatrixPatch.cs index d63f2c2e..66d80076 100644 --- a/Ultima/TileMatrixPatch.cs +++ b/Ultima/TileMatrixPatch.cs @@ -99,7 +99,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; From 3b4d514a6332348c52dc3112e420faba1e0a9125 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:12:54 +0200 Subject: [PATCH 04/15] Move UOP container reading and writing into the SDK The only code in the tree that could write a UOP container lived in the UopPacker plugin, which nothing in the app can reference, so the map tools could read a UOP map and then only ever write a mul back. LegacyMulFileConverter and its helpers move to Ultima/Uop, the container framing splits out into UopContainerWriter, and the entry-table walk that was private to TileMatrix becomes MapUopReader. MapSizes measures a facet shape from the files rather than a hardcoded table, and MapUopWriter and the block sinks let a caller emit a map one block at a time as either container. Packing is byte-identical before and after. --- .../Classes => Ultima/Helpers}/AppLog.cs | 4 +- Ultima/Helpers/UopUtils.cs | 17 ++ Ultima/Maps/MapBlockSink.cs | 200 ++++++++++++++ Ultima/Maps/MapSizes.cs | 220 +++++++++++++++ Ultima/TileMatrix.cs | 99 +------ Ultima/Ultima.csproj | 1 + .../Classes => Ultima/Uop}/FileType.cs | 4 +- .../Uop}/LegacyMulFileConverter.cs | 253 ++++++----------- Ultima/Uop/MapUopReader.cs | 165 +++++++++++ Ultima/Uop/MapUopWriter.cs | 196 +++++++++++++ .../Uop}/MultiComponentSidecar.cs | 4 +- Ultima/Uop/UopContainerWriter.cs | 260 ++++++++++++++++++ .../Classes/DynamicItemsConfig.cs | 1 + UoFiddler.Controls/Plugin/PluginBase.cs | 2 +- UoFiddler.Controls/Plugin/PluginServices.cs | 1 + .../UserControls/UopPackerControl.cs | 3 +- UoFiddler/Classes/UpdateRunner.cs | 1 + UoFiddler/FiddlerAppContext.cs | 1 + UoFiddler/Forms/LoadProfileForm.cs | 1 + UoFiddler/Forms/ManagePluginsForm.cs | 1 + 20 files changed, 1161 insertions(+), 273 deletions(-) rename {UoFiddler.Controls/Classes => Ultima/Helpers}/AppLog.cs (96%) create mode 100644 Ultima/Maps/MapBlockSink.cs create mode 100644 Ultima/Maps/MapSizes.cs rename {UoFiddler.Plugin.UopPacker/Classes => Ultima/Uop}/FileType.cs (75%) rename {UoFiddler.Plugin.UopPacker/Classes => Ultima/Uop}/LegacyMulFileConverter.cs (86%) create mode 100644 Ultima/Uop/MapUopReader.cs create mode 100644 Ultima/Uop/MapUopWriter.cs rename {UoFiddler.Plugin.UopPacker/Classes => Ultima/Uop}/MultiComponentSidecar.cs (99%) create mode 100644 Ultima/Uop/UopContainerWriter.cs 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/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/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/TileMatrix.cs b/Ultima/TileMatrix.cs index 8a51bb25..b88efd11 100644 --- a/Ultima/TileMatrix.cs +++ b/Ultima/TileMatrix.cs @@ -3,6 +3,7 @@ using System.IO; using System.Runtime.InteropServices; using Ultima.Helpers; +using Ultima.Uop; namespace Ultima { @@ -17,7 +18,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 +40,6 @@ public sealed class TileMatrix public void CloseStreams() { _map?.Close(); - _uopReader?.Close(); _statics?.Close(); } @@ -342,111 +341,25 @@ 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); - - _uopReader.BaseStream.Seek(0, SeekOrigin.Begin); - - if (_uopReader.ReadInt32() != 0x50594D) - { - throw new ArgumentException("Bad UOP file."); - } - - _uopReader.ReadInt64(); // version + signature - long nextBlock = _uopReader.ReadInt64(); - _uopReader.ReadInt32(); // block capacity - int count = _uopReader.ReadInt32(); - - UOPFiles = new UopFile[count]; - - var hashes = new Dictionary(); - - for (int i = 0; i < count; i++) - { - string file = $"build/{pattern}/{i:D8}.dat"; - ulong hash = UopUtils.HashFileName(file); - - hashes.TryAdd(hash, i); - } - - _uopReader.BaseStream.Seek(nextBlock, SeekOrigin.Begin); - - do - { - int filesCount = _uopReader.ReadInt32(); - nextBlock = _uopReader.ReadInt64(); - - 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; - } - - // This reader addresses map blocks by slicing straight into the file, so it can only handle - // stored entries. Every map*LegacyMUL.uop EA ships uses flag 0, but the UOP packer can be told - // to zlib them. compressedLength is the byte count on disk; decompressedLength only matches - // it while the entry is uncompressed. - if (flag != 0) - { - throw new NotSupportedException( - $"{pattern}: compressed map UOP entries are not supported " + - $"(entry uses compression flag {flag}). Repack the map with compression set to None."); - } - - if (hashes.TryGetValue(hash, out int idx)) - { - if (idx < 0 || idx >= UOPFiles.Length) - { - throw new IndexOutOfRangeException("hashes dictionary and files collection have different count of entries!"); - } - - 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!"); - } - } - } - while (_uopReader.BaseStream.Seek(nextBlock, SeekOrigin.Begin) != 0); + UOPFiles = MapUopReader.ReadEntryTable(_map, pattern); } private long CalculateOffsetFromUOP(long offset) { long pos = 0; - foreach (UopFile t in UOPFiles) + foreach (MapUopEntry t in UOPFiles) { long currentPosition = pos + t.Length; 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/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 99% rename from UoFiddler.Plugin.UopPacker/Classes/MultiComponentSidecar.cs rename to Ultima/Uop/MultiComponentSidecar.cs index dde978b1..50648964 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. @@ -324,4 +324,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/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/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.Plugin.UopPacker/UserControls/UopPackerControl.cs b/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs index 2c568e39..29e5fcb5 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 { 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/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 { From b838718dab863edc8886d9f85cf33057efad7120 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:12:54 +0200 Subject: [PATCH 05/15] Extract the statics block reader, writer and filter The read-validate-filter-write loop over a staidx/statics pair existed in five places, and only the defragmenter copy checked block lengths, lookup bounds and out-of-block offsets. The three forms still keyed duplicates on hue, which is what let stacked water survive. StaticsBlockIo holds those pieces on their own - index reader, block writer, per-static filter and a shared counter interface - and the defragmenter is rewired onto them, keeping only what is its own. --- Ultima/Statics/StaticsBlockIo.cs | 614 ++++++++++++++++++++++++++ Ultima/Statics/StaticsDefragResult.cs | 50 ++- Ultima/Statics/StaticsDefragmenter.cs | 380 ++++------------ 3 files changed, 746 insertions(+), 298 deletions(-) create mode 100644 Ultima/Statics/StaticsBlockIo.cs 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/StaticsDefragResult.cs b/Ultima/Statics/StaticsDefragResult.cs index 613630e3..3ad4440d 100644 --- a/Ultima/Statics/StaticsDefragResult.cs +++ b/Ultima/Statics/StaticsDefragResult.cs @@ -56,7 +56,7 @@ public override string ToString() } } - public sealed class StaticsDefragResult + public sealed class StaticsDefragResult : IStaticsFilterStats { public int FileIndex { get; set; } @@ -135,6 +135,54 @@ public sealed class StaticsDefragResult 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(); diff --git a/Ultima/Statics/StaticsDefragmenter.cs b/Ultima/Statics/StaticsDefragmenter.cs index ee0255c9..c048c7d7 100644 --- a/Ultima/Statics/StaticsDefragmenter.cs +++ b/Ultima/Statics/StaticsDefragmenter.cs @@ -24,7 +24,7 @@ namespace Ultima.Statics /// statics the client cannot render correctly. /// /// - /// The format is documented in the client decompilation notes: the index holds one 12-byte + /// 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 @@ -39,10 +39,9 @@ public sealed class StaticsDefragmenter private readonly StaticsDefragOptions _options; private readonly List _tiles = new List(256); - private readonly Dictionary _duplicateIndex = new Dictionary(); private readonly Dictionary _stackIndex = new Dictionary(); + private readonly StaticsTileFilter _filter = new StaticsTileFilter(); - private byte[] _buffer = Array.Empty(); private int _maxItemId; private int _itemTableLength; @@ -174,69 +173,83 @@ public StaticsDefragResult Run(Stream index, Stream statics, Stream outIndex, St int blockHeight = result.BlockHeight; int blockCount = blockWidth * blockHeight; - Entry3D[] entries = ReadIndex(index, blockCount, result); - CheckForSurplusBlocks(entries, blockCount, statics.Length, result); + ConfigureFilter(); - if ((long)blockCount * IndexRecordSize > int.MaxValue) - { - throw new StaticsDefragException("The configured map size needs an index larger than 2 GB."); - } + var problems = new StaticsBlockProblems(); - TileMatrix tiles = _options.Map?.Tiles; - var outIndexBytes = new byte[blockCount * IndexRecordSize]; + 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); - long staticsPosition = 0; + TileMatrix tiles = _options.Map?.Tiles; - for (int bx = 0; bx < blockWidth; ++bx) - { - for (int by = 0; by < blockHeight; ++by) + for (int bx = 0; bx < blockWidth; ++bx) { - int blockId = (bx * blockHeight) + by; - - _tiles.Clear(); + for (int by = 0; by < blockHeight; ++by) + { + _tiles.Clear(); - Entry3D entry = entries[blockId]; - bool removed = tiles != null && tiles.IsStaticBlockRemoved(bx, by); + 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) + if (removed) { - ++result.SourceBlocksWithStatics; - ReadBlock(statics, entry, bx, by, result); + ++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); } - CollectPendingStatics(tiles, bx, by, result); - ApplyBlockFilters(bx, by, result); - } - - WriteBlock(outIndexBytes, blockId, entry, ref staticsPosition, outStatics, result); + writer.WriteBlock(bx, by, _tiles, _options.PreserveExtra ? entry.Extra : 0); - ++result.BlocksProcessed; + ++result.BlocksProcessed; - if ((result.BlocksProcessed & (ProgressInterval - 1)) == 0) - { - _options.CancellationToken.ThrowIfCancellationRequested(); - ReportProgress(result, blockCount); + if ((result.BlocksProcessed & (ProgressInterval - 1)) == 0) + { + _options.CancellationToken.ThrowIfCancellationRequested(); + ReportProgress(result, blockCount); + } } } - } - outIndex.Write(outIndexBytes, 0, outIndexBytes.Length); - outIndex.Flush(); - outStatics.Flush(); + writer.Complete(); - result.OutputStaticsBytes = staticsPosition; + 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 @@ -247,8 +260,6 @@ private void ReportProgress(StaticsDefragResult result, int blockCount) }); } - // ---- geometry ------------------------------------------------------------------------- - private void ResolveGeometry(long indexLength, StaticsDefragResult result) { int blockWidth = _options.BlockWidth; @@ -309,34 +320,6 @@ private void ResolveGeometry(long indexLength, StaticsDefragResult result) result.GeometryEvidence = string.Join("; ", evidence); } - private Entry3D[] ReadIndex(Stream index, int blockCount, StaticsDefragResult result) - { - int entriesInFile = (int)Math.Min(index.Length / IndexRecordSize, int.MaxValue / IndexRecordSize); - var 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. The old routine reached this through an exception handler that then - // zero-filled the rest of the map. - for (int i = entriesInFile; i < entries.Length; ++i) - { - entries[i].Lookup = -1; - entries[i].Length = -1; - entries[i].Extra = -1; - } - - if (entriesInFile < blockCount) - { - result.Warnings.Add(string.Format(CultureInfo.InvariantCulture, - "staidx{0}.mul only covers {1:N0} of the {2:N0} blocks the configured map size needs. The remaining blocks were written empty.", - _options.FileIndex, entriesInFile, blockCount)); - } - - return entries; - } - private void CheckForSurplusBlocks(Entry3D[] entries, int blockCount, long staticsLength, StaticsDefragResult result) { if (entries.Length <= blockCount) @@ -377,101 +360,57 @@ private void CheckForSurplusBlocks(Entry3D[] entries, int blockCount, long stati result.Warnings.Add(message + " Truncation was allowed, so they were discarded."); } - // ---- reading -------------------------------------------------------------------------- - - private void ReadBlock(Stream statics, Entry3D entry, int bx, int by, StaticsDefragResult result) + private void CollectPendingStatics(TileMatrix tiles, int bx, int by, StaticsDefragResult result) { - int length = entry.Length; - - if (entry.Lookup >= statics.Length) + if (tiles == null || !tiles.PendingStatic(bx, by)) { - ++result.BlocksWithBadLookup; - result.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.", - bx, by, entry.Lookup)); return; } - long available = statics.Length - entry.Lookup; - if (length > available) - { - ++result.BlocksWithBadLookup; - result.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.", - bx, by, length, available)); - length = (int)(available - (available % TileRecordSize)); - } - - if (length % TileRecordSize != 0) - { - ++result.BlocksWithBadLength; - result.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.", - bx, by, length, TileRecordSize, length % TileRecordSize)); - length -= length % TileRecordSize; - } - - if (length <= 0) + StaticTile[] pending = tiles.GetPendingStatics(bx, by); + if (pending == null) { 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)); - result.TilesRead += source.Length; - - Tile[] land = GetLandBlock(bx, by); - - for (int i = 0; i < source.Length; ++i) + for (int i = 0; i < pending.Length; ++i) { - StaticTile tile = source[i]; - - if (Accept(ref tile, bx, by, land, result)) - { - _tiles.Add(tile); - } + _tiles.Add(pending[i]); + ++result.PendingTilesAdded; } } - private void CollectPendingStatics(TileMatrix tiles, int bx, int by, StaticsDefragResult result) + private void DropBelowTerrain(int bx, int by, StaticsDefragResult result) { - if (tiles == null || !tiles.PendingStatic(bx, by)) - { - return; - } + Tile[] land = GetLandBlock(bx, by); - StaticTile[] pending = tiles.GetPendingStatics(bx, by); - if (pending == null) + if (land == null) { return; } - Tile[] land = GetLandBlock(bx, by); + int write = 0; - for (int i = 0; i < pending.Length; ++i) + for (int i = 0; i < _tiles.Count; ++i) { - StaticTile tile = pending[i]; + StaticTile tile = _tiles[i]; - if (!Accept(ref tile, bx, by, land, result)) + if (IsBelowTerrain(tile, land)) { + ((IStaticsFilterStats)result).TileRejected(bx, by, tile, RejectReason.BelowTerrain); + continue; } - _tiles.Add(tile); - ++result.PendingTilesAdded; + _tiles[write++] = tile; } + + _tiles.RemoveRange(write, _tiles.Count - write); } private Tile[] GetLandBlock(int bx, int by) { - if (!_options.DropBelowTerrain || _options.Map == null) + if (_options.Map == null) { return null; } @@ -481,62 +420,6 @@ private Tile[] GetLandBlock(int bx, int by) return land != null && land.Length >= 64 ? land : null; } - // ---- filtering ------------------------------------------------------------------------ - - private bool Accept(ref StaticTile tile, int bx, int by, Tile[] land, StaticsDefragResult result) - { - if (tile.Id > result.HighestItemIdSeen) - { - result.HighestItemIdSeen = tile.Id; - } - - if (_options.DropInvalidItemIds && tile.Id > _maxItemId) - { - ++result.DroppedInvalidItemId; - AddSample(result, bx, by, tile, RejectReason.InvalidItemId); - return false; - } - - if (tile.X > 7 || tile.Y > 7) - { - switch (_options.OutOfBlockTiles) - { - case OutOfBlockAction.Drop: - ++result.DroppedOutOfBlock; - AddSample(result, bx, by, tile, RejectReason.OutOfBlockOffset); - return false; - - case OutOfBlockAction.Mask: - tile.X &= 0x7; - tile.Y &= 0x7; - ++result.MaskedOutOfBlock; - break; - } - } - - if (_options.DropInvalidZ && tile.Z == sbyte.MinValue) - { - ++result.DroppedInvalidZ; - AddSample(result, bx, by, tile, RejectReason.InvalidZ); - return false; - } - - if (_options.NormalizeNegativeHue && tile.Hue < 0) - { - tile.Hue = 0; - ++result.HuesNormalized; - } - - if (land != null && IsBelowTerrain(tile, land)) - { - ++result.DroppedBelowTerrain; - AddSample(result, bx, by, tile, RejectReason.BelowTerrain); - return false; - } - - return true; - } - /// /// The predicate already uses: the static and /// everything stacked on top of it sit under the land tile, so the client never draws it. @@ -555,9 +438,11 @@ private bool IsBelowTerrain(StaticTile tile, Tile[] land) private void ApplyBlockFilters(int bx, int by, StaticsDefragResult result) { - if (_options.RemoveDuplicates && _tiles.Count > 1) + _filter.Apply(_tiles, bx, by, result); + + if (_options.DropBelowTerrain && _tiles.Count > 0) { - RemoveDuplicates(bx, by, result); + DropBelowTerrain(bx, by, result); } if (_options.CollapseStacks && _tiles.Count > 1) @@ -576,48 +461,6 @@ private void ApplyBlockFilters(int bx, int by, StaticsDefragResult result) /// routine included it, so two water tiles in the same cell differing only in hue both /// survived and fought over the same pixels. /// - private void RemoveDuplicates(int bx, int by, StaticsDefragResult result) - { - _duplicateIndex.Clear(); - - int 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)) - { - // Position of the first occurrence, payload of the last - what StaticFix does. - _tiles[existing] = tile; - ++result.DuplicatesRemoved; - AddSample(result, bx, by, 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 (_options.DuplicatesCompareHue) - { - key |= (ulong)(ushort)tile.Hue << 30; - } - - return key; - } - /// /// 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 @@ -643,8 +486,8 @@ private void CollapseStacks(int bx, int by, StaticsDefragResult result) if (_stackIndex.ContainsKey(key)) { - ++result.StacksCollapsed; - AddSample(result, bx, by, tile, RejectReason.CollapsedStack); + ((IStaticsFilterStats)result).TileRejected(bx, by, tile, RejectReason.CollapsedStack); + continue; } @@ -707,63 +550,6 @@ private static int CompareTiles(StaticTile left, StaticTile right) return result != 0 ? result : left.Hue.CompareTo(right.Hue); } - private void AddSample(StaticsDefragResult result, int bx, int by, StaticTile tile, RejectReason reason) - { - if (result.RejectSamples.Count < _options.RejectSampleLimit) - { - result.RejectSamples.Add(new RejectedStaticTile(bx, by, tile, reason)); - } - } - - // ---- writing -------------------------------------------------------------------------- - - private void WriteBlock(byte[] indexBytes, int blockId, Entry3D entry, ref long staticsPosition, - Stream outStatics, StaticsDefragResult result) - { - Span record = indexBytes.AsSpan(blockId * IndexRecordSize, IndexRecordSize); - - if (_tiles.Count == 0) - { - WriteEmptyEntry(record); - return; - } - - int length = _tiles.Count * TileRecordSize; - - if (staticsPosition + length > int.MaxValue) - { - throw new StaticsDefragException( - "The output statics file would pass 2 GB, which the 32-bit index lookup cannot address."); - } - - int extra = _options.PreserveExtra ? entry.Extra : 0; - if (extra == -1) - { - extra = 0; - } - - BinaryPrimitives.WriteInt32LittleEndian(record, (int)staticsPosition); - BinaryPrimitives.WriteInt32LittleEndian(record.Slice(4), length); - BinaryPrimitives.WriteInt32LittleEndian(record.Slice(8), extra); - - outStatics.Write(MemoryMarshal.AsBytes(CollectionsMarshal.AsSpan(_tiles))); - - staticsPosition += length; - result.TilesWritten += _tiles.Count; - ++result.OutputBlocksWithStatics; - } - - private void WriteEmptyEntry(Span record) - { - int empty = _options.EmptyBlocks == EmptyBlockStyle.Zero ? 0 : -1; - - BinaryPrimitives.WriteInt32LittleEndian(record, -1); - BinaryPrimitives.WriteInt32LittleEndian(record.Slice(4), empty); - BinaryPrimitives.WriteInt32LittleEndian(record.Slice(8), empty); - } - - // ---- file handling -------------------------------------------------------------------- - private static string ResolveSource(string explicitPath, string fileName) { if (!string.IsNullOrEmpty(explicitPath)) From 58f9b69cb30098c0860fde5202edd6607ad63c56 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:12:55 +0200 Subject: [PATCH 06/15] Move the map region copy and diff apply into the SDK Both features lived in button handlers: raw streams without using, no bounds check on the computed block, and a progress bar stepped once per block on the UI thread. MapDiffInsertForm also looked up map{N}.mul and nothing else, so it refused outright on a client whose maps are UOP-only. MapRegionCopier and MapDiffApplier take the work over and read through TileMatrix, so both run on a modern client. Land keeps its 4-byte block header instead of having it rewritten as zero, which makes an identity copy byte-exact. The dialogs get exclusive bounds, normalised rectangles, a background worker with a working Cancel, and a result form that reads the output back and verifies it. --- Ultima/Maps/MapDiffApply.cs | 481 +++++++++++ Ultima/Maps/MapRegionCopy.cs | 716 ++++++++++++++++ Ultima/TileMatrix.cs | 81 +- Ultima/TileMatrixPatch.cs | 33 +- .../Forms/MapDiffApplyResultForm.Designer.cs | 151 ++++ .../Forms/MapDiffApplyResultForm.cs | 212 +++++ .../Forms/MapDiffApplyResultForm.resx | 60 ++ .../Forms/MapDiffInsertForm.Designer.cs | 460 ++++++----- UoFiddler.Controls/Forms/MapDiffInsertForm.cs | 661 ++++++--------- .../Forms/MapRegionCopyResultForm.Designer.cs | 151 ++++ .../Forms/MapRegionCopyResultForm.cs | 214 +++++ .../Forms/MapRegionCopyResultForm.resx | 60 ++ .../Forms/MapReplaceForm.Designer.cs | 730 +++++++++-------- UoFiddler.Controls/Forms/MapReplaceForm.cs | 766 +++++++++--------- 14 files changed, 3414 insertions(+), 1362 deletions(-) create mode 100644 Ultima/Maps/MapDiffApply.cs create mode 100644 Ultima/Maps/MapRegionCopy.cs create mode 100644 UoFiddler.Controls/Forms/MapDiffApplyResultForm.Designer.cs create mode 100644 UoFiddler.Controls/Forms/MapDiffApplyResultForm.cs create mode 100644 UoFiddler.Controls/Forms/MapDiffApplyResultForm.resx create mode 100644 UoFiddler.Controls/Forms/MapRegionCopyResultForm.Designer.cs create mode 100644 UoFiddler.Controls/Forms/MapRegionCopyResultForm.cs create mode 100644 UoFiddler.Controls/Forms/MapRegionCopyResultForm.resx 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..8be7577f --- /dev/null +++ b/Ultima/Maps/MapRegionCopy.cs @@ -0,0 +1,716 @@ +/*************************************************************************** + * + * $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; } + + 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 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(); + 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 (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 (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); + + if (options.CopyLand) + { + CopyLand(options, result, source, destination); + } + + if (options.CopyStatics) + { + CopyStatics(options, result, source, destination); + } + + result.Elapsed = stopwatch.Elapsed; + + return result; + } + + // ---- geometry -------------------------------------------------------------------------- + + 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); + } + } + + // ---- land ------------------------------------------------------------------------------ + + 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); + + ++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; + } + + // ---- statics --------------------------------------------------------------------------- + + 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); + + 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); + } + } + + // ---- helpers --------------------------------------------------------------------------- + + 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/TileMatrix.cs b/Ultima/TileMatrix.cs index b88efd11..ef8bc574 100644 --- a/Ultima/TileMatrix.cs +++ b/Ultima/TileMatrix.cs @@ -9,6 +9,14 @@ 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; @@ -374,31 +382,78 @@ private long CalculateOffsetFromUOP(long offset) return UOPLength; } - private Tile[] ReadLandBlock(int x, int y) + /// + /// 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 (_map?.CanRead != true || !_map.CanSeek) + if (destination.Length != MapBlockSize) { - _map = _mapPath == null - ? null - : new FileStream(_mapPath, FileMode.Open, FileAccess.Read, FileShare.Read); + throw new ArgumentException($"A land block is {MapBlockSize} bytes.", nameof(destination)); + } - if (IsUOPFormat && _mapPath != null && !IsUOPAlreadyRead) - { - var fi = new FileInfo(_mapPath); - string uopPattern = fi.Name.Replace(fi.Extension, "").ToLowerInvariant(); + destination.Clear(); - ReadUOPFiles(uopPattern); - IsUOPAlreadyRead = true; - } + if (x < 0 || y < 0 || x >= BlockWidth || y >= BlockHeight) + { + return; } + EnsureMapStream(); + + if (_map == null) + { + return; + } + + long offset = ((x * BlockHeight) + y) * (long)MapBlockSize; + + if (IsUOPFormat) + { + offset = CalculateOffsetFromUOP(offset); + } + + _map.Seek(offset, SeekOrigin.Begin); + _map.ReadExactly(destination); + } + + private void EnsureMapStream() + { + if (_map?.CanRead == true && _map.CanSeek) + { + return; + } + + // 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); + + if (!IsUOPFormat || _mapPath == null || IsUOPAlreadyRead) + { + return; + } + + ReadUOPFiles(MapUopReader.PatternFromPath(_mapPath)); + IsUOPAlreadyRead = true; + } + + private Tile[] ReadLandBlock(int x, int y) + { + EnsureMapStream(); + var tiles = new Tile[64]; if (_map == null) { return tiles; } - long offset = (((x * BlockHeight) + y) * 196) + 4; + long offset = (((x * BlockHeight) + y) * (long)MapBlockSize) + BlockHeaderSize; if (IsUOPFormat) { diff --git a/Ultima/TileMatrixPatch.cs b/Ultima/TileMatrixPatch.cs index 66d80076..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) 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..5e1d5f8f 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,339 @@ 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.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.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(496, 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.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(496, 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.Controls.Add(this.textBoxPreview); + this.groupBoxPreview.Location = new System.Drawing.Point(12, 170); + this.groupBoxPreview.Name = "groupBoxPreview"; + this.groupBoxPreview.Size = new System.Drawing.Size(496, 80); + this.groupBoxPreview.TabIndex = 4; + this.groupBoxPreview.TabStop = false; + this.groupBoxPreview.Text = "What will be inserted"; + // + // textBoxPreview + // + this.textBoxPreview.BackColor = System.Drawing.SystemColors.Control; + this.textBoxPreview.BorderStyle = System.Windows.Forms.BorderStyle.None; + this.textBoxPreview.Location = new System.Drawing.Point(16, 20); + this.textBoxPreview.Multiline = true; + this.textBoxPreview.Name = "textBoxPreview"; + this.textBoxPreview.ReadOnly = true; + this.textBoxPreview.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; + this.textBoxPreview.Size = new System.Drawing.Size(464, 52); + this.textBoxPreview.TabIndex = 0; + 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.Location = new System.Drawing.Point(12, 258); 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(496, 18); + this.progressBar1.TabIndex = 5; + // + // labelStatus + // + this.labelStatus.AutoEllipsis = true; + this.labelStatus.Location = new System.Drawing.Point(12, 281); + this.labelStatus.Name = "labelStatus"; + this.labelStatus.Size = new System.Drawing.Size(496, 17); + this.labelStatus.TabIndex = 6; + // + // buttonCopy + // + this.buttonCopy.Location = new System.Drawing.Point(244, 306); + 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.Enabled = false; + this.buttonCancel.Location = new System.Drawing.Point(332, 306); + 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.DialogResult = System.Windows.Forms.DialogResult.Cancel; + this.buttonClose.Location = new System.Drawing.Point(420, 306); + 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(520, 346); + 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.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; this.MaximizeBox = false; - this.MinimumSize = new System.Drawing.Size(388, 285); + this.MinimizeBox = false; 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.TextBox textBoxPreview; } } \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/MapDiffInsertForm.cs b/UoFiddler.Controls/Forms/MapDiffInsertForm.cs index a48d98ed..c119692f 100644 --- a/UoFiddler.Controls/Forms/MapDiffInsertForm.cs +++ b/UoFiddler.Controls/Forms/MapDiffInsertForm.cs @@ -10,9 +10,17 @@ ***************************************************************************/ 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; namespace UoFiddler.Controls.Forms @@ -21,473 +29,292 @@ public partial class MapDiffInsertForm : Form { private readonly Map _workingMap; + private CancellationTokenSource _cancellation; + 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 = 0; + + 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; + + 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 (x2 < 0 || x2 > _workingMap.Width) + if (y1 > y2) { - MessageBox.Show("Invalid X2 coordinate!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); - return; + (y1, y2) = (y2, y1); } - if (y1 < 0 || y1 > _workingMap.Height) + 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) { - MessageBox.Show("Invalid Y1 coordinate!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); - return; + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, + "the request {0},{1} - {2},{3} was widened to whole 8-tile blocks", x1, y1, x2, y2)); } - if (y2 < 0 || y2 > _workingMap.Height) + textBoxPreview.Text = sb.ToString(); + } + + 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 + }; - int blockY = _workingMap.Height >> 3; - int blockX = _workingMap.Width >> 3; + 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 + }; + } - progressBar1.Step = 1; + _cancellation?.Dispose(); + _cancellation = new CancellationTokenSource(); + options.CancellationToken = _cancellation.Token; + options.Progress = new Progress(OnProgress); + + 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 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..bfa87938 --- /dev/null +++ b/UoFiddler.Controls/Forms/MapRegionCopyResultForm.cs @@ -0,0 +1,214 @@ +/*************************************************************************** + * + * $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); + } + 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)); + 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(); + } + + 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..6bc44f16 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,488 @@ 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.groupBoxPreview = new System.Windows.Forms.GroupBox(); + 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(); + 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.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(496, 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.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(496, 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.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(496, 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.Controls.Add(this.label6); + this.groupBoxTo.Controls.Add(this.numericUpDownToX1); + this.groupBoxTo.Controls.Add(this.label7); + this.groupBoxTo.Controls.Add(this.numericUpDownToY1); + this.groupBoxTo.Location = new System.Drawing.Point(12, 298); + this.groupBoxTo.Name = "groupBoxTo"; + this.groupBoxTo.Size = new System.Drawing.Size(496, 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); + // + // groupBoxPreview + // + 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(496, 80); + this.groupBoxPreview.TabIndex = 4; + this.groupBoxPreview.TabStop = false; + this.groupBoxPreview.Text = "What will be copied"; + // + // textBoxPreview + // + this.textBoxPreview.BackColor = System.Drawing.SystemColors.Control; + this.textBoxPreview.BorderStyle = System.Windows.Forms.BorderStyle.None; + this.textBoxPreview.Location = new System.Drawing.Point(16, 20); + this.textBoxPreview.Multiline = true; + this.textBoxPreview.Name = "textBoxPreview"; + this.textBoxPreview.ReadOnly = true; + this.textBoxPreview.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; + this.textBoxPreview.Size = new System.Drawing.Size(464, 52); + this.textBoxPreview.TabIndex = 0; + this.textBoxPreview.TabStop = false; + // + // progressBar1 + // + this.progressBar1.Location = new System.Drawing.Point(12, 452); + this.progressBar1.Name = "progressBar1"; + this.progressBar1.Size = new System.Drawing.Size(496, 18); + this.progressBar1.TabIndex = 5; + // + // labelStatus + // + this.labelStatus.AutoEllipsis = true; + this.labelStatus.Location = new System.Drawing.Point(12, 475); + this.labelStatus.Name = "labelStatus"; + this.labelStatus.Size = new System.Drawing.Size(496, 17); + this.labelStatus.TabIndex = 6; + // + // buttonCopy + // + this.buttonCopy.Location = new System.Drawing.Point(244, 500); + 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.Enabled = false; + this.buttonCancel.Location = new System.Drawing.Point(332, 500); + 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.DialogResult = System.Windows.Forms.DialogResult.Cancel; + this.buttonClose.Location = new System.Drawing.Point(420, 500); + 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(520, 540); + 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.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; this.MaximizeBox = false; - this.MinimumSize = new System.Drawing.Size(388, 285); + this.MinimizeBox = false; 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(); + 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.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 +517,7 @@ 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.TextBox textBoxPreview; } } \ No newline at end of file diff --git a/UoFiddler.Controls/Forms/MapReplaceForm.cs b/UoFiddler.Controls/Forms/MapReplaceForm.cs index abe2f51a..1edadb0e 100644 --- a/UoFiddler.Controls/Forms/MapReplaceForm.cs +++ b/UoFiddler.Controls/Forms/MapReplaceForm.cs @@ -1,18 +1,28 @@ /*************************************************************************** * * $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; namespace UoFiddler.Controls.Forms @@ -21,497 +31,495 @@ public partial class MapReplaceForm : Form { private readonly Map _workingMap; + private CancellationTokenSource _cancellation; + private MapSize _detectedSize; + private bool _detectionKnown; + private string _detectionEvidence; + 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 = 0; + + 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; + + OnSourceMapChanged(this, EventArgs.Empty); + OnOptionChanged(this, EventArgs.Empty); + + ActiveControl = buttonBrowse; } + private SupportedMap SelectedMap => comboBoxMapID.SelectedItem as SupportedMap; + + // ---- source selection ------------------------------------------------------------------ + 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(); + } + + /// + /// 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 (dialog.ShowDialog() == DialogResult.OK) + 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) + { + control.Maximum = Math.Max(0, maximum); + + if (control.Value > control.Maximum) + { + 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() { - string path = textBox1.Text; - if (!Directory.Exists(path)) + SupportedMap map = SelectedMap; + + _detectionKnown = false; + _detectionEvidence = null; + labelDetected.Text = string.Empty; + labelSizeWarning.Text = string.Empty; + + if (map == null || !Directory.Exists(textBoxFolder.Text)) { - MessageBox.Show("Path not found!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); + UpdatePreview(); + return; } - if (!(comboBoxMapID.SelectedItem is SupportedMaps replaceMap)) + _detectionKnown = MapSizes.TryDetect(textBoxFolder.Text, map.Id, out _detectedSize, out _detectionEvidence); + + 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(); + } + + // ---- preview --------------------------------------------------------------------------- + + 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 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 UpdatePreview() + { + 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) { - MessageBox.Show("Invalid X1 coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); - return; + (x1, x2) = (x2, x1); } - if (x2 < 0 || x2 > replaceMap.Width) + if (y1 > y2) { - MessageBox.Show("Invalid X2 coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); - return; + (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(); + + sb.AppendLine($"from {source}"); + sb.AppendLine($"to {destination}"); + + bool snapped = source.TileX1 != x1 || source.TileY1 != y1 || source.TileX2 != x2 || source.TileY2 != y2; + + if (snapped) + { + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, + "the request {0},{1} - {2},{3} was widened to whole 8-tile blocks", x1, y1, x2, y2)); } - if (y1 < 0 || y1 > replaceMap.Height) + textBoxPreview.Text = sb.ToString(); + } + + // ---- running --------------------------------------------------------------------------- + + private void OnClickCopy(object sender, EventArgs e) + { + if (worker.IsBusy) { - MessageBox.Show("Invalid Y1 coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); return; } - if (y2 < 0 || y2 > replaceMap.Height) + SupportedMap map = SelectedMap; + + if (map == null) { - MessageBox.Show("Invalid Y2 coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); return; } - if (x1 > x2 || y1 > y2) + if (!Directory.Exists(textBoxFolder.Text)) { - MessageBox.Show("X1 and Y1 cannot be bigger than X2 and Y2!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); + Fail("Choose the folder holding the map files to copy from."); + return; } - if (tox < 0 || tox > _workingMap.Width || tox + (x2 - x1) > _workingMap.Width) + if (!checkBoxMap.Checked && !checkBoxStatics.Checked) { - MessageBox.Show("Invalid toX coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); + Fail("Nothing is selected to copy."); + return; } - if (toy < 0 || toy > _workingMap.Height || toy + (y2 - y1) > _workingMap.Height) + if (!_detectionKnown) { - MessageBox.Show("Invalid toX coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); + Fail($"The size of map {map.Id} in that folder could not be worked out.{Environment.NewLine}{Environment.NewLine}{_detectionEvidence}"); + return; } - x1 >>= 3; - x2 >>= 3; - y1 >>= 3; - y2 >>= 3; + 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."); - tox >>= 3; - toy >>= 3; + return; + } - int tox2 = x2 - x1 + tox; - int toy2 = y2 - y1 + toy; + MapRegionCopyOptions options = BuildOptions(map); - int blockY = _workingMap.Height >> 3; - int blockX = _workingMap.Width >> 3; - int blockYReplace = replaceMap.Height >> 3; - // int blockxreplace = replacemap.Width >> 3; // TODO: unused variable? + _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 = "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; - if (checkBoxMap.Checked) + var options = new MapRegionCopyOptions { - progressBar1.Maximum += blockY * blockX; - } + 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(), + OutputDirectory = Options.OutputPath + }; if (checkBoxStatics.Checked) { - progressBar1.Maximum += blockY * blockX; + options.StaticsFilter = new StaticsTileFilter + { + // 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 + }; } - if (checkBoxMap.Checked) + return options; + } + + private MapOutputFormat ResolveFormat() + { + switch (comboBoxMapFormat.SelectedIndex) { - 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; - } + case 1: return MapOutputFormat.Mul; + case 2: return MapOutputFormat.Uop; + default: return _workingMap.Tiles.IsUOPFormat ? MapOutputFormat.Uop : MapOutputFormat.Mul; + } + } - 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; - } + private void OnProgress(MapCopyProgress progress) + { + if (progress.BlocksTotal <= 0) + { + return; + } - 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); + 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); + } - 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(); - } - } - } - } + private void OnWorkerDoWork(object sender, DoWorkEventArgs e) + { + e.Result = MapRegionCopier.Run((MapRegionCopyOptions)e.Argument); + } + + private void OnWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) + { + SetRunning(false); - copyTileMatrix.CloseStreams(); - workTileMatrix.CloseStreams(); + if (e.Error is OperationCanceledException) + { + progressBar1.Value = 0; + labelStatus.Text = "Cancelled. Nothing was written."; + + return; } - if (checkBoxStatics.Checked) + if (e.Error != null) { - string indexPath = Files.GetFilePath($"staidx{_workingMap.FileIndex}.mul"); - BinaryReader mIndexReader; + progressBar1.Value = 0; + labelStatus.Text = "Failed."; + ShowError("Map and Statics Copy", e.Error); - 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; - } + return; + } - string staticsPath = Files.GetFilePath($"statics{_workingMap.FileIndex}.mul"); - FileStream mStatics; - BinaryReader mStaticsReader; + var result = (MapRegionCopyResult)e.Result; - 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; - } + progressBar1.Value = 100; + labelStatus.Text = "Done."; - 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; - } + using (var form = new MapRegionCopyResultForm(result)) + { + form.ShowDialog(this); + } + } - FileStream mIndexCopy = new FileStream(copyIndexPath, FileMode.Open, FileAccess.Read, FileShare.Read); - BinaryReader mIndexReaderCopy = new BinaryReader(mIndexCopy); + /// + /// 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); - 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; - } + var sb = new StringBuilder(); - FileStream mStaticsCopy = new FileStream(copyStaticsPath, FileMode.Open, FileAccess.Read, FileShare.Read); - BinaryReader mStaticsReaderCopy = new BinaryReader(mStaticsCopy); + 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) - { - 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(); - } - } - } + sb.AppendLine(); } - - mIndexReader.Close(); - mStaticsReader.Close(); - mIndexCopy.Close(); - mStaticsReaderCopy.Close(); } - FileSavedDialog.Show(FindForm(), Options.OutputPath, "Files saved successfully."); - } + sb.AppendLine(); + sb.AppendLine(error.GetType().FullName); - private class SupportedMaps - { - public int Id { get; } - private string Name { get; } - public int Height { get; } - public int Width { get; } + string where = error.StackTrace?.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim(); - protected SupportedMaps(int id, string name, int width, int height) + if (!string.IsNullOrEmpty(where)) { - Id = id; - Name = name; - Width = width; - Height = height; + sb.AppendLine(where); } - public override string ToString() - { - return $"{Id} - {Name} : {Width}x{Height}"; - } + 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; + + 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 From 41b55cb07af7e4cfa77b14d3c1392f3b903bc369 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:12:56 +0200 Subject: [PATCH 07/15] Add a map region preview to the copy and diff dialogs Both dialogs described the region only as coordinates, which says where it is but nothing about what is there. MapRegionPreview draws a window of a facet with the block-aligned region outlined on it, and the region can be dragged. It keeps a view of its own, an origin and a span in blocks, so the left button changes only the selection, the right button pans and the wheel zooms about the block under the pointer. Rendering goes through the existing Map.GetImage family on a worker behind a debounce. Reading a land block past the end of a short map file now returns empty rather than throwing, which is what the statics side already does and what clicking around the preview needs. --- Ultima/Statics/StaticsDefragResult.cs | 2 +- Ultima/TileMatrix.cs | 18 +- UoFiddler.Controls/Classes/FormLayout.cs | 71 ++ .../Forms/MapDiffInsertForm.Designer.cs | 85 +- UoFiddler.Controls/Forms/MapDiffInsertForm.cs | 82 ++ .../Forms/MapReplaceForm.Designer.cs | 118 ++- UoFiddler.Controls/Forms/MapReplaceForm.cs | 187 +++- .../UserControls/MapRegionPreview.Designer.cs | 43 + .../UserControls/MapRegionPreview.cs | 895 ++++++++++++++++++ .../UserControls/MapRegionPreview.resx | 60 ++ 10 files changed, 1519 insertions(+), 42 deletions(-) create mode 100644 UoFiddler.Controls/Classes/FormLayout.cs create mode 100644 UoFiddler.Controls/UserControls/MapRegionPreview.Designer.cs create mode 100644 UoFiddler.Controls/UserControls/MapRegionPreview.cs create mode 100644 UoFiddler.Controls/UserControls/MapRegionPreview.resx diff --git a/Ultima/Statics/StaticsDefragResult.cs b/Ultima/Statics/StaticsDefragResult.cs index 3ad4440d..8fc77127 100644 --- a/Ultima/Statics/StaticsDefragResult.cs +++ b/Ultima/Statics/StaticsDefragResult.cs @@ -51,7 +51,7 @@ public RejectedStaticTile(int blockX, int blockY, StaticTile tile, RejectReason public override string ToString() { return string.Format(CultureInfo.InvariantCulture, - "block {0},{1} tile 0x{2:X4} at {3},{4},{5} hue {6} - {7}", + "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); } } diff --git a/Ultima/TileMatrix.cs b/Ultima/TileMatrix.cs index ef8bc574..f6972a02 100644 --- a/Ultima/TileMatrix.cs +++ b/Ultima/TileMatrix.cs @@ -416,6 +416,11 @@ public void ReadLandBlockBytes(int x, int y, Span destination) offset = CalculateOffsetFromUOP(offset); } + if (offset < 0 || offset + destination.Length > _map.Length) + { + return; + } + _map.Seek(offset, SeekOrigin.Begin); _map.ReadExactly(destination); } @@ -460,9 +465,20 @@ private Tile[] ReadLandBlock(int x, int y) 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/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/Forms/MapDiffInsertForm.Designer.cs b/UoFiddler.Controls/Forms/MapDiffInsertForm.Designer.cs index 5e1d5f8f..eeac4c2e 100644 --- a/UoFiddler.Controls/Forms/MapDiffInsertForm.Designer.cs +++ b/UoFiddler.Controls/Forms/MapDiffInsertForm.Designer.cs @@ -54,6 +54,9 @@ private void InitializeComponent() this.label4 = new System.Windows.Forms.Label(); this.numericUpDownY2 = new System.Windows.Forms.NumericUpDown(); 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.labelStatus = new System.Windows.Forms.Label(); @@ -73,6 +76,7 @@ private void InitializeComponent() // // 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); @@ -81,7 +85,7 @@ private void InitializeComponent() 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(496, 86); + this.groupBoxWhat.Size = new System.Drawing.Size(960, 86); this.groupBoxWhat.TabIndex = 1; this.groupBoxWhat.TabStop = false; this.groupBoxWhat.Text = "Insert"; @@ -143,6 +147,7 @@ private void InitializeComponent() // // 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); @@ -153,7 +158,7 @@ private void InitializeComponent() 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(496, 60); + this.groupBoxFrom.Size = new System.Drawing.Size(960, 60); this.groupBoxFrom.TabIndex = 2; this.groupBoxFrom.TabStop = false; this.groupBoxFrom.Text = "Region, in map tiles"; @@ -228,45 +233,84 @@ private void InitializeComponent() // // 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(496, 80); + this.groupBoxPreview.Size = new System.Drawing.Size(960, 548); this.groupBoxPreview.TabIndex = 4; this.groupBoxPreview.TabStop = false; - this.groupBoxPreview.Text = "What will be inserted"; + 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, 20); + 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.Vertical; - this.textBoxPreview.Size = new System.Drawing.Size(464, 52); - this.textBoxPreview.TabIndex = 0; + 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.Location = new System.Drawing.Point(12, 258); + 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(496, 18); + 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, 281); + this.labelStatus.Location = new System.Drawing.Point(12, 749); this.labelStatus.Name = "labelStatus"; - this.labelStatus.Size = new System.Drawing.Size(496, 17); + this.labelStatus.Size = new System.Drawing.Size(960, 17); this.labelStatus.TabIndex = 6; // // buttonCopy // - this.buttonCopy.Location = new System.Drawing.Point(244, 306); + 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; @@ -276,8 +320,9 @@ private void InitializeComponent() // // 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(332, 306); + 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; @@ -287,8 +332,9 @@ private void InitializeComponent() // // 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(420, 306); + 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; @@ -307,7 +353,7 @@ private void InitializeComponent() 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(520, 346); + this.ClientSize = new System.Drawing.Size(984, 814); this.Controls.Add(this.groupBoxWhat); this.Controls.Add(this.groupBoxFrom); this.Controls.Add(this.groupBoxPreview); @@ -317,9 +363,9 @@ private void InitializeComponent() this.Controls.Add(this.buttonCancel); this.Controls.Add(this.buttonClose); this.DoubleBuffered = true; - this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; - this.MaximizeBox = false; + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.Sizable; this.MinimizeBox = false; + this.MinimumSize = new System.Drawing.Size(700, 640); this.Name = "MapDiffInsertForm"; this.ShowInTaskbar = false; this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; @@ -361,6 +407,9 @@ 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 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 c119692f..0e37d1b5 100644 --- a/UoFiddler.Controls/Forms/MapDiffInsertForm.cs +++ b/UoFiddler.Controls/Forms/MapDiffInsertForm.cs @@ -22,6 +22,7 @@ using Ultima.Maps; using Ultima.Statics; using UoFiddler.Controls.Classes; +using UoFiddler.Controls.UserControls; namespace UoFiddler.Controls.Forms { @@ -31,6 +32,9 @@ public partial class MapDiffInsertForm : Form 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(); @@ -64,6 +68,15 @@ public MapDiffInsertForm(Map currentMap) 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; @@ -124,6 +137,68 @@ private void UpdatePreview() } textBoxPreview.Text = sb.ToString(); + + if (_syncingPreview) + { + return; + } + + _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) + { + return; + } + + 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) @@ -296,6 +371,13 @@ private void SetRunning(bool 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) diff --git a/UoFiddler.Controls/Forms/MapReplaceForm.Designer.cs b/UoFiddler.Controls/Forms/MapReplaceForm.Designer.cs index 6bc44f16..2304a064 100644 --- a/UoFiddler.Controls/Forms/MapReplaceForm.Designer.cs +++ b/UoFiddler.Controls/Forms/MapReplaceForm.Designer.cs @@ -67,6 +67,12 @@ private void InitializeComponent() this.label7 = new System.Windows.Forms.Label(); this.numericUpDownToY1 = new System.Windows.Forms.NumericUpDown(); 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(); @@ -90,6 +96,7 @@ private void InitializeComponent() // // 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); @@ -99,7 +106,7 @@ private void InitializeComponent() 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(496, 122); + this.groupBoxSource.Size = new System.Drawing.Size(1440, 122); this.groupBoxSource.TabIndex = 0; this.groupBoxSource.TabStop = false; this.groupBoxSource.Text = "Copy from"; @@ -163,6 +170,7 @@ private void InitializeComponent() // // 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); @@ -171,7 +179,7 @@ private void InitializeComponent() 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(496, 86); + this.groupBoxWhat.Size = new System.Drawing.Size(1440, 86); this.groupBoxWhat.TabIndex = 1; this.groupBoxWhat.TabStop = false; this.groupBoxWhat.Text = "Copy"; @@ -233,6 +241,7 @@ private void InitializeComponent() // // 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); @@ -243,7 +252,7 @@ private void InitializeComponent() 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(496, 60); + 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"; @@ -318,13 +327,14 @@ private void InitializeComponent() // // 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.Location = new System.Drawing.Point(12, 298); this.groupBoxTo.Name = "groupBoxTo"; - this.groupBoxTo.Size = new System.Drawing.Size(496, 60); + 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"; @@ -365,45 +375,107 @@ private void InitializeComponent() // // 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(496, 80); + 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(16, 20); + 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.Vertical; - this.textBoxPreview.Size = new System.Drawing.Size(464, 52); - this.textBoxPreview.TabIndex = 0; + 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.Location = new System.Drawing.Point(12, 452); + 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(496, 18); + 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, 475); + this.labelStatus.Location = new System.Drawing.Point(12, 907); this.labelStatus.Name = "labelStatus"; - this.labelStatus.Size = new System.Drawing.Size(496, 17); + this.labelStatus.Size = new System.Drawing.Size(1440, 17); this.labelStatus.TabIndex = 6; // // buttonCopy // - this.buttonCopy.Location = new System.Drawing.Point(244, 500); + 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; @@ -413,8 +485,9 @@ private void InitializeComponent() // // 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(332, 500); + 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; @@ -424,8 +497,9 @@ private void InitializeComponent() // // 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(420, 500); + 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; @@ -444,7 +518,7 @@ private void InitializeComponent() 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(520, 540); + this.ClientSize = new System.Drawing.Size(1464, 972); this.Controls.Add(this.groupBoxSource); this.Controls.Add(this.groupBoxWhat); this.Controls.Add(this.groupBoxFrom); @@ -456,9 +530,9 @@ private void InitializeComponent() this.Controls.Add(this.buttonCancel); this.Controls.Add(this.buttonClose); this.DoubleBuffered = true; - this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; - this.MaximizeBox = false; + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.Sizable; this.MinimizeBox = false; + this.MinimumSize = new System.Drawing.Size(900, 760); this.Name = "MapReplaceForm"; this.ShowInTaskbar = false; this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; @@ -518,6 +592,12 @@ private void InitializeComponent() private System.Windows.Forms.NumericUpDown numericUpDownY2; private System.Windows.Forms.ProgressBar progressBar1; 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 1edadb0e..8a22b7aa 100644 --- a/UoFiddler.Controls/Forms/MapReplaceForm.cs +++ b/UoFiddler.Controls/Forms/MapReplaceForm.cs @@ -24,6 +24,7 @@ using Ultima.Maps; using Ultima.Statics; using UoFiddler.Controls.Classes; +using UoFiddler.Controls.UserControls; namespace UoFiddler.Controls.Forms { @@ -36,6 +37,12 @@ public partial class MapReplaceForm : Form 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; + public MapReplaceForm(Map currentMap) { InitializeComponent(); @@ -75,6 +82,20 @@ public MapReplaceForm(Map currentMap) 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(); + OnSourceMapChanged(this, EventArgs.Empty); OnOptionChanged(this, EventArgs.Empty); @@ -83,6 +104,52 @@ public MapReplaceForm(Map currentMap) 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; + } + // ---- source selection ------------------------------------------------------------------ private void OnClickBrowse(object sender, EventArgs e) @@ -154,6 +221,7 @@ private void Detect() if (map == null || !Directory.Exists(textBoxFolder.Text)) { + RebuildSourceMap(null); UpdatePreview(); return; @@ -161,6 +229,8 @@ private void Detect() _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) @@ -235,22 +305,130 @@ private void UpdatePreview() var sb = new StringBuilder(); - sb.AppendLine($"from {source}"); - sb.AppendLine($"to {destination}"); + // 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, - "the request {0},{1} - {2},{3} was widened to whole 8-tile blocks", x1, y1, x2, y2)); + "{0},{1} - {2},{3} widened to whole blocks", x1, y1, x2, y2)); } textBoxPreview.Text = sb.ToString(); + + if (_syncingPreview) + { + return; + } + + _syncingPreview = true; + + try + { + previewSource.Selection = source; + previewTarget.Selection = destination; + previewTarget.OverlaySelection = source; + } + finally + { + _syncingPreview = false; + } } // ---- running --------------------------------------------------------------------------- + /// + /// 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) + { + return; + } + + 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) + { + return; + } + + BlockRectangle selection = previewTarget.Selection; + + _syncingPreview = true; + + try + { + numericUpDownToX1.Value = Clamp(numericUpDownToX1, selection.TileX1); + numericUpDownToY1.Value = Clamp(numericUpDownToY1, selection.TileY1); + } + 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) @@ -491,6 +669,9 @@ protected override void OnFormClosed(FormClosedEventArgs e) _cancellation?.Dispose(); _cancellation = null; + _sourceMap?.Tiles.CloseStreams(); + _sourceMap = null; + base.OnFormClosed(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 From b751a1a0cb031a0bfe34d28a89c9d0b889d3a563 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:12:56 +0200 Subject: [PATCH 08/15] Let a copied region be moved in z A region taken from one facet rarely sits at the right height in another. A Z adjust beside the paste position shifts the land and the statics of the region by the same amount and touches nothing outside it. Both heights are a signed byte, so the dialog surveys the region on a worker and says what the shift makes of it, and the copy refuses one that does not fit, naming how far the region can actually move. A checkbox clamps at the limit instead, and verification compares against the source shifted by the same amount. The diff dialog deliberately has none of this: its records are cut against the facet they belong to. --- Ultima/Maps/MapRegionCopy.cs | 178 +++++++++++- Ultima/Maps/MapRegionZ.cs | 246 +++++++++++++++++ .../Forms/MapRegionCopyResultForm.cs | 24 ++ .../Forms/MapReplaceForm.Designer.cs | 51 ++++ UoFiddler.Controls/Forms/MapReplaceForm.cs | 256 +++++++++++++++++- 5 files changed, 747 insertions(+), 8 deletions(-) create mode 100644 Ultima/Maps/MapRegionZ.cs diff --git a/Ultima/Maps/MapRegionCopy.cs b/Ultima/Maps/MapRegionCopy.cs index 8be7577f..009bc26e 100644 --- a/Ultima/Maps/MapRegionCopy.cs +++ b/Ultima/Maps/MapRegionCopy.cs @@ -122,6 +122,18 @@ public sealed class MapRegionCopyOptions /// 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; } @@ -175,6 +187,15 @@ public sealed class MapRegionCopyResult : IStaticsFilterStats 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; } @@ -249,6 +270,13 @@ public string ToReport() } 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(); @@ -262,6 +290,13 @@ public string ToReport() { 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) @@ -271,6 +306,13 @@ public string ToReport() 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", @@ -355,6 +397,13 @@ public static MapRegionCopyResult Run(MapRegionCopyOptions options) 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); @@ -370,7 +419,116 @@ public static MapRegionCopyResult Run(MapRegionCopyOptions options) return result; } - // ---- geometry -------------------------------------------------------------------------- + /// + /// 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) @@ -437,8 +595,6 @@ private static void Require(bool condition, string message) } } - // ---- land ------------------------------------------------------------------------------ - private static void CopyLand(MapRegionCopyOptions options, MapRegionCopyResult result, MapSize source, MapSize destination) { @@ -470,6 +626,11 @@ private static void CopyLand(MapRegionCopyOptions options, MapRegionCopyResult r y - result.DestinationRegion.BlockY1 + result.Source.BlockY1, block); + if (options.ZAdjust != 0) + { + result.LandZClamped += ShiftLandZ(block, options.ZAdjust); + } + ++result.LandBlocksCopied; } else @@ -530,8 +691,6 @@ private static int SanitizeLandIds(Span block) return reset; } - // ---- statics --------------------------------------------------------------------------- - private static void CopyStatics(MapRegionCopyOptions options, MapRegionCopyResult result, MapSize source, MapSize destination) { @@ -599,6 +758,13 @@ private static void CopyStatics(MapRegionCopyOptions options, MapRegionCopyResul 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) @@ -644,8 +810,6 @@ private static void CopyStatics(MapRegionCopyOptions options, MapRegionCopyResul } } - // ---- helpers --------------------------------------------------------------------------- - private static bool InRegion(BlockRectangle region, int x, int y) { return x >= region.BlockX1 && x <= region.BlockX2 && y >= region.BlockY1 && y <= region.BlockY2; 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/UoFiddler.Controls/Forms/MapRegionCopyResultForm.cs b/UoFiddler.Controls/Forms/MapRegionCopyResultForm.cs index bfa87938..2ec9e964 100644 --- a/UoFiddler.Controls/Forms/MapRegionCopyResultForm.cs +++ b/UoFiddler.Controls/Forms/MapRegionCopyResultForm.cs @@ -102,6 +102,13 @@ private string Verify() 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 { @@ -129,6 +136,11 @@ private string Verify() 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) @@ -152,6 +164,18 @@ private string Verify() 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 diff --git a/UoFiddler.Controls/Forms/MapReplaceForm.Designer.cs b/UoFiddler.Controls/Forms/MapReplaceForm.Designer.cs index 2304a064..6383be9e 100644 --- a/UoFiddler.Controls/Forms/MapReplaceForm.Designer.cs +++ b/UoFiddler.Controls/Forms/MapReplaceForm.Designer.cs @@ -66,6 +66,10 @@ private void InitializeComponent() this.numericUpDownToX1 = new System.Windows.Forms.NumericUpDown(); this.label7 = 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(); @@ -87,6 +91,7 @@ private void InitializeComponent() ((System.ComponentModel.ISupportInitialize)(this.numericUpDownY2)).BeginInit(); ((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(); @@ -332,6 +337,10 @@ private void InitializeComponent() 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); @@ -373,6 +382,43 @@ private void InitializeComponent() 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))); @@ -543,6 +589,7 @@ private void InitializeComponent() ((System.ComponentModel.ISupportInitialize)(this.numericUpDownY2)).EndInit(); ((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); @@ -568,6 +615,10 @@ private void InitializeComponent() private System.Windows.Forms.ComboBox comboBoxMapFormat; private System.Windows.Forms.ComboBox comboBoxMapID; 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; diff --git a/UoFiddler.Controls/Forms/MapReplaceForm.cs b/UoFiddler.Controls/Forms/MapReplaceForm.cs index 8a22b7aa..8fe00d5c 100644 --- a/UoFiddler.Controls/Forms/MapReplaceForm.cs +++ b/UoFiddler.Controls/Forms/MapReplaceForm.cs @@ -43,6 +43,21 @@ public partial class MapReplaceForm : Form /// 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(); @@ -96,6 +111,10 @@ public MapReplaceForm(Map currentMap) groupBoxPreview.SizeChanged += (sender, e) => LayoutPreviewPanels(); LayoutPreviewPanels(); + _zDebounce.Tick += OnZDebounceTick; + _zWorker.DoWork += OnZWorkerDoWork; + _zWorker.RunWorkerCompleted += OnZWorkerCompleted; + OnSourceMapChanged(this, EventArgs.Empty); OnOptionChanged(this, EventArgs.Empty); @@ -266,11 +285,46 @@ 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 UpdatePreview() + private void UpdatePreviewRectangles() { SupportedMap map = SelectedMap; @@ -424,6 +478,197 @@ private void OnTargetPreviewChanged(object sender, EventArgs e) UpdatePreview(); } + // ---- heights --------------------------------------------------------------------------- + + 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; + } + + var region = new BlockRectangle( + (int)numericUpDownX1.Value >> 3, (int)numericUpDownY1.Value >> 3, + (int)numericUpDownX2.Value >> 3, (int)numericUpDownY2.Value >> 3); + + var request = new ZSurveyRequest + { + 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; + } + + _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) + { + return; + } + + if (_zWorker.IsBusy) + { + // Whatever is running is for a region nobody is asking about any more. + _zCancellation?.Cancel(); + _zDebounce.Start(); + + return; + } + + _zCancellation?.Dispose(); + _zCancellation = new CancellationTokenSource(); + _zPending.CancellationToken = _zCancellation.Token; + + _zWorker.RunWorkerAsync(_zPending); + } + + private static void OnZWorkerDoWork(object sender, DoWorkEventArgs e) + { + var request = (ZSurveyRequest)e.Argument; + + 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) + }; + } + + private void OnZWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) + { + if (IsDisposed) + { + return; + } + + if (e.Error is OperationCanceledException) + { + // Cancelled because the region moved. Whatever replaced it is already queued. + _zDebounce.Start(); + + return; + } + + if (e.Error != null) + { + labelZRange.ForeColor = Options.DarkMode ? Color.OrangeRed : Color.Red; + labelZRange.Text = "the heights in this region could not be read: " + e.Error.Message; + + return; + } + + if (e.Result is ZSurveyAnswer answer) + { + _zSurvey = answer.Survey; + _zSurveyKey = answer.Key; + } + + if (_zPending != null && _zPending.Key != _zSurveyKey) + { + // The region moved while that was in flight. + _zDebounce.Start(); + + return; + } + + ShowZ(); + } + + /// Says what the region's z is, and what the adjustment does to it. + private void ShowZ() + { + if (_zSurvey == null) + { + return; + } + + 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) + { + sb.Append(CultureInfo.InvariantCulture, + $" -> land {Span(_zSurvey.Land, adjust)}, statics {Span(_zSurvey.Statics, adjust)}"); + } + + long past = _zSurvey.OutOfRange(adjust); + + if (past == 0) + { + labelZRange.ForeColor = SystemColors.ControlText; + labelZRange.Text = sb.ToString(); + + 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(); + } + + private static string Span(ZHistogram z, int adjust) + { + if (!z.HasTiles) + { + return "none"; + } + + 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)); + } + private static decimal Clamp(NumericUpDown control, int value) { return Math.Clamp(value, (int)control.Minimum, (int)control.Maximum); @@ -509,6 +754,8 @@ private MapRegionCopyOptions BuildOptions(SupportedMap map) CopyLand = checkBoxMap.Checked, CopyStatics = checkBoxStatics.Checked, MapFormat = ResolveFormat(), + ZAdjust = ZAdjust, + ZOverflow = checkBoxZClamp.Checked ? ZOverflowAction.Clamp : ZOverflowAction.Refuse, OutputDirectory = Options.OutputPath }; @@ -669,6 +916,13 @@ protected override void OnFormClosed(FormClosedEventArgs e) _cancellation?.Dispose(); _cancellation = null; + _zDebounce.Stop(); + _zDebounce.Dispose(); + _zCancellation?.Cancel(); + _zCancellation?.Dispose(); + _zCancellation = null; + _zWorker.Dispose(); + _sourceMap?.Tiles.CloseStreams(); _sourceMap = null; From c22ff74bf6933124c70f707505ab83c34f21283d Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:12:57 +0200 Subject: [PATCH 09/15] Let a profile choose whether a save writes mul or uop Art, gumpart, sound, MultiCollection and maps all ship in either container, but only the map tools ever offered the choice, so saving on a modern client produced files that client does not read. SaveFormat is a per-profile option - FollowSource, Mul, Uop or Ask - answered per file, since a client can be UOP art and MUL gumps. ClientFileSaver writes the mul into a temporary folder and packs it, so no domain class learns about UOP. MultiCollection needs the client housing.bin and the per-tile component ids that live only in the UOP, so it unpacks the loaded container first and refuses when there is none. --- Ultima/Uop/ClientFileSaver.cs | 286 ++++++++++++++++++ Ultima/Uop/MultiComponentSidecar.cs | 87 ++++-- Ultima/Uop/UopFileNames.cs | 82 +++++ .../Classes/ClientFileSaveCommand.cs | 221 ++++++++++++++ .../Classes/ClientFileSaveFormat.cs | 78 +++++ UoFiddler.Controls/Classes/Options.cs | 6 + .../Classes/SaveFormatResolver.cs | 72 +++++ UoFiddler.Controls/Forms/MapDiffInsertForm.cs | 2 +- UoFiddler.Controls/Forms/MapReplaceForm.cs | 10 +- .../Forms/SaveFormatDialog.Designer.cs | 249 +++++++++++++++ UoFiddler.Controls/Forms/SaveFormatDialog.cs | 78 +++++ .../Forms/SaveFormatDialog.resx | 120 ++++++++ .../UserControls/GumpControl.cs | 12 +- .../UserControls/ItemsControl.cs | 13 +- .../UserControls/LandTilesControl.cs | 8 +- .../UserControls/MultisControl.cs | 6 +- .../UserControls/SoundsControl.cs | 10 +- .../Forms/MassImportForm.cs | 36 ++- .../UserControls/UopPackerControl.cs | 39 +-- UoFiddler/Classes/FiddlerOptions.cs | 12 + UoFiddler/Forms/OptionsForm.Designer.cs | 38 ++- UoFiddler/Forms/OptionsForm.cs | 12 + UoFiddler/Options_default.xml | 2 + 23 files changed, 1376 insertions(+), 103 deletions(-) create mode 100644 Ultima/Uop/ClientFileSaver.cs create mode 100644 Ultima/Uop/UopFileNames.cs create mode 100644 UoFiddler.Controls/Classes/ClientFileSaveCommand.cs create mode 100644 UoFiddler.Controls/Classes/ClientFileSaveFormat.cs create mode 100644 UoFiddler.Controls/Classes/SaveFormatResolver.cs create mode 100644 UoFiddler.Controls/Forms/SaveFormatDialog.Designer.cs create mode 100644 UoFiddler.Controls/Forms/SaveFormatDialog.cs create mode 100644 UoFiddler.Controls/Forms/SaveFormatDialog.resx 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/Ultima/Uop/MultiComponentSidecar.cs b/Ultima/Uop/MultiComponentSidecar.cs index 50648964..c1a9bcb9 100644 --- a/Ultima/Uop/MultiComponentSidecar.cs +++ b/Ultima/Uop/MultiComponentSidecar.cs @@ -25,16 +25,10 @@ namespace Ultima.Uop /// 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; } } 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/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/Forms/MapDiffInsertForm.cs b/UoFiddler.Controls/Forms/MapDiffInsertForm.cs index 0e37d1b5..65b60a83 100644 --- a/UoFiddler.Controls/Forms/MapDiffInsertForm.cs +++ b/UoFiddler.Controls/Forms/MapDiffInsertForm.cs @@ -52,7 +52,7 @@ public MapDiffInsertForm(Map currentMap) : "the same format as this client (.mul)"); comboBoxMapFormat.Items.Add($"map{_workingMap.FileIndex}.mul"); comboBoxMapFormat.Items.Add($"map{_workingMap.FileIndex}LegacyMUL.uop"); - comboBoxMapFormat.SelectedIndex = 0; + comboBoxMapFormat.SelectedIndex = ClientFileSaveFormats.DefaultIndex(Options.SaveFormat); checkBoxMap.Text = "Map"; checkBoxStatics.Text = "Statics"; diff --git a/UoFiddler.Controls/Forms/MapReplaceForm.cs b/UoFiddler.Controls/Forms/MapReplaceForm.cs index 8fe00d5c..4c6c7674 100644 --- a/UoFiddler.Controls/Forms/MapReplaceForm.cs +++ b/UoFiddler.Controls/Forms/MapReplaceForm.cs @@ -87,7 +87,7 @@ public MapReplaceForm(Map currentMap) : "the same format as this client (.mul)"); comboBoxMapFormat.Items.Add($"map{_workingMap.FileIndex}.mul"); comboBoxMapFormat.Items.Add($"map{_workingMap.FileIndex}LegacyMUL.uop"); - comboBoxMapFormat.SelectedIndex = 0; + comboBoxMapFormat.SelectedIndex = ClientFileSaveFormats.DefaultIndex(Options.SaveFormat); checkBoxMap.Checked = true; checkBoxStatics.Checked = true; @@ -169,8 +169,6 @@ private void LayoutPreviewPanels() textBoxPreview.Width = width; } - // ---- source selection ------------------------------------------------------------------ - private void OnClickBrowse(object sender, EventArgs e) { using (var dialog = new FolderBrowserDialog @@ -268,8 +266,6 @@ private void Detect() UpdatePreview(); } - // ---- preview --------------------------------------------------------------------------- - private void OnOptionChanged(object sender, EventArgs e) { comboBoxMapFormat.Enabled = checkBoxMap.Checked; @@ -394,8 +390,6 @@ private void UpdatePreviewRectangles() } } - // ---- running --------------------------------------------------------------------------- - /// /// 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. @@ -478,8 +472,6 @@ private void OnTargetPreviewChanged(object sender, EventArgs e) UpdatePreview(); } - // ---- heights --------------------------------------------------------------------------- - private int ZAdjust => (int)numericUpDownZ.Value; /// 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/UserControls/GumpControl.cs b/UoFiddler.Controls/UserControls/GumpControl.cs index 8cd8f5c5..89a34b62 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; @@ -651,15 +652,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.cs b/UoFiddler.Controls/UserControls/ItemsControl.cs index b5a4069b..945fcc8d 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; @@ -781,16 +782,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) diff --git a/UoFiddler.Controls/UserControls/LandTilesControl.cs b/UoFiddler.Controls/UserControls/LandTilesControl.cs index 8a16c8aa..527b37fb 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; @@ -636,12 +637,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) 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.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 29e5fcb5..111783be 100644 --- a/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs +++ b/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs @@ -117,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(); @@ -147,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; @@ -171,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"; @@ -193,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. @@ -305,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; @@ -450,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/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 @@ + + From fd86ee6b5881b4ba5840b3efaea6c2742a4a8351 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:12:57 +0200 Subject: [PATCH 10/15] Stop an unused idx row looking like a verdata patch An unused idx row carries lookup 0 and length -1, and -1 has the same high bit that marks a verdata patch, so every one of those rows looked like a patch of length 0x7FFFFFFF. Browsing the textures of a 2005 client then read that many bytes out of Stream.Null and threw from a paint handler. The bit test now also requires the length not to be -1; a real patch never reaches 0x7FFFFFFF, so the two cannot be confused. Gumps had its own copy of the test. Textures.GetTexture also sized its read from the entry 64/128 flag without checking the declared length, so a truncated file over-read the same way. --- Ultima/FileIndex.cs | 22 +++++++++++++++++++--- Ultima/Gumps.cs | 3 ++- Ultima/Textures.cs | 8 ++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) 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/Gumps.cs b/Ultima/Gumps.cs index 6f7442f3..7fff732b 100644 --- a/Ultima/Gumps.cs +++ b/Ultima/Gumps.cs @@ -206,7 +206,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) { diff --git a/Ultima/Textures.cs b/Ultima/Textures.cs index f4e80ffb..454ffb5d 100644 --- a/Ultima/Textures.cs +++ b/Ultima/Textures.cs @@ -133,6 +133,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 { From 51c02e9fb1fb207ecd0ea66227c78a4b85fba3e5 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:12:58 +0200 Subject: [PATCH 11/15] Let tiledata be edited for many entries at once Giving a set of items the same flags meant exporting to CSV, editing it elsewhere and importing it back. Both tiledata lists are now multi-select and the editor pane gains a leave-this-alone state: it blanks every box the selection disagrees on, greys every flag that is not uniformly set or clear, and Save Changes writes only what was actually edited. Also adds Copy tile data and Paste special, and a one-level undo of the last bulk apply, both through one apply engine. Fixes a bug in the code this replaces: OnFlagItemCheckLandTiles mapped the checkbox index through a stale switch, so toggling a land flag flipped the wrong bit. --- .../Classes/TileDataBulkEdit.cs | 311 +++++ .../Classes/TileDataBulkUndo.cs | 74 ++ UoFiddler.Controls/Forms/TileDataHelpForm.cs | 25 + .../TileDataPasteSpecialForm.Designer.cs | 371 ++++++ .../Forms/TileDataPasteSpecialForm.cs | 428 +++++++ .../UserControls/ItemsControl.cs | 12 +- .../UserControls/LandTilesControl.cs | 12 +- .../UserControls/TileDataControl.Designer.cs | 121 +- .../UserControls/TileDataControl.cs | 1077 +++++++++++++++-- 9 files changed, 2351 insertions(+), 80 deletions(-) create mode 100644 UoFiddler.Controls/Classes/TileDataBulkEdit.cs create mode 100644 UoFiddler.Controls/Classes/TileDataBulkUndo.cs create mode 100644 UoFiddler.Controls/Forms/TileDataPasteSpecialForm.Designer.cs create mode 100644 UoFiddler.Controls/Forms/TileDataPasteSpecialForm.cs 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/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/UserControls/ItemsControl.cs b/UoFiddler.Controls/UserControls/ItemsControl.cs index 945fcc8d..e204829b 100644 --- a/UoFiddler.Controls/UserControls/ItemsControl.cs +++ b/UoFiddler.Controls/UserControls/ItemsControl.cs @@ -903,7 +903,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); } @@ -1220,6 +1227,9 @@ private void TileViewContextMenuStrip_Opening(object sender, CancelEventArgs e) 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) { diff --git a/UoFiddler.Controls/UserControls/LandTilesControl.cs b/UoFiddler.Controls/UserControls/LandTilesControl.cs index 527b37fb..74198626 100644 --- a/UoFiddler.Controls/UserControls/LandTilesControl.cs +++ b/UoFiddler.Controls/UserControls/LandTilesControl.cs @@ -720,7 +720,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); } @@ -754,6 +761,9 @@ private void LandTilesContextMenuStrip_Opening(object sender, System.ComponentMo 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 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) { From 5090ea899ec135c6ee3037af015a1dadf7965f6a Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:12:58 +0200 Subject: [PATCH 12/15] Let art move through the clipboard, and mark what was edited Getting a graphic in or out of the Items, Land Tiles, Gumps or Textures tab meant a file dialog either way. All four tabs now copy and paste images: copy puts both a DIB and a PNG on the clipboard, PNG being the only flavour that carries alpha, and paste reads PNG first, then the DIB, then a file. An image with a real alpha channel is taken at its word; one without still goes through ConvertBmp, so paste and Replace agree. Wrong sizes are refused rather than fitted. The second half is telling afterwards what changed. Art, Gumps and Textures now mark every Replace and Remove, clear the marks on reload and after save, and the draw handlers paint a corner wedge over a marked entry. --- Ultima/Art.cs | 41 +++ Ultima/Gumps.cs | 29 ++ Ultima/ModifiedIndexTracker.cs | 55 ++++ Ultima/Textures.cs | 30 ++ UoFiddler.Controls/Classes/ImageClipboard.cs | 276 ++++++++++++++++++ UoFiddler.Controls/Classes/ModifiedMarker.cs | 51 ++++ UoFiddler.Controls/Classes/Utils.cs | 95 ++++++ .../UserControls/GumpControl.Designer.cs | 30 +- .../UserControls/GumpControl.cs | 81 +++++ .../UserControls/ItemsControl.Designer.cs | 59 +++- .../UserControls/ItemsControl.cs | 116 ++++++++ .../UserControls/LandTilesControl.Designer.cs | 29 +- .../UserControls/LandTilesControl.cs | 115 +++++++- .../UserControls/TexturesControl.Designer.cs | 29 +- .../UserControls/TexturesControl.cs | 115 ++++++++ 15 files changed, 1144 insertions(+), 7 deletions(-) create mode 100644 Ultima/ModifiedIndexTracker.cs create mode 100644 UoFiddler.Controls/Classes/ImageClipboard.cs create mode 100644 UoFiddler.Controls/Classes/ModifiedMarker.cs 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/Gumps.cs b/Ultima/Gumps.cs index 7fff732b..e858ee67 100644 --- a/Ultima/Gumps.cs +++ b/Ultima/Gumps.cs @@ -35,6 +35,8 @@ 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; @@ -114,6 +116,7 @@ public static void Reload() _streamBuffer = null; //_colorTable = null; _patched.Clear(); + _modified.Clear(); } public static int GetCount() @@ -133,6 +136,7 @@ public static void ReplaceGump(int index, Bitmap bmp) _removed[index] = false; _patched.Remove(index); _contentState[index] = _contentPresent; + _modified.Mark(index); } /// @@ -142,6 +146,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(); } /// @@ -1354,6 +1381,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/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/Textures.cs b/Ultima/Textures.cs index 454ffb5d..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(); } /// @@ -276,6 +304,8 @@ public static unsafe void Save(string path) } memIdx.Dispose(); + + _modified.Clear(); } private static int GetExtraFlag(int length) 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/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/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 89a34b62..64b76262 100644 --- a/UoFiddler.Controls/UserControls/GumpControl.cs +++ b/UoFiddler.Controls/UserControls/GumpControl.cs @@ -498,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 { @@ -602,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) 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 e204829b..3f9b1cf7 100644 --- a/UoFiddler.Controls/UserControls/ItemsControl.cs +++ b/UoFiddler.Controls/UserControls/ItemsControl.cs @@ -508,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) @@ -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,9 +1313,18 @@ 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..."; @@ -1488,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 74198626..db81f395 100644 --- a/UoFiddler.Controls/UserControls/LandTilesControl.cs +++ b/UoFiddler.Controls/UserControls/LandTilesControl.cs @@ -44,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(); @@ -384,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) @@ -758,6 +847,9 @@ 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"; @@ -850,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; @@ -888,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; } } @@ -1200,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/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"; From 7a4b8fc79e6a186d950d2a991db18e482c6db004 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:12:58 +0200 Subject: [PATCH 13/15] Fix hued gump decoding on UOP clients The hued GetGump overload was the last caller of the legacy FileIndex.Seek, so on a client with gumpartlegacymul.uop it read the compressed payload as raw RLE, derived garbage row offsets and walked the pixel pointer off the buffer. That surfaces as an AccessViolationException, which takes the process down rather than reaching the exception handler. It now decodes through the regular GetGump path, which already covers the index, removed, replaced and decompression checks, and hues a private clone. That retires a static pixel buffer the old decoder returned a Bitmap over, which the following call would overwrite. IsValidIndex and GetGump also guard against a negative index. --- Ultima/Gumps.cs | 168 +++++------------------------------------------- 1 file changed, 15 insertions(+), 153 deletions(-) diff --git a/Ultima/Gumps.cs b/Ultima/Gumps.cs index e858ee67..51c0df3c 100644 --- a/Ultima/Gumps.cs +++ b/Ultima/Gumps.cs @@ -38,9 +38,6 @@ public sealed class Gumps // 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 @@ -112,9 +109,6 @@ public static void Reload() _contentState = new byte[_indexLength]; } - //_pixelBuffer = null; - _streamBuffer = null; - //_colorTable = null; _patched.Clear(); _modified.Clear(); } @@ -183,7 +177,7 @@ public static bool IsValidIndex(int index) return false; } - if (index > _indexLength - 1) + if (index < 0 || index > _indexLength - 1) { return false; } @@ -465,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); - - if (stream == null) - { - return null; - } + // 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 (extra == -1) + if (gump == null) { return null; } - int width = (extra >> 16) & 0xFFFF; - int height = extra & 0xFFFF; - - if (width <= 0 || height <= 0) - { - return null; - } - - 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) + if (hue == null) { - _colorTable = colorTable = new byte[128]; + return gump; } - 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; - - 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]; - } + hue.ApplyTo(hued, onlyHueGrayPixels); - while (pPixel < pRleEnd) - { - *pPixel++ = color; - } - } - - pPixel += pixelsPerStrideDelta; - pPixelEnd += pixelsPerStride; - } - } - - return new Bitmap(width, height, bytesPerStride, PixelFormat.Format16bppArgb1555, (IntPtr)pPixelDataStart); - } - } - } - } + return hued; } /// @@ -850,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; } From c1439c8eaf6ac4741cd552425320b53a0566fa32 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:12:59 +0200 Subject: [PATCH 14/15] Read the variable length AnimationSequence group records ParseSequenceEntry walked each body group records with a fixed 72 byte stride and skipped the walk outright when the group count was 48 or 68. Both are wrong, and they hide each other: 72 bytes is only the record with both trailing lists empty, and 48 and 68 are the counts of the bodies whose records are not. A record is 64 bytes of fixed fields followed by a counted list of 32 byte property records and a counted list of ints, each count checked against the bytes remaining before it is used. On 7.0.114.4 the bodies with aliases go from 383 to 385: body 666 gains 67->66, and body 1253 gains 2->0, 3->1 and 42->38. --- Ultima/AnimationsUopLoader.cs | 102 ++++++++++++++++++++++++++++------ 1 file changed, 85 insertions(+), 17 deletions(-) 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) From cd6bf88b59810c7bf02c65deba30dba250b4973b Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:25:59 +0200 Subject: [PATCH 15/15] Update change log and version Bump UoFiddler to 4.24.0 and add the change log entry for this release: the statics defrag rewrite, the map copy and diff work with the region preview, the per-profile save format, and the texture, gump and AnimationSequence fixes. --- UoFiddler/Forms/AboutBoxForm.resx | 16 +++++++++++++++- UoFiddler/UoFiddler.csproj | 6 +++--- 2 files changed, 18 insertions(+), 4 deletions(-) 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/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