diff --git a/Ultima/AnimationsUopLoader.cs b/Ultima/AnimationsUopLoader.cs
index e7f4a78e..c2a2dac7 100644
--- a/Ultima/AnimationsUopLoader.cs
+++ b/Ultima/AnimationsUopLoader.cs
@@ -22,6 +22,15 @@ internal static class AnimationsUopLoader
internal const int _maxAnimActions = 80;
private const int _maxDirections = 5;
+ ///
+ /// Size of the fixed part of one AnimationSequence group record: the four leading fields,
+ /// the per frame bytes and the reserved block. Two variable length lists follow it.
+ ///
+ private const int _sequenceGroupFixedSize = 64;
+
+ /// Size of one property record hanging off a group: six ints, two shorts and a float.
+ private const int _sequenceGroupPropSize = 32;
+
private static FileStream[] _uopFiles = new FileStream[6];
private static readonly Dictionary _hashTable = new();
private static readonly Dictionary _sequenceReplacements = new();
@@ -258,6 +267,21 @@ private static void LoadAnimationSequence()
}
}
+ ///
+ /// Builds the action replacement table for one body from its AnimationSequence entry.
+ ///
+ ///
+ /// Group records are variable length. The 72 byte stride this used to assume is only the
+ /// degenerate case where both trailing lists are empty: the fixed part is 64 bytes and the
+ /// two counted lists add 8 more when both counts are zero. Three bodies of a shipped client
+ /// - 400, 666 and 1253 in 7.0.114.4 - carry property records and are longer than that.
+ ///
+ /// The walk also used to be skipped outright when the group count was 48 or 68. Those are
+ /// legitimate counts, not sentinels - they are simply the counts of the bodies that carry
+ /// property records, which is what a fixed stride cannot walk. Reading them properly means
+ /// body 666 resolves 67 to 66, and body 1253 resolves 2 to 0, 3 to 1 and 42 to 38.
+ ///
+ ///
private static void ParseSequenceEntry(int animId, byte[] data)
{
if (data.Length < 56)
@@ -271,7 +295,7 @@ private static void ParseSequenceEntry(int animId, byte[] data)
binaryReader.ReadUInt32(); // animId stored in file
binaryReader.BaseStream.Seek(48, SeekOrigin.Current); // skip 12 × u32
- int replaces = binaryReader.ReadInt32();
+ int groupCount = binaryReader.ReadInt32();
var replacements = new int[_maxAnimActions];
for (int i = 0; i < _maxAnimActions; i++)
@@ -279,29 +303,73 @@ private static void ParseSequenceEntry(int animId, byte[] data)
replacements[i] = i;
}
- if (replaces != 48 && replaces != 68)
+ for (int i = 0; i < groupCount; i++)
{
- for (int k = 0; k < replaces; k++)
+ if (!ReadSequenceGroup(binaryReader, replacements))
{
- if (binaryReader.BaseStream.Position + 72 > binaryReader.BaseStream.Length)
- {
- break;
- }
+ break;
+ }
+ }
- int oldGroup = binaryReader.ReadInt32();
- uint frameCount = binaryReader.ReadUInt32();
- int newGroup = binaryReader.ReadInt32();
+ _sequenceReplacements[animId] = replacements;
+ }
- if (frameCount == 0 && oldGroup >= 0 && oldGroup < _maxAnimActions && newGroup >= 0)
- {
- replacements[oldGroup] = newGroup;
- }
+ ///
+ /// Reads one group record, recording the alias it declares.
+ ///
+ ///
+ /// A replacement group of -1 means the body has its own frames for that group; any other
+ /// value borrows another group's, and then the frame count is zero.
+ ///
+ /// False when the record runs past the payload, which stops the walk.
+ private static bool ReadSequenceGroup(BinaryReader reader, int[] replacements)
+ {
+ Stream stream = reader.BaseStream;
- binaryReader.BaseStream.Seek(60, SeekOrigin.Current); // skip remaining per-replacement fields
- }
+ if (stream.Position + _sequenceGroupFixedSize > stream.Length)
+ {
+ return false;
}
- _sequenceReplacements[animId] = replacements;
+ int group = reader.ReadInt32();
+ int frameCount = reader.ReadInt32();
+ int replacementGroup = reader.ReadInt32();
+
+ // The frame rate, the per frame bytes and the reserved ints that make up the rest of the
+ // fixed part carry nothing the replacement table needs.
+ stream.Seek(_sequenceGroupFixedSize - (3 * sizeof(int)), SeekOrigin.Current);
+
+ if (frameCount == 0 && group >= 0 && group < _maxAnimActions && replacementGroup >= 0)
+ {
+ replacements[group] = replacementGroup;
+ }
+
+ return TrySkipList(reader, _sequenceGroupPropSize) && TrySkipList(reader, sizeof(int));
+ }
+
+ ///
+ /// Skips one counted list, checking the count against the bytes left before seeking past it,
+ /// so a malformed entry cannot walk the reader off the payload.
+ ///
+ private static bool TrySkipList(BinaryReader reader, int itemSize)
+ {
+ Stream stream = reader.BaseStream;
+
+ if (stream.Position + sizeof(int) > stream.Length)
+ {
+ return false;
+ }
+
+ int count = reader.ReadInt32();
+
+ if (count < 0 || (long)count * itemSize > stream.Length - stream.Position)
+ {
+ return false;
+ }
+
+ stream.Seek((long)count * itemSize, SeekOrigin.Current);
+
+ return true;
}
public static bool IsUopBody(int body)
diff --git a/Ultima/Art.cs b/Ultima/Art.cs
index 8fe235f3..88f51675 100644
--- a/Ultima/Art.cs
+++ b/Ultima/Art.cs
@@ -23,6 +23,9 @@ public static class Art
private static bool[] _removed;
private static readonly Dictionary _patched = new Dictionary();
public static bool Modified;
+ // Indexes edited since load or since the last save, in the same combined index space the
+ // Replace/Remove methods use (land = index & 0x3FFF, static = legal item id + 0x4000).
+ private static readonly ModifiedIndexTracker _modified = new ModifiedIndexTracker();
private static readonly byte[] _validBuffer = new byte[4];
@@ -180,6 +183,7 @@ public static void Reload()
_replaced.Clear();
_removed = new bool[0x14000];
_patched.Clear();
+ _modified.Clear();
Modified = false;
}
@@ -208,6 +212,7 @@ public static void ReplaceStatic(int index, Bitmap bmp)
_patched.Remove(index);
+ _modified.Mark(index);
Modified = true;
}
@@ -225,6 +230,7 @@ public static void ReplaceLand(int index, Bitmap bmp)
_patched.Remove(index);
+ _modified.Mark(index);
Modified = true;
}
@@ -237,6 +243,7 @@ public static void RemoveStatic(int index)
index = GetLegalItemId(index);
index += 0x4000;
_removed[index] = true;
+ _modified.Mark(index);
Modified = true;
}
@@ -248,9 +255,41 @@ public static void RemoveLand(int index)
{
index &= 0x3FFF;
_removed[index] = true;
+ _modified.Mark(index);
Modified = true;
}
+ ///
+ /// Tests if the Static at was replaced or removed since the art was
+ /// loaded or last saved.
+ ///
+ public static bool IsStaticModified(int index)
+ {
+ return _modified.IsMarked(GetLegalItemId(index) + 0x4000);
+ }
+
+ ///
+ /// Tests if the Land tile at was replaced or removed since the art was
+ /// loaded or last saved.
+ ///
+ public static bool IsLandModified(int index)
+ {
+ return _modified.IsMarked(index & 0x3FFF);
+ }
+
+ ///
+ /// Number of Land tiles and Statics edited since the art was loaded or last saved.
+ ///
+ public static int ModifiedCount => _modified.Count;
+
+ ///
+ /// Drops every modified mark without touching the edits themselves.
+ ///
+ public static void ClearModified()
+ {
+ _modified.Clear();
+ }
+
///
/// Tests if Static is defined (width and height check)
///
@@ -1094,6 +1133,8 @@ public static unsafe void Save(string path)
memmul.WriteTo(fsmul);
}
}
+
+ _modified.Clear();
}
}
diff --git a/Ultima/FileIndex.cs b/Ultima/FileIndex.cs
index c5c43ea4..fafca347 100644
--- a/Ultima/FileIndex.cs
+++ b/Ultima/FileIndex.cs
@@ -249,6 +249,22 @@ public FileIndex(string idxFile, string mulFile, int file)
}
}
+ ///
+ /// True when the entry carries the high bit that sets
+ /// to mark it as coming from verdata.
+ ///
+ ///
+ /// A length of -1 is the "unused entry" filler that real .idx files are padded with, and it
+ /// has that same high bit set. Without the -1 test every unused entry looks like a verdata
+ /// patch of length 0x7FFFFFFF, and the reader then tries to pull that many bytes out of
+ /// - which, with no verdata.mul present, is Stream.Null.
+ /// A real patch never reaches 0x7FFFFFFF bytes, so the two cases cannot be confused.
+ ///
+ private static bool IsVerdataPatched(IEntry e)
+ {
+ return e.Length != -1 && (e.Length & (1 << 31)) != 0;
+ }
+
public Stream Seek(int index, out int length, out int extra, out bool patched)
{
if (FileAccessor is null)
@@ -277,7 +293,7 @@ public Stream Seek(int index, out int length, out int extra, out bool patched)
length = e.Length & 0x7FFFFFFF;
extra = e.Extra;
- if ((e.Length & (1 << 31)) != 0)
+ if (IsVerdataPatched(e))
{
patched = true;
Verdata.Seek(e.Lookup);
@@ -343,7 +359,7 @@ public Stream Seek(int index, ref IEntry entry, out bool patched)
entry = e;
- if ((e.Length & (1 << 31)) != 0)
+ if (IsVerdataPatched(e))
{
patched = true;
Verdata.Seek(e.Lookup);
@@ -443,7 +459,7 @@ public bool Valid(int index, out int length, out int extra, out bool patched)
length = e.Length & 0x7FFFFFFF;
extra = e.Extra;
- if ((e.Length & (1 << 31)) != 0)
+ if (IsVerdataPatched(e))
{
patched = true;
return true;
diff --git a/Ultima/Files.cs b/Ultima/Files.cs
index 5e9a0e3f..23f32855 100644
--- a/Ultima/Files.cs
+++ b/Ultima/Files.cs
@@ -126,11 +126,13 @@ public static void FireFileSaveEvent()
"mapdif2.mul",
"mapdif3.mul",
"mapdif4.mul",
+ "mapdif5.mul",
"mapdifl0.mul",
"mapdifl1.mul",
"mapdifl2.mul",
"mapdifl3.mul",
"mapdifl4.mul",
+ "mapdifl5.mul",
"mobtypes.txt",
"multi.idx",
"multi.mul",
@@ -150,16 +152,19 @@ public static void FireFileSaveEvent()
"stadif2.mul",
"stadif3.mul",
"stadif4.mul",
+ "stadif5.mul",
"stadifi0.mul",
"stadifi1.mul",
"stadifi2.mul",
"stadifi3.mul",
"stadifi4.mul",
+ "stadifi5.mul",
"stadifl0.mul",
"stadifl1.mul",
"stadifl2.mul",
"stadifl3.mul",
"stadifl4.mul",
+ "stadifl5.mul",
"staidx0.mul",
"staidx1.mul",
"staidx2.mul",
diff --git a/Ultima/Gumps.cs b/Ultima/Gumps.cs
index 6f7442f3..51c0df3c 100644
--- a/Ultima/Gumps.cs
+++ b/Ultima/Gumps.cs
@@ -35,10 +35,9 @@ public sealed class Gumps
private static readonly Dictionary _replaced = new Dictionary();
private static bool[] _removed;
private static readonly Dictionary _patched = new Dictionary();
+ // Indexes edited since load or since the last save.
+ private static readonly ModifiedIndexTracker _modified = new ModifiedIndexTracker();
- private static byte[] _pixelBuffer;
- private static byte[] _streamBuffer;
- private static byte[] _colorTable;
// Authoritative id range — what _cache.Length used to be before the
// LRU swap. Sourced from the FileIndex when available, falls back to
@@ -110,10 +109,8 @@ public static void Reload()
_contentState = new byte[_indexLength];
}
- //_pixelBuffer = null;
- _streamBuffer = null;
- //_colorTable = null;
_patched.Clear();
+ _modified.Clear();
}
public static int GetCount()
@@ -133,6 +130,7 @@ public static void ReplaceGump(int index, Bitmap bmp)
_removed[index] = false;
_patched.Remove(index);
_contentState[index] = _contentPresent;
+ _modified.Mark(index);
}
///
@@ -142,6 +140,29 @@ public static void ReplaceGump(int index, Bitmap bmp)
public static void RemoveGump(int index)
{
_removed[index] = true;
+ _modified.Mark(index);
+ }
+
+ ///
+ /// Tests if the Gump at was replaced or removed since the gumps were
+ /// loaded or last saved.
+ ///
+ public static bool IsModified(int index)
+ {
+ return _modified.IsMarked(index);
+ }
+
+ ///
+ /// Number of Gumps edited since the gumps were loaded or last saved.
+ ///
+ public static int ModifiedCount => _modified.Count;
+
+ ///
+ /// Drops every modified mark without touching the edits themselves.
+ ///
+ public static void ClearModified()
+ {
+ _modified.Clear();
}
///
@@ -156,7 +177,7 @@ public static bool IsValidIndex(int index)
return false;
}
- if (index > _indexLength - 1)
+ if (index < 0 || index > _indexLength - 1)
{
return false;
}
@@ -206,7 +227,8 @@ private static bool ProbeContent(int index, int packedExtra)
// The index can answer for stored and verdata patched entries. For zlib it still can: the
// payload is the eight byte width/height header plus pixels, so a declared length of eight or
// less is a 0x0 gump. Mythic cannot - there DecompressedLength is the inner stream length.
- bool verdataPatched = (entry.Length & (1 << 31)) != 0;
+ // -1 is the unused entry filler, not a patch - it carries the same high bit.
+ bool verdataPatched = entry.Length != -1 && (entry.Length & (1 << 31)) != 0;
if (verdataPatched || entry.Flag == CompressionFlag.None)
{
@@ -437,162 +459,30 @@ public static byte[] GetRawGump(int index, out int width, out int height)
///
///
///
- // TODO: Currently unused and may be broken because of recent UOP changes. Needs verdata `patched` checks and compression handling
- public static unsafe Bitmap GetGump(int index, Hue hue, bool onlyHueGrayPixels, out bool patched)
+ public static Bitmap GetGump(int index, Hue hue, bool onlyHueGrayPixels, out bool patched)
{
- Stream stream = _fileIndex.Seek(index, out int length, out int extra, out patched);
+ // Decode through the regular path so this overload inherits the index/removed/
+ // replaced checks and the UOP (zlib / Mythic) handling. The previous hand-rolled
+ // RLE decoder here still used the legacy Seek overload, so it walked compressed
+ // UOP bytes as if they were raw RLE and ran the pixel pointer off the buffer.
+ Bitmap gump = GetGump(index, out patched);
- if (stream == null)
+ if (gump == null)
{
return null;
}
- if (extra == -1)
+ if (hue == null)
{
- return null;
- }
-
- int width = (extra >> 16) & 0xFFFF;
- int height = extra & 0xFFFF;
-
- if (width <= 0 || height <= 0)
- {
- return null;
+ return gump;
}
- int bytesPerLine = width << 1;
- int bytesPerStride = (bytesPerLine + 3) & ~3;
- int bytesForImage = height * bytesPerStride;
-
- int pixelsPerStride = (width + 1) & ~1;
- int pixelsPerStrideDelta = pixelsPerStride - width;
-
- byte[] pixelBuffer = _pixelBuffer;
-
- if (pixelBuffer == null || pixelBuffer.Length < bytesForImage)
- {
- _pixelBuffer = pixelBuffer = new byte[(bytesForImage + 2047) & ~2047];
- }
-
- byte[] streamBuffer = _streamBuffer;
-
- if (streamBuffer == null || streamBuffer.Length < length)
- {
- _streamBuffer = streamBuffer = new byte[(length + 2047) & ~2047];
- }
-
- byte[] colorTable = _colorTable;
-
- if (colorTable == null)
- {
- _colorTable = colorTable = new byte[128];
- }
-
- stream.ReadExactly(streamBuffer, 0, length);
-
- fixed (ushort* psHueColors = hue.Colors)
- {
- fixed (byte* pbStream = streamBuffer)
- {
- fixed (byte* pbPixels = pixelBuffer)
- {
- fixed (byte* pbColorTable = colorTable)
- {
- var pHueColors = psHueColors;
- ushort* pHueColorsEnd = pHueColors + 32;
-
- var pColorTable = (ushort*)pbColorTable;
-
- ushort* pColorTableOpaque = pColorTable;
-
- while (pHueColors < pHueColorsEnd)
- {
- *pColorTableOpaque++ = *pHueColors++;
- }
-
- var pPixelDataStart = (ushort*)pbPixels;
-
- var pLookup = (int*)pbStream;
- int* pLookupEnd = pLookup + height;
- int* pPixelRleStart = pLookup;
- int* pPixelRle;
-
- ushort* pPixel = pPixelDataStart;
- ushort* pRleEnd;
- ushort* pPixelEnd = pPixel + width;
-
- ushort color, count;
+ // GetGump can hand back a cached or replaced instance, so hue a private copy.
+ var hued = gump.Clone(new Rectangle(0, 0, gump.Width, gump.Height), gump.PixelFormat);
- if (onlyHueGrayPixels)
- {
- while (pLookup < pLookupEnd)
- {
- pPixelRle = pPixelRleStart + *pLookup++;
- pRleEnd = pPixel;
-
- while (pPixel < pPixelEnd)
- {
- color = *(ushort*)pPixelRle;
- count = *(1 + (ushort*)pPixelRle);
- ++pPixelRle;
-
- pRleEnd += count;
+ hue.ApplyTo(hued, onlyHueGrayPixels);
- if (color != 0 && (color & 0x1F) == ((color >> 5) & 0x1F) && (color & 0x1F) == ((color >> 10) & 0x1F))
- {
- color = pColorTable[color >> 10];
- }
- else if (color != 0)
- {
- color ^= 0x8000;
- }
-
- while (pPixel < pRleEnd)
- {
- *pPixel++ = color;
- }
- }
-
- pPixel += pixelsPerStrideDelta;
- pPixelEnd += pixelsPerStride;
- }
- }
- else
- {
- while (pLookup < pLookupEnd)
- {
- pPixelRle = pPixelRleStart + *pLookup++;
- pRleEnd = pPixel;
-
- while (pPixel < pPixelEnd)
- {
- color = *(ushort*)pPixelRle;
- count = *(1 + (ushort*)pPixelRle);
- ++pPixelRle;
-
- pRleEnd += count;
-
- if (color != 0)
- {
- color = pColorTable[color >> 10];
- }
-
- while (pPixel < pRleEnd)
- {
- *pPixel++ = color;
- }
- }
-
- pPixel += pixelsPerStrideDelta;
- pPixelEnd += pixelsPerStride;
- }
- }
-
- return new Bitmap(width, height, bytesPerStride, PixelFormat.Format16bppArgb1555, (IntPtr)pPixelDataStart);
- }
- }
- }
- }
+ return hued;
}
///
@@ -822,7 +712,7 @@ public static unsafe Bitmap GetGump(int index, out bool patched)
{
patched = _patched.ContainsKey(index) && _patched[index];
- if (index > _indexLength - 1)
+ if (index < 0 || index > _indexLength - 1)
{
return null;
}
@@ -1353,6 +1243,8 @@ public static unsafe void Save(string path)
binidx.Flush();
fsidx.SetLength(rows * 12);
}
+
+ _modified.Clear();
}
}
}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Classes/AppLog.cs b/Ultima/Helpers/AppLog.cs
similarity index 96%
rename from UoFiddler.Controls/Classes/AppLog.cs
rename to Ultima/Helpers/AppLog.cs
index c2a6570b..31d24d36 100644
--- a/UoFiddler.Controls/Classes/AppLog.cs
+++ b/Ultima/Helpers/AppLog.cs
@@ -13,7 +13,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
-namespace UoFiddler.Controls.Classes
+namespace Ultima.Helpers
{
///
/// Static logger façade for sites that cannot accept ILogger via constructor injection
@@ -33,4 +33,4 @@ public static void Initialize(ILoggerFactory factory)
public static ILogger For(Type type) => _factory.CreateLogger(type.FullName);
}
-}
+}
\ No newline at end of file
diff --git a/Ultima/Helpers/UopUtils.cs b/Ultima/Helpers/UopUtils.cs
index c2e7a9df..81e09dde 100644
--- a/Ultima/Helpers/UopUtils.cs
+++ b/Ultima/Helpers/UopUtils.cs
@@ -91,6 +91,23 @@ public static ulong HashFileName(string input)
/// returned — matching the client function, which only yields the low output
/// word.
///
+ ///
+ /// Adler32 of a UOP entry's bytes, as stored in the 32 bit hash field of its table row.
+ ///
+ public static uint HashAdler32(ReadOnlySpan data)
+ {
+ uint a = 1;
+ uint b = 0;
+
+ for (int i = 0; i < data.Length; i++)
+ {
+ a = (a + data[i]) % 65521;
+ b = (b + a) % 65521;
+ }
+
+ return (b << 16) | a;
+ }
+
public static uint HashWord2(ReadOnlySpan data, uint initValue = 0)
{
int length = data.Length, i = 0;
diff --git a/Ultima/Map.cs b/Ultima/Map.cs
index e96bd6a9..1335cf70 100644
--- a/Ultima/Map.cs
+++ b/Ultima/Map.cs
@@ -3,6 +3,7 @@
using System.Drawing.Imaging;
using System.IO;
using System.Text;
+using Ultima.Statics;
namespace Ultima
{
@@ -1040,275 +1041,20 @@ public unsafe void GetImageQuarter(int x, int y, int width, int height, Bitmap b
bmp.UnlockBits(bd);
}
+ ///
+ /// Kept so existing plugins keep compiling. New code should drive
+ /// directly, which exposes the filters,
+ /// the dry run and the report this overload cannot.
+ ///
+ [Obsolete("Use Ultima.Statics.StaticsDefragmenter, which reports what it removed instead of dropping tiles silently.")]
public static void DefragStatics(string path, Map map, int width, int height, bool remove)
{
- string indexPath = Files.GetFilePath($"staidx{map.FileIndex}.mul");
- BinaryReader indexReader;
- if (indexPath != null)
- {
- FileStream index = new FileStream(indexPath, FileMode.Open, FileAccess.Read, FileShare.Read);
- indexReader = new BinaryReader(index);
- }
- else
- {
- return;
- }
-
- string staticsPath = Files.GetFilePath($"statics{map.FileIndex}.mul");
-
- FileStream staticsStream;
- BinaryReader staticsReader;
- if (staticsPath != null)
- {
- staticsStream = new FileStream(staticsPath, FileMode.Open, FileAccess.Read, FileShare.Read);
- staticsReader = new BinaryReader(staticsStream);
- }
- else
- {
- return;
- }
-
- int blockx = width >> 3;
- int blocky = height >> 3;
+ StaticsDefragOptions options = StaticsDefragOptions.Legacy(path, map, remove);
- string idx = Path.Combine(path, $"staidx{map.FileIndex}.mul");
- string mul = Path.Combine(path, $"statics{map.FileIndex}.mul");
-
- using (var fsidx = new FileStream(idx, FileMode.Create, FileAccess.Write, FileShare.Write))
- using (var fsmul = new FileStream(mul, FileMode.Create, FileAccess.Write, FileShare.Write))
- {
- var memidx = new MemoryStream();
- var memmul = new MemoryStream();
- using (var binidx = new BinaryWriter(memidx))
- using (var binmul = new BinaryWriter(memmul))
- {
- for (int x = 0; x < blockx; ++x)
- {
- for (int y = 0; y < blocky; ++y)
- {
- try
- {
- indexReader.BaseStream.Seek(((x * blocky) + y) * 12, SeekOrigin.Begin);
- int lookup = indexReader.ReadInt32();
- int length = indexReader.ReadInt32();
- int extra = indexReader.ReadInt32();
-
- if (((lookup < 0 || length <= 0) && (!map.Tiles.PendingStatic(x, y))) ||
- (map.Tiles.IsStaticBlockRemoved(x, y)))
- {
- binidx.Write(-1); // lookup
- binidx.Write(-1); // length
- binidx.Write(-1); // extra
- }
- else
- {
- if ((lookup >= 0) && (length > 0))
- {
- staticsStream.Seek(lookup, SeekOrigin.Begin);
- }
-
- var fsmullength = (int)binmul.BaseStream.Position;
- int count = length / 7;
- if (!remove) // without duplicate remove
- {
- bool firstitem = true;
- for (int i = 0; i < count; ++i)
- {
- ushort graphic = staticsReader.ReadUInt16();
- byte sx = staticsReader.ReadByte();
- byte sy = staticsReader.ReadByte();
- sbyte sz = staticsReader.ReadSByte();
- short shue = staticsReader.ReadInt16();
-
- if (graphic > Art.GetMaxItemId())
- {
- continue;
- }
-
- if (shue < 0)
- {
- shue = 0;
- }
-
- if (firstitem)
- {
- binidx.Write((int)binmul.BaseStream.Position); // lookup
- firstitem = false;
- }
-
- binmul.Write(graphic);
- binmul.Write(sx);
- binmul.Write(sy);
- binmul.Write(sz);
- binmul.Write(shue);
- }
-
- StaticTile[] tileList = map.Tiles.GetPendingStatics(x, y);
- if (tileList != null)
- {
- for (int i = 0; i < tileList.Length; ++i)
- {
- if (tileList[i].Id > Art.GetMaxItemId())
- {
- continue;
- }
-
- if (tileList[i].Hue < 0)
- {
- tileList[i].Hue = 0;
- }
-
- if (firstitem)
- {
- binidx.Write((int)binmul.BaseStream.Position); // lookup
- firstitem = false;
- }
-
- binmul.Write(tileList[i].Id);
- binmul.Write(tileList[i].X);
- binmul.Write(tileList[i].Y);
- binmul.Write(tileList[i].Z);
- binmul.Write(tileList[i].Hue);
- }
- }
- }
- else // with duplicate remove
- {
- var tileList = new StaticTile[count];
- int j = 0;
- for (int i = 0; i < count; ++i)
- {
- var tile = new StaticTile
- {
- Id = staticsReader.ReadUInt16(),
- X = staticsReader.ReadByte(),
- Y = staticsReader.ReadByte(),
- Z = staticsReader.ReadSByte(),
- Hue = staticsReader.ReadInt16()
- };
-
- if (tile.Id > Art.GetMaxItemId())
- {
- continue;
- }
-
- if (tile.Hue < 0)
- {
- tile.Hue = 0;
- }
-
- bool first = true;
- for (int k = 0; k < j; ++k)
- {
- if ((tileList[k].Id == tile.Id) && (tileList[k].X == tile.X) && (tileList[k].Y == tile.Y) && (tileList[k].Z == tile.Z) && (tileList[k].Hue == tile.Hue))
- {
- first = false;
- break;
- }
- }
-
- if (!first)
- {
- continue;
- }
-
- tileList[j] = tile;
- j++;
- }
-
- if (map.Tiles.PendingStatic(x, y))
- {
- StaticTile[] pending = map.Tiles.GetPendingStatics(x, y);
- StaticTile[] old = tileList;
- tileList = new StaticTile[old.Length + pending.Length];
- old.CopyTo(tileList, 0);
- for (int i = 0; i < pending.Length; ++i)
- {
- if (pending[i].Id > Art.GetMaxItemId())
- {
- continue;
- }
-
- if (pending[i].Hue < 0)
- {
- pending[i].Hue = 0;
- }
-
- bool first = true;
- for (int k = 0; k < j; ++k)
- {
- if ((tileList[k].Id == pending[i].Id) && (tileList[k].X == pending[i].X) && (tileList[k].Y == pending[i].Y) && (tileList[k].Z == pending[i].Z) && (tileList[k].Hue == pending[i].Hue))
- {
- first = false;
- break;
- }
- }
-
- if (first)
- {
- tileList[j++] = pending[i];
- }
- }
- }
-
- if (j > 0)
- {
- binidx.Write((int)binmul.BaseStream.Position); // lookup
- for (int i = 0; i < j; ++i)
- {
- binmul.Write(tileList[i].Id);
- binmul.Write(tileList[i].X);
- binmul.Write(tileList[i].Y);
- binmul.Write(tileList[i].Z);
- binmul.Write(tileList[i].Hue);
- }
- }
- }
-
- fsmullength = (int)binmul.BaseStream.Position - fsmullength;
- if (fsmullength > 0)
- {
- binidx.Write(fsmullength); // length
- if (extra == -1)
- {
- extra = 0;
- }
-
- binidx.Write(extra); // extra
- }
- else
- {
- binidx.Write(-1); // lookup
- binidx.Write(-1); // length
- binidx.Write(-1); // extra
- }
- }
- }
- catch // fill the rest
- {
- binidx.BaseStream.Seek(((x * blocky) + y) * 12, SeekOrigin.Begin);
- for (; x < blockx; ++x)
- {
- for (; y < blocky; ++y)
- {
- binidx.Write(-1); // lookup
- binidx.Write(-1); // length
- binidx.Write(-1); // extra
- }
-
- y = 0;
- }
- }
- }
- }
-
- memidx.WriteTo(fsidx);
- memmul.WriteTo(fsmul);
- }
- }
+ options.BlockWidth = width >> 3;
+ options.BlockHeight = height >> 3;
- indexReader.Close();
- staticsReader.Close();
+ StaticsDefragmenter.Defrag(options);
}
public static void RewriteMap(string path, int mapIndex, int width, int height)
diff --git a/Ultima/Maps/MapBlockSink.cs b/Ultima/Maps/MapBlockSink.cs
new file mode 100644
index 00000000..b11321c2
--- /dev/null
+++ b/Ultima/Maps/MapBlockSink.cs
@@ -0,0 +1,200 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.IO;
+using Ultima.Uop;
+
+namespace Ultima.Maps
+{
+ public enum MapOutputFormat
+ {
+ Mul,
+ Uop
+ }
+
+ ///
+ /// Somewhere to put a facet's land blocks, in index order, without the producer caring whether
+ /// the result is a map{N}.mul or a map{N}LegacyMUL.uop.
+ ///
+ public interface IMapBlockSink : IDisposable
+ {
+ /// Where the finished file will be, or is.
+ string OutputPath { get; }
+
+ /// Appends one 196-byte block: a 4-byte header followed by 64 three-byte tiles.
+ void WriteBlock(ReadOnlySpan block);
+
+ /// Finishes the file and moves it into place. Without this the output is discarded.
+ void Complete();
+ }
+
+ public static class MapBlockSink
+ {
+ public const int MapBlockSize = 196;
+
+ ///
+ /// Opens a sink for a facet. The file is built beside its destination under a temporary name
+ /// and only moved into place by , so an interrupted run
+ /// leaves whatever was there before untouched.
+ ///
+ public static IMapBlockSink Create(string outputDirectory, int fileIndex, MapOutputFormat format, long blockCount)
+ {
+ if (string.IsNullOrWhiteSpace(outputDirectory))
+ {
+ throw new ArgumentException("No output directory was given.", nameof(outputDirectory));
+ }
+
+ Directory.CreateDirectory(outputDirectory);
+
+ string name = format == MapOutputFormat.Uop
+ ? $"map{fileIndex}LegacyMUL.uop"
+ : $"map{fileIndex}.mul";
+
+ string path = Path.GetFullPath(Path.Combine(outputDirectory, name));
+
+ return format == MapOutputFormat.Uop
+ ? new UopMapBlockSink(path, fileIndex, blockCount)
+ : (IMapBlockSink)new MulMapBlockSink(path, blockCount);
+ }
+
+ internal static FileStream CreateTemporary(string path, out string temporaryPath)
+ {
+ temporaryPath = path + ".tmp-" + Guid.NewGuid().ToString("N");
+
+ return new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1 << 20);
+ }
+
+ internal static void TryDelete(string path)
+ {
+ if (path == null || !File.Exists(path))
+ {
+ return;
+ }
+
+ try
+ {
+ File.Delete(path);
+ }
+ catch (IOException)
+ {
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+ }
+ }
+
+ internal sealed class MulMapBlockSink : IMapBlockSink
+ {
+ private readonly long _blockCount;
+ private readonly FileStream _stream;
+
+ private string _temporaryPath;
+ private long _blocksWritten;
+ private bool _completed;
+
+ public MulMapBlockSink(string path, long blockCount)
+ {
+ OutputPath = path;
+ _blockCount = blockCount;
+ _stream = MapBlockSink.CreateTemporary(path, out _temporaryPath);
+ }
+
+ public string OutputPath { get; }
+
+ public void WriteBlock(ReadOnlySpan block)
+ {
+ if (block.Length != MapBlockSink.MapBlockSize)
+ {
+ throw new ArgumentException($"A land block is {MapBlockSink.MapBlockSize} bytes.", nameof(block));
+ }
+
+ _stream.Write(block);
+ ++_blocksWritten;
+ }
+
+ public void Complete()
+ {
+ if (_completed)
+ {
+ return;
+ }
+
+ if (_blocksWritten != _blockCount)
+ {
+ throw new InvalidOperationException(
+ $"{_blocksWritten:N0} blocks were written but the facet holds {_blockCount:N0}.");
+ }
+
+ _stream.Flush();
+ _stream.Dispose();
+
+ File.Move(_temporaryPath, OutputPath, true);
+
+ _temporaryPath = null;
+ _completed = true;
+ }
+
+ public void Dispose()
+ {
+ _stream.Dispose();
+ MapBlockSink.TryDelete(_temporaryPath);
+ }
+ }
+
+ internal sealed class UopMapBlockSink : IMapBlockSink
+ {
+ private readonly FileStream _stream;
+ private readonly MapUopWriter _writer;
+
+ private string _temporaryPath;
+ private bool _completed;
+
+ public UopMapBlockSink(string path, int fileIndex, long blockCount)
+ {
+ OutputPath = path;
+ _stream = MapBlockSink.CreateTemporary(path, out _temporaryPath);
+ _writer = new MapUopWriter(_stream, fileIndex, blockCount, MapTrailingBlock.Empty, true);
+ }
+
+ public string OutputPath { get; }
+
+ public void WriteBlock(ReadOnlySpan block)
+ {
+ _writer.WriteBlock(block);
+ }
+
+ public void Complete()
+ {
+ if (_completed)
+ {
+ return;
+ }
+
+ _writer.Complete();
+ _stream.Flush();
+ _stream.Dispose();
+
+ File.Move(_temporaryPath, OutputPath, true);
+
+ _temporaryPath = null;
+ _completed = true;
+ }
+
+ public void Dispose()
+ {
+ _writer.Dispose();
+ _stream.Dispose();
+ MapBlockSink.TryDelete(_temporaryPath);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Ultima/Maps/MapDiffApply.cs b/Ultima/Maps/MapDiffApply.cs
new file mode 100644
index 00000000..b47c9710
--- /dev/null
+++ b/Ultima/Maps/MapDiffApply.cs
@@ -0,0 +1,481 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading;
+using Ultima.Statics;
+
+namespace Ultima.Maps
+{
+ public sealed class MapDiffApplyOptions
+ {
+ /// The loaded map whose diff files are being folded in.
+ public Map Map { get; set; }
+
+ /// Region to apply, in tile coordinates, inclusive. Reversed values are normalised.
+ public int X1 { get; set; }
+
+ public int Y1 { get; set; }
+
+ public int X2 { get; set; }
+
+ public int Y2 { get; set; }
+
+ public bool ApplyLand { get; set; } = true;
+
+ public bool ApplyStatics { get; set; } = true;
+
+ public MapOutputFormat MapFormat { get; set; } = MapOutputFormat.Mul;
+
+ public StaticsTileFilter StaticsFilter { get; set; }
+
+ public string OutputDirectory { get; set; }
+
+ public IProgress Progress { get; set; }
+
+ public CancellationToken CancellationToken { get; set; }
+ }
+
+ public sealed class MapDiffApplyResult : IStaticsFilterStats
+ {
+ /// The facet that was patched, so a verification can read the right files back.
+ public int FileIndex { get; set; }
+
+ public BlockRectangle Region { get; set; }
+
+ public int RequestedX1 { get; set; }
+
+ public int RequestedY1 { get; set; }
+
+ public int RequestedX2 { get; set; }
+
+ public int RequestedY2 { get; set; }
+
+ public MapSize MapSize { get; set; }
+
+ public string OutputMapPath { get; set; }
+
+ public string OutputIndexPath { get; set; }
+
+ public string OutputStaticsPath { get; set; }
+
+ /// Blocks the land diff lists, across the whole facet.
+ public int LandBlocksPatched { get; set; }
+
+ /// Blocks the statics diff lists, across the whole facet.
+ public int StaticBlocksPatched { get; set; }
+
+ public long LandBlocksApplied { get; set; }
+
+ public long StaticBlocksApplied { get; set; }
+
+ public long StaticsRead { get; set; }
+
+ public long StaticsWritten { get; set; }
+
+ public long DroppedInvalidItemId { get; set; }
+
+ public long DroppedOutOfBlock { get; set; }
+
+ public long MaskedOutOfBlock { get; set; }
+
+ public long DroppedInvalidZ { get; set; }
+
+ public long DuplicatesRemoved { get; set; }
+
+ public long HuesNormalized { get; set; }
+
+ public int HighestItemIdSeen { get; set; }
+
+ public TimeSpan Elapsed { get; set; }
+
+ public List Warnings { get; } = new List();
+
+ public List RejectSamples { get; } = new List();
+
+ public int RejectSampleLimit { get; set; } = 200;
+
+ public bool RegionWasSnapped =>
+ Region.TileX1 != RequestedX1 || Region.TileY1 != RequestedY1 ||
+ Region.TileX2 != RequestedX2 || Region.TileY2 != RequestedY2;
+
+ void IStaticsFilterStats.TileRejected(int blockX, int blockY, StaticTile tile, RejectReason reason)
+ {
+ switch (reason)
+ {
+ case RejectReason.InvalidItemId:
+ ++DroppedInvalidItemId;
+ break;
+
+ case RejectReason.OutOfBlockOffset:
+ ++DroppedOutOfBlock;
+ break;
+
+ case RejectReason.InvalidZ:
+ ++DroppedInvalidZ;
+ break;
+
+ case RejectReason.Duplicate:
+ ++DuplicatesRemoved;
+ break;
+ }
+
+ if (RejectSamples.Count < RejectSampleLimit)
+ {
+ RejectSamples.Add(new RejectedStaticTile(blockX, blockY, tile, reason));
+ }
+ }
+
+ void IStaticsFilterStats.HueNormalized() => ++HuesNormalized;
+
+ void IStaticsFilterStats.OutOfBlockMasked() => ++MaskedOutOfBlock;
+
+ public string ToReport()
+ {
+ var sb = new StringBuilder();
+
+ sb.AppendLine(Line("Requested region : {0},{1} - {2},{3}", RequestedX1, RequestedY1, RequestedX2, RequestedY2));
+ sb.AppendLine(Line("Applied to : {0}", Region));
+
+ if (RegionWasSnapped)
+ {
+ sb.AppendLine(" the request was widened to whole 8-tile blocks");
+ }
+
+ sb.AppendLine();
+ sb.AppendLine(Line("Map size : {0}", MapSize));
+ sb.AppendLine();
+
+ if (OutputMapPath != null)
+ {
+ sb.AppendLine(Line("Map written : {0}", OutputMapPath));
+ sb.AppendLine(Line(" land diff lists: {0:N0} blocks for this facet", LandBlocksPatched));
+ sb.AppendLine(Line(" applied : {0:N0} of them fall in the region", LandBlocksApplied));
+ }
+
+ if (OutputIndexPath != null)
+ {
+ sb.AppendLine(Line("Statics written : {0}", OutputStaticsPath));
+ sb.AppendLine(Line(" static diff : {0:N0} blocks for this facet", StaticBlocksPatched));
+ sb.AppendLine(Line(" applied : {0:N0} of them fall in the region", StaticBlocksApplied));
+ sb.AppendLine(Line(" statics : {0:N0} read, {1:N0} written", StaticsRead, StaticsWritten));
+ sb.AppendLine(Line(" highest id : 0x{0:X4}", HighestItemIdSeen));
+
+ if (DroppedInvalidItemId > 0 || DuplicatesRemoved > 0 || HuesNormalized > 0)
+ {
+ sb.AppendLine(Line(" removed : {0:N0} invalid id, {1:N0} duplicates; {2:N0} hues reset",
+ DroppedInvalidItemId, DuplicatesRemoved, HuesNormalized));
+ }
+ }
+
+ sb.AppendLine();
+ sb.AppendLine(Line("Elapsed : {0:hh\\:mm\\:ss\\.fff}", Elapsed));
+
+ if (Warnings.Count > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine(Line("Warnings ({0}):", Warnings.Count));
+
+ foreach (string warning in Warnings)
+ {
+ sb.AppendLine(" " + warning);
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ private static string Line(string format, params object[] args)
+ {
+ return string.Format(CultureInfo.InvariantCulture, format, args);
+ }
+ }
+
+ ///
+ /// Folds a facet's mapdif and stadif data into fresh map and statics files, for a chosen region.
+ ///
+ ///
+ /// The land side goes through rather than seeking into map{N}.mul, so a
+ /// client whose maps are UOP-only works. The block header the mul carries is not exposed by the
+ /// patch data, so a patched block is written with a zero header; the renderer ignores it.
+ ///
+ public static class MapDiffApplier
+ {
+ private const int ProgressInterval = 256;
+
+ public static MapDiffApplyResult Run(MapDiffApplyOptions options)
+ {
+ if (options?.Map == null)
+ {
+ throw new MapRegionCopyException("No map was given.");
+ }
+
+ var result = new MapDiffApplyResult();
+ var stopwatch = Stopwatch.StartNew();
+
+ var size = new MapSize(options.Map.Width, options.Map.Height);
+ result.MapSize = size;
+ result.FileIndex = options.Map.FileIndex;
+
+ Normalise(options, result, size);
+
+ TileMatrix tiles = options.Map.Tiles;
+ TileMatrixPatch patch = tiles.Patch;
+
+ result.LandBlocksPatched = patch.LandBlocksCount;
+ result.StaticBlocksPatched = patch.StaticBlocksCount;
+
+ if (patch.LandBlocksCount == 0 && patch.StaticBlocksCount == 0)
+ {
+ result.Warnings.Add(
+ $"No diff data was loaded for map {options.Map.FileIndex}. Check that mapdif{options.Map.FileIndex}.mul " +
+ $"and stadif{options.Map.FileIndex}.mul are present and that Use Map Diff is on.");
+ }
+
+ if (options.ApplyLand)
+ {
+ ApplyLand(options, result, size, tiles, patch);
+ }
+
+ if (options.ApplyStatics)
+ {
+ ApplyStatics(options, result, size, tiles, patch);
+ }
+
+ result.Elapsed = stopwatch.Elapsed;
+
+ return result;
+ }
+
+ private static void Normalise(MapDiffApplyOptions options, MapDiffApplyResult result, MapSize size)
+ {
+ int x1 = options.X1;
+ int y1 = options.Y1;
+ int x2 = options.X2;
+ int y2 = options.Y2;
+
+ if (x1 > x2)
+ {
+ (x1, x2) = (x2, x1);
+ }
+
+ if (y1 > y2)
+ {
+ (y1, y2) = (y2, y1);
+ }
+
+ result.RequestedX1 = x1;
+ result.RequestedY1 = y1;
+ result.RequestedX2 = x2;
+ result.RequestedY2 = y2;
+
+ if (x1 < 0 || x2 >= size.Width)
+ {
+ throw new MapRegionCopyException($"The region's X runs {x1}..{x2}, but the map is {size.Width} tiles wide.");
+ }
+
+ if (y1 < 0 || y2 >= size.Height)
+ {
+ throw new MapRegionCopyException($"The region's Y runs {y1}..{y2}, but the map is {size.Height} tiles tall.");
+ }
+
+ result.Region = new BlockRectangle(x1 >> 3, y1 >> 3, x2 >> 3, y2 >> 3);
+ }
+
+ private static void ApplyLand(MapDiffApplyOptions options, MapDiffApplyResult result, MapSize size,
+ TileMatrix tiles, TileMatrixPatch patch)
+ {
+ long blockCount = size.BlockCount;
+ int done = 0;
+
+ using (IMapBlockSink sink = MapBlockSink.Create(options.OutputDirectory,
+ options.Map.FileIndex, options.MapFormat, blockCount))
+ {
+ var block = new byte[TileMatrix.MapBlockSize];
+
+ for (int x = 0; x < size.BlockWidth; ++x)
+ {
+ for (int y = 0; y < size.BlockHeight; ++y)
+ {
+ bool inRegion = InRegion(result.Region, x, y);
+
+ if (inRegion && patch.IsLandBlockPatched(x, y))
+ {
+ Tile[] patched = patch.GetLandBlock(x, y);
+
+ Array.Clear(block);
+ MemoryMarshal.AsBytes(patched.AsSpan())
+ .CopyTo(block.AsSpan(TileMatrix.BlockHeaderSize));
+
+ ++result.LandBlocksApplied;
+ }
+ else
+ {
+ tiles.ReadLandBlockBytes(x, y, block);
+ }
+
+ sink.WriteBlock(block);
+
+ if ((++done & (ProgressInterval - 1)) == 0)
+ {
+ options.CancellationToken.ThrowIfCancellationRequested();
+ Report(options, "Inserting map", done, (int)blockCount);
+ }
+ }
+ }
+
+ sink.Complete();
+ result.OutputMapPath = sink.OutputPath;
+ }
+
+ Report(options, "Inserting map", (int)blockCount, (int)blockCount);
+ }
+
+ private static void ApplyStatics(MapDiffApplyOptions options, MapDiffApplyResult result, MapSize size,
+ TileMatrix tiles, TileMatrixPatch patch)
+ {
+ int fileIndex = options.Map.FileIndex;
+
+ string sourceIndex = Resolve($"staidx{fileIndex}.mul");
+ string sourceStatics = Resolve($"statics{fileIndex}.mul");
+
+ string outputIndex = Path.Combine(options.OutputDirectory, $"staidx{fileIndex}.mul");
+ string outputStatics = Path.Combine(options.OutputDirectory, $"statics{fileIndex}.mul");
+
+ RefuseToOverwrite(sourceIndex, outputIndex);
+ RefuseToOverwrite(sourceStatics, outputStatics);
+
+ Directory.CreateDirectory(options.OutputDirectory);
+
+ string tempIndex = outputIndex + ".tmp-" + Guid.NewGuid().ToString("N");
+ string tempStatics = outputStatics + ".tmp-" + Guid.NewGuid().ToString("N");
+
+ var problems = new StaticsBlockProblems();
+ var staticTiles = new List(256);
+
+ try
+ {
+ using (StaticsIndexReader reader = StaticsIndexReader.Open(sourceIndex, sourceStatics,
+ size.BlockWidth, size.BlockHeight, result.Warnings))
+ using (var outIndexStream = new FileStream(tempIndex, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1 << 20))
+ using (var outStaticsStream = new FileStream(tempStatics, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1 << 20))
+ using (var writer = new StaticsBlockWriter(outIndexStream, outStaticsStream,
+ size.BlockWidth, size.BlockHeight, EmptyBlockStyle.NegativeOne, true))
+ {
+ int done = 0;
+ int blockCount = size.BlockWidth * size.BlockHeight;
+
+ for (int x = 0; x < size.BlockWidth; ++x)
+ {
+ for (int y = 0; y < size.BlockHeight; ++y)
+ {
+ staticTiles.Clear();
+
+ bool patched = InRegion(result.Region, x, y) && patch.IsStaticBlockPatched(x, y);
+
+ if (patched)
+ {
+ result.StaticsRead += StaticsBlockConversion.FromHuedBlock(patch.GetStaticBlock(x, y), staticTiles);
+ ++result.StaticBlocksApplied;
+ }
+ else
+ {
+ result.StaticsRead += reader.ReadBlock(x, y, staticTiles, result.Warnings, problems);
+ }
+
+ options.StaticsFilter?.Apply(staticTiles, x, y, result);
+
+ writer.WriteBlock(x, y, staticTiles, patched ? 0 : reader.GetEntry(x, y).Extra);
+
+ if ((++done & (ProgressInterval - 1)) == 0)
+ {
+ options.CancellationToken.ThrowIfCancellationRequested();
+ Report(options, "Inserting statics", done, blockCount);
+ }
+ }
+ }
+
+ writer.Complete();
+
+ result.StaticsWritten = writer.TilesWritten;
+
+ Report(options, "Inserting statics", blockCount, blockCount);
+ }
+
+ File.Move(tempStatics, outputStatics, true);
+ tempStatics = null;
+
+ File.Move(tempIndex, outputIndex, true);
+ tempIndex = null;
+
+ result.OutputIndexPath = Path.GetFullPath(outputIndex);
+ result.OutputStaticsPath = Path.GetFullPath(outputStatics);
+
+ if (options.StaticsFilter != null)
+ {
+ result.HighestItemIdSeen = options.StaticsFilter.HighestItemIdSeen;
+ }
+
+ if (problems.BadLookup > 0 || problems.BadLength > 0 || problems.OutOfRange > 0)
+ {
+ result.Warnings.Add(string.Format(CultureInfo.InvariantCulture,
+ "Damaged index records: {0:N0} bad lookup, {1:N0} bad length, {2:N0} out of range.",
+ problems.BadLookup, problems.BadLength, problems.OutOfRange));
+ }
+ }
+ finally
+ {
+ MapBlockSink.TryDelete(tempIndex);
+ MapBlockSink.TryDelete(tempStatics);
+ }
+ }
+
+ private static bool InRegion(BlockRectangle region, int x, int y)
+ {
+ return x >= region.BlockX1 && x <= region.BlockX2 && y >= region.BlockY1 && y <= region.BlockY2;
+ }
+
+ private static void Report(MapDiffApplyOptions options, string stage, int done, int total)
+ {
+ options.Progress?.Report(new MapCopyProgress { Stage = stage, BlocksDone = done, BlocksTotal = total });
+ }
+
+ private static string Resolve(string fileName)
+ {
+ string path = Files.GetFilePath(fileName);
+
+ if (path == null)
+ {
+ throw new MapRegionCopyException(
+ $"{fileName} was not found. Check the path settings for the loaded client.");
+ }
+
+ return Path.GetFullPath(path);
+ }
+
+ private static void RefuseToOverwrite(string sourcePath, string outputPath)
+ {
+ if (!string.Equals(sourcePath, Path.GetFullPath(outputPath), StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ throw new MapRegionCopyException(
+ $"The output directory holds a file being read ({outputPath}). Choose a different output directory.");
+ }
+ }
+}
\ No newline at end of file
diff --git a/Ultima/Maps/MapRegionCopy.cs b/Ultima/Maps/MapRegionCopy.cs
new file mode 100644
index 00000000..009bc26e
--- /dev/null
+++ b/Ultima/Maps/MapRegionCopy.cs
@@ -0,0 +1,880 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using System.Threading;
+using Ultima.Statics;
+
+namespace Ultima.Maps
+{
+ ///
+ /// A rectangle of blocks, and the tile rectangle it covers.
+ ///
+ public readonly struct BlockRectangle
+ {
+ public BlockRectangle(int blockX1, int blockY1, int blockX2, int blockY2)
+ {
+ BlockX1 = blockX1;
+ BlockY1 = blockY1;
+ BlockX2 = blockX2;
+ BlockY2 = blockY2;
+ }
+
+ public int BlockX1 { get; }
+
+ public int BlockY1 { get; }
+
+ public int BlockX2 { get; }
+
+ public int BlockY2 { get; }
+
+ public int BlockWidth => BlockX2 - BlockX1 + 1;
+
+ public int BlockHeight => BlockY2 - BlockY1 + 1;
+
+ public int TileX1 => BlockX1 << 3;
+
+ public int TileY1 => BlockY1 << 3;
+
+ public int TileX2 => (BlockX2 << 3) + 7;
+
+ public int TileY2 => (BlockY2 << 3) + 7;
+
+ public override string ToString()
+ {
+ return string.Format(CultureInfo.InvariantCulture,
+ "{0},{1} - {2},{3} (blocks {4},{5} - {6},{7}, {8} x {9})",
+ TileX1, TileY1, TileX2, TileY2, BlockX1, BlockY1, BlockX2, BlockY2, BlockWidth, BlockHeight);
+ }
+ }
+
+ public sealed class MapCopyProgress
+ {
+ public string Stage { get; init; }
+
+ public int BlocksDone { get; init; }
+
+ public int BlocksTotal { get; init; }
+ }
+
+ public sealed class MapRegionCopyException : Exception
+ {
+ public MapRegionCopyException(string message) : base(message)
+ {
+ }
+
+ public MapRegionCopyException(string message, Exception innerException) : base(message, innerException)
+ {
+ }
+ }
+
+ public sealed class MapRegionCopyOptions
+ {
+ /// Folder holding the client files to copy from.
+ public string SourceDirectory { get; set; }
+
+ public int SourceFileIndex { get; set; }
+
+ /// Block grid of the source facet. Detect it rather than assume it.
+ public MapSize SourceSize { get; set; }
+
+ /// The loaded map being copied into. Its files supply everything outside the region.
+ public Map Destination { get; set; }
+
+ /// Region to take, in source tile coordinates, inclusive. Reversed values are normalised.
+ public int SourceX1 { get; set; }
+
+ public int SourceY1 { get; set; }
+
+ public int SourceX2 { get; set; }
+
+ public int SourceY2 { get; set; }
+
+ /// Where the region lands, in destination tile coordinates.
+ public int DestinationX { get; set; }
+
+ public int DestinationY { get; set; }
+
+ public bool CopyLand { get; set; } = true;
+
+ public bool CopyStatics { get; set; } = true;
+
+ public MapOutputFormat MapFormat { get; set; } = MapOutputFormat.Mul;
+
+ ///
+ /// Applied to the statics of every block written. Leave null to copy them through untouched.
+ ///
+ public StaticsTileFilter StaticsFilter { get; set; }
+
+ /// Replaces land ids of 0x4000 and up with 0. Land art only reaches 0x3FFF.
+ public bool SanitizeLandIds { get; set; }
+
+ ///
+ /// Added to the z of every land tile and static in the copied region. Land and statics move
+ /// together, so a building keeps its footing on the terrain it was copied with.
+ ///
+ public int ZAdjust { get; set; }
+
+ ///
+ /// What to do with a tile the adjustment would push outside the -128..127 the files hold.
+ /// Refusing is the default: a shift that does not fit is almost always the wrong shift.
+ ///
+ public ZOverflowAction ZOverflow { get; set; } = ZOverflowAction.Refuse;
+
+ public string OutputDirectory { get; set; }
+
+ public IProgress Progress { get; set; }
+
+ public CancellationToken CancellationToken { get; set; }
+ }
+
+ public sealed class MapRegionCopyResult : IStaticsFilterStats
+ {
+ /// Folder the region came from, so a verification can read it back.
+ public string SourceDirectory { get; set; }
+
+ public int SourceFileIndex { get; set; }
+
+ /// The facet that was copied into.
+ public int DestinationFileIndex { get; set; }
+
+ public BlockRectangle Source { get; set; }
+
+ public BlockRectangle DestinationRegion { get; set; }
+
+ public int RequestedX1 { get; set; }
+
+ public int RequestedY1 { get; set; }
+
+ public int RequestedX2 { get; set; }
+
+ public int RequestedY2 { get; set; }
+
+ public MapSize SourceSize { get; set; }
+
+ public MapSize DestinationSize { get; set; }
+
+ public string OutputMapPath { get; set; }
+
+ public string OutputIndexPath { get; set; }
+
+ public string OutputStaticsPath { get; set; }
+
+ public long LandBlocksCopied { get; set; }
+
+ public long LandBlocksCarried { get; set; }
+
+ public long StaticBlocksCopied { get; set; }
+
+ public long StaticBlocksCarried { get; set; }
+
+ public long StaticsRead { get; set; }
+
+ public long StaticsWritten { get; set; }
+
+ public long LandIdsSanitized { get; set; }
+
+ public int ZAdjust { get; set; }
+
+ /// The z the region carried before the adjustment. Null when nothing was adjusted.
+ public MapRegionZSurvey SourceZ { get; set; }
+
+ public long LandZClamped { get; set; }
+
+ public long StaticsZClamped { get; set; }
+
+ public long DroppedInvalidItemId { get; set; }
+
+ public long DroppedOutOfBlock { get; set; }
+
+ public long MaskedOutOfBlock { get; set; }
+
+ public long DroppedInvalidZ { get; set; }
+
+ public long DuplicatesRemoved { get; set; }
+
+ public long HuesNormalized { get; set; }
+
+ public int HighestItemIdSeen { get; set; }
+
+ public TimeSpan Elapsed { get; set; }
+
+ public List Warnings { get; } = new List();
+
+ public List RejectSamples { get; } = new List();
+
+ public int RejectSampleLimit { get; set; } = 200;
+
+ public long StaticsAccountedFor => DroppedInvalidItemId + DroppedOutOfBlock + DroppedInvalidZ + DuplicatesRemoved;
+
+ /// Whether the block snap widened what the user asked for.
+ public bool RegionWasSnapped =>
+ Source.TileX1 != RequestedX1 || Source.TileY1 != RequestedY1 ||
+ Source.TileX2 != RequestedX2 || Source.TileY2 != RequestedY2;
+
+ void IStaticsFilterStats.TileRejected(int blockX, int blockY, StaticTile tile, RejectReason reason)
+ {
+ switch (reason)
+ {
+ case RejectReason.InvalidItemId:
+ ++DroppedInvalidItemId;
+ break;
+
+ case RejectReason.OutOfBlockOffset:
+ ++DroppedOutOfBlock;
+ break;
+
+ case RejectReason.InvalidZ:
+ ++DroppedInvalidZ;
+ break;
+
+ case RejectReason.Duplicate:
+ ++DuplicatesRemoved;
+ break;
+ }
+
+ if (RejectSamples.Count < RejectSampleLimit)
+ {
+ RejectSamples.Add(new RejectedStaticTile(blockX, blockY, tile, reason));
+ }
+ }
+
+ void IStaticsFilterStats.HueNormalized() => ++HuesNormalized;
+
+ void IStaticsFilterStats.OutOfBlockMasked() => ++MaskedOutOfBlock;
+
+ public string ToReport()
+ {
+ var sb = new StringBuilder();
+
+ sb.AppendLine(Line("Requested region : {0},{1} - {2},{3}", RequestedX1, RequestedY1, RequestedX2, RequestedY2));
+ sb.AppendLine(Line("Copied from : {0}", Source));
+ sb.AppendLine(Line("Copied to : {0}", DestinationRegion));
+
+ if (RegionWasSnapped)
+ {
+ sb.AppendLine(" the request was widened to whole 8-tile blocks");
+ }
+
+ sb.AppendLine();
+
+ if (ZAdjust != 0)
+ {
+ sb.AppendLine(Line("Z adjusted by : {0:+#;-#;0}", ZAdjust));
+ sb.AppendLine();
+ }
+
+ sb.AppendLine(Line("Source map size : {0}", SourceSize));
+ sb.AppendLine(Line("Target map size : {0}", DestinationSize));
+ sb.AppendLine();
+
+ if (OutputMapPath != null)
+ {
+ sb.AppendLine(Line("Map written : {0}", OutputMapPath));
+ sb.AppendLine(Line(" blocks : {0:N0} copied, {1:N0} carried over", LandBlocksCopied, LandBlocksCarried));
+
+ if (LandIdsSanitized > 0)
+ {
+ sb.AppendLine(Line(" land ids reset : {0:N0} were 0x4000 or above", LandIdsSanitized));
+ }
+
+ if (ZAdjust != 0 && SourceZ != null)
+ {
+ sb.AppendLine(Line(" land z : {0} -> {1}{2}", SourceZ.Land,
+ SourceZ.Land.Describe(ZAdjust),
+ LandZClamped > 0 ? Line(", {0:N0} held at the limit", LandZClamped) : string.Empty));
+ }
+ }
+
+ if (OutputIndexPath != null)
+ {
+ sb.AppendLine(Line("Statics written : {0}", OutputStaticsPath));
+ sb.AppendLine(Line(" blocks : {0:N0} copied, {1:N0} carried over", StaticBlocksCopied, StaticBlocksCarried));
+ sb.AppendLine(Line(" statics : {0:N0} read, {1:N0} written", StaticsRead, StaticsWritten));
+ sb.AppendLine(Line(" highest id : 0x{0:X4}", HighestItemIdSeen));
+
+ if (ZAdjust != 0 && SourceZ != null)
+ {
+ sb.AppendLine(Line(" static z : {0} -> {1}{2}", SourceZ.Statics,
+ SourceZ.Statics.Describe(ZAdjust),
+ StaticsZClamped > 0 ? Line(", {0:N0} held at the limit", StaticsZClamped) : string.Empty));
+ }
+
+ if (StaticsAccountedFor > 0 || HuesNormalized > 0 || MaskedOutOfBlock > 0)
+ {
+ sb.AppendLine(Line(" removed : {0:N0} invalid id, {1:N0} out-of-block ({2:N0} masked), {3:N0} bad z, {4:N0} duplicates",
+ DroppedInvalidItemId, DroppedOutOfBlock, MaskedOutOfBlock, DroppedInvalidZ, DuplicatesRemoved));
+ sb.AppendLine(Line(" hues reset : {0:N0}", HuesNormalized));
+ }
+ }
+
+ sb.AppendLine();
+ sb.AppendLine(Line("Elapsed : {0:hh\\:mm\\:ss\\.fff}", Elapsed));
+
+ if (Warnings.Count > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine(Line("Warnings ({0}):", Warnings.Count));
+
+ foreach (string warning in Warnings)
+ {
+ sb.AppendLine(" " + warning);
+ }
+ }
+
+ if (RejectSamples.Count > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine(Line("Removed static samples (first {0}):", RejectSamples.Count));
+
+ foreach (RejectedStaticTile sample in RejectSamples)
+ {
+ sb.AppendLine(" " + sample);
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ private static string Line(string format, params object[] args)
+ {
+ return string.Format(CultureInfo.InvariantCulture, format, args);
+ }
+ }
+
+ ///
+ /// Copies a rectangle of blocks from one client's facet into another, rewriting the destination
+ /// facet's map and statics files in full.
+ ///
+ public static class MapRegionCopier
+ {
+ private const int ProgressInterval = 256;
+
+ public static MapRegionCopyResult Run(MapRegionCopyOptions options)
+ {
+ if (options == null)
+ {
+ throw new ArgumentNullException(nameof(options));
+ }
+
+ if (options.Destination == null)
+ {
+ throw new MapRegionCopyException("No destination map was given.");
+ }
+
+ var result = new MapRegionCopyResult();
+ var stopwatch = Stopwatch.StartNew();
+
+ MapSize source = options.SourceSize;
+ var destination = new MapSize(options.Destination.Width, options.Destination.Height);
+
+ result.SourceSize = source;
+ result.DestinationSize = destination;
+ result.SourceDirectory = options.SourceDirectory;
+ result.SourceFileIndex = options.SourceFileIndex;
+ result.DestinationFileIndex = options.Destination.FileIndex;
+
+ if (source.IsEmpty)
+ {
+ throw new MapRegionCopyException("The source map size is not known.");
+ }
+
+ Normalise(options, result, source, destination);
+
+ WarnAboutOverrides(options.SourceDirectory, options.SourceFileIndex, "source", result);
+ WarnAboutOverrides(ClientDirectory(options.Destination.FileIndex), options.Destination.FileIndex, "destination", result);
+
+ result.ZAdjust = options.ZAdjust;
+
+ if (options.ZAdjust != 0)
+ {
+ SurveyZ(options, result, source);
+ }
+
+ if (options.CopyLand)
+ {
+ CopyLand(options, result, source, destination);
+ }
+
+ if (options.CopyStatics)
+ {
+ CopyStatics(options, result, source, destination);
+ }
+
+ result.Elapsed = stopwatch.Elapsed;
+
+ return result;
+ }
+
+ ///
+ /// Reads the region's z before a byte is written, so a shift that does not fit is refused
+ /// while refusing still costs nothing. The tally is kept for the report either way.
+ ///
+ private static void SurveyZ(MapRegionCopyOptions options, MapRegionCopyResult result, MapSize source)
+ {
+ MapRegionZSurvey survey = MapRegionZSurvey.Survey(options.SourceDirectory, options.SourceFileIndex,
+ source, result.Source, options.CopyLand, options.CopyStatics, options.Progress,
+ options.CancellationToken);
+
+ result.SourceZ = survey;
+
+ foreach (string warning in survey.Warnings)
+ {
+ result.Warnings.Add(warning);
+ }
+
+ if (options.ZOverflow == ZOverflowAction.Clamp || !survey.Overflows(options.ZAdjust))
+ {
+ return;
+ }
+
+ long land = survey.Land.OutOfRangeAfter(options.ZAdjust);
+ long statics = survey.Statics.OutOfRangeAfter(options.ZAdjust);
+
+ var sb = new StringBuilder();
+
+ sb.Append(string.Format(CultureInfo.InvariantCulture,
+ "Adjusting z by {0:+#;-#;0} would push tiles outside the -128..127 the files hold.", options.ZAdjust));
+
+ if (land > 0)
+ {
+ sb.Append(string.Format(CultureInfo.InvariantCulture,
+ " Land runs {0} and {1:N0} tiles would not fit.", survey.Land, land));
+ }
+
+ if (statics > 0)
+ {
+ sb.Append(string.Format(CultureInfo.InvariantCulture,
+ " Statics run {0} and {1:N0} would not fit.", survey.Statics, statics));
+ }
+
+ sb.Append(string.Format(CultureInfo.InvariantCulture,
+ " The region takes {0:+#;-#;0} to {1:+#;-#;0} without losing anything.",
+ -Headroom(survey, false), Headroom(survey, true)));
+
+ throw new MapRegionCopyException(sb.ToString());
+ }
+
+ /// The largest shift the region takes in one direction with nothing hitting a limit.
+ private static int Headroom(MapRegionZSurvey survey, bool up)
+ {
+ int land = up ? survey.Land.HeadroomUp : survey.Land.HeadroomDown;
+ int statics = up ? survey.Statics.HeadroomUp : survey.Statics.HeadroomDown;
+
+ if (!survey.Land.HasTiles)
+ {
+ return statics;
+ }
+
+ if (!survey.Statics.HasTiles)
+ {
+ return land;
+ }
+
+ return Math.Min(land, statics);
+ }
+
+ /// Moves a static's z, holding it at the limit rather than wrapping round it.
+ private static void ShiftStaticsZ(List tiles, int adjust, MapRegionCopyResult result)
+ {
+ for (int i = 0; i < tiles.Count; ++i)
+ {
+ StaticTile tile = tiles[i];
+
+ int z = tile.Z + adjust;
+
+ if (z < ZHistogram.MinZ || z > ZHistogram.MaxZ)
+ {
+ z = Math.Clamp(z, ZHistogram.MinZ, ZHistogram.MaxZ);
+ ++result.StaticsZClamped;
+ }
+
+ tile.Z = (sbyte)z;
+ tiles[i] = tile;
+ }
+ }
+
+ /// Moves the z of a land block's 64 cells, in the block's own bytes.
+ private static long ShiftLandZ(Span block, int adjust)
+ {
+ long clamped = 0;
+
+ for (int i = 0; i < 64; ++i)
+ {
+ int at = TileMatrix.BlockHeaderSize + (i * 3) + 2;
+
+ int z = (sbyte)block[at] + adjust;
+
+ if (z < ZHistogram.MinZ || z > ZHistogram.MaxZ)
+ {
+ z = Math.Clamp(z, ZHistogram.MinZ, ZHistogram.MaxZ);
+ ++clamped;
+ }
+
+ block[at] = (byte)(sbyte)z;
+ }
+
+ return clamped;
+ }
+
+ private static void Normalise(MapRegionCopyOptions options, MapRegionCopyResult result,
+ MapSize source, MapSize destination)
+ {
+ int x1 = options.SourceX1;
+ int y1 = options.SourceY1;
+ int x2 = options.SourceX2;
+ int y2 = options.SourceY2;
+
+ // A reversed rectangle is a slip, not an error worth refusing over.
+ if (x1 > x2)
+ {
+ (x1, x2) = (x2, x1);
+ }
+
+ if (y1 > y2)
+ {
+ (y1, y2) = (y2, y1);
+ }
+
+ result.RequestedX1 = x1;
+ result.RequestedY1 = y1;
+ result.RequestedX2 = x2;
+ result.RequestedY2 = y2;
+
+ Require(x1 >= 0 && x1 < source.Width, $"Source X1 {x1} is outside the source map, which is {source.Width} tiles wide.");
+ Require(x2 >= 0 && x2 < source.Width, $"Source X2 {x2} is outside the source map, which is {source.Width} tiles wide.");
+ Require(y1 >= 0 && y1 < source.Height, $"Source Y1 {y1} is outside the source map, which is {source.Height} tiles tall.");
+ Require(y2 >= 0 && y2 < source.Height, $"Source Y2 {y2} is outside the source map, which is {source.Height} tiles tall.");
+
+ // Whole blocks only - the files have no finer granularity.
+ var sourceBlocks = new BlockRectangle(x1 >> 3, y1 >> 3, x2 >> 3, y2 >> 3);
+
+ int destinationBlockX = options.DestinationX >> 3;
+ int destinationBlockY = options.DestinationY >> 3;
+
+ var destinationBlocks = new BlockRectangle(
+ destinationBlockX,
+ destinationBlockY,
+ destinationBlockX + sourceBlocks.BlockWidth - 1,
+ destinationBlockY + sourceBlocks.BlockHeight - 1);
+
+ result.Source = sourceBlocks;
+ result.DestinationRegion = destinationBlocks;
+
+ Require(sourceBlocks.BlockX2 < source.BlockWidth,
+ $"The region reaches source block column {sourceBlocks.BlockX2}, but the source map has {source.BlockWidth}.");
+ Require(sourceBlocks.BlockY2 < source.BlockHeight,
+ $"The region reaches source block row {sourceBlocks.BlockY2}, but the source map has {source.BlockHeight}.");
+
+ Require(destinationBlocks.BlockX1 >= 0 && destinationBlocks.BlockY1 >= 0,
+ "The destination position is negative.");
+ Require(destinationBlocks.BlockX2 < destination.BlockWidth,
+ $"The region would reach destination block column {destinationBlocks.BlockX2}, but the destination map has {destination.BlockWidth}.");
+ Require(destinationBlocks.BlockY2 < destination.BlockHeight,
+ $"The region would reach destination block row {destinationBlocks.BlockY2}, but the destination map has {destination.BlockHeight}.");
+ }
+
+ private static void Require(bool condition, string message)
+ {
+ if (!condition)
+ {
+ throw new MapRegionCopyException(message);
+ }
+ }
+
+ private static void CopyLand(MapRegionCopyOptions options, MapRegionCopyResult result,
+ MapSize source, MapSize destination)
+ {
+ var sourceMatrix = new TileMatrix(options.SourceFileIndex, options.SourceFileIndex,
+ source.Width, source.Height, options.SourceDirectory);
+
+ TileMatrix destinationMatrix = options.Destination.Tiles;
+
+ try
+ {
+ long blockCount = destination.BlockCount;
+ int done = 0;
+
+ using (IMapBlockSink sink = MapBlockSink.Create(options.OutputDirectory,
+ options.Destination.FileIndex, options.MapFormat, blockCount))
+ {
+ Span block = stackalloc byte[TileMatrix.MapBlockSize];
+
+ for (int x = 0; x < destination.BlockWidth; ++x)
+ {
+ for (int y = 0; y < destination.BlockHeight; ++y)
+ {
+ bool inRegion = InRegion(result.DestinationRegion, x, y);
+
+ if (inRegion)
+ {
+ sourceMatrix.ReadLandBlockBytes(
+ x - result.DestinationRegion.BlockX1 + result.Source.BlockX1,
+ y - result.DestinationRegion.BlockY1 + result.Source.BlockY1,
+ block);
+
+ if (options.ZAdjust != 0)
+ {
+ result.LandZClamped += ShiftLandZ(block, options.ZAdjust);
+ }
+
+ ++result.LandBlocksCopied;
+ }
+ else
+ {
+ destinationMatrix.ReadLandBlockBytes(x, y, block);
+ ++result.LandBlocksCarried;
+ }
+
+ if (options.SanitizeLandIds)
+ {
+ result.LandIdsSanitized += SanitizeLandIds(block);
+ }
+
+ sink.WriteBlock(block);
+
+ if ((++done & (ProgressInterval - 1)) == 0)
+ {
+ options.CancellationToken.ThrowIfCancellationRequested();
+ Report(options, "Copying land", done, (int)blockCount);
+ }
+ }
+ }
+
+ sink.Complete();
+ result.OutputMapPath = sink.OutputPath;
+ }
+
+ Report(options, "Copying land", (int)blockCount, (int)blockCount);
+ }
+ finally
+ {
+ sourceMatrix.CloseStreams();
+ }
+ }
+
+ ///
+ /// Land art only reaches 0x3FFF; anything above that is not a land tile the client can draw.
+ ///
+ private static int SanitizeLandIds(Span block)
+ {
+ int reset = 0;
+
+ for (int i = 0; i < 64; ++i)
+ {
+ int at = TileMatrix.BlockHeaderSize + (i * 3);
+ ushort id = (ushort)(block[at] | (block[at + 1] << 8));
+
+ if (id < 0x4000)
+ {
+ continue;
+ }
+
+ block[at] = 0;
+ block[at + 1] = 0;
+ ++reset;
+ }
+
+ return reset;
+ }
+
+ private static void CopyStatics(MapRegionCopyOptions options, MapRegionCopyResult result,
+ MapSize source, MapSize destination)
+ {
+ string sourceIndex = Require(Path.Combine(options.SourceDirectory, $"staidx{options.SourceFileIndex}.mul"));
+ string sourceStatics = Require(Path.Combine(options.SourceDirectory, $"statics{options.SourceFileIndex}.mul"));
+
+ int destinationIndexFile = options.Destination.FileIndex;
+
+ string destinationIndex = ResolveLoaded($"staidx{destinationIndexFile}.mul");
+ string destinationStatics = ResolveLoaded($"statics{destinationIndexFile}.mul");
+
+ string outputIndex = Path.Combine(options.OutputDirectory, $"staidx{destinationIndexFile}.mul");
+ string outputStatics = Path.Combine(options.OutputDirectory, $"statics{destinationIndexFile}.mul");
+
+ RefuseToOverwrite(sourceIndex, outputIndex);
+ RefuseToOverwrite(destinationIndex, outputIndex);
+ RefuseToOverwrite(sourceStatics, outputStatics);
+ RefuseToOverwrite(destinationStatics, outputStatics);
+
+ Directory.CreateDirectory(options.OutputDirectory);
+
+ string tempIndex = outputIndex + ".tmp-" + Guid.NewGuid().ToString("N");
+ string tempStatics = outputStatics + ".tmp-" + Guid.NewGuid().ToString("N");
+
+ var problems = new StaticsBlockProblems();
+ var tiles = new List(256);
+
+ try
+ {
+ using (StaticsIndexReader sourceReader = StaticsIndexReader.Open(sourceIndex, sourceStatics,
+ source.BlockWidth, source.BlockHeight, result.Warnings))
+ using (StaticsIndexReader destinationReader = StaticsIndexReader.Open(destinationIndex, destinationStatics,
+ destination.BlockWidth, destination.BlockHeight, result.Warnings))
+ using (var outIndexStream = new FileStream(tempIndex, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1 << 20))
+ using (var outStaticsStream = new FileStream(tempStatics, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1 << 20))
+ using (var writer = new StaticsBlockWriter(outIndexStream, outStaticsStream,
+ destination.BlockWidth, destination.BlockHeight, EmptyBlockStyle.NegativeOne, true))
+ {
+ int done = 0;
+ int blockCount = destination.BlockWidth * destination.BlockHeight;
+
+ for (int x = 0; x < destination.BlockWidth; ++x)
+ {
+ for (int y = 0; y < destination.BlockHeight; ++y)
+ {
+ tiles.Clear();
+
+ bool inRegion = InRegion(result.DestinationRegion, x, y);
+
+ int readX = inRegion ? x - result.DestinationRegion.BlockX1 + result.Source.BlockX1 : x;
+ int readY = inRegion ? y - result.DestinationRegion.BlockY1 + result.Source.BlockY1 : y;
+
+ StaticsIndexReader reader = inRegion ? sourceReader : destinationReader;
+
+ result.StaticsRead += reader.ReadBlock(readX, readY, tiles, result.Warnings, problems);
+
+ if (inRegion)
+ {
+ ++result.StaticBlocksCopied;
+ }
+ else
+ {
+ ++result.StaticBlocksCarried;
+ }
+
+ options.StaticsFilter?.Apply(tiles, x, y, result);
+
+ // After the filter: what it judges is the source data, not a shifted
+ // copy of it, so a static sitting at the sentinel z is still seen as one.
+ if (inRegion && options.ZAdjust != 0)
+ {
+ ShiftStaticsZ(tiles, options.ZAdjust, result);
+ }
+
+ writer.WriteBlock(x, y, tiles, reader.GetEntry(readX, readY).Extra);
+
+ if ((++done & (ProgressInterval - 1)) == 0)
+ {
+ options.CancellationToken.ThrowIfCancellationRequested();
+ Report(options, "Copying statics", done, blockCount);
+ }
+ }
+ }
+
+ writer.Complete();
+
+ result.StaticsWritten = writer.TilesWritten;
+
+ Report(options, "Copying statics", blockCount, blockCount);
+ }
+
+ File.Move(tempStatics, outputStatics, true);
+ tempStatics = null;
+
+ File.Move(tempIndex, outputIndex, true);
+ tempIndex = null;
+
+ result.OutputIndexPath = Path.GetFullPath(outputIndex);
+ result.OutputStaticsPath = Path.GetFullPath(outputStatics);
+
+ if (options.StaticsFilter != null)
+ {
+ result.HighestItemIdSeen = options.StaticsFilter.HighestItemIdSeen;
+ }
+
+ if (problems.BadLookup > 0 || problems.BadLength > 0 || problems.OutOfRange > 0)
+ {
+ result.Warnings.Add(string.Format(CultureInfo.InvariantCulture,
+ "Damaged index records: {0:N0} bad lookup, {1:N0} bad length, {2:N0} out of range.",
+ problems.BadLookup, problems.BadLength, problems.OutOfRange));
+ }
+ }
+ finally
+ {
+ MapBlockSink.TryDelete(tempIndex);
+ MapBlockSink.TryDelete(tempStatics);
+ }
+ }
+
+ private static bool InRegion(BlockRectangle region, int x, int y)
+ {
+ return x >= region.BlockX1 && x <= region.BlockX2 && y >= region.BlockY1 && y <= region.BlockY2;
+ }
+
+ private static void Report(MapRegionCopyOptions options, string stage, int done, int total)
+ {
+ options.Progress?.Report(new MapCopyProgress { Stage = stage, BlocksDone = done, BlocksTotal = total });
+ }
+
+ private static string Require(string path)
+ {
+ if (!File.Exists(path))
+ {
+ throw new MapRegionCopyException($"{path} was not found.");
+ }
+
+ return Path.GetFullPath(path);
+ }
+
+ private static string ResolveLoaded(string fileName)
+ {
+ string path = Files.GetFilePath(fileName);
+
+ if (path == null)
+ {
+ throw new MapRegionCopyException(
+ $"{fileName} was not found. Check the path settings for the loaded client.");
+ }
+
+ return Path.GetFullPath(path);
+ }
+
+ private static void RefuseToOverwrite(string sourcePath, string outputPath)
+ {
+ if (!string.Equals(sourcePath, Path.GetFullPath(outputPath), StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ throw new MapRegionCopyException(
+ $"The output directory holds a file being read ({outputPath}). Choose a different output directory.");
+ }
+
+ private static string ClientDirectory(int fileIndex)
+ {
+ string path = Files.GetFilePath($"staidx{fileIndex}.mul") ?? Files.GetFilePath($"map{fileIndex}.mul");
+
+ return path == null ? null : Path.GetDirectoryName(path);
+ }
+
+ private static void WarnAboutOverrides(string directory, int fileIndex, string which, MapRegionCopyResult result)
+ {
+ if (directory == null)
+ {
+ return;
+ }
+
+ if (File.Exists(Path.Combine(directory, $"staidx{fileIndex}x.mul")) ||
+ File.Exists(Path.Combine(directory, $"map{fileIndex}xLegacyMUL.uop")))
+ {
+ result.Warnings.Add(
+ $"The {which} client ships facet {fileIndex} override files (staidx{fileIndex}x.mul or map{fileIndex}xLegacyMUL.uop). " +
+ "The client prefers those over the pair being written here, so the result may not show up in game.");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Ultima/Maps/MapRegionZ.cs b/Ultima/Maps/MapRegionZ.cs
new file mode 100644
index 00000000..bb06c4d0
--- /dev/null
+++ b/Ultima/Maps/MapRegionZ.cs
@@ -0,0 +1,246 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Threading;
+using Ultima.Statics;
+
+namespace Ultima.Maps
+{
+ ///
+ /// What to do with a tile an adjustment would push outside the z the files can hold.
+ ///
+ public enum ZOverflowAction
+ {
+ /// Refuse the run, before anything is written.
+ Refuse,
+
+ /// Hold the tile at the limit it ran past.
+ Clamp
+ }
+
+ ///
+ /// A tally of the z values in a region, one bucket per value the format can hold. Small enough
+ /// to keep around, which means any question about shifting the region is answered without
+ /// reading the files again.
+ ///
+ ///
+ /// Both a land tile's z and a static's z are a signed byte, so -128 to 127 is the whole of what
+ /// either file can carry.
+ ///
+ public sealed class ZHistogram
+ {
+ /// Lowest z either file format can hold.
+ public const int MinZ = sbyte.MinValue;
+
+ /// Highest z either file format can hold.
+ public const int MaxZ = sbyte.MaxValue;
+
+ private readonly long[] _counts = new long[256];
+
+ public long Total { get; private set; }
+
+ public bool HasTiles => Total > 0;
+
+ public int Min { get; private set; } = MaxZ;
+
+ public int Max { get; private set; } = MinZ;
+
+ public void Add(int z)
+ {
+ ++_counts[z - MinZ];
+ ++Total;
+
+ if (z < Min)
+ {
+ Min = z;
+ }
+
+ if (z > Max)
+ {
+ Max = z;
+ }
+ }
+
+ /// How many tiles a shift of would push past a limit.
+ public long OutOfRangeAfter(int adjust)
+ {
+ if (!HasTiles || adjust == 0)
+ {
+ return 0;
+ }
+
+ long count = 0;
+
+ for (int z = Min; z <= Max; ++z)
+ {
+ int shifted = z + adjust;
+
+ if (shifted < MinZ || shifted > MaxZ)
+ {
+ count += _counts[z - MinZ];
+ }
+ }
+
+ return count;
+ }
+
+ /// The largest shift up that keeps every tile inside the format, or 0 when none is needed.
+ public int HeadroomUp => HasTiles ? MaxZ - Max : 0;
+
+ /// The largest shift down that keeps every tile inside the format.
+ public int HeadroomDown => HasTiles ? Min - MinZ : 0;
+
+ public override string ToString()
+ {
+ return HasTiles
+ ? string.Format(CultureInfo.InvariantCulture, "{0} to {1}", Min, Max)
+ : "none";
+ }
+
+ /// The range this becomes when shifted, clamped to what the format holds.
+ public string Describe(int adjust)
+ {
+ if (!HasTiles)
+ {
+ return "none";
+ }
+
+ return string.Format(CultureInfo.InvariantCulture, "{0} to {1}",
+ Math.Clamp(Min + adjust, MinZ, MaxZ), Math.Clamp(Max + adjust, MinZ, MaxZ));
+ }
+ }
+
+ ///
+ /// The z a region carries, land and statics separately, read straight from the files.
+ ///
+ public sealed class MapRegionZSurvey
+ {
+ public ZHistogram Land { get; } = new ZHistogram();
+
+ public ZHistogram Statics { get; } = new ZHistogram();
+
+ public List Warnings { get; } = new List();
+
+ /// True when a shift of would push something past a limit.
+ public bool Overflows(int adjust) => OutOfRange(adjust) > 0;
+
+ public long OutOfRange(int adjust) => Land.OutOfRangeAfter(adjust) + Statics.OutOfRangeAfter(adjust);
+
+ ///
+ /// Reads the z of every land tile and static in a region of a facet on disk. Proportional to
+ /// the region, not the facet, so it is worth doing before a copy rather than during one.
+ ///
+ public static MapRegionZSurvey Survey(string directory, int fileIndex, MapSize size,
+ BlockRectangle region, bool land, bool statics,
+ IProgress progress = null, CancellationToken cancellationToken = default)
+ {
+ if (size.IsEmpty)
+ {
+ throw new ArgumentException("The map size is not known.", nameof(size));
+ }
+
+ var survey = new MapRegionZSurvey();
+
+ int blocks = region.BlockWidth * region.BlockHeight;
+ int done = 0;
+
+ if (land)
+ {
+ var matrix = new TileMatrix(fileIndex, fileIndex, size.Width, size.Height, directory);
+
+ try
+ {
+ Span block = stackalloc byte[TileMatrix.MapBlockSize];
+
+ for (int x = region.BlockX1; x <= region.BlockX2; ++x)
+ {
+ for (int y = region.BlockY1; y <= region.BlockY2; ++y)
+ {
+ matrix.ReadLandBlockBytes(x, y, block);
+
+ for (int i = 0; i < 64; ++i)
+ {
+ survey.Land.Add((sbyte)block[TileMatrix.BlockHeaderSize + (i * 3) + 2]);
+ }
+
+ if ((++done & 255) == 0)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Report(progress, "Reading land heights", done, blocks);
+ }
+ }
+ }
+ }
+ finally
+ {
+ matrix.CloseStreams();
+ }
+ }
+
+ if (!statics)
+ {
+ return survey;
+ }
+
+ string indexPath = Path.Combine(directory, $"staidx{fileIndex}.mul");
+ string staticsPath = Path.Combine(directory, $"statics{fileIndex}.mul");
+
+ if (!File.Exists(indexPath) || !File.Exists(staticsPath))
+ {
+ survey.Warnings.Add($"staidx{fileIndex}.mul or statics{fileIndex}.mul was not found in {directory}.");
+
+ return survey;
+ }
+
+ var problems = new StaticsBlockProblems();
+ var tiles = new List(256);
+
+ done = 0;
+
+ using (StaticsIndexReader reader = StaticsIndexReader.Open(indexPath, staticsPath,
+ size.BlockWidth, size.BlockHeight, survey.Warnings))
+ {
+ for (int x = region.BlockX1; x <= region.BlockX2; ++x)
+ {
+ for (int y = region.BlockY1; y <= region.BlockY2; ++y)
+ {
+ tiles.Clear();
+ reader.ReadBlock(x, y, tiles, survey.Warnings, problems);
+
+ foreach (StaticTile tile in tiles)
+ {
+ survey.Statics.Add(tile.Z);
+ }
+
+ if ((++done & 255) == 0)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Report(progress, "Reading static heights", done, blocks);
+ }
+ }
+ }
+ }
+
+ Report(progress, "Reading static heights", blocks, blocks);
+
+ return survey;
+ }
+
+ private static void Report(IProgress progress, string stage, int done, int total)
+ {
+ progress?.Report(new MapCopyProgress { Stage = stage, BlocksDone = done, BlocksTotal = total });
+ }
+ }
+}
\ No newline at end of file
diff --git a/Ultima/Maps/MapSizes.cs b/Ultima/Maps/MapSizes.cs
new file mode 100644
index 00000000..e7b4126d
--- /dev/null
+++ b/Ultima/Maps/MapSizes.cs
@@ -0,0 +1,220 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using Ultima.Uop;
+
+namespace Ultima.Maps
+{
+ public readonly struct MapSize : IEquatable
+ {
+ public MapSize(int width, int height)
+ {
+ Width = width;
+ Height = height;
+ }
+
+ public int Width { get; }
+
+ public int Height { get; }
+
+ public int BlockWidth => Width >> 3;
+
+ public int BlockHeight => Height >> 3;
+
+ public long BlockCount => (long)BlockWidth * BlockHeight;
+
+ public bool IsEmpty => Width <= 0 || Height <= 0;
+
+ public bool Equals(MapSize other)
+ {
+ return Width == other.Width && Height == other.Height;
+ }
+
+ public override bool Equals(object obj)
+ {
+ return obj is MapSize other && Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+ return (Width * 397) ^ Height;
+ }
+
+ public static bool operator ==(MapSize left, MapSize right) => left.Equals(right);
+
+ public static bool operator !=(MapSize left, MapSize right) => !left.Equals(right);
+
+ public override string ToString()
+ {
+ return string.Format(CultureInfo.InvariantCulture, "{0} x {1}", Width, Height);
+ }
+ }
+
+ ///
+ /// Works out how big a facet's files actually are, rather than trusting a hardcoded table.
+ ///
+ ///
+ /// A facet cannot be identified by file length alone - map3.mul and map5.mul are both
+ /// 16,056,320 bytes for different block grids - so detection is always per file index.
+ /// staidx{N}.mul is the preferred signal because it is a flat array of 12-byte records with no
+ /// container around it and it ships even on clients whose maps are UOP-only.
+ ///
+ public static class MapSizes
+ {
+ private const int IndexRecordSize = 12;
+ private const int MapBlockSize = 196;
+
+ private static readonly MapSize[] _facet01 = { new MapSize(6144, 4096), new MapSize(7168, 4096) };
+ private static readonly MapSize[] _facet2 = { new MapSize(2304, 1600) };
+ private static readonly MapSize[] _facet3 = { new MapSize(2560, 2048) };
+ private static readonly MapSize[] _facet4 = { new MapSize(1448, 1448) };
+ private static readonly MapSize[] _facet5 = { new MapSize(1280, 4096) };
+
+ ///
+ /// The shapes a facet is known to ship in. Facets 0 and 1 both have a pre-T2A 6144-wide
+ /// form and the modern 7168-wide one, and an install can legitimately mix the two.
+ ///
+ public static IReadOnlyList Candidates(int fileIndex)
+ {
+ switch (fileIndex)
+ {
+ case 0:
+ case 1: return _facet01;
+ case 2: return _facet2;
+ case 3: return _facet3;
+ case 4: return _facet4;
+ case 5: return _facet5;
+ default: return Array.Empty();
+ }
+ }
+
+ ///
+ /// What to assume when nothing can be measured.
+ ///
+ public static MapSize Fallback(int fileIndex)
+ {
+ IReadOnlyList candidates = Candidates(fileIndex);
+
+ // For facets 0 and 1 the modern shape is the safer guess: reading a 7168-wide grid off a
+ // 6144-wide file yields empty tail blocks, while the reverse silently loses the east edge.
+ return candidates.Count == 0 ? default : candidates[candidates.Count - 1];
+ }
+
+ ///
+ /// Detects the shape of a facet in the given directory.
+ ///
+ /// Always set: what was measured, in a form fit to show a user.
+ /// True when the measured block count matched a known shape.
+ public static bool TryDetect(string directory, int fileIndex, out MapSize size, out string evidence)
+ {
+ return TryDetect(name => Resolve(directory, name), fileIndex, out size, out evidence);
+ }
+
+ ///
+ /// Detects the shape of a facet in the currently loaded client.
+ ///
+ public static bool TryDetect(int fileIndex, out MapSize size, out string evidence)
+ {
+ return TryDetect(Files.GetFilePath, fileIndex, out size, out evidence);
+ }
+
+ private static bool TryDetect(Func resolve, int fileIndex, out MapSize size, out string evidence)
+ {
+ if (!TryMeasureBlocks(resolve, fileIndex, out long blocks, out string source))
+ {
+ size = Fallback(fileIndex);
+ evidence = string.Format(CultureInfo.InvariantCulture,
+ "no map or statics index found for facet {0}; assuming {1}", fileIndex, size);
+
+ return false;
+ }
+
+ foreach (MapSize candidate in Candidates(fileIndex))
+ {
+ if (candidate.BlockCount != blocks)
+ {
+ continue;
+ }
+
+ size = candidate;
+ evidence = string.Format(CultureInfo.InvariantCulture,
+ "{0} holds {1:N0} blocks, which is {2}", source, blocks, candidate);
+
+ return true;
+ }
+
+ size = Fallback(fileIndex);
+ evidence = string.Format(CultureInfo.InvariantCulture,
+ "{0} holds {1:N0} blocks, which matches no known shape for facet {2}; assuming {3}",
+ source, blocks, fileIndex, size);
+
+ return false;
+ }
+
+ private static bool TryMeasureBlocks(Func resolve, int fileIndex, out long blocks, out string source)
+ {
+ string indexPath = resolve($"staidx{fileIndex}.mul");
+
+ if (indexPath != null)
+ {
+ blocks = new FileInfo(indexPath).Length / IndexRecordSize;
+ source = $"staidx{fileIndex}.mul";
+
+ return true;
+ }
+
+ string mapPath = resolve($"map{fileIndex}.mul");
+
+ if (mapPath != null)
+ {
+ blocks = new FileInfo(mapPath).Length / MapBlockSize;
+ source = $"map{fileIndex}.mul";
+
+ return true;
+ }
+
+ string uopPath = resolve($"map{fileIndex}LegacyMUL.uop");
+
+ if (uopPath != null)
+ {
+ // Every shipped container carries one block more than the facet has, so the payload
+ // length overshoots by exactly one block.
+ long payloadBlocks = MapUopReader.TotalPayloadLength(MapUopReader.ReadEntryTable(uopPath)) / MapBlockSize;
+
+ blocks = payloadBlocks - 1;
+ source = $"map{fileIndex}LegacyMUL.uop";
+
+ return true;
+ }
+
+ blocks = 0;
+ source = null;
+
+ return false;
+ }
+
+ private static string Resolve(string directory, string fileName)
+ {
+ if (string.IsNullOrEmpty(directory))
+ {
+ return null;
+ }
+
+ string path = Path.Combine(directory, fileName);
+
+ return File.Exists(path) ? path : null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Ultima/ModifiedIndexTracker.cs b/Ultima/ModifiedIndexTracker.cs
new file mode 100644
index 00000000..cf284eb6
--- /dev/null
+++ b/Ultima/ModifiedIndexTracker.cs
@@ -0,0 +1,55 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System.Collections.Generic;
+
+namespace Ultima
+{
+ ///
+ /// Tracks which indexes of a file type have been edited since the data was loaded or last saved.
+ /// Kept deliberately apart from the replaced-bitmap dictionaries: those hold the live edits and
+ /// must survive a save, whereas a mark here only means "changed and not written out yet".
+ ///
+ public sealed class ModifiedIndexTracker
+ {
+ private readonly HashSet _marked = new HashSet();
+
+ ///
+ /// Number of indexes currently marked as modified.
+ ///
+ public int Count => _marked.Count;
+
+ ///
+ /// Marks as modified.
+ ///
+ public void Mark(int index)
+ {
+ _marked.Add(index);
+ }
+
+ ///
+ /// Tests whether was modified. Called once per tile per paint, so the
+ /// empty case skips hashing altogether.
+ ///
+ public bool IsMarked(int index)
+ {
+ return _marked.Count != 0 && _marked.Contains(index);
+ }
+
+ ///
+ /// Drops every mark. Called on reload and after a successful save.
+ ///
+ public void Clear()
+ {
+ _marked.Clear();
+ }
+ }
+}
diff --git a/Ultima/Statics/StaticsBlockIo.cs b/Ultima/Statics/StaticsBlockIo.cs
new file mode 100644
index 00000000..796b16ac
--- /dev/null
+++ b/Ultima/Statics/StaticsBlockIo.cs
@@ -0,0 +1,614 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Runtime.InteropServices;
+
+namespace Ultima.Statics
+{
+ ///
+ /// Where a filter reports what it removed. Implemented by the defrag and copy result types so
+ /// each feature counts into its own report.
+ ///
+ public interface IStaticsFilterStats
+ {
+ void TileRejected(int blockX, int blockY, StaticTile tile, RejectReason reason);
+
+ void HueNormalized();
+
+ void OutOfBlockMasked();
+ }
+
+ ///
+ /// Reads a staidx/statics pair block by block, with the bounds and length checks the format
+ /// needs. Shared by the defrag, the region copy and the diff apply so those checks exist once.
+ ///
+ ///
+ /// The index is a flat array of 12-byte {lookup, length, extra} records addressed as
+ /// blockX * blockHeight + blockY, and lookup == -1 means the block has no statics.
+ ///
+ public sealed class StaticsIndexReader : IDisposable
+ {
+ internal const int IndexRecordSize = 12;
+ internal const int TileRecordSize = 7;
+
+ private readonly Stream _statics;
+ private readonly bool _leaveOpen;
+ private readonly Stream _index;
+
+ private byte[] _buffer = Array.Empty();
+
+ public StaticsIndexReader(Stream index, Stream statics, int blockWidth, int blockHeight,
+ IList warnings = null, bool leaveOpen = false, string label = null)
+ {
+ _index = index ?? throw new ArgumentNullException(nameof(index));
+ _statics = statics ?? throw new ArgumentNullException(nameof(statics));
+ _leaveOpen = leaveOpen;
+
+ BlockWidth = blockWidth;
+ BlockHeight = blockHeight;
+ Label = label ?? "staidx";
+
+ int blockCount = blockWidth * blockHeight;
+
+ EntriesInFile = (int)Math.Min(index.Length / IndexRecordSize, int.MaxValue / IndexRecordSize);
+ Entries = new Entry3D[Math.Max(EntriesInFile, blockCount)];
+
+ index.Seek(0, SeekOrigin.Begin);
+ index.ReadExactly(MemoryMarshal.AsBytes(Entries.AsSpan(0, EntriesInFile)));
+
+ // A short index is not an error - the tail is simply empty, which is how TileMatrix
+ // treats it too.
+ for (int i = EntriesInFile; i < Entries.Length; ++i)
+ {
+ Entries[i].Lookup = -1;
+ Entries[i].Length = -1;
+ Entries[i].Extra = -1;
+ }
+
+ if (EntriesInFile < blockCount)
+ {
+ warnings?.Add(string.Format(CultureInfo.InvariantCulture,
+ "{0} only covers {1:N0} of the {2:N0} blocks the configured map size needs. The remaining blocks are empty.",
+ Label, EntriesInFile, blockCount));
+ }
+ }
+
+ public static StaticsIndexReader Open(string indexPath, string staticsPath, int blockWidth, int blockHeight,
+ IList warnings = null)
+ {
+ FileStream index = null;
+ FileStream statics = null;
+
+ try
+ {
+ index = new FileStream(indexPath, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 20, FileOptions.SequentialScan);
+ statics = new FileStream(staticsPath, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 20);
+
+ return new StaticsIndexReader(index, statics, blockWidth, blockHeight, warnings, false,
+ Path.GetFileName(indexPath));
+ }
+ catch
+ {
+ index?.Dispose();
+ statics?.Dispose();
+
+ throw;
+ }
+ }
+
+ public int BlockWidth { get; }
+
+ public int BlockHeight { get; }
+
+ /// How many 12-byte records the index file actually holds.
+ public int EntriesInFile { get; }
+
+ /// One entry per block of the configured grid, padded with the empty sentinel.
+ public Entry3D[] Entries { get; }
+
+ public string Label { get; }
+
+ public long StaticsLength => _statics.Length;
+
+ /// Whether a block coordinate is inside the configured grid.
+ public bool Contains(int blockX, int blockY)
+ {
+ return blockX >= 0 && blockY >= 0 && blockX < BlockWidth && blockY < BlockHeight;
+ }
+
+ public Entry3D GetEntry(int blockX, int blockY)
+ {
+ if (!Contains(blockX, blockY))
+ {
+ return new Entry3D { Lookup = -1, Length = -1, Extra = -1 };
+ }
+
+ return Entries[(blockX * BlockHeight) + blockY];
+ }
+
+ ///
+ /// Appends a block's statics to , which is not cleared. A block that
+ /// is empty, out of range or damaged adds nothing and is reported rather than thrown.
+ ///
+ /// How many statics were appended.
+ public int ReadBlock(int blockX, int blockY, List into, IList warnings = null,
+ StaticsBlockProblems problems = null)
+ {
+ if (!Contains(blockX, blockY))
+ {
+ warnings?.Add(string.Format(CultureInfo.InvariantCulture,
+ "Block {0},{1} is outside the {2} x {3} block grid of {4} and was read as empty.",
+ blockX, blockY, BlockWidth, BlockHeight, Label));
+
+ problems?.CountOutOfRange();
+
+ return 0;
+ }
+
+ Entry3D entry = Entries[(blockX * BlockHeight) + blockY];
+
+ if (entry.Lookup < 0 || entry.Length <= 0)
+ {
+ return 0;
+ }
+
+ int length = entry.Length;
+
+ if (entry.Lookup >= _statics.Length)
+ {
+ warnings?.Add(string.Format(CultureInfo.InvariantCulture,
+ "Block {0},{1} points at offset {2:N0}, past the end of the statics file. It was treated as empty.",
+ blockX, blockY, entry.Lookup));
+
+ problems?.CountBadLookup();
+
+ return 0;
+ }
+
+ long available = _statics.Length - entry.Lookup;
+
+ if (length > available)
+ {
+ warnings?.Add(string.Format(CultureInfo.InvariantCulture,
+ "Block {0},{1} claims {2:N0} bytes but only {3:N0} remain in the statics file. It was clamped.",
+ blockX, blockY, length, available));
+
+ problems?.CountBadLookup();
+
+ length = (int)(available - (available % TileRecordSize));
+ }
+
+ if (length % TileRecordSize != 0)
+ {
+ warnings?.Add(string.Format(CultureInfo.InvariantCulture,
+ "Block {0},{1} has a length of {2} bytes, which is not a whole number of {3}-byte statics. The trailing {4} bytes were dropped.",
+ blockX, blockY, length, TileRecordSize, length % TileRecordSize));
+
+ problems?.CountBadLength();
+
+ length -= length % TileRecordSize;
+ }
+
+ if (length <= 0)
+ {
+ return 0;
+ }
+
+ if (_buffer.Length < length)
+ {
+ _buffer = new byte[Math.Max(length, 4096)];
+ }
+
+ _statics.Seek(entry.Lookup, SeekOrigin.Begin);
+ _statics.ReadExactly(_buffer, 0, length);
+
+ ReadOnlySpan source = MemoryMarshal.Cast(_buffer.AsSpan(0, length));
+
+ for (int i = 0; i < source.Length; ++i)
+ {
+ into.Add(source[i]);
+ }
+
+ return source.Length;
+ }
+
+ public void Dispose()
+ {
+ if (_leaveOpen)
+ {
+ return;
+ }
+
+ _index.Dispose();
+ _statics.Dispose();
+ }
+ }
+
+ ///
+ /// Counts the damaged-index conditions works around.
+ ///
+ public sealed class StaticsBlockProblems
+ {
+ public int BadLookup { get; private set; }
+
+ public int BadLength { get; private set; }
+
+ public int OutOfRange { get; private set; }
+
+ public void CountBadLookup() => ++BadLookup;
+
+ public void CountBadLength() => ++BadLength;
+
+ public void CountOutOfRange() => ++OutOfRange;
+ }
+
+ ///
+ /// Writes a compacted staidx/statics pair: blocks laid down back to back with no gaps, one
+ /// 12-byte index record per block of the grid.
+ ///
+ public sealed class StaticsBlockWriter : IDisposable
+ {
+ private readonly Stream _index;
+ private readonly Stream _statics;
+ private readonly bool _leaveOpen;
+ private readonly EmptyBlockStyle _emptyStyle;
+ private readonly byte[] _indexBytes;
+
+ private long _staticsPosition;
+ private bool _completed;
+
+ public StaticsBlockWriter(Stream index, Stream statics, int blockWidth, int blockHeight,
+ EmptyBlockStyle emptyStyle = EmptyBlockStyle.NegativeOne, bool leaveOpen = false)
+ {
+ _index = index ?? throw new ArgumentNullException(nameof(index));
+ _statics = statics ?? throw new ArgumentNullException(nameof(statics));
+ _leaveOpen = leaveOpen;
+ _emptyStyle = emptyStyle;
+
+ BlockWidth = blockWidth;
+ BlockHeight = blockHeight;
+
+ long bytes = (long)blockWidth * blockHeight * StaticsIndexReader.IndexRecordSize;
+
+ if (bytes > int.MaxValue)
+ {
+ throw new ArgumentOutOfRangeException(nameof(blockWidth),
+ "The configured map size needs a statics index larger than 2 GB.");
+ }
+
+ _indexBytes = new byte[bytes];
+
+ // Blocks that are never written stay empty rather than zero.
+ for (int i = 0; i < blockWidth * blockHeight; ++i)
+ {
+ WriteEmptyEntry(_indexBytes.AsSpan(i * StaticsIndexReader.IndexRecordSize, StaticsIndexReader.IndexRecordSize));
+ }
+ }
+
+ public int BlockWidth { get; }
+
+ public int BlockHeight { get; }
+
+ /// Bytes written to the statics file so far.
+ public long StaticsLength => _staticsPosition;
+
+ public int BlocksWithStatics { get; private set; }
+
+ public long TilesWritten { get; private set; }
+
+ public void WriteBlock(int blockX, int blockY, IReadOnlyList tiles, int extra)
+ {
+ if (_completed)
+ {
+ throw new InvalidOperationException("The statics pair has already been completed.");
+ }
+
+ if (blockX < 0 || blockY < 0 || blockX >= BlockWidth || blockY >= BlockHeight)
+ {
+ throw new ArgumentOutOfRangeException(nameof(blockX),
+ $"Block {blockX},{blockY} is outside the {BlockWidth} x {BlockHeight} block grid.");
+ }
+
+ int blockId = (blockX * BlockHeight) + blockY;
+ Span record = _indexBytes.AsSpan(blockId * StaticsIndexReader.IndexRecordSize, StaticsIndexReader.IndexRecordSize);
+
+ if (tiles == null || tiles.Count == 0)
+ {
+ WriteEmptyEntry(record);
+
+ return;
+ }
+
+ int length = tiles.Count * StaticsIndexReader.TileRecordSize;
+
+ if (_staticsPosition + length > int.MaxValue)
+ {
+ throw new InvalidOperationException(
+ "The output statics file would pass 2 GB, which the 32-bit index lookup cannot address.");
+ }
+
+ if (extra == -1)
+ {
+ extra = 0;
+ }
+
+ BinaryPrimitives.WriteInt32LittleEndian(record, (int)_staticsPosition);
+ BinaryPrimitives.WriteInt32LittleEndian(record.Slice(4), length);
+ BinaryPrimitives.WriteInt32LittleEndian(record.Slice(8), extra);
+
+ if (tiles is List list)
+ {
+ _statics.Write(MemoryMarshal.AsBytes(CollectionsMarshal.AsSpan(list)));
+ }
+ else
+ {
+ var copy = new StaticTile[tiles.Count];
+
+ for (int i = 0; i < tiles.Count; ++i)
+ {
+ copy[i] = tiles[i];
+ }
+
+ _statics.Write(MemoryMarshal.AsBytes(copy.AsSpan()));
+ }
+
+ _staticsPosition += length;
+ TilesWritten += tiles.Count;
+ ++BlocksWithStatics;
+ }
+
+ public void Complete()
+ {
+ if (_completed)
+ {
+ return;
+ }
+
+ _index.Write(_indexBytes, 0, _indexBytes.Length);
+ _index.Flush();
+ _statics.Flush();
+
+ _completed = true;
+ }
+
+ public void Dispose()
+ {
+ if (_leaveOpen)
+ {
+ return;
+ }
+
+ _index.Dispose();
+ _statics.Dispose();
+ }
+
+ private void WriteEmptyEntry(Span record)
+ {
+ int empty = _emptyStyle == EmptyBlockStyle.Zero ? 0 : -1;
+
+ BinaryPrimitives.WriteInt32LittleEndian(record, -1);
+ BinaryPrimitives.WriteInt32LittleEndian(record.Slice(4), empty);
+ BinaryPrimitives.WriteInt32LittleEndian(record.Slice(8), empty);
+ }
+ }
+
+ ///
+ /// The per-static decisions every statics rewrite has to make: is the item id one the client
+ /// knows, is the offset inside its block, is the z legal, is the hue sane, and is this static a
+ /// copy of one already in the block.
+ ///
+ public sealed class StaticsTileFilter
+ {
+ private readonly Dictionary _duplicateIndex = new Dictionary();
+
+ /// Drops statics whose id is above .
+ public bool DropInvalidItemIds { get; set; }
+
+ public int MaxItemId { get; set; } = 0xFFFF;
+
+ ///
+ /// What to do with an offset outside 0..7. The client masks those with & 7 when reading,
+ /// so a static with one draws in the wrong cell.
+ ///
+ public OutOfBlockAction OutOfBlockTiles { get; set; } = OutOfBlockAction.Keep;
+
+ /// Drops statics at z == -128.
+ public bool DropInvalidZ { get; set; }
+
+ public bool NormalizeNegativeHue { get; set; }
+
+ /// Removes statics sharing id, x, y and z.
+ public bool RemoveDuplicates { get; set; }
+
+ /// Adds hue to the duplicate key. Two water tiles in a cell differing only in hue then both survive.
+ public bool DuplicatesCompareHue { get; set; }
+
+ /// The highest item id any static passed through this filter carried.
+ public int HighestItemIdSeen { get; private set; }
+
+ ///
+ /// Decides on one static, adjusting it in place where the filter normalises rather than drops.
+ ///
+ public bool Accept(ref StaticTile tile, int blockX, int blockY, IStaticsFilterStats stats)
+ {
+ if (tile.Id > HighestItemIdSeen)
+ {
+ HighestItemIdSeen = tile.Id;
+ }
+
+ if (DropInvalidItemIds && tile.Id > MaxItemId)
+ {
+ stats?.TileRejected(blockX, blockY, tile, RejectReason.InvalidItemId);
+
+ return false;
+ }
+
+ if (tile.X > 7 || tile.Y > 7)
+ {
+ switch (OutOfBlockTiles)
+ {
+ case OutOfBlockAction.Drop:
+ stats?.TileRejected(blockX, blockY, tile, RejectReason.OutOfBlockOffset);
+
+ return false;
+
+ case OutOfBlockAction.Mask:
+ tile.X &= 0x7;
+ tile.Y &= 0x7;
+ stats?.OutOfBlockMasked();
+
+ break;
+ }
+ }
+
+ if (DropInvalidZ && tile.Z == sbyte.MinValue)
+ {
+ stats?.TileRejected(blockX, blockY, tile, RejectReason.InvalidZ);
+
+ return false;
+ }
+
+ if (NormalizeNegativeHue && tile.Hue < 0)
+ {
+ tile.Hue = 0;
+ stats?.HueNormalized();
+ }
+
+ return true;
+ }
+
+ ///
+ /// Runs over a block's statics in place, then removes duplicates when
+ /// asked. The survivor of a duplicate keeps the position of the first occurrence and the
+ /// payload of the last.
+ ///
+ public void Apply(List tiles, int blockX, int blockY, IStaticsFilterStats stats)
+ {
+ if (tiles == null || tiles.Count == 0)
+ {
+ return;
+ }
+
+ int write = 0;
+
+ for (int i = 0; i < tiles.Count; ++i)
+ {
+ StaticTile tile = tiles[i];
+
+ if (Accept(ref tile, blockX, blockY, stats))
+ {
+ tiles[write++] = tile;
+ }
+ }
+
+ tiles.RemoveRange(write, tiles.Count - write);
+
+ if (!RemoveDuplicates || tiles.Count < 2)
+ {
+ return;
+ }
+
+ _duplicateIndex.Clear();
+ write = 0;
+
+ for (int i = 0; i < tiles.Count; ++i)
+ {
+ StaticTile tile = tiles[i];
+ ulong key = DuplicateKey(tile);
+
+ if (_duplicateIndex.TryGetValue(key, out int existing))
+ {
+ tiles[existing] = tile;
+ stats?.TileRejected(blockX, blockY, tile, RejectReason.Duplicate);
+
+ continue;
+ }
+
+ _duplicateIndex[key] = write;
+ tiles[write++] = tile;
+ }
+
+ tiles.RemoveRange(write, tiles.Count - write);
+ }
+
+ private ulong DuplicateKey(StaticTile tile)
+ {
+ ulong key = ((ulong)tile.Id << 14) |
+ ((ulong)(byte)tile.Z << 6) |
+ ((ulong)(uint)(tile.X & 0x7) << 3) |
+ (uint)(tile.Y & 0x7);
+
+ if (DuplicatesCompareHue)
+ {
+ key |= (ulong)(ushort)tile.Hue << 30;
+ }
+
+ return key;
+ }
+ }
+
+ ///
+ /// Turns the 8x8 grid hands back into the flat list the writer takes.
+ ///
+ public static class StaticsBlockConversion
+ {
+ public static int FromHuedBlock(HuedTile[][][] block, List into)
+ {
+ if (block == null)
+ {
+ return 0;
+ }
+
+ int added = 0;
+
+ for (int x = 0; x < block.Length; ++x)
+ {
+ HuedTile[][] column = block[x];
+
+ if (column == null)
+ {
+ continue;
+ }
+
+ for (int y = 0; y < column.Length; ++y)
+ {
+ HuedTile[] cell = column[y];
+
+ if (cell == null)
+ {
+ continue;
+ }
+
+ for (int i = 0; i < cell.Length; ++i)
+ {
+ into.Add(new StaticTile
+ {
+ Id = cell[i].Id,
+ X = (byte)x,
+ Y = (byte)y,
+ Z = cell[i].Z,
+ Hue = (short)cell[i].Hue
+ });
+
+ ++added;
+ }
+ }
+ }
+
+ return added;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Ultima/Statics/StaticsComparer.cs b/Ultima/Statics/StaticsComparer.cs
new file mode 100644
index 00000000..d74299b3
--- /dev/null
+++ b/Ultima/Statics/StaticsComparer.cs
@@ -0,0 +1,344 @@
+// /***************************************************************************
+// *
+// * $Author: Turley
+// *
+// * "THE BEER-WARE LICENSE"
+// * As long as you retain this notice you can do whatever you want with
+// * this stuff. If we meet some day, and you think this stuff is worth it,
+// * you can buy me a beer in return.
+// *
+// ***************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace Ultima.Statics
+{
+ public enum StaticsCompareMode
+ {
+ ///
+ /// Every block must hold the same statics on both sides. Use after a run with no filters:
+ /// the output should differ from the source only in layout.
+ ///
+ Identical,
+
+ ///
+ /// The second file may only be missing statics, never hold one the first does not. Use after
+ /// a filtered run, then check the missing count against the filter counters.
+ ///
+ Subset
+ }
+
+ public sealed class StaticsCompareResult
+ {
+ public StaticsCompareMode Mode { get; set; }
+
+ public int BlockWidth { get; set; }
+
+ public int BlockHeight { get; set; }
+
+ public int BlocksCompared { get; set; }
+
+ public int BlocksDiffering { get; set; }
+
+ public long TilesLeft { get; set; }
+
+ public long TilesRight { get; set; }
+
+ /// Statics the first file holds that the second does not.
+ public long TilesMissing { get; set; }
+
+ /// Statics the second file holds that the first does not. Always a defect.
+ public long TilesAdded { get; set; }
+
+ public List StructuralProblems { get; } = new List();
+
+ public List Differences { get; } = new List();
+
+ public bool Passed => StructuralProblems.Count == 0 && TilesAdded == 0 &&
+ (Mode == StaticsCompareMode.Subset || TilesMissing == 0);
+
+ public string ToReport()
+ {
+ var sb = new StringBuilder();
+
+ sb.AppendLine(Passed ? "PASSED" : "FAILED");
+ sb.AppendLine();
+ sb.AppendLine(Line("Mode : {0}", Mode));
+ sb.AppendLine(Line("Block grid : {0} x {1}", BlockWidth, BlockHeight));
+ sb.AppendLine(Line("Blocks compared : {0:N0} ({1:N0} differ)", BlocksCompared, BlocksDiffering));
+ sb.AppendLine(Line("Statics : {0:N0} -> {1:N0}", TilesLeft, TilesRight));
+ sb.AppendLine(Line(" missing : {0:N0}", TilesMissing));
+ sb.AppendLine(Line(" added : {0:N0}", TilesAdded));
+
+ if (StructuralProblems.Count > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine(Line("Structural problems ({0}):", StructuralProblems.Count));
+
+ foreach (string problem in StructuralProblems)
+ {
+ sb.AppendLine(" " + problem);
+ }
+ }
+
+ if (Differences.Count > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine(Line("Differing blocks (first {0}):", Differences.Count));
+
+ foreach (string difference in Differences)
+ {
+ sb.AppendLine(" " + difference);
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ private static string Line(string format, params object[] args)
+ {
+ return string.Format(CultureInfo.InvariantCulture, format, args);
+ }
+ }
+
+ ///
+ /// Compares two staidx/statics pairs block by block, as multisets of statics, so a defrag can be
+ /// checked against the file it was produced from. Layout differences are ignored on purpose -
+ /// compacting is the whole point of a defrag; what matters is that no static was lost or invented.
+ ///
+ public static class StaticsComparer
+ {
+ private const int IndexRecordSize = 12;
+ private const int TileRecordSize = 7;
+ private const int MaxReportedDifferences = 50;
+
+ public static StaticsCompareResult Compare(string leftIndexPath, string leftStaticsPath,
+ string rightIndexPath, string rightStaticsPath, int blockWidth, int blockHeight,
+ StaticsCompareMode mode)
+ {
+ using (var leftIndex = File.OpenRead(leftIndexPath))
+ using (var leftStatics = File.OpenRead(leftStaticsPath))
+ using (var rightIndex = File.OpenRead(rightIndexPath))
+ using (var rightStatics = File.OpenRead(rightStaticsPath))
+ {
+ return Compare(leftIndex, leftStatics, rightIndex, rightStatics, blockWidth, blockHeight, mode);
+ }
+ }
+
+ public static StaticsCompareResult Compare(Stream leftIndex, Stream leftStatics,
+ Stream rightIndex, Stream rightStatics, int blockWidth, int blockHeight,
+ StaticsCompareMode mode)
+ {
+ var result = new StaticsCompareResult
+ {
+ Mode = mode,
+ BlockWidth = blockWidth,
+ BlockHeight = blockHeight
+ };
+
+ int blockCount = blockWidth * blockHeight;
+
+ Entry3D[] left = ReadIndex(leftIndex, blockCount);
+ Entry3D[] right = ReadIndex(rightIndex, blockCount);
+
+ CheckStructure(right, rightStatics.Length, result);
+
+ var leftTiles = new List(128);
+ var rightTiles = new List(128);
+ var counts = new Dictionary();
+
+ byte[] leftBuffer = Array.Empty();
+ byte[] rightBuffer = Array.Empty();
+
+ for (int blockId = 0; blockId < blockCount; ++blockId)
+ {
+ ReadBlock(leftStatics, left[blockId], ref leftBuffer, leftTiles);
+ ReadBlock(rightStatics, right[blockId], ref rightBuffer, rightTiles);
+
+ result.BlocksCompared++;
+ result.TilesLeft += leftTiles.Count;
+ result.TilesRight += rightTiles.Count;
+
+ if (leftTiles.Count == 0 && rightTiles.Count == 0)
+ {
+ continue;
+ }
+
+ counts.Clear();
+
+ foreach (StaticTile tile in leftTiles)
+ {
+ ulong key = TileKey(tile);
+ counts[key] = counts.TryGetValue(key, out int count) ? count + 1 : 1;
+ }
+
+ int added = 0;
+
+ foreach (StaticTile tile in rightTiles)
+ {
+ ulong key = TileKey(tile);
+
+ if (counts.TryGetValue(key, out int count) && count > 0)
+ {
+ counts[key] = count - 1;
+ }
+ else
+ {
+ ++added;
+ }
+ }
+
+ int missing = 0;
+
+ foreach (int remaining in counts.Values)
+ {
+ missing += remaining;
+ }
+
+ if (missing == 0 && added == 0)
+ {
+ continue;
+ }
+
+ result.TilesMissing += missing;
+ result.TilesAdded += added;
+ result.BlocksDiffering++;
+
+ if (result.Differences.Count < MaxReportedDifferences)
+ {
+ int blockX = blockId / blockHeight;
+ int blockY = blockId % blockHeight;
+
+ result.Differences.Add(string.Format(CultureInfo.InvariantCulture,
+ "block {0},{1} (world {2},{3}): {4} statics -> {5}, {6} missing, {7} added",
+ blockX, blockY, blockX << 3, blockY << 3, leftTiles.Count, rightTiles.Count, missing, added));
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Invariants the output of a defrag has to satisfy on its own, regardless of the source:
+ /// one index record per block, lookups walking forward with no gaps, and every block length a
+ /// whole number of statics.
+ ///
+ private static void CheckStructure(Entry3D[] entries, long staticsLength, StaticsCompareResult result)
+ {
+ long expected = 0;
+
+ for (int i = 0; i < entries.Length; ++i)
+ {
+ Entry3D entry = entries[i];
+
+ if (entry.Lookup < 0 || entry.Length <= 0)
+ {
+ continue;
+ }
+
+ if (entry.Length % TileRecordSize != 0)
+ {
+ Add(result, string.Format(CultureInfo.InvariantCulture,
+ "block {0} has a length of {1}, which is not a multiple of {2}.", i, entry.Length, TileRecordSize));
+ }
+
+ if (entry.Lookup != expected)
+ {
+ Add(result, string.Format(CultureInfo.InvariantCulture,
+ "block {0} starts at {1:N0}, expected {2:N0} - the file is not compact.", i, entry.Lookup, expected));
+ }
+
+ if (entry.Lookup + (long)entry.Length > staticsLength)
+ {
+ Add(result, string.Format(CultureInfo.InvariantCulture,
+ "block {0} reaches past the end of the statics file.", i));
+ }
+
+ expected = entry.Lookup + (long)entry.Length;
+ }
+
+ if (expected != staticsLength)
+ {
+ Add(result, string.Format(CultureInfo.InvariantCulture,
+ "the statics file is {0:N0} bytes but the index accounts for {1:N0}.", staticsLength, expected));
+ }
+ }
+
+ private static void Add(StaticsCompareResult result, string problem)
+ {
+ if (result.StructuralProblems.Count < MaxReportedDifferences)
+ {
+ result.StructuralProblems.Add(problem);
+ }
+ }
+
+ private static Entry3D[] ReadIndex(Stream index, int blockCount)
+ {
+ var entries = new Entry3D[blockCount];
+ int entriesInFile = (int)Math.Min(index.Length / IndexRecordSize, blockCount);
+
+ index.Seek(0, SeekOrigin.Begin);
+ index.ReadExactly(MemoryMarshal.AsBytes(entries.AsSpan(0, entriesInFile)));
+
+ for (int i = entriesInFile; i < blockCount; ++i)
+ {
+ entries[i].Lookup = -1;
+ entries[i].Length = -1;
+ entries[i].Extra = -1;
+ }
+
+ return entries;
+ }
+
+ private static void ReadBlock(Stream statics, Entry3D entry, ref byte[] buffer, List tiles)
+ {
+ tiles.Clear();
+
+ if (entry.Lookup < 0 || entry.Length <= 0 || entry.Lookup >= statics.Length)
+ {
+ return;
+ }
+
+ long available = statics.Length - entry.Lookup;
+ int length = (int)Math.Min(entry.Length, available);
+
+ length -= length % TileRecordSize;
+
+ if (length <= 0)
+ {
+ return;
+ }
+
+ if (buffer.Length < length)
+ {
+ buffer = new byte[Math.Max(length, 4096)];
+ }
+
+ statics.Seek(entry.Lookup, SeekOrigin.Begin);
+ statics.ReadExactly(buffer, 0, length);
+
+ ReadOnlySpan source = MemoryMarshal.Cast(buffer.AsSpan(0, length));
+
+ for (int i = 0; i < source.Length; ++i)
+ {
+ tiles.Add(source[i]);
+ }
+ }
+
+ private static ulong TileKey(StaticTile tile)
+ {
+ // Raw x and y, not masked: two statics that differ only in an out-of-block offset are
+ // different statics, and a comparison has to be able to see that.
+ return ((ulong)(ushort)tile.Hue << 40) |
+ ((ulong)tile.Id << 24) |
+ ((ulong)(byte)tile.Z << 16) |
+ ((ulong)tile.X << 8) |
+ tile.Y;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Ultima/Statics/StaticsDefragException.cs b/Ultima/Statics/StaticsDefragException.cs
new file mode 100644
index 00000000..6263a151
--- /dev/null
+++ b/Ultima/Statics/StaticsDefragException.cs
@@ -0,0 +1,30 @@
+// /***************************************************************************
+// *
+// * $Author: Turley
+// *
+// * "THE BEER-WARE LICENSE"
+// * As long as you retain this notice you can do whatever you want with
+// * this stuff. If we meet some day, and you think this stuff is worth it,
+// * you can buy me a beer in return.
+// *
+// ***************************************************************************/
+
+using System;
+
+namespace Ultima.Statics
+{
+ ///
+ /// Raised when a statics defrag run cannot continue. Unlike the routine it replaces, the
+ /// defragmenter never swallows an error and never silently emits a partial file.
+ ///
+ public sealed class StaticsDefragException : Exception
+ {
+ public StaticsDefragException(string message) : base(message)
+ {
+ }
+
+ public StaticsDefragException(string message, Exception innerException) : base(message, innerException)
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/Ultima/Statics/StaticsDefragOptions.cs b/Ultima/Statics/StaticsDefragOptions.cs
new file mode 100644
index 00000000..2db5429a
--- /dev/null
+++ b/Ultima/Statics/StaticsDefragOptions.cs
@@ -0,0 +1,180 @@
+// /***************************************************************************
+// *
+// * $Author: Turley
+// *
+// * "THE BEER-WARE LICENSE"
+// * As long as you retain this notice you can do whatever you want with
+// * this stuff. If we meet some day, and you think this stuff is worth it,
+// * you can buy me a beer in return.
+// *
+// ***************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+
+namespace Ultima.Statics
+{
+ ///
+ /// What to do with a static whose in-block offset is outside the 0..7 range the format allows.
+ /// The client masks these with & 7 when reading, so they show up in the wrong cell.
+ ///
+ public enum OutOfBlockAction
+ {
+ Drop,
+ Mask,
+ Keep
+ }
+
+ ///
+ /// Byte pattern written for a block that ends up with no statics.
+ ///
+ public enum EmptyBlockStyle
+ {
+ /// lookup/length/extra all -1, as shipped by the client and written by UOFiddler today.
+ NegativeOne,
+
+ /// lookup -1, length 0, extra 0.
+ Zero
+ }
+
+ ///
+ /// Where the "this item id does not exist" ceiling comes from.
+ ///
+ public enum ItemIdCeiling
+ {
+ /// Highest id described by tiledata.mul. The count the client can actually resolve.
+ TileData,
+
+ /// Highest id reported by .
+ Art,
+
+ /// 0x3FFF, the pre-ML item id limit.
+ Legacy
+ }
+
+ public sealed class StaticsDefragProgress
+ {
+ public int BlocksDone { get; init; }
+ public int BlocksTotal { get; init; }
+ public long TilesWritten { get; init; }
+ }
+
+ public sealed class StaticsDefragOptions
+ {
+ /// Facet file index, used to resolve staidx{N}.mul / statics{N}.mul.
+ public int FileIndex { get; set; }
+
+ /// Explicit source override. When null the files are resolved through .
+ public string SourceIndexPath { get; set; }
+
+ public string SourceStaticsPath { get; set; }
+
+ ///
+ /// Optional. Supplies frozen/melted in-memory edits and the land tiles the below-terrain
+ /// filter needs. Without it those features are unavailable.
+ ///
+ public Map Map { get; set; }
+
+ public int BlockWidth { get; set; }
+
+ public int BlockHeight { get; set; }
+
+ ///
+ /// Permits writing fewer blocks than the source index holds. Without it a source index
+ /// that reaches past the configured map size is refused rather than truncated.
+ ///
+ public bool AllowGeometryTruncation { get; set; }
+
+ public bool DropInvalidItemIds { get; set; } = true;
+
+ public ItemIdCeiling ItemIdCeiling { get; set; } = ItemIdCeiling.TileData;
+
+ /// Overrides when greater than zero.
+ public int MaxItemId { get; set; }
+
+ public OutOfBlockAction OutOfBlockTiles { get; set; } = OutOfBlockAction.Drop;
+
+ /// Drops statics at z == -128, the sentinel the client will not place.
+ public bool DropInvalidZ { get; set; } = true;
+
+ public bool NormalizeNegativeHue { get; set; } = true;
+
+ /// Drops statics buried under the land tile. Requires .
+ public bool DropBelowTerrain { get; set; }
+
+ /// Removes statics sharing id, x, y and z. Hue is not part of the key.
+ public bool RemoveDuplicates { get; set; }
+
+ /// Adds hue to the duplicate key, which is what the old routine did.
+ public bool DuplicatesCompareHue { get; set; }
+
+ /// Collapses eligible statics sharing a cell down to one.
+ public bool CollapseStacks { get; set; }
+
+ /// A static is eligible for stack collapsing when any of these flags is set on its tile data.
+ public TileFlag CollapseFlagMask { get; set; } = TileFlag.Wet;
+
+ /// A static is also eligible when its id appears here.
+ public HashSet CollapseIds { get; } = new HashSet();
+
+ /// Groups stack candidates by x/y only, ignoring z.
+ public bool CollapseIgnoreZ { get; set; }
+
+ public EmptyBlockStyle EmptyBlocks { get; set; } = EmptyBlockStyle.NegativeOne;
+
+ /// Carries the source index extra field through instead of always writing 0.
+ public bool PreserveExtra { get; set; } = true;
+
+ /// Emits each block's statics in a stable order. Off by default so a no-filter run is a pure compaction.
+ public bool SortTiles { get; set; }
+
+ public string OutputDirectory { get; set; }
+
+ public bool DryRun { get; set; }
+
+ public int RejectSampleLimit { get; set; } = 200;
+
+ public IProgress Progress { get; set; }
+
+ public CancellationToken CancellationToken { get; set; }
+
+ ///
+ /// Resolves the item id ceiling actually in force. Ids above it are considered invalid.
+ ///
+ public int ResolveMaxItemId()
+ {
+ if (MaxItemId > 0)
+ {
+ return MaxItemId;
+ }
+
+ switch (ItemIdCeiling)
+ {
+ case ItemIdCeiling.Legacy:
+ return 0x3FFF;
+
+ case ItemIdCeiling.Art:
+ return Art.GetMaxItemId();
+
+ default:
+ int count = TileData.ItemTable?.Length ?? 0;
+ return count > 0 ? count - 1 : Art.GetMaxItemId();
+ }
+ }
+
+ ///
+ /// Reproduces the call shape of the old Map.DefragStatics overload.
+ ///
+ public static StaticsDefragOptions Legacy(string outputDirectory, Map map, bool removeDuplicates)
+ {
+ return new StaticsDefragOptions
+ {
+ FileIndex = map.FileIndex,
+ Map = map,
+ OutputDirectory = outputDirectory,
+ RemoveDuplicates = removeDuplicates
+ };
+ }
+ }
+}
\ No newline at end of file
diff --git a/Ultima/Statics/StaticsDefragResult.cs b/Ultima/Statics/StaticsDefragResult.cs
new file mode 100644
index 00000000..8fc77127
--- /dev/null
+++ b/Ultima/Statics/StaticsDefragResult.cs
@@ -0,0 +1,286 @@
+// /***************************************************************************
+// *
+// * $Author: Turley
+// *
+// * "THE BEER-WARE LICENSE"
+// * As long as you retain this notice you can do whatever you want with
+// * this stuff. If we meet some day, and you think this stuff is worth it,
+// * you can buy me a beer in return.
+// *
+// ***************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text;
+
+namespace Ultima.Statics
+{
+ public enum RejectReason
+ {
+ InvalidItemId,
+ OutOfBlockOffset,
+ InvalidZ,
+ BelowTerrain,
+ Duplicate,
+ CollapsedStack
+ }
+
+ public readonly struct RejectedStaticTile
+ {
+ public RejectedStaticTile(int blockX, int blockY, StaticTile tile, RejectReason reason)
+ {
+ BlockX = blockX;
+ BlockY = blockY;
+ Tile = tile;
+ Reason = reason;
+ }
+
+ public int BlockX { get; }
+
+ public int BlockY { get; }
+
+ public StaticTile Tile { get; }
+
+ public RejectReason Reason { get; }
+
+ public int WorldX => (BlockX << 3) + (Tile.X & 0x7);
+
+ public int WorldY => (BlockY << 3) + (Tile.Y & 0x7);
+
+ public override string ToString()
+ {
+ return string.Format(CultureInfo.InvariantCulture,
+ "block {0},{1} tile 0x{2:X4} at {3} {4} {5} hue {6} - {7}",
+ BlockX, BlockY, Tile.Id, WorldX, WorldY, Tile.Z, Tile.Hue, Reason);
+ }
+ }
+
+ public sealed class StaticsDefragResult : IStaticsFilterStats
+ {
+ public int FileIndex { get; set; }
+
+ public bool DryRun { get; set; }
+
+ public string SourceIndexPath { get; set; }
+
+ public string SourceStaticsPath { get; set; }
+
+ public string OutputIndexPath { get; set; }
+
+ public string OutputStaticsPath { get; set; }
+
+ public int BlockWidth { get; set; }
+
+ public int BlockHeight { get; set; }
+
+ public int BlockCount => BlockWidth * BlockHeight;
+
+ public long SourceIndexEntries { get; set; }
+
+ public string GeometryEvidence { get; set; }
+
+ public int MaxItemIdUsed { get; set; }
+
+ public int HighestItemIdSeen { get; set; }
+
+ public long SourceStaticsBytes { get; set; }
+
+ public long OutputStaticsBytes { get; set; }
+
+ public int BlocksProcessed { get; set; }
+
+ public int SourceBlocksWithStatics { get; set; }
+
+ public int OutputBlocksWithStatics { get; set; }
+
+ public int BlocksClearedByRemove { get; set; }
+
+ public int BlocksWithBadLength { get; set; }
+
+ public int BlocksWithBadLookup { get; set; }
+
+ public long TilesRead { get; set; }
+
+ public long TilesWritten { get; set; }
+
+ public long PendingTilesAdded { get; set; }
+
+ public long DroppedInvalidItemId { get; set; }
+
+ public long DroppedOutOfBlock { get; set; }
+
+ public long MaskedOutOfBlock { get; set; }
+
+ public long DroppedInvalidZ { get; set; }
+
+ public long DroppedBelowTerrain { get; set; }
+
+ public long DuplicatesRemoved { get; set; }
+
+ public long StacksCollapsed { get; set; }
+
+ public long HuesNormalized { get; set; }
+
+ public TimeSpan Elapsed { get; set; }
+
+ public List Warnings { get; } = new List();
+
+ public List RejectSamples { get; } = new List();
+
+ ///
+ /// Every source tile the output does not carry, as accounted for by the filters. When a real
+ /// comparison of the two files disagrees with this number the engine lost or invented data.
+ ///
+ public long TilesAccountedFor => DroppedInvalidItemId + DroppedOutOfBlock + DroppedInvalidZ +
+ DroppedBelowTerrain + DuplicatesRemoved + StacksCollapsed;
+
+ void IStaticsFilterStats.TileRejected(int blockX, int blockY, StaticTile tile, RejectReason reason)
+ {
+ switch (reason)
+ {
+ case RejectReason.InvalidItemId:
+ ++DroppedInvalidItemId;
+ break;
+
+ case RejectReason.OutOfBlockOffset:
+ ++DroppedOutOfBlock;
+ break;
+
+ case RejectReason.InvalidZ:
+ ++DroppedInvalidZ;
+ break;
+
+ case RejectReason.BelowTerrain:
+ ++DroppedBelowTerrain;
+ break;
+
+ case RejectReason.Duplicate:
+ ++DuplicatesRemoved;
+ break;
+
+ case RejectReason.CollapsedStack:
+ ++StacksCollapsed;
+ break;
+ }
+
+ if (RejectSamples.Count < RejectSampleLimit)
+ {
+ RejectSamples.Add(new RejectedStaticTile(blockX, blockY, tile, reason));
+ }
+ }
+
+ void IStaticsFilterStats.HueNormalized()
+ {
+ ++HuesNormalized;
+ }
+
+ void IStaticsFilterStats.OutOfBlockMasked()
+ {
+ ++MaskedOutOfBlock;
+ }
+
+ /// How many removed statics to keep as examples.
+ public int RejectSampleLimit { get; set; } = 200;
+
+ public string ToReport()
+ {
+ var sb = new StringBuilder();
+
+ if (DryRun)
+ {
+ sb.AppendLine("DRY RUN - no files were written.");
+ sb.AppendLine();
+ }
+
+ sb.AppendLine(Line("Facet : {0}", FileIndex));
+ sb.AppendLine(Line("Source index : {0}", SourceIndexPath));
+ sb.AppendLine(Line("Source statics : {0} ({1:N0} bytes)", SourceStaticsPath, SourceStaticsBytes));
+
+ if (!DryRun)
+ {
+ sb.AppendLine(Line("Output index : {0}", OutputIndexPath));
+ sb.AppendLine(Line("Output statics : {0}", OutputStaticsPath));
+ }
+
+ sb.AppendLine(Line("Output size : {0:N0} bytes ({1:+#,0;-#,0;0} vs source)",
+ OutputStaticsBytes, OutputStaticsBytes - SourceStaticsBytes));
+ sb.AppendLine();
+
+ sb.AppendLine(Line("Block grid : {0} x {1} = {2:N0} blocks", BlockWidth, BlockHeight, BlockCount));
+ sb.AppendLine(Line("Source index : {0:N0} entries", SourceIndexEntries));
+
+ if (!string.IsNullOrEmpty(GeometryEvidence))
+ {
+ sb.AppendLine(Line("Geometry : {0}", GeometryEvidence));
+ }
+
+ sb.AppendLine();
+ sb.AppendLine(Line("Item id ceiling : 0x{0:X4} (highest id present: 0x{1:X4})", MaxItemIdUsed, HighestItemIdSeen));
+ sb.AppendLine();
+ sb.AppendLine(Line("Tiles read : {0:N0}", TilesRead));
+ sb.AppendLine(Line("Tiles written : {0:N0}", TilesWritten));
+
+ if (PendingTilesAdded > 0)
+ {
+ sb.AppendLine(Line(" of which added : {0:N0} (frozen in memory)", PendingTilesAdded));
+ }
+
+ sb.AppendLine();
+ sb.AppendLine("Removed:");
+ sb.AppendLine(Line(" invalid item id : {0:N0}", DroppedInvalidItemId));
+ sb.AppendLine(Line(" out-of-block x/y : {0:N0} dropped, {1:N0} masked", DroppedOutOfBlock, MaskedOutOfBlock));
+ sb.AppendLine(Line(" invalid z : {0:N0}", DroppedInvalidZ));
+ sb.AppendLine(Line(" below terrain : {0:N0}", DroppedBelowTerrain));
+ sb.AppendLine(Line(" duplicates : {0:N0}", DuplicatesRemoved));
+ sb.AppendLine(Line(" collapsed stacks : {0:N0}", StacksCollapsed));
+ sb.AppendLine(Line(" hues normalized : {0:N0}", HuesNormalized));
+ sb.AppendLine();
+
+ sb.AppendLine(Line("Blocks processed : {0:N0}", BlocksProcessed));
+ sb.AppendLine(Line(" with statics : {0:N0} source -> {1:N0} output", SourceBlocksWithStatics, OutputBlocksWithStatics));
+
+ if (BlocksClearedByRemove > 0)
+ {
+ sb.AppendLine(Line(" cleared : {0:N0} (removed in memory)", BlocksClearedByRemove));
+ }
+
+ if (BlocksWithBadLength > 0 || BlocksWithBadLookup > 0)
+ {
+ sb.AppendLine(Line(" damaged index : {0:N0} bad lookup, {1:N0} bad length", BlocksWithBadLookup, BlocksWithBadLength));
+ }
+
+ sb.AppendLine();
+ sb.AppendLine(Line("Elapsed : {0:hh\\:mm\\:ss\\.fff}", Elapsed));
+
+ if (Warnings.Count > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine(Line("Warnings ({0}):", Warnings.Count));
+
+ foreach (string warning in Warnings)
+ {
+ sb.AppendLine(" " + warning);
+ }
+ }
+
+ if (RejectSamples.Count > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine(Line("Removed tile samples (first {0}):", RejectSamples.Count));
+
+ foreach (RejectedStaticTile sample in RejectSamples)
+ {
+ sb.AppendLine(" " + sample);
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ private static string Line(string format, params object[] args)
+ {
+ return string.Format(CultureInfo.InvariantCulture, format, args);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Ultima/Statics/StaticsDefragmenter.cs b/Ultima/Statics/StaticsDefragmenter.cs
new file mode 100644
index 00000000..c048c7d7
--- /dev/null
+++ b/Ultima/Statics/StaticsDefragmenter.cs
@@ -0,0 +1,642 @@
+// /***************************************************************************
+// *
+// * $Author: Turley
+// *
+// * "THE BEER-WARE LICENSE"
+// * As long as you retain this notice you can do whatever you want with
+// * this stuff. If we meet some day, and you think this stuff is worth it,
+// * you can buy me a beer in return.
+// *
+// ***************************************************************************/
+
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Runtime.InteropServices;
+
+namespace Ultima.Statics
+{
+ ///
+ /// Rewrites a staidx{N}.mul / statics{N}.mul pair, compacting it and optionally filtering out
+ /// statics the client cannot render correctly.
+ ///
+ ///
+ /// The index holds one 12-byte
+ /// {lookup, length, extra} record per 8x8 block addressed as blockX * blockHeight + blockY, the
+ /// data file holds 7-byte {id, x, y, z, hue} records, and a block with no statics is signalled by
+ /// lookup == -1. The client reads x and y masked with & 7 and sorts by z at draw time, so it
+ /// needs no particular tile order but will happily render an out-of-block offset in the wrong cell.
+ ///
+ public sealed class StaticsDefragmenter
+ {
+ private const int IndexRecordSize = 12;
+ private const int TileRecordSize = 7;
+ private const int ProgressInterval = 256;
+ private const int StreamBufferSize = 1 << 20;
+
+ private readonly StaticsDefragOptions _options;
+ private readonly List _tiles = new List(256);
+ private readonly Dictionary _stackIndex = new Dictionary();
+ private readonly StaticsTileFilter _filter = new StaticsTileFilter();
+
+ private int _maxItemId;
+ private int _itemTableLength;
+
+ public StaticsDefragmenter(StaticsDefragOptions options)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ public static StaticsDefragResult Defrag(StaticsDefragOptions options)
+ {
+ return new StaticsDefragmenter(options).Run();
+ }
+
+ ///
+ /// Resolves the source files, validates the geometry, writes to temporary files and moves
+ /// them into place only once the whole run has succeeded.
+ ///
+ public StaticsDefragResult Run()
+ {
+ var result = new StaticsDefragResult
+ {
+ FileIndex = _options.FileIndex,
+ DryRun = _options.DryRun
+ };
+
+ string indexPath = ResolveSource(_options.SourceIndexPath, $"staidx{_options.FileIndex}.mul");
+ string staticsPath = ResolveSource(_options.SourceStaticsPath, $"statics{_options.FileIndex}.mul");
+
+ result.SourceIndexPath = indexPath;
+ result.SourceStaticsPath = staticsPath;
+ result.SourceStaticsBytes = new FileInfo(staticsPath).Length;
+
+ WarnAboutOverrideFiles(indexPath, result);
+
+ string outputIndexPath = null;
+ string outputStaticsPath = null;
+
+ if (!_options.DryRun)
+ {
+ string outputDirectory = _options.OutputDirectory;
+ if (string.IsNullOrWhiteSpace(outputDirectory))
+ {
+ throw new StaticsDefragException("No output directory was given.");
+ }
+
+ Directory.CreateDirectory(outputDirectory);
+
+ outputIndexPath = Path.GetFullPath(Path.Combine(outputDirectory, $"staidx{_options.FileIndex}.mul"));
+ outputStaticsPath = Path.GetFullPath(Path.Combine(outputDirectory, $"statics{_options.FileIndex}.mul"));
+
+ RefuseToOverwriteSource(indexPath, outputIndexPath);
+ RefuseToOverwriteSource(staticsPath, outputStaticsPath);
+
+ result.OutputIndexPath = outputIndexPath;
+ result.OutputStaticsPath = outputStaticsPath;
+ }
+
+ string tempIndexPath = null;
+ string tempStaticsPath = null;
+
+ try
+ {
+ using (var index = new FileStream(indexPath, FileMode.Open, FileAccess.Read, FileShare.Read, StreamBufferSize, FileOptions.SequentialScan))
+ using (var statics = new FileStream(staticsPath, FileMode.Open, FileAccess.Read, FileShare.Read, StreamBufferSize))
+ {
+ if (_options.DryRun)
+ {
+ Run(index, statics, Stream.Null, Stream.Null, result);
+ }
+ else
+ {
+ string suffix = Guid.NewGuid().ToString("N");
+ tempIndexPath = outputIndexPath + ".tmp-" + suffix;
+ tempStaticsPath = outputStaticsPath + ".tmp-" + suffix;
+
+ using (var outIndex = new FileStream(tempIndexPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, StreamBufferSize))
+ using (var outStatics = new FileStream(tempStaticsPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, StreamBufferSize))
+ {
+ Run(index, statics, outIndex, outStatics, result);
+ }
+ }
+ }
+
+ if (!_options.DryRun)
+ {
+ MoveIntoPlace(tempIndexPath, outputIndexPath, tempStaticsPath, outputStaticsPath);
+ tempIndexPath = null;
+ tempStaticsPath = null;
+ }
+ }
+ catch (StaticsDefragException)
+ {
+ throw;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new StaticsDefragException($"Defrag of statics{_options.FileIndex}.mul failed: {ex.Message}", ex);
+ }
+ finally
+ {
+ TryDelete(tempIndexPath);
+ TryDelete(tempStaticsPath);
+ }
+
+ return result;
+ }
+
+ ///
+ /// The whole algorithm, over streams. Everything that touches the file system lives in the
+ /// other overload, so this one can be driven from memory buffers.
+ ///
+ public StaticsDefragResult Run(Stream index, Stream statics, Stream outIndex, Stream outStatics, StaticsDefragResult result = null)
+ {
+ result ??= new StaticsDefragResult { FileIndex = _options.FileIndex, DryRun = _options.DryRun };
+
+ var stopwatch = Stopwatch.StartNew();
+
+ _itemTableLength = TileData.ItemTable?.Length ?? 0;
+ _maxItemId = _options.ResolveMaxItemId();
+ result.MaxItemIdUsed = _maxItemId;
+
+ ResolveGeometry(index.Length, result);
+
+ int blockWidth = result.BlockWidth;
+ int blockHeight = result.BlockHeight;
+ int blockCount = blockWidth * blockHeight;
+
+ ConfigureFilter();
+
+ var problems = new StaticsBlockProblems();
+
+ using (var reader = new StaticsIndexReader(index, statics, blockWidth, blockHeight,
+ result.Warnings, true, $"staidx{_options.FileIndex}.mul"))
+ using (var writer = new StaticsBlockWriter(outIndex, outStatics, blockWidth, blockHeight,
+ _options.EmptyBlocks, true))
+ {
+ CheckForSurplusBlocks(reader.Entries, blockCount, statics.Length, result);
+
+ TileMatrix tiles = _options.Map?.Tiles;
+
+ for (int bx = 0; bx < blockWidth; ++bx)
+ {
+ for (int by = 0; by < blockHeight; ++by)
+ {
+ _tiles.Clear();
+
+ Entry3D entry = reader.GetEntry(bx, by);
+ bool removed = tiles != null && tiles.IsStaticBlockRemoved(bx, by);
+
+ if (removed)
+ {
+ ++result.BlocksClearedByRemove;
+ }
+ else
+ {
+ if (entry.Lookup >= 0 && entry.Length > 0)
+ {
+ ++result.SourceBlocksWithStatics;
+ result.TilesRead += reader.ReadBlock(bx, by, _tiles, result.Warnings, problems);
+ }
+
+ CollectPendingStatics(tiles, bx, by, result);
+ ApplyBlockFilters(bx, by, result);
+ }
+
+ writer.WriteBlock(bx, by, _tiles, _options.PreserveExtra ? entry.Extra : 0);
+
+ ++result.BlocksProcessed;
+
+ if ((result.BlocksProcessed & (ProgressInterval - 1)) == 0)
+ {
+ _options.CancellationToken.ThrowIfCancellationRequested();
+ ReportProgress(result, blockCount);
+ }
+ }
+ }
+
+ writer.Complete();
+
+ result.OutputStaticsBytes = writer.StaticsLength;
+ result.TilesWritten = writer.TilesWritten;
+ result.OutputBlocksWithStatics = writer.BlocksWithStatics;
+ }
+
+ result.BlocksWithBadLookup = problems.BadLookup;
+ result.BlocksWithBadLength = problems.BadLength;
+ result.HighestItemIdSeen = _filter.HighestItemIdSeen;
+ result.Elapsed = stopwatch.Elapsed;
+ ReportProgress(result, blockCount);
+
+ return result;
+ }
+
+ private void ConfigureFilter()
+ {
+ _filter.DropInvalidItemIds = _options.DropInvalidItemIds;
+ _filter.MaxItemId = _maxItemId;
+ _filter.OutOfBlockTiles = _options.OutOfBlockTiles;
+ _filter.DropInvalidZ = _options.DropInvalidZ;
+ _filter.NormalizeNegativeHue = _options.NormalizeNegativeHue;
+ _filter.RemoveDuplicates = _options.RemoveDuplicates;
+ _filter.DuplicatesCompareHue = _options.DuplicatesCompareHue;
+ }
+
+ private void ReportProgress(StaticsDefragResult result, int blockCount)
+ {
+ _options.Progress?.Report(new StaticsDefragProgress
+ {
+ BlocksDone = result.BlocksProcessed,
+ BlocksTotal = blockCount,
+ TilesWritten = result.TilesWritten
+ });
+ }
+
+ private void ResolveGeometry(long indexLength, StaticsDefragResult result)
+ {
+ int blockWidth = _options.BlockWidth;
+ int blockHeight = _options.BlockHeight;
+
+ if (blockWidth <= 0 || blockHeight <= 0)
+ {
+ Map map = _options.Map;
+ if (map == null)
+ {
+ throw new StaticsDefragException(
+ "No block dimensions were given and no map was supplied to derive them from.");
+ }
+
+ blockWidth = map.Width >> 3;
+ blockHeight = map.Height >> 3;
+ }
+
+ if (blockWidth <= 0 || blockHeight <= 0)
+ {
+ throw new StaticsDefragException($"Invalid block grid {blockWidth} x {blockHeight}.");
+ }
+
+ result.BlockWidth = blockWidth;
+ result.BlockHeight = blockHeight;
+ result.SourceIndexEntries = indexLength / IndexRecordSize;
+
+ if (indexLength % IndexRecordSize != 0)
+ {
+ result.Warnings.Add(string.Format(CultureInfo.InvariantCulture,
+ "staidx{0}.mul is {1:N0} bytes, which is not a whole number of {2}-byte records. The trailing {3} bytes were ignored.",
+ _options.FileIndex, indexLength, IndexRecordSize, indexLength % IndexRecordSize));
+ }
+
+ var evidence = new List
+ {
+ string.Format(CultureInfo.InvariantCulture, "staidx holds {0:N0} blocks", result.SourceIndexEntries)
+ };
+
+ string mapPath = Files.GetFilePath($"map{_options.FileIndex}.mul");
+ if (mapPath != null)
+ {
+ // Skipped for map{N}LegacyMUL.uop - the UOP chunking makes length / 196 meaningless.
+ long mapBlocks = new FileInfo(mapPath).Length / 196;
+ evidence.Add(string.Format(CultureInfo.InvariantCulture, "map{0}.mul holds {1:N0} blocks", _options.FileIndex, mapBlocks));
+
+ if (mapBlocks != (long)blockWidth * blockHeight)
+ {
+ result.Warnings.Add(string.Format(CultureInfo.InvariantCulture,
+ "map{0}.mul covers {1:N0} blocks but the configured map size covers {2:N0}. Check the map size setting.",
+ _options.FileIndex, mapBlocks, (long)blockWidth * blockHeight));
+ }
+ }
+
+ evidence.Add(string.Format(CultureInfo.InvariantCulture, "configured grid {0} x {1} = {2:N0}",
+ blockWidth, blockHeight, (long)blockWidth * blockHeight));
+
+ result.GeometryEvidence = string.Join("; ", evidence);
+ }
+
+ private void CheckForSurplusBlocks(Entry3D[] entries, int blockCount, long staticsLength, StaticsDefragResult result)
+ {
+ if (entries.Length <= blockCount)
+ {
+ return;
+ }
+
+ int surplusBlocks = 0;
+ long surplusTiles = 0;
+
+ for (int i = blockCount; i < entries.Length; ++i)
+ {
+ if (entries[i].Lookup < 0 || entries[i].Length <= 0 || entries[i].Lookup >= staticsLength)
+ {
+ continue;
+ }
+
+ ++surplusBlocks;
+ surplusTiles += entries[i].Length / TileRecordSize;
+ }
+
+ if (surplusBlocks == 0)
+ {
+ return;
+ }
+
+ string message = string.Format(CultureInfo.InvariantCulture,
+ "staidx{0}.mul holds {1:N0} blocks but the configured map size covers only {2:N0}. " +
+ "{3:N0} of the surplus blocks hold statics ({4:N0} tiles) and would be discarded.",
+ _options.FileIndex, entries.Length, blockCount, surplusBlocks, surplusTiles);
+
+ if (!_options.AllowGeometryTruncation)
+ {
+ throw new StaticsDefragException(message +
+ " Correct the map size setting, or allow truncation if the loss is intended.");
+ }
+
+ result.Warnings.Add(message + " Truncation was allowed, so they were discarded.");
+ }
+
+ private void CollectPendingStatics(TileMatrix tiles, int bx, int by, StaticsDefragResult result)
+ {
+ if (tiles == null || !tiles.PendingStatic(bx, by))
+ {
+ return;
+ }
+
+ StaticTile[] pending = tiles.GetPendingStatics(bx, by);
+ if (pending == null)
+ {
+ return;
+ }
+
+ for (int i = 0; i < pending.Length; ++i)
+ {
+ _tiles.Add(pending[i]);
+ ++result.PendingTilesAdded;
+ }
+ }
+
+ private void DropBelowTerrain(int bx, int by, StaticsDefragResult result)
+ {
+ Tile[] land = GetLandBlock(bx, by);
+
+ if (land == null)
+ {
+ return;
+ }
+
+ int write = 0;
+
+ for (int i = 0; i < _tiles.Count; ++i)
+ {
+ StaticTile tile = _tiles[i];
+
+ if (IsBelowTerrain(tile, land))
+ {
+ ((IStaticsFilterStats)result).TileRejected(bx, by, tile, RejectReason.BelowTerrain);
+
+ continue;
+ }
+
+ _tiles[write++] = tile;
+ }
+
+ _tiles.RemoveRange(write, _tiles.Count - write);
+ }
+
+ private Tile[] GetLandBlock(int bx, int by)
+ {
+ if (_options.Map == null)
+ {
+ return null;
+ }
+
+ Tile[] land = _options.Map.Tiles.GetLandBlock(bx, by);
+
+ return land != null && land.Length >= 64 ? land : null;
+ }
+
+ ///
+ /// The predicate already uses: the static and
+ /// everything stacked on top of it sit under the land tile, so the client never draws it.
+ ///
+ private bool IsBelowTerrain(StaticTile tile, Tile[] land)
+ {
+ if (tile.Id >= _itemTableLength)
+ {
+ return false;
+ }
+
+ Tile landTile = land[((tile.Y & 0x7) << 3) + (tile.X & 0x7)];
+
+ return tile.Z < landTile.Z && TileData.ItemTable[tile.Id].Height + tile.Z < landTile.Z;
+ }
+
+ private void ApplyBlockFilters(int bx, int by, StaticsDefragResult result)
+ {
+ _filter.Apply(_tiles, bx, by, result);
+
+ if (_options.DropBelowTerrain && _tiles.Count > 0)
+ {
+ DropBelowTerrain(bx, by, result);
+ }
+
+ if (_options.CollapseStacks && _tiles.Count > 1)
+ {
+ CollapseStacks(bx, by, result);
+ }
+
+ if (_options.SortTiles && _tiles.Count > 1)
+ {
+ _tiles.Sort(CompareTiles);
+ }
+ }
+
+ ///
+ /// Drops statics sharing id, x, y and z. Hue is left out of the key on purpose: the old
+ /// routine included it, so two water tiles in the same cell differing only in hue both
+ /// survived and fought over the same pixels.
+ ///
+ ///
+ /// Keeps a single eligible static per cell. Only tiles matching the flag mask or the id list
+ /// take part, so a floor with a chair on it is left alone while a cell holding six stacked
+ /// water tiles is reduced to one.
+ ///
+ private void CollapseStacks(int bx, int by, StaticsDefragResult result)
+ {
+ _stackIndex.Clear();
+
+ int write = 0;
+
+ for (int i = 0; i < _tiles.Count; ++i)
+ {
+ StaticTile tile = _tiles[i];
+
+ if (!IsCollapsible(tile.Id))
+ {
+ _tiles[write++] = tile;
+ continue;
+ }
+
+ uint key = CollapseKey(tile);
+
+ if (_stackIndex.ContainsKey(key))
+ {
+ ((IStaticsFilterStats)result).TileRejected(bx, by, tile, RejectReason.CollapsedStack);
+
+ continue;
+ }
+
+ _stackIndex[key] = write;
+ _tiles[write++] = tile;
+ }
+
+ _tiles.RemoveRange(write, _tiles.Count - write);
+ }
+
+ private bool IsCollapsible(ushort id)
+ {
+ if (_options.CollapseIds.Contains(id))
+ {
+ return true;
+ }
+
+ if (_options.CollapseFlagMask == 0 || id >= _itemTableLength)
+ {
+ return false;
+ }
+
+ return (TileData.ItemTable[id].Flags & _options.CollapseFlagMask) != 0;
+ }
+
+ private uint CollapseKey(StaticTile tile)
+ {
+ uint key = ((uint)(tile.X & 0x7) << 3) | (uint)(tile.Y & 0x7);
+
+ if (!_options.CollapseIgnoreZ)
+ {
+ key |= (uint)((byte)tile.Z) << 6;
+ }
+
+ return key;
+ }
+
+ private static int CompareTiles(StaticTile left, StaticTile right)
+ {
+ int result = left.Y.CompareTo(right.Y);
+ if (result != 0)
+ {
+ return result;
+ }
+
+ result = left.X.CompareTo(right.X);
+ if (result != 0)
+ {
+ return result;
+ }
+
+ result = left.Z.CompareTo(right.Z);
+ if (result != 0)
+ {
+ return result;
+ }
+
+ result = left.Id.CompareTo(right.Id);
+
+ return result != 0 ? result : left.Hue.CompareTo(right.Hue);
+ }
+
+ private static string ResolveSource(string explicitPath, string fileName)
+ {
+ if (!string.IsNullOrEmpty(explicitPath))
+ {
+ if (!File.Exists(explicitPath))
+ {
+ throw new StaticsDefragException($"{explicitPath} does not exist.");
+ }
+
+ return Path.GetFullPath(explicitPath);
+ }
+
+ string path = Files.GetFilePath(fileName);
+
+ if (path == null)
+ {
+ throw new StaticsDefragException(
+ $"{fileName} was not found. Check the path settings for the loaded client.");
+ }
+
+ return Path.GetFullPath(path);
+ }
+
+ private static void RefuseToOverwriteSource(string sourcePath, string outputPath)
+ {
+ if (!string.Equals(sourcePath, outputPath, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ throw new StaticsDefragException(
+ $"The output directory holds the file being read ({outputPath}). Choose a different output directory.");
+ }
+
+ private void WarnAboutOverrideFiles(string indexPath, StaticsDefragResult result)
+ {
+ string directory = Path.GetDirectoryName(indexPath);
+ if (directory == null)
+ {
+ return;
+ }
+
+ string overrideIndex = Path.Combine(directory, $"staidx{_options.FileIndex}x.mul");
+
+ if (File.Exists(overrideIndex))
+ {
+ result.Warnings.Add(
+ $"staidx{_options.FileIndex}x.mul is present. The client prefers those override files over the pair being rewritten here, so the result may not show up in game.");
+ }
+ }
+
+ private static void MoveIntoPlace(string tempIndexPath, string indexPath, string tempStaticsPath, string staticsPath)
+ {
+ // Both files are complete on disk by now, so the pair moves back to back. If the second
+ // move still fails the caller is told which half made it, because an index and a data
+ // file from different runs do not describe the same world.
+ File.Move(tempStaticsPath, staticsPath, true);
+
+ try
+ {
+ File.Move(tempIndexPath, indexPath, true);
+ }
+ catch (Exception ex)
+ {
+ throw new StaticsDefragException(
+ $"{staticsPath} was replaced but {indexPath} could not be: {ex.Message}. The two files no longer match.", ex);
+ }
+ }
+
+ private static void TryDelete(string path)
+ {
+ if (path == null || !File.Exists(path))
+ {
+ return;
+ }
+
+ try
+ {
+ File.Delete(path);
+ }
+ catch (IOException)
+ {
+ // Nothing useful to do - the temporary file is named so it cannot be mistaken for output.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Ultima/Textures.cs b/Ultima/Textures.cs
index f4e80ffb..f2390751 100644
--- a/Ultima/Textures.cs
+++ b/Ultima/Textures.cs
@@ -14,6 +14,9 @@ public sealed class Textures
private static Bitmap[] _cache = new Bitmap[0x4000];
private static bool[] _removed = new bool[0x4000];
private static readonly Dictionary _patched = new Dictionary();
+ // Indexes edited since load or since the last save. Replace() writes straight into _cache,
+ // which is also the read cache, so there is no other way to tell an edit from a disk load.
+ private static readonly ModifiedIndexTracker _modified = new ModifiedIndexTracker();
private struct Checksums
{
@@ -31,6 +34,7 @@ public static void Reload()
_cache = new Bitmap[0x4000];
_removed = new bool[0x4000];
_patched.Clear();
+ _modified.Clear();
}
public static int GetIdxLength()
@@ -45,6 +49,7 @@ public static int GetIdxLength()
public static void Remove(int index)
{
_removed[index] = true;
+ _modified.Mark(index & 0x3FFF);
}
///
@@ -57,6 +62,29 @@ public static void Replace(int index, Bitmap bmp)
_cache[index] = bmp;
_removed[index] = false;
_patched.Remove(index);
+ _modified.Mark(index & 0x3FFF);
+ }
+
+ ///
+ /// Tests if the Texture at was replaced or removed since the textures
+ /// were loaded or last saved.
+ ///
+ public static bool IsModified(int index)
+ {
+ return _modified.IsMarked(index & 0x3FFF);
+ }
+
+ ///
+ /// Number of Textures edited since the textures were loaded or last saved.
+ ///
+ public static int ModifiedCount => _modified.Count;
+
+ ///
+ /// Drops every modified mark without touching the edits themselves.
+ ///
+ public static void ClearModified()
+ {
+ _modified.Clear();
}
///
@@ -133,6 +161,14 @@ public static unsafe Bitmap GetTexture(int index, out bool patched)
int max = size * size * 2;
+ // An entry that claims fewer bytes than the size flag implies is truncated - reading
+ // the full tile off it would run past the end of the stream, and this is called from a
+ // paint handler, where an exception takes the whole window down.
+ if (length < max)
+ {
+ return null;
+ }
+
byte[] streamBuffer = ArrayPool.Shared.Rent(max);
try
{
@@ -268,6 +304,8 @@ public static unsafe void Save(string path)
}
memIdx.Dispose();
+
+ _modified.Clear();
}
private static int GetExtraFlag(int length)
diff --git a/Ultima/TileMatrix.cs b/Ultima/TileMatrix.cs
index 4c956978..f6972a02 100644
--- a/Ultima/TileMatrix.cs
+++ b/Ultima/TileMatrix.cs
@@ -3,11 +3,20 @@
using System.IO;
using System.Runtime.InteropServices;
using Ultima.Helpers;
+using Ultima.Uop;
namespace Ultima
{
public sealed class TileMatrix
{
+ /// On disk size of one land block: a 4-byte header plus 64 three-byte tiles.
+ public const int MapBlockSize = 196;
+
+ /// Size of the per-block header the renderer ignores.
+ public const int BlockHeaderSize = 4;
+
+ private const int MapStreamBufferSize = 1 << 20;
+
private readonly HuedTile[][][][][] _staticTiles;
private readonly Tile[][][] _landTiles;
private bool[][] _removedStaticBlock;
@@ -17,7 +26,6 @@ public sealed class TileMatrix
public static HuedTile[][][] EmptyStaticBlock { get; private set; }
private FileStream _map;
- private BinaryReader _uopReader;
private FileStream _statics;
private Entry3D[] _staticIndex;
@@ -40,7 +48,6 @@ public sealed class TileMatrix
public void CloseStreams()
{
_map?.Close();
- _uopReader?.Close();
_statics?.Close();
}
@@ -125,7 +132,10 @@ public TileMatrix(int fileIndex, int mapId, int width, int height, string path)
}
}
- InvalidLandBlock = new Tile[196];
+ // 64 tiles, not 196 - 196 is the on-disk byte size of a block (4-byte header plus
+ // 64 three-byte tiles), which is a different thing. A caller that enumerates the block
+ // rather than indexing 0..63 used to get 196 tiles back and write a malformed block.
+ InvalidLandBlock = new Tile[64];
_landTiles = new Tile[BlockWidth][][];
_staticTiles = new HuedTile[BlockWidth][][][][];
@@ -339,142 +349,108 @@ private unsafe HuedTile[][][] ReadStaticBlock(int x, int y)
/*
* UOP map files support code, written by Wyatt (c) www.ruosi.org
- * It's not possible if some entry has unknown hash. Thrown exception
- * means that EA changed maps UOPs again.
+ * The container walk now lives in Ultima.Uop.MapUopReader so the map writer and size
+ * detection can use it without building a TileMatrix.
*/
public bool IsUOPFormat { get; set; }
public bool IsUOPAlreadyRead { get; set; }
- private readonly struct UopFile
- {
- public readonly long Offset;
- public readonly int Length;
-
- public UopFile(long offset, int length)
- {
- Offset = offset;
- Length = length;
- }
- }
-
- private UopFile[] UOPFiles { get; set; }
+ private MapUopEntry[] UOPFiles { get; set; }
private long UOPLength { get { return _map.Length; } }
private void ReadUOPFiles(string pattern)
{
- _uopReader = new BinaryReader(_map);
+ UOPFiles = MapUopReader.ReadEntryTable(_map, pattern);
+ }
- _uopReader.BaseStream.Seek(0, SeekOrigin.Begin);
+ private long CalculateOffsetFromUOP(long offset)
+ {
+ long pos = 0;
- if (_uopReader.ReadInt32() != 0x50594D)
+ foreach (MapUopEntry t in UOPFiles)
{
- throw new ArgumentException("Bad UOP file.");
- }
+ long currentPosition = pos + t.Length;
- _uopReader.ReadInt64(); // version + signature
- long nextBlock = _uopReader.ReadInt64();
- _uopReader.ReadInt32(); // block capacity
- int count = _uopReader.ReadInt32();
+ if (offset < currentPosition)
+ {
+ return t.Offset + (offset - pos);
+ }
- UOPFiles = new UopFile[count];
+ pos = currentPosition;
+ }
- var hashes = new Dictionary();
+ return UOPLength;
+ }
- for (int i = 0; i < count; i++)
+ ///
+ /// Reads one land block as it sits on disk: the 4-byte block header followed by 64
+ /// three-byte tiles. Copying through this rather than through
+ /// keeps the header, which the renderer ignores but which real files do carry - a shard map
+ /// may hold the same constant in every block, a shipped one the block index.
+ ///
+ public void ReadLandBlockBytes(int x, int y, Span destination)
+ {
+ if (destination.Length != MapBlockSize)
{
- string file = $"build/{pattern}/{i:D8}.dat";
- ulong hash = UopUtils.HashFileName(file);
-
- hashes.TryAdd(hash, i);
+ throw new ArgumentException($"A land block is {MapBlockSize} bytes.", nameof(destination));
}
- _uopReader.BaseStream.Seek(nextBlock, SeekOrigin.Begin);
+ destination.Clear();
- do
+ if (x < 0 || y < 0 || x >= BlockWidth || y >= BlockHeight)
{
- int filesCount = _uopReader.ReadInt32();
- nextBlock = _uopReader.ReadInt64();
+ return;
+ }
- for (int i = 0; i < filesCount; i++)
- {
- long offset = _uopReader.ReadInt64();
- int headerLength = _uopReader.ReadInt32();
- int compressedLength = _uopReader.ReadInt32();
- _uopReader.ReadInt32(); // decompressed length - equal to the compressed one while stored
- ulong hash = _uopReader.ReadUInt64();
- _uopReader.ReadUInt32(); // Adler32
- short flag = _uopReader.ReadInt16();
-
- if (offset == 0)
- {
- continue;
- }
+ EnsureMapStream();
- // This reader addresses map blocks by slicing straight into the file, so it can only handle
- // stored entries. Every map*LegacyMUL.uop EA ships uses flag 0, but the UOP packer can be told
- // to zlib them. compressedLength is the byte count on disk; decompressedLength only matches
- // it while the entry is uncompressed.
- if (flag != 0)
- {
- throw new NotSupportedException(
- $"{pattern}: compressed map UOP entries are not supported " +
- $"(entry uses compression flag {flag}). Repack the map with compression set to None.");
- }
+ if (_map == null)
+ {
+ return;
+ }
- if (hashes.TryGetValue(hash, out int idx))
- {
- if (idx < 0 || idx >= UOPFiles.Length)
- {
- throw new IndexOutOfRangeException("hashes dictionary and files collection have different count of entries!");
- }
+ long offset = ((x * BlockHeight) + y) * (long)MapBlockSize;
- UOPFiles[idx] = new UopFile(offset + headerLength, compressedLength);
- }
- else
- {
- throw new ArgumentException($"File with hash 0x{hash:X8} was not found in hashes dictionary! EA Mythic changed UOP format!");
- }
- }
+ if (IsUOPFormat)
+ {
+ offset = CalculateOffsetFromUOP(offset);
+ }
+
+ if (offset < 0 || offset + destination.Length > _map.Length)
+ {
+ return;
}
- while (_uopReader.BaseStream.Seek(nextBlock, SeekOrigin.Begin) != 0);
+
+ _map.Seek(offset, SeekOrigin.Begin);
+ _map.ReadExactly(destination);
}
- private long CalculateOffsetFromUOP(long offset)
+ private void EnsureMapStream()
{
- long pos = 0;
-
- foreach (UopFile t in UOPFiles)
+ if (_map?.CanRead == true && _map.CanSeek)
{
- long currentPosition = pos + t.Length;
+ return;
+ }
- if (offset < currentPosition)
- {
- return t.Offset + (offset - pos);
- }
+ // Land blocks are 196 bytes and are normally walked in index order, so the default
+ // 4 KB buffer means one physical read per twenty blocks. A megabyte turns a full-facet
+ // pass from hundreds of thousands of reads into a few hundred.
+ _map = _mapPath == null
+ ? null
+ : new FileStream(_mapPath, FileMode.Open, FileAccess.Read, FileShare.Read, MapStreamBufferSize);
- pos = currentPosition;
+ if (!IsUOPFormat || _mapPath == null || IsUOPAlreadyRead)
+ {
+ return;
}
- return UOPLength;
+ ReadUOPFiles(MapUopReader.PatternFromPath(_mapPath));
+ IsUOPAlreadyRead = true;
}
private Tile[] ReadLandBlock(int x, int y)
{
- if (_map?.CanRead != true || !_map.CanSeek)
- {
- _map = _mapPath == null
- ? null
- : new FileStream(_mapPath, FileMode.Open, FileAccess.Read, FileShare.Read);
-
- if (IsUOPFormat && _mapPath != null && !IsUOPAlreadyRead)
- {
- var fi = new FileInfo(_mapPath);
- string uopPattern = fi.Name.Replace(fi.Extension, "").ToLowerInvariant();
-
- ReadUOPFiles(uopPattern);
- IsUOPAlreadyRead = true;
- }
- }
+ EnsureMapStream();
var tiles = new Tile[64];
if (_map == null)
@@ -482,16 +458,27 @@ private Tile[] ReadLandBlock(int x, int y)
return tiles;
}
- long offset = (((x * BlockHeight) + y) * 196) + 4;
+ long offset = (((x * BlockHeight) + y) * (long)MapBlockSize) + BlockHeaderSize;
if (IsUOPFormat)
{
offset = CalculateOffsetFromUOP(offset);
}
+ Span destination = MemoryMarshal.AsBytes(tiles.AsSpan());
+
+ // The configured map size can cover more blocks than the file actually holds - a client
+ // whose map0 is the pre-T2A 6144 wide one read as the modern 7168 grid, say - and the
+ // tail blocks are then simply absent. Reading them as empty is what the statics index
+ // already does with a short staidx, and it beats throwing at whoever asked to draw them.
+ if (offset < 0 || offset + destination.Length > _map.Length)
+ {
+ return tiles;
+ }
+
_map.Seek(offset, SeekOrigin.Begin);
- _map.ReadExactly(MemoryMarshal.AsBytes(tiles.AsSpan()));
+ _map.ReadExactly(destination);
return tiles;
}
diff --git a/Ultima/TileMatrixPatch.cs b/Ultima/TileMatrixPatch.cs
index d63f2c2e..dcfdce36 100644
--- a/Ultima/TileMatrixPatch.cs
+++ b/Ultima/TileMatrixPatch.cs
@@ -16,6 +16,11 @@ public sealed class TileMatrixPatch
private static StaticTile[] _tileBuffer = new StaticTile[128];
+ ///
+ /// The arrays are only allocated when the matching diff files are present, and a client can
+ /// ship one half of the set without the other - 7.0.114.4 has stadif but no mapdif - so the
+ /// accessors have to cope with a null array rather than assume the files were there.
+ ///
public bool IsLandBlockPatched(int x, int y)
{
if (x < 0 || y < 0 || x >= _blockWidth || y >= _blockHeight)
@@ -23,12 +28,7 @@ public bool IsLandBlockPatched(int x, int y)
return false;
}
- if (LandBlocks[x] == null)
- {
- return false;
- }
-
- if (LandBlocks[x][y] == null)
+ if (LandBlocks?[x]?[y] == null)
{
return false;
}
@@ -43,12 +43,7 @@ public Tile[] GetLandBlock(int x, int y)
return TileMatrix.InvalidLandBlock;
}
- if (LandBlocks[x]==null)
- {
- return TileMatrix.InvalidLandBlock;
- }
-
- return LandBlocks[x][y];
+ return LandBlocks?[x]?[y] ?? TileMatrix.InvalidLandBlock;
}
public Tile GetLandTile(int x, int y)
@@ -63,12 +58,7 @@ public bool IsStaticBlockPatched(int x, int y)
return false;
}
- if (StaticBlocks[x] == null)
- {
- return false;
- }
-
- if (StaticBlocks[x][y] == null)
+ if (StaticBlocks?[x]?[y] == null)
{
return false;
}
@@ -83,12 +73,7 @@ public HuedTile[][][] GetStaticBlock(int x, int y)
return TileMatrix.EmptyStaticBlock;
}
- if (StaticBlocks[x] == null)
- {
- return TileMatrix.EmptyStaticBlock;
- }
-
- return StaticBlocks[x][y];
+ return StaticBlocks?[x]?[y] ?? TileMatrix.EmptyStaticBlock;
}
public HuedTile[] GetStaticTiles(int x, int y)
@@ -99,7 +84,7 @@ public HuedTile[] GetStaticTiles(int x, int y)
public TileMatrixPatch(TileMatrix matrix, int index, string path)
{
_blockWidth = matrix.BlockWidth;
- _blockHeight = matrix.BlockWidth;
+ _blockHeight = matrix.BlockHeight;
LandBlocksCount = StaticBlocksCount = 0;
string mapDataPath, mapIndexPath;
diff --git a/Ultima/Ultima.csproj b/Ultima/Ultima.csproj
index 0207699f..c1db92c3 100644
--- a/Ultima/Ultima.csproj
+++ b/Ultima/Ultima.csproj
@@ -13,6 +13,7 @@
true
+
diff --git a/Ultima/Uop/ClientFileSaver.cs b/Ultima/Uop/ClientFileSaver.cs
new file mode 100644
index 00000000..bc60f98b
--- /dev/null
+++ b/Ultima/Uop/ClientFileSaver.cs
@@ -0,0 +1,286 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+namespace Ultima.Uop
+{
+ ///
+ /// Which container a save writes. The same two choices the map sinks already offer, for the file
+ /// types that are packed whole rather than streamed block by block.
+ ///
+ public enum ContainerFormat
+ {
+ Mul,
+ Uop
+ }
+
+ /// What a save wrote, and anything the user should know about it.
+ public sealed class ClientFileSaveResult
+ {
+ internal ClientFileSaveResult(string directory, IReadOnlyList filesWritten,
+ IReadOnlyList warnings)
+ {
+ Directory = directory;
+ FilesWritten = filesWritten;
+ Warnings = warnings;
+ }
+
+ /// Folder the files landed in.
+ public string Directory { get; }
+
+ /// File names, without their directory.
+ public IReadOnlyList FilesWritten { get; }
+
+ /// Losses worth telling the user about. Empty on a clean save.
+ public IReadOnlyList Warnings { get; }
+ }
+
+ ///
+ /// Saves one of the packable file types as either a mul/idx pair or a uop, from whatever the
+ /// in-memory model holds.
+ ///
+ ///
+ /// packs file to file, so a uop save writes the mul into a
+ /// temporary folder with the domain's own save method, packs that, and throws the temporary copy
+ /// away. Nothing in the domain classes has to learn about uop.
+ ///
+ public static class ClientFileSaver
+ {
+ ///
+ /// Whether the loaded client keeps this type in a uop. The choice is per file - a client can be
+ /// uop art and mul gumps - so it is asked per type rather than once for the whole client.
+ ///
+ public static bool ClientUsesUop(FileType type, int mapIndex = 0)
+ {
+ return UopFileNames.ClientUopPath(type, mapIndex) != null;
+ }
+
+ ///
+ /// What the user should be asked to confirm before this save runs. Empty when there is nothing
+ /// to weigh up. Conditions that make a save impossible are not listed here - those throw from
+ /// .
+ ///
+ public static IReadOnlyList Preflight(FileType type, ContainerFormat format, int mapIndex = 0)
+ {
+ if (type != FileType.MultiCollection || format != ContainerFormat.Uop)
+ {
+ return Array.Empty();
+ }
+
+ if (UopFileNames.ClientUopPath(FileType.MultiCollection) == null)
+ {
+ return Array.Empty();
+ }
+
+ // The multi list is read from multi.idx and multi.mul, and on a modern client those describe
+ // a reduced collection next to MultiCollection.uop: the live 7.0.114.4 client ships 800
+ // multis of 62 177 tiles in the mul against the uop's 872 of 188 349. Packing the list
+ // therefore writes a smaller collection than the client shipped, and the file itself will
+ // not show it.
+ int multisInModel = 0;
+ int multisInClientUop = 0;
+ long tilesInModel = 0;
+ long tilesInClientUop = 0;
+
+ for (int index = 0; index < Multis.MaximumMultiIndex; ++index)
+ {
+ MultiComponentList fromMul = Multis.GetComponents(index);
+
+ if (fromMul != MultiComponentList.Empty)
+ {
+ ++multisInModel;
+ tilesInModel += fromMul.SortedTiles.Length;
+ }
+
+ MultiComponentList fromUop = Multis.GetUopComponents(index);
+
+ if (fromUop != MultiComponentList.Empty)
+ {
+ ++multisInClientUop;
+ tilesInClientUop += fromUop.SortedTiles.Length;
+ }
+ }
+
+ if (multisInClientUop <= multisInModel && tilesInClientUop <= tilesInModel)
+ {
+ return Array.Empty();
+ }
+
+ return new[]
+ {
+ $"This client's MultiCollection.uop describes {multisInClientUop:N0} multis of "
+ + $"{tilesInClientUop:N0} tiles. Its multi.mul, which is what the Multis tab shows and "
+ + $"what a save writes, describes {multisInModel:N0} of {tilesInModel:N0}. Saving as "
+ + "MultiCollection.uop writes what the tab holds and nothing else, so the difference "
+ + "would be lost. Save as multi.mul instead, or unpack the client's MultiCollection.uop "
+ + "with the UOP Packer and point the profile at the result first."
+ };
+ }
+
+ ///
+ /// Writes into in the requested
+ /// container. is the domain's existing save method, which is handed
+ /// the directory to write its mul and idx into.
+ ///
+ public static ClientFileSaveResult Save(FileType type, string outputDirectory, ContainerFormat format,
+ Action writeMul, int mapIndex = 0, IProgress progress = null)
+ {
+ if (string.IsNullOrWhiteSpace(outputDirectory))
+ {
+ throw new ArgumentException("No output directory was given.", nameof(outputDirectory));
+ }
+
+ if (writeMul == null)
+ {
+ throw new ArgumentNullException(nameof(writeMul));
+ }
+
+ Directory.CreateDirectory(outputDirectory);
+
+ var (mulName, idxName, uopName) = UopFileNames.For(type, mapIndex);
+
+ if (format == ContainerFormat.Mul)
+ {
+ writeMul(outputDirectory);
+
+ string[] written = idxName == null
+ ? new[] { mulName }
+ : new[] { mulName, idxName };
+
+ return new ClientFileSaveResult(outputDirectory, written, Array.Empty());
+ }
+
+ string temporaryDirectory = Path.Combine(Path.GetTempPath(),
+ "UoFiddler-save-" + Guid.NewGuid().ToString("N"));
+
+ Directory.CreateDirectory(temporaryDirectory);
+
+ try
+ {
+ string temporaryMul = Path.Combine(temporaryDirectory, mulName);
+ string temporaryIdx = idxName == null ? null : Path.Combine(temporaryDirectory, idxName);
+ var warnings = new List();
+
+ string housingBin = string.Empty;
+ string componentsFile = string.Empty;
+
+ if (type == FileType.MultiCollection)
+ {
+ // Unpacks the client's own file first, for the two things the in-memory multi model
+ // cannot supply. writeMul then overwrites the mul and idx it left behind.
+ (housingBin, componentsFile) = PrepareMultiCollection(temporaryDirectory, temporaryMul,
+ temporaryIdx, warnings);
+ }
+
+ writeMul(temporaryDirectory);
+
+ if (!File.Exists(temporaryMul))
+ {
+ throw new FileNotFoundException(
+ $"The save wrote no {mulName}, so there is nothing to pack into {uopName}.", temporaryMul);
+ }
+
+ string outputUop = Path.Combine(outputDirectory, uopName);
+
+ LegacyMulFileConverter.ToUop(temporaryMul, temporaryIdx, outputUop, type, mapIndex,
+ UopFileNames.DefaultCompression(type), housingBin, progress, componentsFile);
+
+ return new ClientFileSaveResult(outputDirectory, new[] { uopName }, warnings);
+ }
+ finally
+ {
+ TryDeleteDirectory(temporaryDirectory);
+ }
+ }
+
+ ///
+ /// Produces housing.bin and the component id sidecar in by
+ /// unpacking the loaded client's MultiCollection.uop.
+ ///
+ ///
+ /// MultiCollection.uop carries two things multi.mul does not: build/multicollection/housing.bin,
+ /// the custom housing piece catalog, and a component id per tile that marks its interactive role -
+ /// a boat without its tiller man cannot be steered and a house door stops being a door. The reader
+ /// drops the component ids (see Multis.LoadUop) and multi.mul has nowhere to keep either, so the
+ /// only honest source for both is the client's own file.
+ ///
+ private static (string HousingBin, string ComponentsFile) PrepareMultiCollection(string temporaryDirectory,
+ string temporaryMul, string temporaryIdx, ICollection warnings)
+ {
+ string clientUop = UopFileNames.ClientUopPath(FileType.MultiCollection);
+
+ if (clientUop == null)
+ {
+ throw new InvalidOperationException(
+ "Multis can only be saved as MultiCollection.uop when the loaded client has one to take "
+ + "build/multicollection/housing.bin and the tile component ids from. This client has no "
+ + "MultiCollection.uop, so save multis as multi.mul instead, or use the UOP Packer with a "
+ + "housing.bin of your own.");
+ }
+
+ if (UopFileNames.ClientMulPath(FileType.MultiCollection) == null)
+ {
+ throw new InvalidOperationException(
+ "This client has no multi.mul, so the multi list was never loaded and packing it would "
+ + "write an empty MultiCollection.uop. Unpack the client's MultiCollection.uop with the "
+ + "UOP Packer first, point the profile at the result, then save.");
+ }
+
+ string housingBin = Path.Combine(temporaryDirectory, "housing.bin");
+ string componentsFile = MultiComponentSidecar.GetDefaultPath(temporaryMul);
+
+ new LegacyMulFileConverter().FromUop(clientUop, temporaryMul, temporaryIdx,
+ FileType.MultiCollection, 0, housingBin, null, componentsFile);
+
+ if (!File.Exists(housingBin))
+ {
+ throw new InvalidOperationException(
+ $"{Path.GetFileName(clientUop)} contains no build/multicollection/housing.bin, which the "
+ + "client needs to place customisable house pieces. Packing without it would produce a "
+ + "file the client cannot use.");
+ }
+
+ MultiComponentSidecar.Status sidecar = MultiComponentSidecar.Probe(temporaryMul, componentsFile);
+
+ if (sidecar.IsEmpty)
+ {
+ warnings.Add(
+ $"{Path.GetFileName(clientUop)} carries no tile component ids, so every tile is written "
+ + "with none. Boats lose their tiller man, hatch and planks, and customisable houses lose "
+ + "their doors.");
+ }
+
+ return (housingBin, componentsFile);
+ }
+
+ private static void TryDeleteDirectory(string path)
+ {
+ try
+ {
+ if (Directory.Exists(path))
+ {
+ Directory.Delete(path, true);
+ }
+ }
+ catch (IOException)
+ {
+ // A leftover temporary folder is untidy, not a failure; the original result stands.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // As above.
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Plugin.UopPacker/Classes/FileType.cs b/Ultima/Uop/FileType.cs
similarity index 75%
rename from UoFiddler.Plugin.UopPacker/Classes/FileType.cs
rename to Ultima/Uop/FileType.cs
index 0a53a3ad..abae6b56 100644
--- a/UoFiddler.Plugin.UopPacker/Classes/FileType.cs
+++ b/Ultima/Uop/FileType.cs
@@ -1,4 +1,4 @@
-namespace UoFiddler.Plugin.UopPacker.Classes
+namespace Ultima.Uop
{
public enum FileType
{
@@ -8,4 +8,4 @@ public enum FileType
SoundLegacyMul,
MultiCollection
}
-}
+}
\ No newline at end of file
diff --git a/UoFiddler.Plugin.UopPacker/Classes/LegacyMulFileConverter.cs b/Ultima/Uop/LegacyMulFileConverter.cs
similarity index 86%
rename from UoFiddler.Plugin.UopPacker/Classes/LegacyMulFileConverter.cs
rename to Ultima/Uop/LegacyMulFileConverter.cs
index aa1039a6..fe3c7d2b 100644
--- a/UoFiddler.Plugin.UopPacker/Classes/LegacyMulFileConverter.cs
+++ b/Ultima/Uop/LegacyMulFileConverter.cs
@@ -1,25 +1,20 @@
-using System;
+using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using Microsoft.Extensions.Logging;
-using Ultima;
using Ultima.Helpers;
-using UoFiddler.Controls.Classes;
+using Ultima.Maps;
-namespace UoFiddler.Plugin.UopPacker.Classes
+namespace Ultima.Uop
{
public class LegacyMulFileConverter
{
- private struct IdxEntry
- {
- public int Id;
- public int Offset;
- public int Size;
- public int Extra;
- }
-
+ ///
+ /// One row of a block's entry table, as read back when unpacking. The pack direction hands
+ /// its rows to instead.
+ ///
private struct TableEntry
{
public long Offset;
@@ -32,6 +27,14 @@ private struct TableEntry
public bool Compressed;
}
+ private struct IdxEntry
+ {
+ public int Id;
+ public int Offset;
+ public int Size;
+ public int Extra;
+ }
+
//
// IO shortcuts
//
@@ -221,9 +224,6 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
|| type == FileType.SoundLegacyMul
|| type == FileType.MultiCollection;
- int tableSize = version4Layout ? 0x64 : 0x3E8;
- long firstTable = version4Layout ? 0x28 : 0x200;
-
// Stamped once per file, not per entry, so a repack of the same input is byte identical. The
// shipped files vary it per entry (a build machine timestamp), but nothing reads it back.
long entryHeaderTimestamp = DateTime.UtcNow.ToFileTimeUtc();
@@ -316,48 +316,16 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
});
}
- // File header
- writer.Write(0x50594D); // MYP
- writer.Write(version4Layout ? 4 : 5); // version
- writer.Write(0xFD23EC43); // format timestamp?
- writer.Write(firstTable); // first table
- writer.Write(tableSize); // table size
- writer.Write(idxEntries.Count); // file count
- writer.Write(0); // modified count? (wseq, version 5 only)
- writer.Write(0); // ? (cseq, version 5 only)
- writer.Write(0); // reserved
-
- // Padding
- for (long i = 0x28; i < firstTable; ++i)
- {
- writer.Write((byte)0);
- }
-
- int tableCount = (int)Math.Ceiling((double)idxEntries.Count / tableSize);
- TableEntry[] tableEntries = new TableEntry[tableSize];
-
string[] hashFormat = GetHashFormat(type, typeIndex, out int _);
int totalEntries = idxEntries.Count;
int lastReportedPct = -1;
progress?.Report(0);
- for (int i = 0; i < tableCount; ++i)
+ using (var container = new UopContainerWriter(writer.BaseStream,
+ version4Layout ? UopLayout.Version4 : UopLayout.Version5, idxEntries.Count, true))
{
- long thisTable = writer.BaseStream.Position;
-
- int idxStart = i * tableSize;
- int idxEnd = Math.Min((i + 1) * tableSize, idxEntries.Count);
-
- // Table header
- writer.Write(idxEnd - idxStart);
- writer.Write((long)0); // next table, filled in later
- writer.Seek(_tableEntrySize * tableSize, SeekOrigin.Current); // table entries, filled in later
-
- // Data
- int tableIdx = 0;
-
- for (int j = idxStart; j < idxEnd; ++j, ++tableIdx)
+ for (int j = 0; j < idxEntries.Count; ++j)
{
byte[] data;
@@ -371,10 +339,9 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
data = reader.ReadBytes(idxEntries[j].Size);
}
- tableEntries[tableIdx].Offset = writer.BaseStream.Position;
- tableEntries[tableIdx].DecompressedSize = data.Length;
- tableEntries[tableIdx].CompressionFlag = (short)compressionFlag;
- tableEntries[tableIdx].HeaderLength = 0;
+ byte[] payload = null;
+ ulong identifier;
+ int decompressedSize = data.Length;
/*
* Every entry of every shipped version 4 UOP carries a 12 byte header block in front
@@ -388,8 +355,6 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
if (version4Layout)
{
entryHeader = BuildEntryHeader(entryHeaderTimestamp);
- writer.Write(entryHeader);
- tableEntries[tableIdx].HeaderLength = entryHeader.Length;
}
// hash 906142efe9fdb38a, which is file 0009834.tga (and no others, as 7.0.59.5) use a different name format (7 digits instead of 8);
@@ -397,23 +362,22 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
// (even if this seems so much like a typo from someone from the UO development team :P)
if ((type == FileType.GumpartLegacyMul) && (idxEntries[j].Id == 9834))
{
- tableEntries[tableIdx].Identifier = HashLittle2(string.Format(hashFormat[1], idxEntries[j].Id));
+ identifier = HashLittle2(string.Format(hashFormat[1], idxEntries[j].Id));
}
else if (type == FileType.MultiCollection && idxEntries[j].Id == _housingBinSentinelId)
{
- tableEntries[tableIdx].Identifier = _housingBinIdentifier;
+ identifier = _housingBinIdentifier;
}
else
{
- tableEntries[tableIdx].Identifier = HashLittle2(string.Format(hashFormat[0], idxEntries[j].Id));
+ identifier = HashLittle2(string.Format(hashFormat[0], idxEntries[j].Id));
}
if (type == FileType.MultiCollection && idxEntries[j].Id != _housingBinSentinelId)
{
byte[] multiData = BuildMultiUopEntryFromMul(data, idxEntries[j].Id, componentTable);
- tableEntries[tableIdx].DecompressedSize = multiData.Length;
- tableEntries[tableIdx].Size = multiData.Length;
+ decompressedSize = multiData.Length;
if (compressionFlag >= CompressionFlag.Zlib)
{
@@ -423,11 +387,9 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
throw new InvalidDataException($"Compression failed for multi {idxEntries[j].Id}.");
}
multiData = result.compressedData;
- tableEntries[tableIdx].Size = multiData.Length;
}
- tableEntries[tableIdx].Hash = HashAdler32(multiData);
- writer.Write(multiData);
+ payload = multiData;
}
else if (type == FileType.GumpartLegacyMul)
{
@@ -442,8 +404,7 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
gumpArtWriter.Write(height);
gumpArtWriter.Write(data);
- tableEntries[tableIdx].DecompressedSize += 8;
- tableEntries[tableIdx].Size = tableEntries[tableIdx].DecompressedSize;
+ decompressedSize += 8;
}
if (compressionFlag == CompressionFlag.Mythic)
@@ -460,8 +421,7 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
}
}
gumpArtData = gumpArtData2;
- tableEntries[tableIdx].DecompressedSize = (int)gumpArtData.Length;
- tableEntries[tableIdx].Size = tableEntries[tableIdx].DecompressedSize;
+ decompressedSize = (int)gumpArtData.Length;
}
if (compressionFlag >= CompressionFlag.Zlib)
{
@@ -471,17 +431,14 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
throw new InvalidDataException($"Compression failed for gump {idxEntries[j].Id}.");
}
- tableEntries[tableIdx].Size = result.compressedData.Length;
gumpArtData = result.compressedData;
}
- tableEntries[tableIdx].Hash = HashAdler32(gumpArtData);
- writer.Write(gumpArtData);
+ payload = gumpArtData;
}
else if (type == FileType.MultiCollection && idxEntries[j].Id == _housingBinSentinelId)
{
byte[] binData = data;
- tableEntries[tableIdx].DecompressedSize = binData.Length;
- tableEntries[tableIdx].Size = binData.Length;
+ decompressedSize = binData.Length;
if (compressionFlag >= CompressionFlag.Zlib)
{
@@ -491,18 +448,16 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
throw new InvalidDataException("Compression failed for housing.bin.");
}
binData = result.compressedData;
- tableEntries[tableIdx].Size = binData.Length;
}
- tableEntries[tableIdx].Hash = HashAdler32(binData);
- writer.Write(binData);
+ payload = binData;
}
else
{
// Art / Map / Sound. The compression flag was already stamped on the entry above, so
// the data has to actually be compressed here - otherwise the entry claims zlib over
// raw bytes and neither the client nor FromUop can read it back.
- byte[] payload = data;
+ byte[] storedPayload = data;
if (compressionFlag == CompressionFlag.Mythic)
{
@@ -513,24 +468,20 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
if (compressionFlag == CompressionFlag.Zlib)
{
- var result = UopUtils.Compress(payload);
+ var result = UopUtils.Compress(storedPayload);
if (!result.success)
{
throw new InvalidDataException($"Compression failed for chunk {idxEntries[j].Id}.");
}
- payload = result.compressedData;
+ storedPayload = result.compressedData;
}
- tableEntries[tableIdx].Size = payload.Length;
- tableEntries[tableIdx].Hash = HashAdler32(payload);
- writer.Write(payload);
+ payload = storedPayload;
}
- if (entryHeader != null)
- {
- tableEntries[tableIdx].Hash = HashAdler32(entryHeader);
- }
+ container.WriteEntry(identifier, payload, decompressedSize,
+ (short)compressionFlag, entryHeader ?? ReadOnlySpan.Empty);
if (totalEntries > 0)
{
@@ -543,41 +494,7 @@ private static void WriteUop(string inFile, string inFileIdx, string outFile, Fi
}
}
- long nextTable = writer.BaseStream.Position;
-
- // Go back and fix table header
- if (i < tableCount - 1)
- {
- writer.BaseStream.Seek(thisTable + _nextBlockOffsetField, SeekOrigin.Begin);
- writer.Write(nextTable);
- }
- else
- {
- writer.BaseStream.Seek(thisTable + _blockHeaderSize, SeekOrigin.Begin);
- // No need to fix the next table address, it's the last
- }
-
- // Table entries
- tableIdx = 0;
-
- for (int j = idxStart; j < idxEnd; ++j, ++tableIdx)
- {
- writer.Write(tableEntries[tableIdx].Offset);
- writer.Write(tableEntries[tableIdx].HeaderLength); // header length
- writer.Write(tableEntries[tableIdx].Size); // compressed size
- writer.Write(tableEntries[tableIdx].DecompressedSize); // decompressed size
- writer.Write(tableEntries[tableIdx].Identifier);
- writer.Write(tableEntries[tableIdx].Hash);
- writer.Write(tableEntries[tableIdx].CompressionFlag); // compression method
- }
-
- // Fill remainder with empty entries
- for (; tableIdx < tableSize; ++tableIdx)
- {
- writer.Write(_emptyTableEntry);
- }
-
- writer.BaseStream.Seek(nextTable, SeekOrigin.Begin);
+ container.Complete();
}
}
}
@@ -606,20 +523,6 @@ private static void ReportComponentSidecarProblems(MultiComponentSidecar.Table c
///
private const int _uoahsArtIdxEntryCount = 0x13FDC;
- ///
- /// On disk size of one entry in a block's entry table:
- /// offset(8) headerLength(4) compressedSize(4) decompressedSize(4) identifier(8) hash(4) flag(2).
- ///
- private const int _tableEntrySize = 8 + 4 + 4 + 4 + 8 + 4 + 2;
-
- /// Size of a block header: usedEntryCount(4) nextBlockOffset(8).
- private const int _blockHeaderSize = 4 + 8;
-
- /// Offset of the next-block pointer inside a block header.
- private const int _nextBlockOffsetField = 4;
-
- private static readonly byte[] _emptyTableEntry = new byte[_tableEntrySize];
-
///
/// The 12 byte block the client writes in front of every entry payload in a version 4 UOP:
/// two constant shorts (3, 8) followed by a FILETIME. The (3, 8) pair holds across every
@@ -954,27 +857,54 @@ private static void CheckAndFixMapFiles(string outFile, FileType type, int typeI
return;
}
- int expectedSize = GetExpectedMapFileSize(typeIndex);
-
- if (expectedSize == 0)
- {
- // do nothing. Map file is wrong, or it's some weird size we don't know about
- return;
- }
-
using (var mapFile = File.Open(outFile, FileMode.Open, FileAccess.ReadWrite))
{
+ long blocks = mapFile.Length / MapUopWriter.MapBlockSize;
+
+ /*
+ * Every shipped container carries one block more than its facet holds, and our own
+ * writer reproduces that, so the usual overshoot is a single block. Recognising it
+ * against the known shapes rather than against one hardcoded size per facet is what
+ * lets a pre-T2A 6144-wide map0 come back out at its own size instead of keeping the
+ * padding because it does not match the modern 7168-wide one.
+ */
+ foreach (MapSize candidate in MapSizes.Candidates(typeIndex))
+ {
+ if (candidate.BlockCount == blocks)
+ {
+ return;
+ }
+ }
+
+ foreach (MapSize candidate in MapSizes.Candidates(typeIndex))
+ {
+ if (candidate.BlockCount == blocks - 1)
+ {
+ mapFile.SetLength(candidate.BlockCount * MapUopWriter.MapBlockSize);
+
+ return;
+ }
+ }
+
+ int expectedSize = GetExpectedMapFileSize(typeIndex);
+
+ if (expectedSize == 0)
+ {
+ // Some shape we do not know about. Leave it alone rather than guess.
+ return;
+ }
+
long sizeDiff = mapFile.Length - expectedSize;
+
if (sizeDiff <= 0)
{
return;
}
/*
- * The overshoot we are here to remove is chunk padding: the UOP stores the map in 0xC4000 byte
- * chunks, so the last one runs past the end of the facet by less than a chunk (752 640 bytes for
- * map2, 1 372 for map4, nothing for map0/1). Anything larger is a custom map that is genuinely
- * bigger than the stock facet, and truncating it would throw away real terrain.
+ * Anything left is chunk padding from a packer that rounded the last chunk up. More
+ * than a chunk of it means a custom map genuinely bigger than the stock facet, and
+ * truncating that would throw away real terrain.
*/
if (sizeDiff >= _mapChunkSize)
{
@@ -992,16 +922,9 @@ private static void CheckAndFixMapFiles(string outFile, FileType type, int typeI
private static int GetExpectedMapFileSize(int typeIndex)
{
- return typeIndex switch
- {
- 0 => 89_915_392,
- 1 => 89_915_392,
- 2 => 11_289_600,
- 3 => 16_056_320,
- 4 => 6_421_156,
- 5 => 16_056_320,
- _ => 0
- };
+ MapSize size = MapSizes.Fallback(typeIndex);
+
+ return size.IsEmpty ? 0 : (int)(size.BlockCount * 196);
}
//
@@ -1054,20 +977,6 @@ private static string[] GetHashFormat(FileType type, int typeIndex, out int maxI
///
private static ulong HashLittle2(string input) => UopUtils.HashFileName(input);
- private static uint HashAdler32(byte[] d)
- {
- uint a = 1;
- uint b = 0;
-
- for (int i = 0; i < d.Length; i++)
- {
- a = (a + d[i]) % 65521;
- b = (b + a) % 65521;
- }
-
- return b << 16 | a;
- }
-
/*
* MUL row layout: [itemId:2][x:2][y:2][z:2][flag:4][extra:4] = 16 bytes (High Seas / 7.0.9+)
* UOP tile: [itemId:2][x:2][y:2][z:2][flag:2][componentCount:4] = 14 bytes, followed by
@@ -1230,4 +1139,4 @@ private static byte[] BuildMultiUopEntryFromMul(byte[] mulData, int multiId, Mul
return result;
}
}
-}
+}
\ No newline at end of file
diff --git a/Ultima/Uop/MapUopReader.cs b/Ultima/Uop/MapUopReader.cs
new file mode 100644
index 00000000..36c09f08
--- /dev/null
+++ b/Ultima/Uop/MapUopReader.cs
@@ -0,0 +1,165 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Ultima.Helpers;
+
+namespace Ultima.Uop
+{
+ ///
+ /// One entry of a map UOP, resolved to where its payload actually starts in the file.
+ ///
+ public readonly struct MapUopEntry
+ {
+ public MapUopEntry(long offset, int length)
+ {
+ Offset = offset;
+ Length = length;
+ }
+
+ /// Byte offset of the payload, past the entry header.
+ public long Offset { get; }
+
+ /// Payload length in bytes. Map entries are stored, so this is also the decompressed length.
+ public int Length { get; }
+
+ public bool IsPresent => Length > 0;
+ }
+
+ ///
+ /// Reads the entry table of a map{N}LegacyMUL.uop.
+ ///
+ ///
+ /// Originally written by Wyatt (c) www.ruosi.org as part of TileMatrix; lifted out so map size
+ /// detection and the writer's verification can walk a container without building a TileMatrix.
+ /// Entries are addressed by the hash of their name, not by their position in the table, so a
+ /// container whose entries sit in a different order than their chunk index still reads correctly.
+ ///
+ public static class MapUopReader
+ {
+ private const int Magic = 0x50594D;
+
+ ///
+ /// Derives the entry-name pattern from a container's file name, e.g. map0legacymul.
+ ///
+ public static string PatternFromPath(string path)
+ {
+ var info = new FileInfo(path);
+
+ return info.Name.Replace(info.Extension, string.Empty).ToLowerInvariant();
+ }
+
+ public static MapUopEntry[] ReadEntryTable(string path)
+ {
+ using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
+ {
+ return ReadEntryTable(stream, PatternFromPath(path));
+ }
+ }
+
+ ///
+ /// Walks the block list and returns one entry per chunk index, in chunk order.
+ ///
+ public static MapUopEntry[] ReadEntryTable(Stream stream, string pattern)
+ {
+ var reader = new BinaryReader(stream);
+
+ stream.Seek(0, SeekOrigin.Begin);
+
+ if (reader.ReadInt32() != Magic)
+ {
+ throw new ArgumentException($"{pattern}: not a UOP file.");
+ }
+
+ reader.ReadInt64(); // version + signature
+ long nextBlock = reader.ReadInt64();
+ reader.ReadInt32(); // block capacity
+ int count = reader.ReadInt32();
+
+ var entries = new MapUopEntry[count];
+ var hashes = new Dictionary(count);
+
+ for (int i = 0; i < count; i++)
+ {
+ hashes.TryAdd(UopUtils.HashFileName($"build/{pattern}/{i:D8}.dat"), i);
+ }
+
+ stream.Seek(nextBlock, SeekOrigin.Begin);
+
+ do
+ {
+ int filesCount = reader.ReadInt32();
+ nextBlock = reader.ReadInt64();
+
+ for (int i = 0; i < filesCount; i++)
+ {
+ long offset = reader.ReadInt64();
+ int headerLength = reader.ReadInt32();
+ int compressedLength = reader.ReadInt32();
+ reader.ReadInt32(); // decompressed length - equal to the compressed one while stored
+ ulong hash = reader.ReadUInt64();
+ reader.ReadUInt32(); // Adler32
+ short flag = reader.ReadInt16();
+
+ if (offset == 0)
+ {
+ continue;
+ }
+
+ // Map blocks are addressed by slicing straight into the file, so only stored
+ // entries can be handled. Every map*LegacyMUL.uop EA ships uses flag 0, but the
+ // UOP packer can be told to zlib them.
+ if (flag != 0)
+ {
+ throw new NotSupportedException(
+ $"{pattern}: compressed map UOP entries are not supported " +
+ $"(entry uses compression flag {flag}). Repack the map with compression set to None.");
+ }
+
+ if (!hashes.TryGetValue(hash, out int idx))
+ {
+ throw new ArgumentException(
+ $"File with hash 0x{hash:X8} was not found in hashes dictionary! EA Mythic changed UOP format!");
+ }
+
+ if (idx < 0 || idx >= entries.Length)
+ {
+ throw new IndexOutOfRangeException(
+ "hashes dictionary and files collection have different count of entries!");
+ }
+
+ entries[idx] = new MapUopEntry(offset + headerLength, compressedLength);
+ }
+ }
+ while (stream.Seek(nextBlock, SeekOrigin.Begin) != 0);
+
+ return entries;
+ }
+
+ ///
+ /// Total payload bytes the container holds. Divided by 196 this is the number of map blocks
+ /// it carries, which is normally one more than the facet actually has.
+ ///
+ public static long TotalPayloadLength(MapUopEntry[] entries)
+ {
+ long total = 0;
+
+ foreach (MapUopEntry entry in entries)
+ {
+ total += entry.Length;
+ }
+
+ return total;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Ultima/Uop/MapUopWriter.cs b/Ultima/Uop/MapUopWriter.cs
new file mode 100644
index 00000000..7a66bbbd
--- /dev/null
+++ b/Ultima/Uop/MapUopWriter.cs
@@ -0,0 +1,196 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.IO;
+using Ultima.Helpers;
+
+namespace Ultima.Uop
+{
+ ///
+ /// What to put in the extra block every shipped map container carries past the end of its facet.
+ ///
+ public enum MapTrailingBlock
+ {
+ /// Write only the facet's own blocks. Smaller, and the client reads it fine.
+ None,
+
+ /// Write one empty block past the end, matching the shape of the shipped files.
+ Empty,
+
+ /// Write a block supplied by the caller, for passing a source container's through.
+ Supplied
+ }
+
+ ///
+ /// Writes a map{N}LegacyMUL.uop one land block at a time, so a caller that is already producing
+ /// blocks does not have to spill a whole map{N}.mul to disk first.
+ ///
+ ///
+ /// Blocks are packed 4096 to an entry, uncompressed, under names of the form
+ /// build/map{N}legacymul/00000000.dat. Every shipped facet carries exactly one block more than
+ /// its grid holds, which is why the trailing block is written by default. The reader never
+ /// addresses it.
+ ///
+ public sealed class MapUopWriter : IDisposable
+ {
+ public const int MapBlockSize = 196;
+
+ /// Land blocks per container entry.
+ public const int ChunkBlocks = 4096;
+
+ /// Bytes per container entry, 0xC4000.
+ public const int ChunkBytes = ChunkBlocks * MapBlockSize;
+
+ private readonly UopContainerWriter _container;
+ private readonly Stream _output;
+ private readonly bool _leaveOpen;
+ private readonly string _pattern;
+ private readonly long _totalBlocks;
+ private readonly byte[] _chunk = new byte[ChunkBytes];
+ private readonly byte[] _trailing = new byte[MapBlockSize];
+
+ private readonly MapTrailingBlock _trailingMode;
+
+ private int _chunkLength;
+ private int _chunkIndex;
+ private long _blocksWritten;
+ private bool _completed;
+
+ public MapUopWriter(Stream output, int mapId, long blockCount,
+ MapTrailingBlock trailing = MapTrailingBlock.Empty, bool leaveOpen = false)
+ {
+ if (blockCount < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(blockCount));
+ }
+
+ _output = output ?? throw new ArgumentNullException(nameof(output));
+ _leaveOpen = leaveOpen;
+ _trailingMode = trailing;
+ _pattern = $"map{mapId}legacymul";
+
+ BlockCount = blockCount;
+ _totalBlocks = blockCount + (trailing == MapTrailingBlock.None ? 0 : 1);
+
+ long bytes = _totalBlocks * MapBlockSize;
+ int entryCount = (int)((bytes + ChunkBytes - 1) / ChunkBytes);
+
+ _container = new UopContainerWriter(output, UopLayout.Version5, entryCount, true);
+ }
+
+ /// Blocks the facet itself holds, not counting the trailing one.
+ public long BlockCount { get; }
+
+ ///
+ /// Supplies the block written past the end of the facet. Only used with
+ /// .
+ ///
+ public void SetTrailingBlock(ReadOnlySpan block)
+ {
+ if (block.Length != MapBlockSize)
+ {
+ throw new ArgumentException($"A land block is {MapBlockSize} bytes.", nameof(block));
+ }
+
+ block.CopyTo(_trailing);
+ }
+
+ ///
+ /// Appends one 196-byte land block: a 4-byte header followed by 64 three-byte tiles.
+ /// Blocks must arrive in index order, blockX * blockHeight + blockY.
+ ///
+ public void WriteBlock(ReadOnlySpan block)
+ {
+ if (block.Length != MapBlockSize)
+ {
+ throw new ArgumentException($"A land block is {MapBlockSize} bytes.", nameof(block));
+ }
+
+ if (_completed)
+ {
+ throw new InvalidOperationException("The container has already been completed.");
+ }
+
+ if (_blocksWritten >= BlockCount)
+ {
+ throw new InvalidOperationException(
+ $"More blocks were written than the {BlockCount:N0} this facet holds.");
+ }
+
+ Append(block);
+ ++_blocksWritten;
+ }
+
+ public void Complete()
+ {
+ if (_completed)
+ {
+ return;
+ }
+
+ if (_blocksWritten != BlockCount)
+ {
+ throw new InvalidOperationException(
+ $"{_blocksWritten:N0} blocks were written but the facet holds {BlockCount:N0}.");
+ }
+
+ if (_trailingMode != MapTrailingBlock.None)
+ {
+ Append(_trailing);
+ }
+
+ if (_chunkLength > 0)
+ {
+ FlushChunk();
+ }
+
+ _container.Complete();
+ _output.Flush();
+ _completed = true;
+ }
+
+ public void Dispose()
+ {
+ _container.Dispose();
+
+ if (!_leaveOpen)
+ {
+ _output.Dispose();
+ }
+ }
+
+ private void Append(ReadOnlySpan block)
+ {
+ block.CopyTo(_chunk.AsSpan(_chunkLength));
+ _chunkLength += MapBlockSize;
+
+ if (_chunkLength == ChunkBytes)
+ {
+ FlushChunk();
+ }
+ }
+
+ private void FlushChunk()
+ {
+ ReadOnlySpan payload = _chunk.AsSpan(0, _chunkLength);
+
+ _container.WriteEntry(
+ UopUtils.HashFileName($"build/{_pattern}/{_chunkIndex:D8}.dat"),
+ payload,
+ _chunkLength,
+ 0);
+
+ ++_chunkIndex;
+ _chunkLength = 0;
+ }
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Plugin.UopPacker/Classes/MultiComponentSidecar.cs b/Ultima/Uop/MultiComponentSidecar.cs
similarity index 77%
rename from UoFiddler.Plugin.UopPacker/Classes/MultiComponentSidecar.cs
rename to Ultima/Uop/MultiComponentSidecar.cs
index dde978b1..c1a9bcb9 100644
--- a/UoFiddler.Plugin.UopPacker/Classes/MultiComponentSidecar.cs
+++ b/Ultima/Uop/MultiComponentSidecar.cs
@@ -15,7 +15,7 @@
using System.IO;
using System.Text;
-namespace UoFiddler.Plugin.UopPacker.Classes
+namespace Ultima.Uop
{
///
/// Side storage for the per tile component ids carried by MultiCollection.uop entries.
@@ -25,16 +25,10 @@ namespace UoFiddler.Plugin.UopPacker.Classes
/// componentCount 32 bit component ids. A multi.mul row is a fixed 16 bytes and has nowhere to put
/// those ids, so they are written next to the mul/idx pair instead and merged back in when packing.
///
- /// In the shipped client file 3200 of 186695 tiles carry ids, drawn from a shared vocabulary of only
- /// 59 values (119404 - 119462) reused across 304 multis, so ids for newly authored multis can be
- /// written by hand.
- ///
- /// A component id marks a tile's interactive role within the multi, not its graphic and not a cliloc:
- /// every tile carrying 119405 is a "tiller man" in tiledata, every 119406 is a "hatch", 119404 is the
- /// hull (mast/deck), 119407/119408 are the planks, and 119453/119454 sit on doors. All 24 boat multis
- /// (6 hulls x 4 facings) share the same 119404-119408 signature, and 1121 of the 1273 item ids that
- /// carry a component always carry the same one. That is why dropping them breaks a client: a boat
- /// without its tiller man cannot be steered and a house door stops being a door.
+ /// A component id marks a tile's interactive role within the multi, not its graphic and not a
+ /// cliloc - tiller man, hatch, hull, plank, door. They come from a small shared vocabulary reused
+ /// across multis. Dropping them breaks a client: a boat without its tiller man cannot be steered
+ /// and a house door stops being a door.
///
public static class MultiComponentSidecar
{
@@ -259,6 +253,8 @@ public sealed class Table
private readonly Dictionary<(int MultiId, int TileIndex), Row> _rows;
private readonly List _problems;
+ private Dictionary<(int MultiId, ushort ItemId, short X, short Y, short Z), uint[]> _byIdentity;
+
internal Table(string path, Dictionary<(int MultiId, int TileIndex), Row> rows, List malformed)
{
Path = path;
@@ -289,26 +285,81 @@ public int ComponentCount
public IReadOnlyList Problems => _problems;
///
- /// Component ids for a tile, or an empty span when the sidecar has no entry for it. A row whose
- /// itemId/x/y/z disagree with the mul row is dropped and recorded in -
- /// that happens when a multi's tile list was re-authored after the sidecar was written.
+ /// Component ids for a tile, or an empty span when the sidecar describes no such tile. The row
+ /// at the tile's index is used when its itemId/x/y/z agree; otherwise the tile is looked up by
+ /// that identity instead, which covers a mul written in a different tile order from the sidecar.
+ /// Only when neither finds it are the ids dropped and the mismatch recorded in
+ /// - that happens when a multi's tile list was re-authored after the
+ /// sidecar was written.
///
public uint[] GetComponentIds(int multiId, int tileIndex, ushort itemId, short x, short y, short z)
{
- if (!_rows.TryGetValue((multiId, tileIndex), out Row row))
+ bool found = _rows.TryGetValue((multiId, tileIndex), out Row row);
+
+ if (found && row.ItemId == itemId && row.X == x && row.Y == y && row.Z == z)
+ {
+ return row.ComponentIds;
+ }
+
+ // A sidecar taken from a uop lists tiles in that file's order, while the mul being packed
+ // is written in the editor's, and the two need not agree even when nothing was edited. So
+ // a tile whose index no longer lines up is looked up by what it is instead of where it sits.
+ uint[] byIdentity = LookupByIdentity(multiId, itemId, x, y, z);
+
+ if (byIdentity != null)
{
- return _none;
+ return byIdentity;
}
- if (row.ItemId != itemId || row.X != x || row.Y != y || row.Z != z)
+ if (found)
{
_problems.Add(
$"multi {multiId} tile {tileIndex}: sidecar describes 0x{row.ItemId:X4} at ({row.X},{row.Y},{row.Z}) " +
$"but multi.mul has 0x{itemId:X4} at ({x},{y},{z}) - component ids dropped");
- return _none;
}
- return row.ComponentIds;
+ return _none;
+ }
+
+ ///
+ /// Component ids for the one row describing this tile, or null when no row does or when more
+ /// than one does and they disagree - an ambiguous match is no better than none.
+ ///
+ private uint[] LookupByIdentity(int multiId, ushort itemId, short x, short y, short z)
+ {
+ _byIdentity ??= BuildIdentityIndex();
+
+ return _byIdentity.TryGetValue((multiId, itemId, x, y, z), out uint[] ids) ? ids : null;
+ }
+
+ private Dictionary<(int MultiId, ushort ItemId, short X, short Y, short Z), uint[]> BuildIdentityIndex()
+ {
+ var index = new Dictionary<(int MultiId, ushort ItemId, short X, short Y, short Z), uint[]>();
+ var ambiguous = new List<(int MultiId, ushort ItemId, short X, short Y, short Z)>();
+
+ foreach (KeyValuePair<(int MultiId, int TileIndex), Row> pair in _rows)
+ {
+ Row row = pair.Value;
+ var key = (pair.Key.MultiId, row.ItemId, row.X, row.Y, row.Z);
+
+ if (!index.TryGetValue(key, out uint[] existing))
+ {
+ index[key] = row.ComponentIds;
+ continue;
+ }
+
+ if (!existing.AsSpan().SequenceEqual(row.ComponentIds))
+ {
+ ambiguous.Add(key);
+ }
+ }
+
+ foreach (var key in ambiguous)
+ {
+ index.Remove(key);
+ }
+
+ return index;
}
}
@@ -324,4 +375,4 @@ private static bool TryParse(string field, out long value)
return long.TryParse(span, NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
}
}
-}
+}
\ No newline at end of file
diff --git a/Ultima/Uop/UopContainerWriter.cs b/Ultima/Uop/UopContainerWriter.cs
new file mode 100644
index 00000000..502431a3
--- /dev/null
+++ b/Ultima/Uop/UopContainerWriter.cs
@@ -0,0 +1,260 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.IO;
+using Ultima.Helpers;
+
+namespace Ultima.Uop
+{
+ ///
+ /// The two container shapes the shipped client files come in.
+ ///
+ ///
+ /// Which shape a file type uses depends on the client build rather than on the type alone.
+ /// Version 4 holds 100 entries per block with the first block right behind the 0x28 byte header
+ /// and a 12 byte header in front of every entry payload - MultiCollection and tileart in every
+ /// build that has them, sound from 7.0.65.4 on, gumpart from 7.0.114.4 on.
+ /// Version 5 holds 1000 entries per block after a gap, and its entry headers are 135..137 bytes
+ /// whose tail is high entropy and cannot be reproduced, so we write none - art and maps in every
+ /// build, sound and gumpart in the older ones.
+ ///
+ public enum UopLayout
+ {
+ Version4,
+ Version5
+ }
+
+ ///
+ /// Writes the framing of a UOP container: the file header, the linked list of blocks and each
+ /// block's entry table. Callers supply one payload per entry and own whatever transform or
+ /// compression that payload needed.
+ ///
+ public sealed class UopContainerWriter : IDisposable
+ {
+ private const int Magic = 0x50594D;
+ private const uint Signature = 0xFD23EC43;
+ private const int FileHeaderSize = 0x28;
+
+ ///
+ /// On disk size of one entry in a block's entry table:
+ /// offset(8) headerLength(4) compressedSize(4) decompressedSize(4) identifier(8) hash(4) flag(2).
+ ///
+ internal const int TableEntrySize = 8 + 4 + 4 + 4 + 8 + 4 + 2;
+
+ /// Size of a block header: usedEntryCount(4) nextBlockOffset(8).
+ internal const int BlockHeaderSize = 4 + 8;
+
+ /// Offset of the next-block pointer inside a block header.
+ internal const int NextBlockOffsetField = 4;
+
+ private static readonly byte[] _emptyTableEntry = new byte[TableEntrySize];
+
+ private readonly BinaryWriter _writer;
+ private readonly bool _leaveOpen;
+ private readonly int _blockCapacity;
+ private readonly int _entryCount;
+ private readonly TableEntry[] _tableEntries;
+
+ private int _entriesWritten;
+ private int _indexInBlock;
+ private long _blockStart = -1;
+ private bool _completed;
+
+ public UopContainerWriter(Stream output, UopLayout layout, int entryCount, bool leaveOpen = false)
+ {
+ if (output == null)
+ {
+ throw new ArgumentNullException(nameof(output));
+ }
+
+ if (entryCount < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(entryCount));
+ }
+
+ _writer = new BinaryWriter(output);
+ _leaveOpen = leaveOpen;
+ _entryCount = entryCount;
+ _blockCapacity = layout == UopLayout.Version4 ? 0x64 : 0x3E8;
+ _tableEntries = new TableEntry[_blockCapacity];
+
+ long firstBlock = layout == UopLayout.Version4 ? 0x28 : 0x200;
+
+ _writer.Write(Magic);
+ _writer.Write(layout == UopLayout.Version4 ? 4 : 5);
+ _writer.Write(Signature);
+ _writer.Write(firstBlock);
+ _writer.Write(_blockCapacity);
+ _writer.Write(entryCount);
+ _writer.Write(0); // modified count (wseq, version 5 only)
+ _writer.Write(0); // cseq, version 5 only
+ _writer.Write(0); // reserved
+
+ for (long i = FileHeaderSize; i < firstBlock; ++i)
+ {
+ _writer.Write((byte)0);
+ }
+ }
+
+ public int BlockCapacity => _blockCapacity;
+
+ ///
+ /// Appends one entry. The entry's hash field is the Adler32 of
+ /// when one is given and of the payload otherwise, which is what the shipped files carry.
+ ///
+ ///
+ /// Size of the payload before compression. Equal to the payload length for a stored entry.
+ ///
+ public void WriteEntry(ulong identifier, ReadOnlySpan payload, int decompressedSize,
+ short compressionFlag, ReadOnlySpan entryHeader = default)
+ {
+ if (_completed)
+ {
+ throw new InvalidOperationException("The container has already been completed.");
+ }
+
+ if (_entriesWritten >= _entryCount)
+ {
+ throw new InvalidOperationException(
+ $"More entries were written than the {_entryCount} declared in the header.");
+ }
+
+ if (_blockStart < 0)
+ {
+ BeginBlock();
+ }
+
+ ref TableEntry entry = ref _tableEntries[_indexInBlock];
+
+ entry.Offset = _writer.BaseStream.Position;
+ entry.HeaderLength = entryHeader.Length;
+ entry.Size = payload.Length;
+ entry.DecompressedSize = decompressedSize;
+ entry.Identifier = identifier;
+ entry.CompressionFlag = compressionFlag;
+
+ if (!entryHeader.IsEmpty)
+ {
+ _writer.Write(entryHeader);
+ }
+
+ _writer.Write(payload);
+
+ entry.Hash = UopUtils.HashAdler32(entryHeader.IsEmpty ? payload : entryHeader);
+
+ ++_entriesWritten;
+ ++_indexInBlock;
+
+ if (_indexInBlock == _blockCapacity)
+ {
+ EndBlock(_entriesWritten < _entryCount);
+ }
+ }
+
+ ///
+ /// Closes the trailing block and back-fills its entry table. Must be called before the
+ /// stream is used for anything else.
+ ///
+ public void Complete()
+ {
+ if (_completed)
+ {
+ return;
+ }
+
+ if (_entriesWritten != _entryCount)
+ {
+ throw new InvalidOperationException(
+ $"{_entriesWritten} entries were written but the header declares {_entryCount}.");
+ }
+
+ if (_blockStart >= 0)
+ {
+ EndBlock(false);
+ }
+
+ _writer.Flush();
+ _completed = true;
+ }
+
+ public void Dispose()
+ {
+ if (!_leaveOpen)
+ {
+ _writer.Dispose();
+ }
+ }
+
+ private void BeginBlock()
+ {
+ _blockStart = _writer.BaseStream.Position;
+ _indexInBlock = 0;
+
+ int used = Math.Min(_blockCapacity, _entryCount - _entriesWritten);
+
+ _writer.Write(used);
+ _writer.Write((long)0); // next block, back-filled once this one is full
+ _writer.Seek(TableEntrySize * _blockCapacity, SeekOrigin.Current);
+ }
+
+ private void EndBlock(bool moreToCome)
+ {
+ long afterBlock = _writer.BaseStream.Position;
+
+ if (moreToCome)
+ {
+ _writer.BaseStream.Seek(_blockStart + NextBlockOffsetField, SeekOrigin.Begin);
+ _writer.Write(afterBlock);
+ }
+ else
+ {
+ _writer.BaseStream.Seek(_blockStart + BlockHeaderSize, SeekOrigin.Begin);
+ }
+
+ for (int i = 0; i < _indexInBlock; ++i)
+ {
+ _writer.Write(_tableEntries[i].Offset);
+ _writer.Write(_tableEntries[i].HeaderLength);
+ _writer.Write(_tableEntries[i].Size);
+ _writer.Write(_tableEntries[i].DecompressedSize);
+ _writer.Write(_tableEntries[i].Identifier);
+ _writer.Write(_tableEntries[i].Hash);
+ _writer.Write(_tableEntries[i].CompressionFlag);
+ }
+
+ for (int i = _indexInBlock; i < _blockCapacity; ++i)
+ {
+ _writer.Write(_emptyTableEntry);
+ }
+
+ _writer.BaseStream.Seek(afterBlock, SeekOrigin.Begin);
+
+ _blockStart = -1;
+
+ if (moreToCome)
+ {
+ BeginBlock();
+ }
+ }
+
+ private struct TableEntry
+ {
+ public long Offset;
+ public int HeaderLength;
+ public int Size;
+ public int DecompressedSize;
+ public ulong Identifier;
+ public uint Hash;
+ public short CompressionFlag;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Ultima/Uop/UopFileNames.cs b/Ultima/Uop/UopFileNames.cs
new file mode 100644
index 00000000..1422ce68
--- /dev/null
+++ b/Ultima/Uop/UopFileNames.cs
@@ -0,0 +1,82 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+
+namespace Ultima.Uop
+{
+ ///
+ /// The names the client gives each packable file type, and the compression its shipped UOPs use.
+ /// One table, so a save, a pack and a batch repack cannot disagree about what to write.
+ ///
+ public static class UopFileNames
+ {
+ ///
+ /// The conventional file names for a type: the mul, its idx (null for maps, which have none)
+ /// and the uop that replaces the pair.
+ ///
+ public static (string Mul, string Idx, string Uop) For(FileType type, int mapIndex = 0)
+ {
+ return type switch
+ {
+ FileType.ArtLegacyMul => ("art.mul", "artidx.mul", "artLegacyMUL.uop"),
+ FileType.GumpartLegacyMul => ("gumpart.mul", "gumpidx.mul", "gumpartLegacyMUL.uop"),
+ FileType.MapLegacyMul => ($"map{mapIndex}.mul", null, $"map{mapIndex}LegacyMUL.uop"),
+ FileType.SoundLegacyMul => ("sound.mul", "soundidx.mul", "soundLegacyMUL.uop"),
+ FileType.MultiCollection => ("multi.mul", "multi.idx", "MultiCollection.uop"),
+ _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown file type.")
+ };
+ }
+
+ ///
+ /// Asks the loaded client for the type's uop. is keyed case
+ /// insensitively, so the conventional name doubles as the lookup key.
+ ///
+ public static string ClientUopPath(FileType type, int mapIndex = 0)
+ {
+ return Files.GetFilePath(For(type, mapIndex).Uop);
+ }
+
+ ///
+ /// Asks the loaded client for the type's mul.
+ ///
+ public static string ClientMulPath(FileType type, int mapIndex = 0)
+ {
+ return Files.GetFilePath(For(type, mapIndex).Mul);
+ }
+
+ ///
+ /// What the shipped clients compress a type with.
+ ///
+ ///
+ /// Every entry of every shipped MultiCollection.uop is zlib compressed: packing it uncompressed
+ /// produces a file several times larger than the original, and Mythic is not a valid compression
+ /// for this type at all, so the choice is fixed rather than merely defaulted
+ /// (see ).
+ /// Every art, map and sound entry of every shipped client is stored uncompressed, and UOFiddler's
+ /// own map reader can only address stored entries. Gumpart is stored in the shipped files too, but
+ /// the client does accept zlib and Mythic there, so that one is a default rather than a rule.
+ ///
+ public static CompressionFlag DefaultCompression(FileType type)
+ {
+ return type == FileType.MultiCollection ? CompressionFlag.Zlib : CompressionFlag.None;
+ }
+
+ ///
+ /// True when the type accepts only and the caller must not offer
+ /// a choice.
+ ///
+ public static bool IsCompressionFixed(FileType type)
+ {
+ return type == FileType.MultiCollection;
+ }
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Classes/ClientFileSaveCommand.cs b/UoFiddler.Controls/Classes/ClientFileSaveCommand.cs
new file mode 100644
index 00000000..a87d79ba
--- /dev/null
+++ b/UoFiddler.Controls/Classes/ClientFileSaveCommand.cs
@@ -0,0 +1,221 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Windows.Forms;
+using Microsoft.Extensions.Logging;
+using Ultima.Helpers;
+using Ultima.Uop;
+using UoFiddler.Controls.Forms;
+
+namespace UoFiddler.Controls.Classes
+{
+ ///
+ /// One save, from the format question through to the dialog that says where the files went.
+ /// The five tabs whose file type the client ships in either container all go through here.
+ ///
+ public static class ClientFileSaveCommand
+ {
+ ///
+ /// Resolves the container, writes the files and reports the outcome. Returns false when the
+ /// user cancelled or the save failed, in which case the dirty flag is left alone.
+ ///
+ /// Control the dialogs belong to.
+ /// What is being saved.
+ ///
+ /// The domain's own save method. It is handed a directory to write its mul and idx into, which
+ /// is not necessarily the output directory - a uop save writes the mul somewhere temporary.
+ ///
+ /// Key in to clear on success.
+ ///
+ /// Optional progress dialog for the slow types. It is created before the save and disposed after,
+ /// and its caption carries the pack percentage once the mul has been written.
+ ///
+ public static bool Run(Control owner, FileType type, Action writeMul, string dirtyKey = null,
+ int mapIndex = 0, Func createProgress = null)
+ {
+ string outputDirectory = Options.OutputPath;
+
+ if (!SaveFormatResolver.TryResolve(owner, type, outputDirectory, out ContainerFormat format, mapIndex))
+ {
+ return false;
+ }
+
+ if (!ConfirmPreflight(owner, type, format, mapIndex))
+ {
+ return false;
+ }
+
+ string uopName = UopFileNames.For(type, mapIndex).Uop;
+ ClientFileSaveResult result;
+
+ try
+ {
+ using (new WaitCursorScope(owner))
+ {
+ ProgressBarDialog progressDialog = createProgress?.Invoke();
+
+ try
+ {
+ IProgress packProgress = progressDialog == null || format != ContainerFormat.Uop
+ ? null
+ : new CaptionProgress(progressDialog, uopName);
+
+ result = ClientFileSaver.Save(type, outputDirectory, format, writeMul, mapIndex, packProgress);
+ }
+ finally
+ {
+ progressDialog?.Dispose();
+ }
+ }
+ }
+ catch (Exception error)
+ {
+ ShowError(owner, type, error);
+ return false;
+ }
+
+ if (!string.IsNullOrEmpty(dirtyKey))
+ {
+ Options.ChangedUltimaClass[dirtyKey] = false;
+ }
+
+ FileSavedDialog.Show(owner?.FindForm(), outputDirectory, BuildMessage(result));
+
+ return true;
+ }
+
+ ///
+ /// Puts anything the save would quietly cost in front of the user before it runs. Answering no
+ /// leaves the output folder untouched.
+ ///
+ private static bool ConfirmPreflight(Control owner, FileType type, ContainerFormat format, int mapIndex)
+ {
+ IReadOnlyList concerns;
+
+ try
+ {
+ concerns = ClientFileSaver.Preflight(type, format, mapIndex);
+ }
+ catch (Exception error)
+ {
+ ShowError(owner, type, error);
+ return false;
+ }
+
+ if (concerns.Count == 0)
+ {
+ return true;
+ }
+
+ var sb = new StringBuilder();
+
+ foreach (string concern in concerns)
+ {
+ if (sb.Length > 0)
+ {
+ sb.AppendLine().AppendLine();
+ }
+
+ sb.Append(concern);
+ }
+
+ sb.AppendLine().AppendLine().Append("Save anyway?");
+
+ return MessageBox.Show(owner?.FindForm(), sb.ToString(), "Save", MessageBoxButtons.YesNo,
+ MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2) == DialogResult.Yes;
+ }
+
+ private static string BuildMessage(ClientFileSaveResult result)
+ {
+ var sb = new StringBuilder();
+
+ sb.Append("Saved ").Append(string.Join(", ", result.FilesWritten)).Append('.');
+
+ foreach (string warning in result.Warnings)
+ {
+ sb.AppendLine().AppendLine().Append(warning);
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Shows what actually went wrong rather than a bare framework message, and logs the rest.
+ ///
+ private static void ShowError(Control owner, FileType type, Exception error)
+ {
+ AppLog.For(typeof(ClientFileSaveCommand)).LogError(error, "Saving {Type} failed.", type);
+
+ var sb = new StringBuilder();
+
+ for (Exception current = error; current != null; current = current.InnerException)
+ {
+ sb.AppendLine(current.Message);
+
+ if (current.InnerException != null)
+ {
+ sb.AppendLine();
+ }
+ }
+
+ sb.AppendLine();
+ sb.AppendLine(error.GetType().FullName);
+
+ string where = error.StackTrace?
+ .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)
+ .FirstOrDefault()?
+ .Trim();
+
+ if (!string.IsNullOrEmpty(where))
+ {
+ sb.AppendLine(where);
+ }
+
+ MessageBox.Show(owner?.FindForm(), sb.ToString(), "Save failed", MessageBoxButtons.OK,
+ MessageBoxIcon.Error);
+ }
+
+ ///
+ /// Puts the pack percentage in the progress dialog's caption. The save blocks the UI thread, so
+ /// the report has to be applied and repainted where it happens rather than posted back.
+ ///
+ private sealed class CaptionProgress : IProgress
+ {
+ private readonly ProgressBarDialog _dialog;
+ private readonly string _fileName;
+
+ private int _lastReported = -1;
+
+ public CaptionProgress(ProgressBarDialog dialog, string fileName)
+ {
+ _dialog = dialog;
+ _fileName = fileName;
+ }
+
+ public void Report(int value)
+ {
+ if (value == _lastReported || _dialog.IsDisposed)
+ {
+ return;
+ }
+
+ _lastReported = value;
+
+ _dialog.Text = $"Packing {_fileName} - {value}%";
+ _dialog.Refresh();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Classes/ClientFileSaveFormat.cs b/UoFiddler.Controls/Classes/ClientFileSaveFormat.cs
new file mode 100644
index 00000000..19b087e7
--- /dev/null
+++ b/UoFiddler.Controls/Classes/ClientFileSaveFormat.cs
@@ -0,0 +1,78 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System.Collections.Generic;
+
+namespace UoFiddler.Controls.Classes
+{
+ ///
+ /// What a save writes for the file types the client ships in either container.
+ ///
+ public enum ClientFileSaveFormat
+ {
+ /// Whatever the loaded client keeps that type in. Asked per type, not per client.
+ FollowSource,
+
+ /// Always the mul and idx pair, even on a uop client.
+ Mul,
+
+ /// Always the uop, even on a mul client.
+ Uop,
+
+ /// Choose at save time.
+ Ask
+ }
+
+ public static class ClientFileSaveFormats
+ {
+ public static IReadOnlyList All { get; } = new[]
+ {
+ ClientFileSaveFormat.FollowSource,
+ ClientFileSaveFormat.Mul,
+ ClientFileSaveFormat.Uop,
+ ClientFileSaveFormat.Ask
+ };
+
+ /// Position of a format in , for driving a combo box.
+ public static int IndexOf(ClientFileSaveFormat format)
+ {
+ for (int i = 0; i < All.Count; ++i)
+ {
+ if (All[i] == format)
+ {
+ return i;
+ }
+ }
+
+ return 0;
+ }
+
+ ///
+ /// Which entry of a "the same format as this client / .mul / .uop" list the option opens on.
+ /// Ask opens on the source format: a form carrying its own format control asks by being there.
+ ///
+ public static int DefaultIndex(ClientFileSaveFormat format) => format switch
+ {
+ ClientFileSaveFormat.Mul => 1,
+ ClientFileSaveFormat.Uop => 2,
+ _ => 0
+ };
+
+ public static string DisplayName(ClientFileSaveFormat format) => format switch
+ {
+ ClientFileSaveFormat.FollowSource => "The same format as this client",
+ ClientFileSaveFormat.Mul => "Always .mul and .idx",
+ ClientFileSaveFormat.Uop => "Always .uop",
+ ClientFileSaveFormat.Ask => "Ask every time I save",
+ _ => format.ToString()
+ };
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Classes/DynamicItemsConfig.cs b/UoFiddler.Controls/Classes/DynamicItemsConfig.cs
index fb1c928e..290f336c 100644
--- a/UoFiddler.Controls/Classes/DynamicItemsConfig.cs
+++ b/UoFiddler.Controls/Classes/DynamicItemsConfig.cs
@@ -16,6 +16,7 @@
using System.Xml;
using Microsoft.Extensions.Logging;
using Ultima;
+using Ultima.Helpers;
namespace UoFiddler.Controls.Classes
{
diff --git a/UoFiddler.Controls/Classes/FormLayout.cs b/UoFiddler.Controls/Classes/FormLayout.cs
new file mode 100644
index 00000000..6014476e
--- /dev/null
+++ b/UoFiddler.Controls/Classes/FormLayout.cs
@@ -0,0 +1,71 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Drawing;
+using System.Windows.Forms;
+
+namespace UoFiddler.Controls.Classes
+{
+ public static class FormLayout
+ {
+ ///
+ /// Shrinks a dialog that is laid out larger than the screen it opens on, so a designer size
+ /// picked for a wide monitor does not push the buttons off the bottom of a small one. The
+ /// form keeps its own minimum size, and nothing happens when it already fits.
+ ///
+ public static void FitToScreen(Form form)
+ {
+ if (form == null)
+ {
+ return;
+ }
+
+ Rectangle work = Screen.FromControl(form).WorkingArea;
+
+ int width = form.Width;
+ int height = form.Height;
+
+ if (width > work.Width)
+ {
+ width = work.Width;
+ }
+
+ if (height > work.Height)
+ {
+ height = work.Height;
+ }
+
+ if (width == form.Width && height == form.Height)
+ {
+ return;
+ }
+
+ form.Size = new Size(width, height);
+
+ // Centring happened against the old size, so put it back inside the screen.
+ int left = form.Left;
+ int top = form.Top;
+
+ if (left + form.Width > work.Right)
+ {
+ left = work.Right - form.Width;
+ }
+
+ if (top + form.Height > work.Bottom)
+ {
+ top = work.Bottom - form.Height;
+ }
+
+ form.Location = new Point(Math.Max(work.Left, left), Math.Max(work.Top, top));
+ }
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Classes/ImageClipboard.cs b/UoFiddler.Controls/Classes/ImageClipboard.cs
new file mode 100644
index 00000000..b0088c53
--- /dev/null
+++ b/UoFiddler.Controls/Classes/ImageClipboard.cs
@@ -0,0 +1,276 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Drawing;
+using System.Drawing.Drawing2D;
+using System.Drawing.Imaging;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Windows.Forms;
+
+namespace UoFiddler.Controls.Classes
+{
+ ///
+ /// Moves single images between the graphic tabs and the system clipboard.
+ ///
+ public static class ImageClipboard
+ {
+ ///
+ /// Clipboard format name Photoshop, GIMP, Paint.NET and Krita read and write. It is the only
+ /// one of the flavours we handle that carries an alpha channel - CF_DIB flattens it.
+ ///
+ private const string PngFormat = "PNG";
+
+ private static readonly string[] _acceptedDropExtensions =
+ {
+ ".png", ".bmp", ".tif", ".tiff", ".jpg", ".jpeg", ".gif"
+ };
+
+ ///
+ /// True when the clipboard holds something we could paste as an image.
+ ///
+ public static bool ContainsImage()
+ {
+ try
+ {
+ IDataObject data = Clipboard.GetDataObject();
+ if (data == null)
+ {
+ return false;
+ }
+
+ return data.GetDataPresent(PngFormat)
+ || data.GetDataPresent(DataFormats.Bitmap)
+ || data.GetDataPresent(DataFormats.Dib)
+ || GetDroppedImagePath(data) != null;
+ }
+ catch (ExternalException)
+ {
+ // Another process has the clipboard open. Treat it as "nothing to paste" rather than
+ // popping a dialog from a context menu Opening handler.
+ return false;
+ }
+ }
+
+ ///
+ /// Puts on the clipboard as both a DIB (so Paint, Word and chat
+ /// clients see it) and a PNG stream (so image editors keep the transparent areas).
+ ///
+ public static bool TryCopy(Bitmap source, out string error)
+ {
+ error = null;
+
+ if (source == null)
+ {
+ error = "There is no image to copy.";
+ return false;
+ }
+
+ Bitmap flattened = null;
+ MemoryStream png = null;
+
+ try
+ {
+ // The tabs hand us 16bppArgb1555 bitmaps straight out of the SDK cache; other
+ // processes cope far better with a plain 32bpp copy.
+ flattened = ToArgb32(source);
+
+ png = new MemoryStream();
+ flattened.Save(png, ImageFormat.Png);
+ png.Position = 0;
+
+ DataObject data = new DataObject();
+ data.SetImage(flattened);
+ data.SetData(PngFormat, false, png);
+
+ // copy: true flushes everything to the OS clipboard now, so the bitmap and the
+ // stream can be disposed as soon as this returns.
+ Clipboard.SetDataObject(data, true);
+ return true;
+ }
+ catch (ExternalException)
+ {
+ error = "The clipboard is in use by another program. Try again.";
+ return false;
+ }
+ finally
+ {
+ png?.Dispose();
+ flattened?.Dispose();
+ }
+ }
+
+ ///
+ /// Reads an image off the clipboard as a 32bppArgb bitmap owned by the caller, or null with a
+ /// message in .
+ ///
+ public static Bitmap TryPaste(out string error)
+ {
+ error = null;
+
+ IDataObject data;
+ try
+ {
+ data = Clipboard.GetDataObject();
+ }
+ catch (ExternalException)
+ {
+ error = "The clipboard is in use by another program. Try again.";
+ return null;
+ }
+
+ if (data == null)
+ {
+ error = "The clipboard does not contain an image.";
+ return null;
+ }
+
+ // PNG first - it is the only flavour that survives with transparency intact.
+ try
+ {
+ if (data.GetDataPresent(PngFormat) && data.GetData(PngFormat) is Stream pngStream)
+ {
+ using (pngStream)
+ using (Image png = Image.FromStream(pngStream))
+ {
+ return ToArgb32(png);
+ }
+ }
+ }
+ catch (Exception ex) when (ex is ArgumentException || ex is ExternalException)
+ {
+ // Malformed PNG flavour - fall through and try the DIB.
+ }
+
+ try
+ {
+ Image dib = Clipboard.GetImage();
+ if (dib != null)
+ {
+ using (dib)
+ {
+ Bitmap bitmap = ToArgb32(dib);
+
+ // Clipboard DIBs routinely arrive with the alpha byte left at zero, which
+ // would otherwise read as a fully transparent image. Nothing useful is ever
+ // wholly transparent, so treat that as "no alpha information".
+ MakeOpaqueIfFullyTransparent(bitmap);
+ return bitmap;
+ }
+ }
+ }
+ catch (ExternalException)
+ {
+ // Fall through to the file drop.
+ }
+
+ string path = GetDroppedImagePath(data);
+ if (path != null)
+ {
+ try
+ {
+ using (Image fromFile = Image.FromFile(path))
+ {
+ return ToArgb32(fromFile);
+ }
+ }
+ catch (Exception ex) when (ex is OutOfMemoryException || ex is IOException || ex is ArgumentException)
+ {
+ error = $"'{Path.GetFileName(path)}' could not be read as an image.";
+ return null;
+ }
+ }
+
+ error = "The clipboard does not contain an image.";
+ return null;
+ }
+
+ private static Bitmap ToArgb32(Image source)
+ {
+ Bitmap result = new Bitmap(source.Width, source.Height, PixelFormat.Format32bppArgb);
+
+ using (Graphics graphics = Graphics.FromImage(result))
+ {
+ // SourceCopy so the alpha channel arrives byte for byte rather than being blended
+ // against the transparent backdrop.
+ graphics.CompositingMode = CompositingMode.SourceCopy;
+ graphics.DrawImageUnscaled(source, 0, 0);
+ }
+
+ return result;
+ }
+
+ private static unsafe void MakeOpaqueIfFullyTransparent(Bitmap bitmap)
+ {
+ BitmapData data = bitmap.LockBits(new Rectangle(0, 0, bitmap.Width, bitmap.Height),
+ ImageLockMode.ReadWrite, PixelFormat.Format32bppArgb);
+
+ try
+ {
+ int delta = data.Stride >> 2;
+ uint* line = (uint*)data.Scan0;
+
+ for (int y = 0; y < bitmap.Height; ++y, line += delta)
+ {
+ for (int x = 0; x < bitmap.Width; ++x)
+ {
+ if ((line[x] & 0xFF000000) != 0)
+ {
+ return;
+ }
+ }
+ }
+
+ line = (uint*)data.Scan0;
+ for (int y = 0; y < bitmap.Height; ++y, line += delta)
+ {
+ for (int x = 0; x < bitmap.Width; ++x)
+ {
+ line[x] |= 0xFF000000;
+ }
+ }
+ }
+ finally
+ {
+ bitmap.UnlockBits(data);
+ }
+ }
+
+ private static string GetDroppedImagePath(IDataObject data)
+ {
+ if (!data.GetDataPresent(DataFormats.FileDrop))
+ {
+ return null;
+ }
+
+ if (data.GetData(DataFormats.FileDrop) is not string[] files)
+ {
+ return null;
+ }
+
+ foreach (string file in files)
+ {
+ string extension = Path.GetExtension(file);
+
+ foreach (string accepted in _acceptedDropExtensions)
+ {
+ if (string.Equals(extension, accepted, StringComparison.OrdinalIgnoreCase))
+ {
+ return file;
+ }
+ }
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/UoFiddler.Controls/Classes/ModifiedMarker.cs b/UoFiddler.Controls/Classes/ModifiedMarker.cs
new file mode 100644
index 00000000..8da5e18a
--- /dev/null
+++ b/UoFiddler.Controls/Classes/ModifiedMarker.cs
@@ -0,0 +1,51 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Drawing;
+
+namespace UoFiddler.Controls.Classes
+{
+ ///
+ /// Draws the corner wedge that flags a graphic edited since the file type was loaded or last
+ /// saved. Shared so the Items, LandTiles, Gumps and Textures tabs all mark entries the same way.
+ ///
+ public static class ModifiedMarker
+ {
+ private const int MaxSize = 9;
+ private const int MinSize = 4;
+
+ // Kept as statics: Draw runs once per visible tile on every paint, so nothing may allocate.
+ // The outline matters because Options.PreviewBackgroundColor is user configurable and the
+ // wedge would otherwise vanish against an orange background.
+ private static readonly Brush _fill = new SolidBrush(Color.FromArgb(255, 120, 0));
+ private static readonly Pen _outline = new Pen(Color.FromArgb(40, 40, 40));
+
+ ///
+ /// Draws the wedge into the top left corner of .
+ ///
+ public static void Draw(Graphics graphics, Rectangle bounds)
+ {
+ int size = Math.Min(MaxSize, Math.Min(bounds.Width, bounds.Height));
+ if (size < MinSize)
+ {
+ return;
+ }
+
+ Point corner = new Point(bounds.X, bounds.Y);
+ Point right = new Point(bounds.X + size, bounds.Y);
+ Point down = new Point(bounds.X, bounds.Y + size);
+
+ graphics.FillPolygon(_fill, new[] { corner, right, down });
+ graphics.DrawLine(_outline, right, down);
+ }
+ }
+}
diff --git a/UoFiddler.Controls/Classes/Options.cs b/UoFiddler.Controls/Classes/Options.cs
index 1cceb134..3f291d8a 100644
--- a/UoFiddler.Controls/Classes/Options.cs
+++ b/UoFiddler.Controls/Classes/Options.cs
@@ -43,6 +43,12 @@ public static class Options
///
public static bool PolSoundIdOffset { get; set; }
+ ///
+ /// Which container a save writes for the file types the client ships as either a mul/idx pair
+ /// or a uop - art, gumpart, sound, multis and maps. Everything else has only one format.
+ ///
+ public static ClientFileSaveFormat SaveFormat { get; set; } = ClientFileSaveFormat.FollowSource;
+
///
/// Runtime flag set from AppSettings at startup. Not persisted in profiles.
///
diff --git a/UoFiddler.Controls/Classes/SaveFormatResolver.cs b/UoFiddler.Controls/Classes/SaveFormatResolver.cs
new file mode 100644
index 00000000..3474fcce
--- /dev/null
+++ b/UoFiddler.Controls/Classes/SaveFormatResolver.cs
@@ -0,0 +1,72 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System.Windows.Forms;
+using Ultima.Uop;
+using UoFiddler.Controls.Forms;
+
+namespace UoFiddler.Controls.Classes
+{
+ ///
+ /// Turns the save format option into the one container a save is about to write.
+ ///
+ public static class SaveFormatResolver
+ {
+ ///
+ /// The container to write without asking anything. Use this where a prompt would be wrong -
+ /// a batch, or a form that already has its own format control.
+ ///
+ public static ContainerFormat Resolve(FileType type, int mapIndex = 0)
+ {
+ switch (Options.SaveFormat)
+ {
+ case ClientFileSaveFormat.Mul:
+ return ContainerFormat.Mul;
+
+ case ClientFileSaveFormat.Uop:
+ return ContainerFormat.Uop;
+
+ default:
+ // Ask falls back to the source format: a batch must not stop on a dialog.
+ return ClientFileSaver.ClientUsesUop(type, mapIndex)
+ ? ContainerFormat.Uop
+ : ContainerFormat.Mul;
+ }
+ }
+
+ ///
+ /// The container to write, prompting when the option says to ask. Returns false when the user
+ /// cancelled, in which case nothing should be written.
+ ///
+ public static bool TryResolve(IWin32Window owner, FileType type, string outputDirectory,
+ out ContainerFormat format, int mapIndex = 0)
+ {
+ format = Resolve(type, mapIndex);
+
+ if (Options.SaveFormat != ClientFileSaveFormat.Ask)
+ {
+ return true;
+ }
+
+ using (var dialog = new SaveFormatDialog(type, outputDirectory, format, mapIndex))
+ {
+ if (dialog.ShowDialog(owner) != DialogResult.OK)
+ {
+ return false;
+ }
+
+ format = dialog.SelectedFormat;
+ }
+
+ return true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Classes/TileDataBulkEdit.cs b/UoFiddler.Controls/Classes/TileDataBulkEdit.cs
new file mode 100644
index 00000000..8af6768b
--- /dev/null
+++ b/UoFiddler.Controls/Classes/TileDataBulkEdit.cs
@@ -0,0 +1,311 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+using Ultima;
+
+namespace UoFiddler.Controls.Classes
+{
+ ///
+ /// Sparse description of an edit to an entry. Every member
+ /// left null - and every flag left out of both masks - means "leave the target's
+ /// current value alone". That is what lets a single edit be applied across a whole
+ /// selection without flattening the fields the user never touched.
+ ///
+ public sealed class ItemDataEdit
+ {
+ public string Name { get; set; }
+ public short? Animation { get; set; }
+ public byte? Weight { get; set; }
+ public byte? Quality { get; set; }
+ public byte? Quantity { get; set; }
+ public byte? Hue { get; set; }
+ public byte? StackingOffset { get; set; }
+ public byte? Value { get; set; }
+ public byte? Height { get; set; }
+ public short? MiscData { get; set; }
+ public byte? Unk2 { get; set; }
+ public byte? Unk3 { get; set; }
+
+ /// Flags to turn on for every target.
+ public TileFlag SetFlags { get; set; }
+
+ /// Flags to turn off for every target.
+ public TileFlag ClearFlags { get; set; }
+ }
+
+ ///
+ /// Sparse description of an edit to a entry.
+ /// See for the "null means leave alone" contract.
+ ///
+ public sealed class LandDataEdit
+ {
+ public string Name { get; set; }
+ public ushort? TextureId { get; set; }
+ public TileFlag SetFlags { get; set; }
+ public TileFlag ClearFlags { get; set; }
+ }
+
+ ///
+ /// Applies sparse tiledata edits and describes them for confirmation prompts.
+ /// Shared by the TileData tab's multi-selection "Save Changes" and by
+ /// "Paste special", so both routes behave identically.
+ ///
+ public static class TileDataBulkEdit
+ {
+ /// tiledata.mul stores names as 20 ASCII bytes.
+ public const int MaxNameLength = 20;
+
+ /// How many flag names a description spells out before it gives up and counts.
+ private const int MaxDescribedFlags = 6;
+
+ public static ItemData Apply(ItemData data, ItemDataEdit edit)
+ {
+ if (edit == null)
+ {
+ return data;
+ }
+
+ if (edit.Name != null)
+ {
+ data.Name = TruncateName(edit.Name);
+ }
+
+ if (edit.Animation.HasValue)
+ {
+ data.Animation = edit.Animation.Value;
+ }
+
+ if (edit.Weight.HasValue)
+ {
+ data.Weight = edit.Weight.Value;
+ }
+
+ if (edit.Quality.HasValue)
+ {
+ data.Quality = edit.Quality.Value;
+ }
+
+ if (edit.Quantity.HasValue)
+ {
+ data.Quantity = edit.Quantity.Value;
+ }
+
+ if (edit.Hue.HasValue)
+ {
+ data.Hue = edit.Hue.Value;
+ }
+
+ if (edit.StackingOffset.HasValue)
+ {
+ data.StackingOffset = edit.StackingOffset.Value;
+ }
+
+ if (edit.Value.HasValue)
+ {
+ data.Value = edit.Value.Value;
+ }
+
+ if (edit.Height.HasValue)
+ {
+ data.Height = edit.Height.Value;
+ }
+
+ if (edit.MiscData.HasValue)
+ {
+ data.MiscData = edit.MiscData.Value;
+ }
+
+ if (edit.Unk2.HasValue)
+ {
+ data.Unk2 = edit.Unk2.Value;
+ }
+
+ if (edit.Unk3.HasValue)
+ {
+ data.Unk3 = edit.Unk3.Value;
+ }
+
+ data.Flags = (data.Flags | edit.SetFlags) & ~edit.ClearFlags;
+
+ return data;
+ }
+
+ public static LandData Apply(LandData data, LandDataEdit edit)
+ {
+ if (edit == null)
+ {
+ return data;
+ }
+
+ if (edit.Name != null)
+ {
+ data.Name = TruncateName(edit.Name);
+ }
+
+ if (edit.TextureId.HasValue)
+ {
+ data.TextureId = edit.TextureId.Value;
+ }
+
+ data.Flags = (data.Flags | edit.SetFlags) & ~edit.ClearFlags;
+
+ return data;
+ }
+
+ public static bool IsEmpty(ItemDataEdit edit)
+ {
+ return edit == null
+ || (edit.Name == null
+ && !edit.Animation.HasValue
+ && !edit.Weight.HasValue
+ && !edit.Quality.HasValue
+ && !edit.Quantity.HasValue
+ && !edit.Hue.HasValue
+ && !edit.StackingOffset.HasValue
+ && !edit.Value.HasValue
+ && !edit.Height.HasValue
+ && !edit.MiscData.HasValue
+ && !edit.Unk2.HasValue
+ && !edit.Unk3.HasValue
+ && edit.SetFlags == TileFlag.None
+ && edit.ClearFlags == TileFlag.None);
+ }
+
+ public static bool IsEmpty(LandDataEdit edit)
+ {
+ return edit == null
+ || (edit.Name == null
+ && !edit.TextureId.HasValue
+ && edit.SetFlags == TileFlag.None
+ && edit.ClearFlags == TileFlag.None);
+ }
+
+ ///
+ /// Human readable summary of what an edit will change, e.g.
+ /// "Name, Weight, Height, +Impassable, -Wall". Used in the confirmation prompt
+ /// so a bulk write always says what it is about to touch.
+ ///
+ public static string Describe(ItemDataEdit edit)
+ {
+ if (IsEmpty(edit))
+ {
+ return string.Empty;
+ }
+
+ var parts = new List();
+
+ AddIf(parts, edit.Name != null, "Name");
+ AddIf(parts, edit.Animation.HasValue, "Anim");
+ AddIf(parts, edit.Weight.HasValue, "Weight");
+ AddIf(parts, edit.Quality.HasValue, "Layer");
+ AddIf(parts, edit.Quantity.HasValue, "Quantity");
+ AddIf(parts, edit.Hue.HasValue, "Hue");
+ AddIf(parts, edit.StackingOffset.HasValue, "StackOff");
+ AddIf(parts, edit.Value.HasValue, "Value");
+ AddIf(parts, edit.Height.HasValue, "Height");
+ AddIf(parts, edit.MiscData.HasValue, "MiscData");
+ AddIf(parts, edit.Unk2.HasValue, "Unk2");
+ AddIf(parts, edit.Unk3.HasValue, "Unk3");
+
+ AppendFlags(parts, edit.SetFlags, '+');
+ AppendFlags(parts, edit.ClearFlags, '-');
+
+ return string.Join(", ", parts);
+ }
+
+ public static string Describe(LandDataEdit edit)
+ {
+ if (IsEmpty(edit))
+ {
+ return string.Empty;
+ }
+
+ var parts = new List();
+
+ AddIf(parts, edit.Name != null, "Name");
+ AddIf(parts, edit.TextureId.HasValue, "TexID");
+
+ AppendFlags(parts, edit.SetFlags, '+');
+ AppendFlags(parts, edit.ClearFlags, '-');
+
+ return string.Join(", ", parts);
+ }
+
+ public static string TruncateName(string name)
+ {
+ if (name == null)
+ {
+ return string.Empty;
+ }
+
+ return name.Length > MaxNameLength ? name.Substring(0, MaxNameLength) : name;
+ }
+
+ private static void AddIf(List parts, bool condition, string text)
+ {
+ if (condition)
+ {
+ parts.Add(text);
+ }
+ }
+
+ private static void AppendFlags(List parts, TileFlag flags, char prefix)
+ {
+ if (flags == TileFlag.None)
+ {
+ return;
+ }
+
+ Array enumValues = Enum.GetValues(typeof(TileFlag));
+ var builder = new StringBuilder();
+ int named = 0;
+ int total = 0;
+
+ for (int i = 1; i < enumValues.Length; ++i)
+ {
+ var flag = (TileFlag)enumValues.GetValue(i);
+ if ((flags & flag) == 0)
+ {
+ continue;
+ }
+
+ ++total;
+ if (named >= MaxDescribedFlags)
+ {
+ continue;
+ }
+
+ if (named > 0)
+ {
+ builder.Append(", ");
+ }
+
+ builder.Append(prefix).Append(flag);
+ ++named;
+ }
+
+ if (total == 0)
+ {
+ return;
+ }
+
+ if (total > named)
+ {
+ builder.Append(", ").Append(prefix).Append('(').Append(total - named).Append(" more)");
+ }
+
+ parts.Add(builder.ToString());
+ }
+ }
+}
diff --git a/UoFiddler.Controls/Classes/TileDataBulkUndo.cs b/UoFiddler.Controls/Classes/TileDataBulkUndo.cs
new file mode 100644
index 00000000..24f823a8
--- /dev/null
+++ b/UoFiddler.Controls/Classes/TileDataBulkUndo.cs
@@ -0,0 +1,74 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using Ultima;
+
+namespace UoFiddler.Controls.Classes
+{
+ ///
+ /// Snapshot of the tiledata entries a single bulk apply was about to overwrite,
+ /// so one misplaced "apply to 4000 entries" can be taken back without reloading
+ /// tiledata.mul and losing every other unsaved edit.
+ ///
+ /// Only one level is kept - this is an escape hatch for the misclick, not an
+ /// edit history.
+ ///
+ ///
+ public sealed class TileDataBulkUndo
+ {
+ private TileDataBulkUndo(bool land, int[] ids, ItemData[] items, LandData[] lands, string description)
+ {
+ Land = land;
+ Ids = ids;
+ Items = items;
+ Lands = lands;
+ Description = description;
+ }
+
+ public bool Land { get; }
+
+ /// Graphic ids that were overwritten, in the order they were applied.
+ public int[] Ids { get; }
+
+ /// Pre-edit item entries, parallel to . Null when .
+ public ItemData[] Items { get; }
+
+ /// Pre-edit land entries, parallel to . Null unless .
+ public LandData[] Lands { get; }
+
+ /// What the apply changed, e.g. "Weight, +Impassable" - shown in the undo prompt.
+ public string Description { get; }
+
+ public int Count => Ids.Length;
+
+ public static TileDataBulkUndo ForItems(int[] ids, string description)
+ {
+ var snapshot = new ItemData[ids.Length];
+ for (int i = 0; i < ids.Length; ++i)
+ {
+ snapshot[i] = TileData.ItemTable[ids[i]];
+ }
+
+ return new TileDataBulkUndo(false, ids, snapshot, null, description);
+ }
+
+ public static TileDataBulkUndo ForLand(int[] ids, string description)
+ {
+ var snapshot = new LandData[ids.Length];
+ for (int i = 0; i < ids.Length; ++i)
+ {
+ snapshot[i] = TileData.LandTable[ids[i]];
+ }
+
+ return new TileDataBulkUndo(true, ids, null, snapshot, description);
+ }
+ }
+}
diff --git a/UoFiddler.Controls/Classes/Utils.cs b/UoFiddler.Controls/Classes/Utils.cs
index a55c1544..9ee7c287 100644
--- a/UoFiddler.Controls/Classes/Utils.cs
+++ b/UoFiddler.Controls/Classes/Utils.cs
@@ -18,6 +18,11 @@ namespace UoFiddler.Controls.Classes
{
public static class Utils
{
+ ///
+ /// Alpha value at which a pasted pixel is still considered opaque.
+ ///
+ private const uint AlphaCutoff = 128;
+
///
/// Converts string to int with Hex recognition
///
@@ -110,6 +115,96 @@ public static unsafe Bitmap ConvertBmp(Bitmap bmp)
return bmpNew;
}
+ ///
+ /// Converts an arbitrary bitmap into the 16bppArgb1555 form the mul save paths expect.
+ ///
+ ///
+ /// Which pixels end up transparent depends on what the source actually carries. An image with
+ /// a real alpha channel is taken at its word, so pure black stays black. An image without one
+ /// - a screenshot, a flattened bmp - falls back to , whose pure
+ /// black/pure white rule is what the Replace from file paths have always used.
+ ///
+ public static unsafe Bitmap ToUoBitmap(Bitmap source)
+ {
+ if (!HasTranslucency(source))
+ {
+ return ConvertBmp(source);
+ }
+
+ Rectangle rectangle = new Rectangle(0, 0, source.Width, source.Height);
+
+ Bitmap result = new Bitmap(source.Width, source.Height, PixelFormat.Format16bppArgb1555);
+ BitmapData sourceData = source.LockBits(rectangle, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
+ BitmapData resultData = result.LockBits(rectangle, ImageLockMode.WriteOnly, PixelFormat.Format16bppArgb1555);
+
+ try
+ {
+ uint* sourceLine = (uint*)sourceData.Scan0;
+ int sourceDelta = sourceData.Stride >> 2;
+
+ ushort* resultLine = (ushort*)resultData.Scan0;
+ int resultDelta = resultData.Stride >> 1;
+
+ for (int y = 0; y < source.Height; ++y, sourceLine += sourceDelta, resultLine += resultDelta)
+ {
+ for (int x = 0; x < source.Width; ++x)
+ {
+ uint argb = sourceLine[x];
+
+ // One bit of alpha is all the format has, so anything half transparent or
+ // more drops out entirely.
+ resultLine[x] = (argb >> 24) < AlphaCutoff
+ ? (ushort)0
+ : (ushort)(0x8000 | ((argb >> 9) & 0x7C00) | ((argb >> 6) & 0x03E0) | ((argb >> 3) & 0x001F));
+ }
+ }
+ }
+ finally
+ {
+ source.UnlockBits(sourceData);
+ result.UnlockBits(resultData);
+ }
+
+ return result;
+ }
+
+ ///
+ /// True when the bitmap declares an alpha channel and at least one pixel actually uses it.
+ ///
+ private static unsafe bool HasTranslucency(Bitmap bmp)
+ {
+ if (!Image.IsAlphaPixelFormat(bmp.PixelFormat))
+ {
+ return false;
+ }
+
+ BitmapData data = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height),
+ ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
+
+ try
+ {
+ uint* line = (uint*)data.Scan0;
+ int delta = data.Stride >> 2;
+
+ for (int y = 0; y < bmp.Height; ++y, line += delta)
+ {
+ for (int x = 0; x < bmp.Width; ++x)
+ {
+ if ((line[x] >> 24) != 0xFF)
+ {
+ return true;
+ }
+ }
+ }
+ }
+ finally
+ {
+ bmp.UnlockBits(data);
+ }
+
+ return false;
+ }
+
public static string GetFileExtensionFor(ImageFormat imageFormat)
{
if (Equals(imageFormat, ImageFormat.Bmp))
diff --git a/UoFiddler.Controls/Forms/MapDefragStaticsForm.Designer.cs b/UoFiddler.Controls/Forms/MapDefragStaticsForm.Designer.cs
new file mode 100644
index 00000000..dc1b8b0b
--- /dev/null
+++ b/UoFiddler.Controls/Forms/MapDefragStaticsForm.Designer.cs
@@ -0,0 +1,476 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+namespace UoFiddler.Controls.Forms
+{
+ partial class MapDefragStaticsForm
+ {
+ ///
+ /// Required designer variable.
+ ///
+ private System.ComponentModel.IContainer components = null;
+
+ ///
+ /// Clean up any resources being used.
+ ///
+ /// true if managed resources should be disposed; otherwise, false.
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && (components != null))
+ {
+ components.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ private void InitializeComponent()
+ {
+ this.components = new System.ComponentModel.Container();
+ this.groupBoxSource = new System.Windows.Forms.GroupBox();
+ this.textBoxSource = new System.Windows.Forms.TextBox();
+ this.labelGeometry = new System.Windows.Forms.Label();
+ this.checkBoxAllowTruncation = new System.Windows.Forms.CheckBox();
+ this.groupBoxFilters = new System.Windows.Forms.GroupBox();
+ this.checkBoxDropInvalidIds = new System.Windows.Forms.CheckBox();
+ this.labelCeiling = new System.Windows.Forms.Label();
+ this.comboBoxIdCeiling = new System.Windows.Forms.ComboBox();
+ this.labelCeilingValue = new System.Windows.Forms.Label();
+ this.labelOutOfBlock = new System.Windows.Forms.Label();
+ this.comboBoxOutOfBlock = new System.Windows.Forms.ComboBox();
+ this.checkBoxDropInvalidZ = new System.Windows.Forms.CheckBox();
+ this.checkBoxNormalizeHue = new System.Windows.Forms.CheckBox();
+ this.checkBoxBelowTerrain = new System.Windows.Forms.CheckBox();
+ this.checkBoxRemoveDuplicates = new System.Windows.Forms.CheckBox();
+ this.checkBoxDuplicatesHue = new System.Windows.Forms.CheckBox();
+ this.checkBoxCollapseStacks = new System.Windows.Forms.CheckBox();
+ this.labelCollapseFlags = new System.Windows.Forms.Label();
+ this.checkBoxCollapseWet = new System.Windows.Forms.CheckBox();
+ this.checkBoxCollapseSurface = new System.Windows.Forms.CheckBox();
+ this.checkBoxCollapseIgnoreZ = new System.Windows.Forms.CheckBox();
+ this.labelCollapseIds = new System.Windows.Forms.Label();
+ this.textBoxCollapseIds = new System.Windows.Forms.TextBox();
+ this.checkBoxSortTiles = new System.Windows.Forms.CheckBox();
+ this.groupBoxOutput = new System.Windows.Forms.GroupBox();
+ this.textBoxOutput = new System.Windows.Forms.TextBox();
+ this.buttonBrowse = new System.Windows.Forms.Button();
+ this.progressBar = new System.Windows.Forms.ProgressBar();
+ this.labelStatus = new System.Windows.Forms.Label();
+ this.buttonAnalyze = new System.Windows.Forms.Button();
+ this.buttonDefrag = new System.Windows.Forms.Button();
+ this.buttonCancel = new System.Windows.Forms.Button();
+ this.buttonClose = new System.Windows.Forms.Button();
+ this.worker = new System.ComponentModel.BackgroundWorker();
+ this.components.Add(this.worker);
+ this.groupBoxSource.SuspendLayout();
+ this.groupBoxFilters.SuspendLayout();
+ this.groupBoxOutput.SuspendLayout();
+ this.SuspendLayout();
+ //
+ // groupBoxSource
+ //
+ this.groupBoxSource.Controls.Add(this.textBoxSource);
+ this.groupBoxSource.Controls.Add(this.labelGeometry);
+ this.groupBoxSource.Controls.Add(this.checkBoxAllowTruncation);
+ this.groupBoxSource.Location = new System.Drawing.Point(12, 12);
+ this.groupBoxSource.Name = "groupBoxSource";
+ this.groupBoxSource.Size = new System.Drawing.Size(536, 146);
+ this.groupBoxSource.TabIndex = 0;
+ this.groupBoxSource.TabStop = false;
+ this.groupBoxSource.Text = "Source";
+ //
+ // textBoxSource
+ //
+ this.textBoxSource.BackColor = System.Drawing.SystemColors.Control;
+ this.textBoxSource.BorderStyle = System.Windows.Forms.BorderStyle.None;
+ this.textBoxSource.Location = new System.Drawing.Point(12, 20);
+ this.textBoxSource.Multiline = true;
+ this.textBoxSource.Name = "textBoxSource";
+ this.textBoxSource.ReadOnly = true;
+ this.textBoxSource.ScrollBars = System.Windows.Forms.ScrollBars.Vertical;
+ this.textBoxSource.Size = new System.Drawing.Size(512, 62);
+ this.textBoxSource.TabIndex = 0;
+ this.textBoxSource.TabStop = false;
+ //
+ // labelGeometry
+ //
+ this.labelGeometry.Location = new System.Drawing.Point(12, 86);
+ this.labelGeometry.Name = "labelGeometry";
+ this.labelGeometry.Size = new System.Drawing.Size(512, 32);
+ this.labelGeometry.TabIndex = 1;
+ //
+ // checkBoxAllowTruncation
+ //
+ this.checkBoxAllowTruncation.Enabled = false;
+ this.checkBoxAllowTruncation.Location = new System.Drawing.Point(12, 120);
+ this.checkBoxAllowTruncation.Name = "checkBoxAllowTruncation";
+ this.checkBoxAllowTruncation.Size = new System.Drawing.Size(512, 21);
+ this.checkBoxAllowTruncation.TabIndex = 2;
+ this.checkBoxAllowTruncation.Text = "Discard the blocks past the configured map size";
+ this.checkBoxAllowTruncation.UseVisualStyleBackColor = true;
+ //
+ // groupBoxFilters
+ //
+ this.groupBoxFilters.Controls.Add(this.checkBoxDropInvalidIds);
+ this.groupBoxFilters.Controls.Add(this.labelCeiling);
+ this.groupBoxFilters.Controls.Add(this.comboBoxIdCeiling);
+ this.groupBoxFilters.Controls.Add(this.labelCeilingValue);
+ this.groupBoxFilters.Controls.Add(this.labelOutOfBlock);
+ this.groupBoxFilters.Controls.Add(this.comboBoxOutOfBlock);
+ this.groupBoxFilters.Controls.Add(this.checkBoxDropInvalidZ);
+ this.groupBoxFilters.Controls.Add(this.checkBoxNormalizeHue);
+ this.groupBoxFilters.Controls.Add(this.checkBoxBelowTerrain);
+ this.groupBoxFilters.Controls.Add(this.checkBoxRemoveDuplicates);
+ this.groupBoxFilters.Controls.Add(this.checkBoxDuplicatesHue);
+ this.groupBoxFilters.Controls.Add(this.checkBoxCollapseStacks);
+ this.groupBoxFilters.Controls.Add(this.labelCollapseFlags);
+ this.groupBoxFilters.Controls.Add(this.checkBoxCollapseWet);
+ this.groupBoxFilters.Controls.Add(this.checkBoxCollapseSurface);
+ this.groupBoxFilters.Controls.Add(this.checkBoxCollapseIgnoreZ);
+ this.groupBoxFilters.Controls.Add(this.labelCollapseIds);
+ this.groupBoxFilters.Controls.Add(this.textBoxCollapseIds);
+ this.groupBoxFilters.Controls.Add(this.checkBoxSortTiles);
+ this.groupBoxFilters.Location = new System.Drawing.Point(12, 164);
+ this.groupBoxFilters.Name = "groupBoxFilters";
+ this.groupBoxFilters.Size = new System.Drawing.Size(536, 290);
+ this.groupBoxFilters.TabIndex = 1;
+ this.groupBoxFilters.TabStop = false;
+ this.groupBoxFilters.Text = "Filters";
+ //
+ // checkBoxDropInvalidIds
+ //
+ this.checkBoxDropInvalidIds.Location = new System.Drawing.Point(12, 22);
+ this.checkBoxDropInvalidIds.Name = "checkBoxDropInvalidIds";
+ this.checkBoxDropInvalidIds.Size = new System.Drawing.Size(226, 21);
+ this.checkBoxDropInvalidIds.TabIndex = 0;
+ this.checkBoxDropInvalidIds.Text = "Drop statics with an unknown item id";
+ this.checkBoxDropInvalidIds.UseVisualStyleBackColor = true;
+ this.checkBoxDropInvalidIds.CheckedChanged += new System.EventHandler(this.OnFilterChanged);
+ //
+ // labelCeiling
+ //
+ this.labelCeiling.Location = new System.Drawing.Point(244, 25);
+ this.labelCeiling.Name = "labelCeiling";
+ this.labelCeiling.Size = new System.Drawing.Size(50, 17);
+ this.labelCeiling.TabIndex = 1;
+ this.labelCeiling.Text = "ceiling:";
+ //
+ // comboBoxIdCeiling
+ //
+ this.comboBoxIdCeiling.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
+ this.comboBoxIdCeiling.Location = new System.Drawing.Point(296, 21);
+ this.comboBoxIdCeiling.Name = "comboBoxIdCeiling";
+ this.comboBoxIdCeiling.Size = new System.Drawing.Size(126, 23);
+ this.comboBoxIdCeiling.TabIndex = 2;
+ this.comboBoxIdCeiling.SelectedIndexChanged += new System.EventHandler(this.OnFilterChanged);
+ //
+ // labelCeilingValue
+ //
+ this.labelCeilingValue.Location = new System.Drawing.Point(428, 25);
+ this.labelCeilingValue.Name = "labelCeilingValue";
+ this.labelCeilingValue.Size = new System.Drawing.Size(96, 17);
+ this.labelCeilingValue.TabIndex = 3;
+ //
+ // labelOutOfBlock
+ //
+ this.labelOutOfBlock.Location = new System.Drawing.Point(12, 56);
+ this.labelOutOfBlock.Name = "labelOutOfBlock";
+ this.labelOutOfBlock.Size = new System.Drawing.Size(162, 17);
+ this.labelOutOfBlock.TabIndex = 4;
+ this.labelOutOfBlock.Text = "Out-of-block x/y offsets:";
+ //
+ // comboBoxOutOfBlock
+ //
+ this.comboBoxOutOfBlock.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
+ this.comboBoxOutOfBlock.Location = new System.Drawing.Point(180, 52);
+ this.comboBoxOutOfBlock.Name = "comboBoxOutOfBlock";
+ this.comboBoxOutOfBlock.Size = new System.Drawing.Size(150, 23);
+ this.comboBoxOutOfBlock.TabIndex = 5;
+ //
+ // checkBoxDropInvalidZ
+ //
+ this.checkBoxDropInvalidZ.Location = new System.Drawing.Point(12, 82);
+ this.checkBoxDropInvalidZ.Name = "checkBoxDropInvalidZ";
+ this.checkBoxDropInvalidZ.Size = new System.Drawing.Size(300, 21);
+ this.checkBoxDropInvalidZ.TabIndex = 6;
+ this.checkBoxDropInvalidZ.Text = "Drop statics at z = -128";
+ this.checkBoxDropInvalidZ.UseVisualStyleBackColor = true;
+ //
+ // checkBoxNormalizeHue
+ //
+ this.checkBoxNormalizeHue.Location = new System.Drawing.Point(12, 106);
+ this.checkBoxNormalizeHue.Name = "checkBoxNormalizeHue";
+ this.checkBoxNormalizeHue.Size = new System.Drawing.Size(300, 21);
+ this.checkBoxNormalizeHue.TabIndex = 7;
+ this.checkBoxNormalizeHue.Text = "Normalize negative hues to 0";
+ this.checkBoxNormalizeHue.UseVisualStyleBackColor = true;
+ //
+ // checkBoxBelowTerrain
+ //
+ this.checkBoxBelowTerrain.Location = new System.Drawing.Point(12, 130);
+ this.checkBoxBelowTerrain.Name = "checkBoxBelowTerrain";
+ this.checkBoxBelowTerrain.Size = new System.Drawing.Size(400, 21);
+ this.checkBoxBelowTerrain.TabIndex = 8;
+ this.checkBoxBelowTerrain.Text = "Drop statics buried under the land tile (never drawn)";
+ this.checkBoxBelowTerrain.UseVisualStyleBackColor = true;
+ //
+ // checkBoxRemoveDuplicates
+ //
+ this.checkBoxRemoveDuplicates.Location = new System.Drawing.Point(12, 154);
+ this.checkBoxRemoveDuplicates.Name = "checkBoxRemoveDuplicates";
+ this.checkBoxRemoveDuplicates.Size = new System.Drawing.Size(282, 21);
+ this.checkBoxRemoveDuplicates.TabIndex = 9;
+ this.checkBoxRemoveDuplicates.Text = "Remove duplicates (same id, x, y and z)";
+ this.checkBoxRemoveDuplicates.UseVisualStyleBackColor = true;
+ this.checkBoxRemoveDuplicates.CheckedChanged += new System.EventHandler(this.OnFilterChanged);
+ //
+ // checkBoxDuplicatesHue
+ //
+ this.checkBoxDuplicatesHue.Location = new System.Drawing.Point(300, 154);
+ this.checkBoxDuplicatesHue.Name = "checkBoxDuplicatesHue";
+ this.checkBoxDuplicatesHue.Size = new System.Drawing.Size(224, 21);
+ this.checkBoxDuplicatesHue.TabIndex = 10;
+ this.checkBoxDuplicatesHue.Text = "compare hue too (legacy)";
+ this.checkBoxDuplicatesHue.UseVisualStyleBackColor = true;
+ //
+ // checkBoxCollapseStacks
+ //
+ this.checkBoxCollapseStacks.Location = new System.Drawing.Point(12, 178);
+ this.checkBoxCollapseStacks.Name = "checkBoxCollapseStacks";
+ this.checkBoxCollapseStacks.Size = new System.Drawing.Size(400, 21);
+ this.checkBoxCollapseStacks.TabIndex = 11;
+ this.checkBoxCollapseStacks.Text = "Collapse stacked statics sharing a cell down to one";
+ this.checkBoxCollapseStacks.UseVisualStyleBackColor = true;
+ this.checkBoxCollapseStacks.CheckedChanged += new System.EventHandler(this.OnFilterChanged);
+ //
+ // labelCollapseFlags
+ //
+ this.labelCollapseFlags.Location = new System.Drawing.Point(32, 205);
+ this.labelCollapseFlags.Name = "labelCollapseFlags";
+ this.labelCollapseFlags.Size = new System.Drawing.Size(42, 17);
+ this.labelCollapseFlags.TabIndex = 12;
+ this.labelCollapseFlags.Text = "flags:";
+ //
+ // checkBoxCollapseWet
+ //
+ this.checkBoxCollapseWet.Location = new System.Drawing.Point(76, 202);
+ this.checkBoxCollapseWet.Name = "checkBoxCollapseWet";
+ this.checkBoxCollapseWet.Size = new System.Drawing.Size(60, 21);
+ this.checkBoxCollapseWet.TabIndex = 13;
+ this.checkBoxCollapseWet.Text = "Wet";
+ this.checkBoxCollapseWet.UseVisualStyleBackColor = true;
+ //
+ // checkBoxCollapseSurface
+ //
+ this.checkBoxCollapseSurface.Location = new System.Drawing.Point(140, 202);
+ this.checkBoxCollapseSurface.Name = "checkBoxCollapseSurface";
+ this.checkBoxCollapseSurface.Size = new System.Drawing.Size(78, 21);
+ this.checkBoxCollapseSurface.TabIndex = 14;
+ this.checkBoxCollapseSurface.Text = "Surface";
+ this.checkBoxCollapseSurface.UseVisualStyleBackColor = true;
+ //
+ // checkBoxCollapseIgnoreZ
+ //
+ this.checkBoxCollapseIgnoreZ.Location = new System.Drawing.Point(228, 202);
+ this.checkBoxCollapseIgnoreZ.Name = "checkBoxCollapseIgnoreZ";
+ this.checkBoxCollapseIgnoreZ.Size = new System.Drawing.Size(120, 21);
+ this.checkBoxCollapseIgnoreZ.TabIndex = 15;
+ this.checkBoxCollapseIgnoreZ.Text = "ignore z";
+ this.checkBoxCollapseIgnoreZ.UseVisualStyleBackColor = true;
+ //
+ // labelCollapseIds
+ //
+ this.labelCollapseIds.Location = new System.Drawing.Point(32, 231);
+ this.labelCollapseIds.Name = "labelCollapseIds";
+ this.labelCollapseIds.Size = new System.Drawing.Size(62, 17);
+ this.labelCollapseIds.TabIndex = 16;
+ this.labelCollapseIds.Text = "item ids:";
+ //
+ // textBoxCollapseIds
+ //
+ this.textBoxCollapseIds.Location = new System.Drawing.Point(96, 228);
+ this.textBoxCollapseIds.Name = "textBoxCollapseIds";
+ this.textBoxCollapseIds.Size = new System.Drawing.Size(428, 23);
+ this.textBoxCollapseIds.TabIndex = 17;
+ //
+ // checkBoxSortTiles
+ //
+ this.checkBoxSortTiles.Location = new System.Drawing.Point(12, 258);
+ this.checkBoxSortTiles.Name = "checkBoxSortTiles";
+ this.checkBoxSortTiles.Size = new System.Drawing.Size(300, 21);
+ this.checkBoxSortTiles.TabIndex = 18;
+ this.checkBoxSortTiles.Text = "Sort the statics within each block";
+ this.checkBoxSortTiles.UseVisualStyleBackColor = true;
+ //
+ // groupBoxOutput
+ //
+ this.groupBoxOutput.Controls.Add(this.textBoxOutput);
+ this.groupBoxOutput.Controls.Add(this.buttonBrowse);
+ this.groupBoxOutput.Location = new System.Drawing.Point(12, 462);
+ this.groupBoxOutput.Name = "groupBoxOutput";
+ this.groupBoxOutput.Size = new System.Drawing.Size(536, 58);
+ this.groupBoxOutput.TabIndex = 2;
+ this.groupBoxOutput.TabStop = false;
+ this.groupBoxOutput.Text = "Output folder";
+ //
+ // textBoxOutput
+ //
+ this.textBoxOutput.Location = new System.Drawing.Point(12, 22);
+ this.textBoxOutput.Name = "textBoxOutput";
+ this.textBoxOutput.Size = new System.Drawing.Size(422, 23);
+ this.textBoxOutput.TabIndex = 0;
+ //
+ // buttonBrowse
+ //
+ this.buttonBrowse.Location = new System.Drawing.Point(440, 21);
+ this.buttonBrowse.Name = "buttonBrowse";
+ this.buttonBrowse.Size = new System.Drawing.Size(84, 25);
+ this.buttonBrowse.TabIndex = 1;
+ this.buttonBrowse.Text = "Browse...";
+ this.buttonBrowse.UseVisualStyleBackColor = true;
+ this.buttonBrowse.Click += new System.EventHandler(this.OnClickBrowse);
+ //
+ // progressBar
+ //
+ this.progressBar.Location = new System.Drawing.Point(12, 528);
+ this.progressBar.Name = "progressBar";
+ this.progressBar.Size = new System.Drawing.Size(536, 18);
+ this.progressBar.TabIndex = 3;
+ //
+ // labelStatus
+ //
+ this.labelStatus.AutoEllipsis = true;
+ this.labelStatus.Location = new System.Drawing.Point(12, 551);
+ this.labelStatus.Name = "labelStatus";
+ this.labelStatus.Size = new System.Drawing.Size(536, 17);
+ this.labelStatus.TabIndex = 4;
+ //
+ // buttonAnalyze
+ //
+ this.buttonAnalyze.Location = new System.Drawing.Point(190, 574);
+ this.buttonAnalyze.Name = "buttonAnalyze";
+ this.buttonAnalyze.Size = new System.Drawing.Size(96, 28);
+ this.buttonAnalyze.TabIndex = 5;
+ this.buttonAnalyze.Text = "Analyze";
+ this.buttonAnalyze.UseVisualStyleBackColor = true;
+ this.buttonAnalyze.Click += new System.EventHandler(this.OnClickAnalyze);
+ //
+ // buttonDefrag
+ //
+ this.buttonDefrag.Location = new System.Drawing.Point(292, 574);
+ this.buttonDefrag.Name = "buttonDefrag";
+ this.buttonDefrag.Size = new System.Drawing.Size(96, 28);
+ this.buttonDefrag.TabIndex = 6;
+ this.buttonDefrag.Text = "Defrag";
+ this.buttonDefrag.UseVisualStyleBackColor = true;
+ this.buttonDefrag.Click += new System.EventHandler(this.OnClickDefrag);
+ //
+ // buttonCancel
+ //
+ this.buttonCancel.Enabled = false;
+ this.buttonCancel.Location = new System.Drawing.Point(394, 574);
+ this.buttonCancel.Name = "buttonCancel";
+ this.buttonCancel.Size = new System.Drawing.Size(72, 28);
+ this.buttonCancel.TabIndex = 7;
+ this.buttonCancel.Text = "Cancel";
+ this.buttonCancel.UseVisualStyleBackColor = true;
+ this.buttonCancel.Click += new System.EventHandler(this.OnClickCancel);
+ //
+ // buttonClose
+ //
+ this.buttonClose.DialogResult = System.Windows.Forms.DialogResult.Cancel;
+ this.buttonClose.Location = new System.Drawing.Point(472, 574);
+ this.buttonClose.Name = "buttonClose";
+ this.buttonClose.Size = new System.Drawing.Size(76, 28);
+ this.buttonClose.TabIndex = 8;
+ this.buttonClose.Text = "Close";
+ this.buttonClose.UseVisualStyleBackColor = true;
+ //
+ // worker
+ //
+ this.worker.WorkerReportsProgress = true;
+ this.worker.DoWork += new System.ComponentModel.DoWorkEventHandler(this.OnWorkerDoWork);
+ this.worker.ProgressChanged += new System.ComponentModel.ProgressChangedEventHandler(this.OnWorkerProgressChanged);
+ this.worker.RunWorkerCompleted += new System.ComponentModel.RunWorkerCompletedEventHandler(this.OnWorkerCompleted);
+ //
+ // MapDefragStaticsForm
+ //
+ this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
+ this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
+ this.CancelButton = this.buttonClose;
+ this.ClientSize = new System.Drawing.Size(560, 614);
+ this.Controls.Add(this.groupBoxSource);
+ this.Controls.Add(this.groupBoxFilters);
+ this.Controls.Add(this.groupBoxOutput);
+ this.Controls.Add(this.progressBar);
+ this.Controls.Add(this.labelStatus);
+ this.Controls.Add(this.buttonAnalyze);
+ this.Controls.Add(this.buttonDefrag);
+ this.Controls.Add(this.buttonCancel);
+ this.Controls.Add(this.buttonClose);
+ this.DoubleBuffered = true;
+ this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
+ this.MaximizeBox = false;
+ this.MinimizeBox = false;
+ this.Name = "MapDefragStaticsForm";
+ this.ShowInTaskbar = false;
+ this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
+ this.Text = "Defrag Statics";
+ this.groupBoxSource.ResumeLayout(false);
+ this.groupBoxSource.PerformLayout();
+ this.groupBoxFilters.ResumeLayout(false);
+ this.groupBoxFilters.PerformLayout();
+ this.groupBoxOutput.ResumeLayout(false);
+ this.groupBoxOutput.PerformLayout();
+ this.ResumeLayout(false);
+
+ }
+
+ #endregion
+
+ private System.ComponentModel.BackgroundWorker worker;
+ private System.Windows.Forms.Button buttonAnalyze;
+ private System.Windows.Forms.Button buttonBrowse;
+ private System.Windows.Forms.Button buttonCancel;
+ private System.Windows.Forms.Button buttonClose;
+ private System.Windows.Forms.Button buttonDefrag;
+ private System.Windows.Forms.CheckBox checkBoxAllowTruncation;
+ private System.Windows.Forms.CheckBox checkBoxBelowTerrain;
+ private System.Windows.Forms.CheckBox checkBoxCollapseIgnoreZ;
+ private System.Windows.Forms.CheckBox checkBoxCollapseStacks;
+ private System.Windows.Forms.CheckBox checkBoxCollapseSurface;
+ private System.Windows.Forms.CheckBox checkBoxCollapseWet;
+ private System.Windows.Forms.CheckBox checkBoxDropInvalidIds;
+ private System.Windows.Forms.CheckBox checkBoxDropInvalidZ;
+ private System.Windows.Forms.CheckBox checkBoxDuplicatesHue;
+ private System.Windows.Forms.CheckBox checkBoxNormalizeHue;
+ private System.Windows.Forms.CheckBox checkBoxRemoveDuplicates;
+ private System.Windows.Forms.CheckBox checkBoxSortTiles;
+ private System.Windows.Forms.ComboBox comboBoxIdCeiling;
+ private System.Windows.Forms.ComboBox comboBoxOutOfBlock;
+ private System.Windows.Forms.GroupBox groupBoxFilters;
+ private System.Windows.Forms.GroupBox groupBoxOutput;
+ private System.Windows.Forms.GroupBox groupBoxSource;
+ private System.Windows.Forms.Label labelCeiling;
+ private System.Windows.Forms.Label labelCeilingValue;
+ private System.Windows.Forms.Label labelCollapseFlags;
+ private System.Windows.Forms.Label labelCollapseIds;
+ private System.Windows.Forms.Label labelGeometry;
+ private System.Windows.Forms.Label labelOutOfBlock;
+ private System.Windows.Forms.Label labelStatus;
+ private System.Windows.Forms.ProgressBar progressBar;
+ private System.Windows.Forms.TextBox textBoxCollapseIds;
+ private System.Windows.Forms.TextBox textBoxOutput;
+ private System.Windows.Forms.TextBox textBoxSource;
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/MapDefragStaticsForm.cs b/UoFiddler.Controls/Forms/MapDefragStaticsForm.cs
new file mode 100644
index 00000000..1223bdcf
--- /dev/null
+++ b/UoFiddler.Controls/Forms/MapDefragStaticsForm.cs
@@ -0,0 +1,432 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Drawing;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using System.Threading;
+using System.Windows.Forms;
+using Ultima;
+using Ultima.Statics;
+using UoFiddler.Controls.Classes;
+
+namespace UoFiddler.Controls.Forms
+{
+ public sealed partial class MapDefragStaticsForm : Form
+ {
+ private const int IndexRecordSize = 12;
+
+ /// Water, which is what the stack filter was written for.
+ private const string DefaultCollapseIds = "0x1797-0x179C";
+
+ private readonly Map _map;
+
+ private CancellationTokenSource _cancellation;
+ private bool _lastRunUsedFilters;
+ private bool _truncationAvailable;
+
+ public MapDefragStaticsForm(Map map, string outputPath)
+ {
+ InitializeComponent();
+
+ Icon = Options.GetFiddlerIcon();
+
+ _map = map ?? throw new ArgumentNullException(nameof(map));
+
+ comboBoxIdCeiling.Items.AddRange(new object[] { "tiledata", "art", "legacy 0x3FFF" });
+ comboBoxIdCeiling.SelectedIndex = 0;
+
+ comboBoxOutOfBlock.Items.AddRange(new object[] { "Drop", "Mask to 0-7", "Keep" });
+ comboBoxOutOfBlock.SelectedIndex = 0;
+
+ checkBoxDropInvalidIds.Checked = true;
+ checkBoxDropInvalidZ.Checked = true;
+ checkBoxNormalizeHue.Checked = true;
+ checkBoxRemoveDuplicates.Checked = true;
+ checkBoxCollapseWet.Checked = true;
+ textBoxCollapseIds.Text = DefaultCollapseIds;
+
+ textBoxOutput.Text = outputPath;
+
+ DescribeSource();
+ OnFilterChanged(this, EventArgs.Empty);
+
+ ActiveControl = buttonAnalyze;
+ }
+
+ ///
+ /// Shows what is about to be read and how it lines up with the configured map size. The
+ /// engine performs the authoritative check; this is here so the mismatch is visible before
+ /// anyone presses a button.
+ ///
+ private void DescribeSource()
+ {
+ string indexPath = Files.GetFilePath($"staidx{_map.FileIndex}.mul");
+ string staticsPath = Files.GetFilePath($"statics{_map.FileIndex}.mul");
+
+ var sb = new StringBuilder();
+
+ sb.AppendLine($"Facet {_map.FileIndex} map size {_map.Width} x {_map.Height} blocks {_map.Width >> 3} x {_map.Height >> 3}");
+
+ if (indexPath == null || staticsPath == null)
+ {
+ sb.AppendLine($"staidx{_map.FileIndex}.mul or statics{_map.FileIndex}.mul was not found in the loaded client.");
+
+ textBoxSource.Text = sb.ToString();
+ buttonAnalyze.Enabled = false;
+ buttonDefrag.Enabled = false;
+
+ return;
+ }
+
+ long indexLength = new FileInfo(indexPath).Length;
+ long staticsLength = new FileInfo(staticsPath).Length;
+
+ sb.AppendLine($"{indexPath} ({indexLength:N0} bytes)");
+ sb.AppendLine($"{staticsPath} ({staticsLength:N0} bytes)");
+
+ if (File.Exists(Path.Combine(Path.GetDirectoryName(indexPath) ?? string.Empty, $"staidx{_map.FileIndex}x.mul")))
+ {
+ sb.AppendLine($"staidx{_map.FileIndex}x.mul is present - the client prefers those override files over this pair.");
+ }
+
+ textBoxSource.Text = sb.ToString();
+
+ long indexBlocks = indexLength / IndexRecordSize;
+ long configuredBlocks = (long)(_map.Width >> 3) * (_map.Height >> 3);
+
+ if (indexBlocks > configuredBlocks)
+ {
+ labelGeometry.ForeColor = Options.DarkMode ? Color.OrangeRed : Color.Red;
+ labelGeometry.Text = string.Format(CultureInfo.InvariantCulture,
+ "staidx holds {0:N0} blocks but the configured map size covers only {1:N0}." + Environment.NewLine +
+ "The statics in the surplus blocks would be discarded.",
+ indexBlocks, configuredBlocks);
+
+ _truncationAvailable = true;
+ checkBoxAllowTruncation.Enabled = true;
+ }
+ else if (indexBlocks < configuredBlocks)
+ {
+ labelGeometry.ForeColor = SystemColors.ControlText;
+ labelGeometry.Text = string.Format(CultureInfo.InvariantCulture,
+ "staidx holds {0:N0} blocks, the configured map size covers {1:N0}." + Environment.NewLine +
+ "The blocks past the end of the index will be written empty.",
+ indexBlocks, configuredBlocks);
+ }
+ else
+ {
+ labelGeometry.ForeColor = SystemColors.ControlText;
+ labelGeometry.Text = string.Format(CultureInfo.InvariantCulture,
+ "staidx holds {0:N0} blocks, matching the configured map size.", indexBlocks);
+ }
+ }
+
+ private void OnFilterChanged(object sender, EventArgs e)
+ {
+ comboBoxIdCeiling.Enabled = checkBoxDropInvalidIds.Checked;
+ labelCeilingValue.Text = checkBoxDropInvalidIds.Checked
+ ? $"= 0x{BuildOptions(true).ResolveMaxItemId():X4}"
+ : string.Empty;
+
+ checkBoxDuplicatesHue.Enabled = checkBoxRemoveDuplicates.Checked;
+
+ bool collapse = checkBoxCollapseStacks.Checked;
+ checkBoxCollapseWet.Enabled = collapse;
+ checkBoxCollapseSurface.Enabled = collapse;
+ checkBoxCollapseIgnoreZ.Enabled = collapse;
+ textBoxCollapseIds.Enabled = collapse;
+ }
+
+ private StaticsDefragOptions BuildOptions(bool dryRun)
+ {
+ var options = new StaticsDefragOptions
+ {
+ FileIndex = _map.FileIndex,
+ Map = _map,
+ BlockWidth = _map.Width >> 3,
+ BlockHeight = _map.Height >> 3,
+ AllowGeometryTruncation = checkBoxAllowTruncation.Checked,
+ OutputDirectory = textBoxOutput.Text,
+ DryRun = dryRun,
+ DropInvalidItemIds = checkBoxDropInvalidIds.Checked,
+ ItemIdCeiling = comboBoxIdCeiling.SelectedIndex switch
+ {
+ 1 => ItemIdCeiling.Art,
+ 2 => ItemIdCeiling.Legacy,
+ _ => ItemIdCeiling.TileData
+ },
+ OutOfBlockTiles = comboBoxOutOfBlock.SelectedIndex switch
+ {
+ 1 => OutOfBlockAction.Mask,
+ 2 => OutOfBlockAction.Keep,
+ _ => OutOfBlockAction.Drop
+ },
+ DropInvalidZ = checkBoxDropInvalidZ.Checked,
+ NormalizeNegativeHue = checkBoxNormalizeHue.Checked,
+ DropBelowTerrain = checkBoxBelowTerrain.Checked,
+ RemoveDuplicates = checkBoxRemoveDuplicates.Checked,
+ DuplicatesCompareHue = checkBoxRemoveDuplicates.Checked && checkBoxDuplicatesHue.Checked,
+ CollapseStacks = checkBoxCollapseStacks.Checked,
+ CollapseIgnoreZ = checkBoxCollapseIgnoreZ.Checked,
+ SortTiles = checkBoxSortTiles.Checked
+ };
+
+ if (options.CollapseStacks)
+ {
+ TileFlag mask = 0;
+
+ if (checkBoxCollapseWet.Checked)
+ {
+ mask |= TileFlag.Wet;
+ }
+
+ if (checkBoxCollapseSurface.Checked)
+ {
+ mask |= TileFlag.Surface;
+ }
+
+ options.CollapseFlagMask = mask;
+ options.CollapseIds.UnionWith(ParseIds(textBoxCollapseIds.Text));
+ }
+ else
+ {
+ options.CollapseFlagMask = 0;
+ }
+
+ return options;
+ }
+
+ ///
+ /// Accepts decimal and 0x values separated by commas or spaces, plus inclusive ranges
+ /// written with a dash, so "0x1797-0x179C, 6100" works.
+ ///
+ private static IEnumerable ParseIds(string text)
+ {
+ var ids = new List();
+
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return ids;
+ }
+
+ foreach (string part in text.Split(new[] { ',', ';', ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ int dash = part.IndexOf('-', 1);
+
+ if (dash > 0)
+ {
+ if (TryParseId(part.Substring(0, dash), out int from) &&
+ TryParseId(part.Substring(dash + 1), out int to) && to >= from)
+ {
+ for (int id = from; id <= to; ++id)
+ {
+ ids.Add(id);
+ }
+ }
+
+ continue;
+ }
+
+ if (TryParseId(part, out int single))
+ {
+ ids.Add(single);
+ }
+ }
+
+ return ids;
+ }
+
+ private static bool TryParseId(string text, out int id)
+ {
+ text = text.Trim();
+
+ if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
+ {
+ return int.TryParse(text.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out id);
+ }
+
+ return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out id);
+ }
+
+ private void OnClickBrowse(object sender, EventArgs e)
+ {
+ using (var dialog = new FolderBrowserDialog
+ {
+ Description = "Choose where the rewritten statics files go",
+ SelectedPath = Directory.Exists(textBoxOutput.Text) ? textBoxOutput.Text : Options.OutputPath
+ })
+ {
+ if (dialog.ShowDialog(this) == DialogResult.OK)
+ {
+ textBoxOutput.Text = dialog.SelectedPath;
+ }
+ }
+ }
+
+ private void OnClickAnalyze(object sender, EventArgs e)
+ {
+ Start(true);
+ }
+
+ private void OnClickDefrag(object sender, EventArgs e)
+ {
+ Start(false);
+ }
+
+ private void Start(bool dryRun)
+ {
+ if (worker.IsBusy)
+ {
+ return;
+ }
+
+ StaticsDefragOptions options;
+
+ try
+ {
+ options = BuildOptions(dryRun);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, "Defrag Statics", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ return;
+ }
+
+ _lastRunUsedFilters = UsesFilters(options);
+
+ _cancellation?.Dispose();
+ _cancellation = new CancellationTokenSource();
+ options.CancellationToken = _cancellation.Token;
+ options.Progress = new Progress(OnProgress);
+
+ SetRunning(true);
+
+ progressBar.Style = ProgressBarStyle.Continuous;
+ progressBar.Value = 0;
+ labelStatus.Text = dryRun ? "Analyzing..." : "Defragging...";
+
+ worker.RunWorkerAsync(options);
+ }
+
+ ///
+ /// Whether the run could remove anything. Decides which mode the verification runs in.
+ ///
+ private static bool UsesFilters(StaticsDefragOptions options)
+ {
+ return options.DropInvalidItemIds ||
+ options.OutOfBlockTiles != OutOfBlockAction.Keep ||
+ options.DropInvalidZ ||
+ options.NormalizeNegativeHue ||
+ options.DropBelowTerrain ||
+ options.RemoveDuplicates ||
+ options.CollapseStacks;
+ }
+
+ private void OnProgress(StaticsDefragProgress progress)
+ {
+ if (progress.BlocksTotal <= 0)
+ {
+ return;
+ }
+
+ int percent = (int)(progress.BlocksDone * 100L / progress.BlocksTotal);
+
+ progressBar.Value = Math.Min(100, Math.Max(0, percent));
+ labelStatus.Text = string.Format(CultureInfo.InvariantCulture,
+ "{0:N0} of {1:N0} blocks, {2:N0} statics written", progress.BlocksDone, progress.BlocksTotal, progress.TilesWritten);
+ }
+
+ private void OnWorkerDoWork(object sender, DoWorkEventArgs e)
+ {
+ e.Result = StaticsDefragmenter.Defrag((StaticsDefragOptions)e.Argument);
+ }
+
+ private void OnWorkerProgressChanged(object sender, ProgressChangedEventArgs e)
+ {
+ progressBar.Value = Math.Min(100, Math.Max(0, e.ProgressPercentage));
+ }
+
+ private void OnWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
+ {
+ SetRunning(false);
+
+ if (e.Error is OperationCanceledException)
+ {
+ labelStatus.Text = "Cancelled. Nothing was written.";
+ progressBar.Value = 0;
+ return;
+ }
+
+ if (e.Error != null)
+ {
+ labelStatus.Text = "Failed.";
+ progressBar.Value = 0;
+
+ MessageBox.Show(this, e.Error.Message, "Defrag Statics", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ return;
+ }
+
+ var result = (StaticsDefragResult)e.Result;
+
+ progressBar.Value = 100;
+ labelStatus.Text = result.DryRun
+ ? "Analyzed. Nothing was written."
+ : $"Done. {result.TilesWritten:N0} statics written.";
+
+ using (var form = new MapDefragStaticsResultForm(result, _lastRunUsedFilters))
+ {
+ form.ShowDialog(this);
+ }
+ }
+
+ private void OnClickCancel(object sender, EventArgs e)
+ {
+ _cancellation?.Cancel();
+ labelStatus.Text = "Cancelling...";
+ }
+
+ private void SetRunning(bool running)
+ {
+ buttonAnalyze.Enabled = !running;
+ buttonDefrag.Enabled = !running;
+ buttonClose.Enabled = !running;
+ buttonCancel.Enabled = running;
+ groupBoxFilters.Enabled = !running;
+ groupBoxOutput.Enabled = !running;
+ checkBoxAllowTruncation.Enabled = !running && _truncationAvailable;
+ }
+
+ protected override void OnFormClosing(FormClosingEventArgs e)
+ {
+ if (worker.IsBusy)
+ {
+ _cancellation?.Cancel();
+ e.Cancel = true;
+ return;
+ }
+
+ base.OnFormClosing(e);
+ }
+
+ protected override void OnFormClosed(FormClosedEventArgs e)
+ {
+ _cancellation?.Dispose();
+ _cancellation = null;
+
+ base.OnFormClosed(e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/MapDefragStaticsForm.resx b/UoFiddler.Controls/Forms/MapDefragStaticsForm.resx
new file mode 100644
index 00000000..6dae11dd
--- /dev/null
+++ b/UoFiddler.Controls/Forms/MapDefragStaticsForm.resx
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.Designer.cs b/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.Designer.cs
new file mode 100644
index 00000000..07654df0
--- /dev/null
+++ b/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.Designer.cs
@@ -0,0 +1,151 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+namespace UoFiddler.Controls.Forms
+{
+ partial class MapDefragStaticsResultForm
+ {
+ ///
+ /// Required designer variable.
+ ///
+ private System.ComponentModel.IContainer components = null;
+
+ ///
+ /// Clean up any resources being used.
+ ///
+ /// true if managed resources should be disposed; otherwise, false.
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && (components != null))
+ {
+ components.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ private void InitializeComponent()
+ {
+ this.reportTextBox = new System.Windows.Forms.TextBox();
+ this.buttonVerify = new System.Windows.Forms.Button();
+ this.buttonSave = new System.Windows.Forms.Button();
+ this.buttonCopy = new System.Windows.Forms.Button();
+ this.buttonOpenFolder = new System.Windows.Forms.Button();
+ this.buttonClose = new System.Windows.Forms.Button();
+ this.SuspendLayout();
+ //
+ // reportTextBox
+ //
+ this.reportTextBox.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
+ | System.Windows.Forms.AnchorStyles.Left)
+ | System.Windows.Forms.AnchorStyles.Right)));
+ this.reportTextBox.Font = new System.Drawing.Font("Consolas", 9F);
+ this.reportTextBox.Location = new System.Drawing.Point(12, 12);
+ this.reportTextBox.Multiline = true;
+ this.reportTextBox.Name = "reportTextBox";
+ this.reportTextBox.ReadOnly = true;
+ this.reportTextBox.ScrollBars = System.Windows.Forms.ScrollBars.Both;
+ this.reportTextBox.Size = new System.Drawing.Size(660, 420);
+ this.reportTextBox.TabIndex = 0;
+ this.reportTextBox.WordWrap = false;
+ //
+ // buttonVerify
+ //
+ this.buttonVerify.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
+ this.buttonVerify.Location = new System.Drawing.Point(12, 442);
+ this.buttonVerify.Name = "buttonVerify";
+ this.buttonVerify.Size = new System.Drawing.Size(110, 27);
+ this.buttonVerify.TabIndex = 1;
+ this.buttonVerify.Text = "Verify output";
+ this.buttonVerify.UseVisualStyleBackColor = true;
+ this.buttonVerify.Click += new System.EventHandler(this.OnClickVerify);
+ //
+ // buttonSave
+ //
+ this.buttonSave.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
+ this.buttonSave.Location = new System.Drawing.Point(128, 442);
+ this.buttonSave.Name = "buttonSave";
+ this.buttonSave.Size = new System.Drawing.Size(110, 27);
+ this.buttonSave.TabIndex = 2;
+ this.buttonSave.Text = "Save report...";
+ this.buttonSave.UseVisualStyleBackColor = true;
+ this.buttonSave.Click += new System.EventHandler(this.OnClickSave);
+ //
+ // buttonCopy
+ //
+ this.buttonCopy.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
+ this.buttonCopy.Location = new System.Drawing.Point(244, 442);
+ this.buttonCopy.Name = "buttonCopy";
+ this.buttonCopy.Size = new System.Drawing.Size(80, 27);
+ this.buttonCopy.TabIndex = 3;
+ this.buttonCopy.Text = "Copy";
+ this.buttonCopy.UseVisualStyleBackColor = true;
+ this.buttonCopy.Click += new System.EventHandler(this.OnClickCopy);
+ //
+ // buttonOpenFolder
+ //
+ this.buttonOpenFolder.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
+ this.buttonOpenFolder.Location = new System.Drawing.Point(452, 442);
+ this.buttonOpenFolder.Name = "buttonOpenFolder";
+ this.buttonOpenFolder.Size = new System.Drawing.Size(130, 27);
+ this.buttonOpenFolder.TabIndex = 4;
+ this.buttonOpenFolder.Text = "Open output folder";
+ this.buttonOpenFolder.UseVisualStyleBackColor = true;
+ this.buttonOpenFolder.Click += new System.EventHandler(this.OnClickOpenFolder);
+ //
+ // buttonClose
+ //
+ this.buttonClose.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
+ this.buttonClose.DialogResult = System.Windows.Forms.DialogResult.OK;
+ this.buttonClose.Location = new System.Drawing.Point(588, 442);
+ this.buttonClose.Name = "buttonClose";
+ this.buttonClose.Size = new System.Drawing.Size(84, 27);
+ this.buttonClose.TabIndex = 5;
+ this.buttonClose.Text = "Close";
+ this.buttonClose.UseVisualStyleBackColor = true;
+ //
+ // MapDefragStaticsResultForm
+ //
+ this.AcceptButton = this.buttonClose;
+ this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
+ this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
+ this.CancelButton = this.buttonClose;
+ this.ClientSize = new System.Drawing.Size(684, 481);
+ this.Controls.Add(this.buttonClose);
+ this.Controls.Add(this.buttonOpenFolder);
+ this.Controls.Add(this.buttonCopy);
+ this.Controls.Add(this.buttonSave);
+ this.Controls.Add(this.buttonVerify);
+ this.Controls.Add(this.reportTextBox);
+ this.DoubleBuffered = true;
+ this.MinimizeBox = false;
+ this.MinimumSize = new System.Drawing.Size(520, 320);
+ this.Name = "MapDefragStaticsResultForm";
+ this.ShowInTaskbar = false;
+ this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
+ this.Text = "Defrag Statics - Result";
+ this.ResumeLayout(false);
+ this.PerformLayout();
+
+ }
+
+ #endregion
+
+ private System.Windows.Forms.Button buttonClose;
+ private System.Windows.Forms.Button buttonCopy;
+ private System.Windows.Forms.Button buttonOpenFolder;
+ private System.Windows.Forms.Button buttonSave;
+ private System.Windows.Forms.Button buttonVerify;
+ private System.Windows.Forms.TextBox reportTextBox;
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.cs b/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.cs
new file mode 100644
index 00000000..3f00fae7
--- /dev/null
+++ b/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.cs
@@ -0,0 +1,137 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Windows.Forms;
+using Ultima.Statics;
+using UoFiddler.Controls.Classes;
+
+namespace UoFiddler.Controls.Forms
+{
+ public sealed partial class MapDefragStaticsResultForm : Form
+ {
+ private readonly StaticsDefragResult _result;
+ private readonly bool _filtered;
+
+ public MapDefragStaticsResultForm(StaticsDefragResult result, bool filtered)
+ {
+ InitializeComponent();
+
+ Icon = Options.GetFiddlerIcon();
+
+ _result = result;
+ _filtered = filtered;
+
+ reportTextBox.Text = result.ToReport();
+
+ // Nothing was written in a dry run, so there is no output to verify or to open.
+ buttonVerify.Enabled = !result.DryRun;
+ buttonOpenFolder.Enabled = !result.DryRun;
+ }
+
+ ///
+ /// Reads the output back and compares it against the file it was made from. With no filters
+ /// the two must hold exactly the same statics; with filters the output may only be missing
+ /// statics, and the number missing has to match what the filters reported removing.
+ ///
+ private void OnClickVerify(object sender, EventArgs e)
+ {
+ using (new WaitCursorScope(this))
+ {
+ try
+ {
+ StaticsCompareMode mode = _filtered ? StaticsCompareMode.Subset : StaticsCompareMode.Identical;
+
+ StaticsCompareResult compare = StaticsComparer.Compare(
+ _result.SourceIndexPath, _result.SourceStaticsPath,
+ _result.OutputIndexPath, _result.OutputStaticsPath,
+ _result.BlockWidth, _result.BlockHeight, mode);
+
+ string reconciliation = string.Empty;
+
+ if (_filtered)
+ {
+ bool reconciled = compare.TilesMissing == _result.TilesAccountedFor;
+
+ reconciliation = Environment.NewLine +
+ $"Filters reported removing {_result.TilesAccountedFor:N0} statics, the comparison found {compare.TilesMissing:N0} missing: " +
+ (reconciled ? "reconciled." : "MISMATCH - statics were lost outside the filters.") +
+ Environment.NewLine;
+ }
+
+ reportTextBox.Text = compare.ToReport() + reconciliation +
+ Environment.NewLine + new string('-', 60) + Environment.NewLine +
+ _result.ToReport();
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, "Verify failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+ }
+
+ private void OnClickSave(object sender, EventArgs e)
+ {
+ using (var dialog = new SaveFileDialog
+ {
+ Title = "Save the defrag report",
+ Filter = "Text files (*.txt)|*.txt|All files (*.*)|*.*",
+ FileName = $"defrag-statics{_result.FileIndex}.txt",
+ InitialDirectory = Options.OutputPath
+ })
+ {
+ if (dialog.ShowDialog(this) != DialogResult.OK)
+ {
+ return;
+ }
+
+ try
+ {
+ File.WriteAllText(dialog.FileName, reportTextBox.Text);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, "Save failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+ }
+
+ private void OnClickCopy(object sender, EventArgs e)
+ {
+ if (reportTextBox.TextLength > 0)
+ {
+ Clipboard.SetText(reportTextBox.Text);
+ }
+ }
+
+ private void OnClickOpenFolder(object sender, EventArgs e)
+ {
+ string folder = Path.GetDirectoryName(_result.OutputIndexPath);
+
+ if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder))
+ {
+ return;
+ }
+
+ try
+ {
+ Process.Start(new ProcessStartInfo { FileName = folder, UseShellExecute = true });
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, $"Unable to open folder: {ex.Message}", "Error",
+ MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.resx b/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.resx
new file mode 100644
index 00000000..6dae11dd
--- /dev/null
+++ b/UoFiddler.Controls/Forms/MapDefragStaticsResultForm.resx
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/MapDiffApplyResultForm.Designer.cs b/UoFiddler.Controls/Forms/MapDiffApplyResultForm.Designer.cs
new file mode 100644
index 00000000..7641e907
--- /dev/null
+++ b/UoFiddler.Controls/Forms/MapDiffApplyResultForm.Designer.cs
@@ -0,0 +1,151 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+namespace UoFiddler.Controls.Forms
+{
+ partial class MapDiffApplyResultForm
+ {
+ ///
+ /// Required designer variable.
+ ///
+ private System.ComponentModel.IContainer components = null;
+
+ ///
+ /// Clean up any resources being used.
+ ///
+ /// true if managed resources should be disposed; otherwise, false.
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && (components != null))
+ {
+ components.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ private void InitializeComponent()
+ {
+ this.reportTextBox = new System.Windows.Forms.TextBox();
+ this.buttonVerify = new System.Windows.Forms.Button();
+ this.buttonSave = new System.Windows.Forms.Button();
+ this.buttonCopy = new System.Windows.Forms.Button();
+ this.buttonOpenFolder = new System.Windows.Forms.Button();
+ this.buttonClose = new System.Windows.Forms.Button();
+ this.SuspendLayout();
+ //
+ // reportTextBox
+ //
+ this.reportTextBox.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
+ | System.Windows.Forms.AnchorStyles.Left)
+ | System.Windows.Forms.AnchorStyles.Right)));
+ this.reportTextBox.Font = new System.Drawing.Font("Consolas", 9F);
+ this.reportTextBox.Location = new System.Drawing.Point(12, 12);
+ this.reportTextBox.Multiline = true;
+ this.reportTextBox.Name = "reportTextBox";
+ this.reportTextBox.ReadOnly = true;
+ this.reportTextBox.ScrollBars = System.Windows.Forms.ScrollBars.Both;
+ this.reportTextBox.Size = new System.Drawing.Size(660, 420);
+ this.reportTextBox.TabIndex = 0;
+ this.reportTextBox.WordWrap = false;
+ //
+ // buttonVerify
+ //
+ this.buttonVerify.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
+ this.buttonVerify.Location = new System.Drawing.Point(12, 442);
+ this.buttonVerify.Name = "buttonVerify";
+ this.buttonVerify.Size = new System.Drawing.Size(110, 27);
+ this.buttonVerify.TabIndex = 1;
+ this.buttonVerify.Text = "Verify output";
+ this.buttonVerify.UseVisualStyleBackColor = true;
+ this.buttonVerify.Click += new System.EventHandler(this.OnClickVerify);
+ //
+ // buttonSave
+ //
+ this.buttonSave.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
+ this.buttonSave.Location = new System.Drawing.Point(128, 442);
+ this.buttonSave.Name = "buttonSave";
+ this.buttonSave.Size = new System.Drawing.Size(110, 27);
+ this.buttonSave.TabIndex = 2;
+ this.buttonSave.Text = "Save report...";
+ this.buttonSave.UseVisualStyleBackColor = true;
+ this.buttonSave.Click += new System.EventHandler(this.OnClickSave);
+ //
+ // buttonCopy
+ //
+ this.buttonCopy.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
+ this.buttonCopy.Location = new System.Drawing.Point(244, 442);
+ this.buttonCopy.Name = "buttonCopy";
+ this.buttonCopy.Size = new System.Drawing.Size(80, 27);
+ this.buttonCopy.TabIndex = 3;
+ this.buttonCopy.Text = "Copy";
+ this.buttonCopy.UseVisualStyleBackColor = true;
+ this.buttonCopy.Click += new System.EventHandler(this.OnClickCopy);
+ //
+ // buttonOpenFolder
+ //
+ this.buttonOpenFolder.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
+ this.buttonOpenFolder.Location = new System.Drawing.Point(452, 442);
+ this.buttonOpenFolder.Name = "buttonOpenFolder";
+ this.buttonOpenFolder.Size = new System.Drawing.Size(130, 27);
+ this.buttonOpenFolder.TabIndex = 4;
+ this.buttonOpenFolder.Text = "Open output folder";
+ this.buttonOpenFolder.UseVisualStyleBackColor = true;
+ this.buttonOpenFolder.Click += new System.EventHandler(this.OnClickOpenFolder);
+ //
+ // buttonClose
+ //
+ this.buttonClose.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
+ this.buttonClose.DialogResult = System.Windows.Forms.DialogResult.OK;
+ this.buttonClose.Location = new System.Drawing.Point(588, 442);
+ this.buttonClose.Name = "buttonClose";
+ this.buttonClose.Size = new System.Drawing.Size(84, 27);
+ this.buttonClose.TabIndex = 5;
+ this.buttonClose.Text = "Close";
+ this.buttonClose.UseVisualStyleBackColor = true;
+ //
+ // MapDiffApplyResultForm
+ //
+ this.AcceptButton = this.buttonClose;
+ this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
+ this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
+ this.CancelButton = this.buttonClose;
+ this.ClientSize = new System.Drawing.Size(684, 481);
+ this.Controls.Add(this.buttonClose);
+ this.Controls.Add(this.buttonOpenFolder);
+ this.Controls.Add(this.buttonCopy);
+ this.Controls.Add(this.buttonSave);
+ this.Controls.Add(this.buttonVerify);
+ this.Controls.Add(this.reportTextBox);
+ this.DoubleBuffered = true;
+ this.MinimizeBox = false;
+ this.MinimumSize = new System.Drawing.Size(520, 320);
+ this.Name = "MapDiffApplyResultForm";
+ this.ShowInTaskbar = false;
+ this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
+ this.Text = "Diff to Map Copy - Result";
+ this.ResumeLayout(false);
+ this.PerformLayout();
+
+ }
+
+ #endregion
+
+ private System.Windows.Forms.Button buttonClose;
+ private System.Windows.Forms.Button buttonCopy;
+ private System.Windows.Forms.Button buttonOpenFolder;
+ private System.Windows.Forms.Button buttonSave;
+ private System.Windows.Forms.Button buttonVerify;
+ private System.Windows.Forms.TextBox reportTextBox;
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/MapDiffApplyResultForm.cs b/UoFiddler.Controls/Forms/MapDiffApplyResultForm.cs
new file mode 100644
index 00000000..3aee339b
--- /dev/null
+++ b/UoFiddler.Controls/Forms/MapDiffApplyResultForm.cs
@@ -0,0 +1,212 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Windows.Forms;
+using Ultima;
+using Ultima.Maps;
+using UoFiddler.Controls.Classes;
+
+namespace UoFiddler.Controls.Forms
+{
+ public sealed partial class MapDiffApplyResultForm : Form
+ {
+ private readonly MapDiffApplyResult _result;
+
+ public MapDiffApplyResultForm(MapDiffApplyResult result)
+ {
+ InitializeComponent();
+
+ Icon = Options.GetFiddlerIcon();
+
+ _result = result;
+
+ reportTextBox.Text = result.ToReport();
+
+ buttonVerify.Enabled = result.OutputMapPath != null;
+ buttonOpenFolder.Enabled = result.OutputMapPath != null || result.OutputIndexPath != null;
+ }
+
+ ///
+ /// Reads the written map back and checks every block against what it should hold: the patch
+ /// where the diff covers a block inside the region, the unpatched map everywhere else.
+ ///
+ private void OnClickVerify(object sender, EventArgs e)
+ {
+ using (new WaitCursorScope(this))
+ {
+ try
+ {
+ reportTextBox.Text = Verify() + Environment.NewLine +
+ new string('-', 60) + Environment.NewLine + _result.ToReport();
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, "Verify failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+ }
+
+ private string Verify()
+ {
+ var sb = new StringBuilder();
+
+ MapSize size = _result.MapSize;
+ string outputDirectory = Path.GetDirectoryName(_result.OutputMapPath);
+
+ var written = new TileMatrix(_result.FileIndex, _result.FileIndex, size.Width, size.Height, outputDirectory);
+ var original = new TileMatrix(_result.FileIndex, _result.FileIndex, size.Width, size.Height, null);
+
+ try
+ {
+ var actual = new byte[TileMatrix.MapBlockSize];
+ var expected = new byte[TileMatrix.MapBlockSize];
+
+ long fromDiff = 0;
+ long untouched = 0;
+ long mismatches = 0;
+ string firstMismatch = null;
+
+ TileMatrixPatch patch = original.Patch;
+
+ for (int x = 0; x < size.BlockWidth; ++x)
+ {
+ for (int y = 0; y < size.BlockHeight; ++y)
+ {
+ bool inRegion = x >= _result.Region.BlockX1 && x <= _result.Region.BlockX2 &&
+ y >= _result.Region.BlockY1 && y <= _result.Region.BlockY2;
+
+ bool patched = inRegion && patch.IsLandBlockPatched(x, y);
+
+ written.ReadLandBlockBytes(x, y, actual);
+
+ if (patched)
+ {
+ ++fromDiff;
+
+ Array.Clear(expected);
+ MemoryMarshal.AsBytes(patch.GetLandBlock(x, y).AsSpan())
+ .CopyTo(expected.AsSpan(TileMatrix.BlockHeaderSize));
+ }
+ else
+ {
+ ++untouched;
+ original.ReadLandBlockBytes(x, y, expected);
+ }
+
+ if (actual.AsSpan().SequenceEqual(expected))
+ {
+ continue;
+ }
+
+ ++mismatches;
+
+ firstMismatch ??= Line("block {0},{1} (world {2},{3}) {4}",
+ x, y, x << 3, y << 3,
+ patched ? "does not match the diff" : "does not match the unpatched map");
+ }
+ }
+
+ bool countsAgree = fromDiff == _result.LandBlocksApplied;
+
+ sb.AppendLine(mismatches == 0 && countsAgree ? "PASSED" : "FAILED");
+ sb.AppendLine();
+ sb.AppendLine(Line("Read back : {0}", _result.OutputMapPath));
+ sb.AppendLine(Line("Blocks compared : {0:N0}", fromDiff + untouched));
+ sb.AppendLine(Line(" from the diff : {0:N0}", fromDiff));
+ sb.AppendLine(Line(" left alone : {0:N0}", untouched));
+ sb.AppendLine(Line("Blocks differing : {0:N0}", mismatches));
+
+ if (firstMismatch != null)
+ {
+ sb.AppendLine(Line("First difference : {0}", firstMismatch));
+ }
+
+ if (!countsAgree)
+ {
+ sb.AppendLine(Line("Counts disagree with the insert report, which says {0:N0} blocks came from the diff.",
+ _result.LandBlocksApplied));
+ }
+ }
+ finally
+ {
+ written.CloseStreams();
+ original.CloseStreams();
+ }
+
+ return sb.ToString();
+ }
+
+ private void OnClickSave(object sender, EventArgs e)
+ {
+ using (var dialog = new SaveFileDialog
+ {
+ Title = "Save the insert report",
+ Filter = "Text files (*.txt)|*.txt|All files (*.*)|*.*",
+ FileName = "map-diff-insert.txt",
+ InitialDirectory = Options.OutputPath
+ })
+ {
+ if (dialog.ShowDialog(this) != DialogResult.OK)
+ {
+ return;
+ }
+
+ try
+ {
+ File.WriteAllText(dialog.FileName, reportTextBox.Text);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, "Save failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+ }
+
+ private void OnClickCopy(object sender, EventArgs e)
+ {
+ if (reportTextBox.TextLength > 0)
+ {
+ Clipboard.SetText(reportTextBox.Text);
+ }
+ }
+
+ private void OnClickOpenFolder(object sender, EventArgs e)
+ {
+ string folder = Path.GetDirectoryName(_result.OutputMapPath ?? _result.OutputIndexPath);
+
+ if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder))
+ {
+ return;
+ }
+
+ try
+ {
+ Process.Start(new ProcessStartInfo { FileName = folder, UseShellExecute = true });
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, $"Unable to open folder: {ex.Message}", "Error",
+ MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+
+ private static string Line(string format, params object[] args)
+ {
+ return string.Format(CultureInfo.InvariantCulture, format, args);
+ }
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/MapDiffApplyResultForm.resx b/UoFiddler.Controls/Forms/MapDiffApplyResultForm.resx
new file mode 100644
index 00000000..6dae11dd
--- /dev/null
+++ b/UoFiddler.Controls/Forms/MapDiffApplyResultForm.resx
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/MapDiffInsertForm.Designer.cs b/UoFiddler.Controls/Forms/MapDiffInsertForm.Designer.cs
index 2c63ffab..eeac4c2e 100644
--- a/UoFiddler.Controls/Forms/MapDiffInsertForm.Designer.cs
+++ b/UoFiddler.Controls/Forms/MapDiffInsertForm.Designer.cs
@@ -1,9 +1,9 @@
/***************************************************************************
*
* $Author: Turley
- *
+ *
* "THE BEER-WARE LICENSE"
- * As long as you retain this notice you can do whatever you want with
+ * As long as you retain this notice you can do whatever you want with
* this stuff. If we meet some day, and you think this stuff is worth it,
* you can buy me a beer in return.
*
@@ -28,279 +28,388 @@ protected override void Dispose(bool disposing)
{
components.Dispose();
}
+
base.Dispose(disposing);
}
#region Windows Form Designer generated code
- ///
- /// Required method for Designer support - do not modify
- /// the contents of this method with the code editor.
- ///
private void InitializeComponent()
{
+ this.components = new System.ComponentModel.Container();
+ this.groupBoxWhat = new System.Windows.Forms.GroupBox();
this.checkBoxMap = new System.Windows.Forms.CheckBox();
+ this.labelMapFormat = new System.Windows.Forms.Label();
+ this.comboBoxMapFormat = new System.Windows.Forms.ComboBox();
this.checkBoxStatics = new System.Windows.Forms.CheckBox();
- this.numericUpDownX1 = new System.Windows.Forms.NumericUpDown();
+ this.RemoveDupl = new System.Windows.Forms.CheckBox();
+ this.checkBoxDuplicatesHue = new System.Windows.Forms.CheckBox();
+ this.groupBoxFrom = new System.Windows.Forms.GroupBox();
this.label1 = new System.Windows.Forms.Label();
+ this.numericUpDownX1 = new System.Windows.Forms.NumericUpDown();
this.label2 = new System.Windows.Forms.Label();
this.numericUpDownY1 = new System.Windows.Forms.NumericUpDown();
this.label3 = new System.Windows.Forms.Label();
this.numericUpDownX2 = new System.Windows.Forms.NumericUpDown();
this.label4 = new System.Windows.Forms.Label();
this.numericUpDownY2 = new System.Windows.Forms.NumericUpDown();
- this.button2 = new System.Windows.Forms.Button();
+ this.groupBoxPreview = new System.Windows.Forms.GroupBox();
+ this.preview = new UoFiddler.Controls.UserControls.MapRegionPreview();
+ this.checkBoxPreviewStatics = new System.Windows.Forms.CheckBox();
+ this.checkBoxPreviewPatched = new System.Windows.Forms.CheckBox();
+ this.textBoxPreview = new System.Windows.Forms.TextBox();
this.progressBar1 = new System.Windows.Forms.ProgressBar();
- this.RemoveDupl = new System.Windows.Forms.CheckBox();
- this.groupBox1 = new System.Windows.Forms.GroupBox();
- this.groupBox2 = new System.Windows.Forms.GroupBox();
- this.groupBox3 = new System.Windows.Forms.GroupBox();
+ this.labelStatus = new System.Windows.Forms.Label();
+ this.buttonCopy = new System.Windows.Forms.Button();
+ this.buttonCancel = new System.Windows.Forms.Button();
+ this.buttonClose = new System.Windows.Forms.Button();
+ this.worker = new System.ComponentModel.BackgroundWorker();
+ this.components.Add(this.worker);
((System.ComponentModel.ISupportInitialize)(this.numericUpDownX1)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.numericUpDownY1)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.numericUpDownX2)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.numericUpDownY2)).BeginInit();
- this.groupBox1.SuspendLayout();
- this.groupBox2.SuspendLayout();
- this.groupBox3.SuspendLayout();
+ this.groupBoxWhat.SuspendLayout();
+ this.groupBoxFrom.SuspendLayout();
+ this.groupBoxPreview.SuspendLayout();
this.SuspendLayout();
- //
+ //
+ // groupBoxWhat
+ //
+ this.groupBoxWhat.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
+ this.groupBoxWhat.Controls.Add(this.checkBoxMap);
+ this.groupBoxWhat.Controls.Add(this.labelMapFormat);
+ this.groupBoxWhat.Controls.Add(this.comboBoxMapFormat);
+ this.groupBoxWhat.Controls.Add(this.checkBoxStatics);
+ this.groupBoxWhat.Controls.Add(this.RemoveDupl);
+ this.groupBoxWhat.Controls.Add(this.checkBoxDuplicatesHue);
+ this.groupBoxWhat.Location = new System.Drawing.Point(12, 12);
+ this.groupBoxWhat.Name = "groupBoxWhat";
+ this.groupBoxWhat.Size = new System.Drawing.Size(960, 86);
+ this.groupBoxWhat.TabIndex = 1;
+ this.groupBoxWhat.TabStop = false;
+ this.groupBoxWhat.Text = "Insert";
+ //
// checkBoxMap
- //
- this.checkBoxMap.AutoSize = true;
- this.checkBoxMap.Location = new System.Drawing.Point(7, 22);
- this.checkBoxMap.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ //
+ this.checkBoxMap.Location = new System.Drawing.Point(16, 22);
this.checkBoxMap.Name = "checkBoxMap";
- this.checkBoxMap.Size = new System.Drawing.Size(82, 19);
- this.checkBoxMap.TabIndex = 2;
- this.checkBoxMap.Text = "Insert Map";
+ this.checkBoxMap.Size = new System.Drawing.Size(110, 21);
+ this.checkBoxMap.TabIndex = 0;
+ this.checkBoxMap.Text = "Map";
this.checkBoxMap.UseVisualStyleBackColor = true;
- //
+ this.checkBoxMap.CheckedChanged += new System.EventHandler(this.OnOptionChanged);
+ //
+ // labelMapFormat
+ //
+ this.labelMapFormat.Location = new System.Drawing.Point(140, 25);
+ this.labelMapFormat.Name = "labelMapFormat";
+ this.labelMapFormat.Size = new System.Drawing.Size(80, 17);
+ this.labelMapFormat.TabIndex = 1;
+ this.labelMapFormat.Text = "written as:";
+ //
+ // comboBoxMapFormat
+ //
+ this.comboBoxMapFormat.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
+ this.comboBoxMapFormat.Location = new System.Drawing.Point(224, 21);
+ this.comboBoxMapFormat.Name = "comboBoxMapFormat";
+ this.comboBoxMapFormat.Size = new System.Drawing.Size(256, 23);
+ this.comboBoxMapFormat.TabIndex = 2;
+ //
// checkBoxStatics
- //
- this.checkBoxStatics.AutoSize = true;
- this.checkBoxStatics.Location = new System.Drawing.Point(13, 22);
- this.checkBoxStatics.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ //
+ this.checkBoxStatics.Location = new System.Drawing.Point(16, 52);
this.checkBoxStatics.Name = "checkBoxStatics";
- this.checkBoxStatics.Size = new System.Drawing.Size(92, 19);
+ this.checkBoxStatics.Size = new System.Drawing.Size(110, 21);
this.checkBoxStatics.TabIndex = 3;
- this.checkBoxStatics.Text = "Insert Statics";
+ this.checkBoxStatics.Text = "Statics";
this.checkBoxStatics.UseVisualStyleBackColor = true;
- //
- // numericUpDownX1
- //
- this.numericUpDownX1.Increment = new decimal(new int[] {
- 8,
- 0,
- 0,
- 0});
- this.numericUpDownX1.Location = new System.Drawing.Point(99, 22);
- this.numericUpDownX1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.numericUpDownX1.Name = "numericUpDownX1";
- this.numericUpDownX1.Size = new System.Drawing.Size(63, 23);
- this.numericUpDownX1.TabIndex = 4;
- //
+ this.checkBoxStatics.CheckedChanged += new System.EventHandler(this.OnOptionChanged);
+ //
+ // RemoveDupl
+ //
+ this.RemoveDupl.Location = new System.Drawing.Point(140, 52);
+ this.RemoveDupl.Name = "RemoveDupl";
+ this.RemoveDupl.Size = new System.Drawing.Size(150, 21);
+ this.RemoveDupl.TabIndex = 4;
+ this.RemoveDupl.Text = "remove duplicates";
+ this.RemoveDupl.UseVisualStyleBackColor = true;
+ this.RemoveDupl.CheckedChanged += new System.EventHandler(this.OnOptionChanged);
+ //
+ // checkBoxDuplicatesHue
+ //
+ this.checkBoxDuplicatesHue.Location = new System.Drawing.Point(296, 52);
+ this.checkBoxDuplicatesHue.Name = "checkBoxDuplicatesHue";
+ this.checkBoxDuplicatesHue.Size = new System.Drawing.Size(184, 21);
+ this.checkBoxDuplicatesHue.TabIndex = 5;
+ this.checkBoxDuplicatesHue.Text = "comparing hue too (legacy)";
+ this.checkBoxDuplicatesHue.UseVisualStyleBackColor = true;
+ //
+ // groupBoxFrom
+ //
+ this.groupBoxFrom.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
+ this.groupBoxFrom.Controls.Add(this.label1);
+ this.groupBoxFrom.Controls.Add(this.numericUpDownX1);
+ this.groupBoxFrom.Controls.Add(this.label2);
+ this.groupBoxFrom.Controls.Add(this.numericUpDownY1);
+ this.groupBoxFrom.Controls.Add(this.label3);
+ this.groupBoxFrom.Controls.Add(this.numericUpDownX2);
+ this.groupBoxFrom.Controls.Add(this.label4);
+ this.groupBoxFrom.Controls.Add(this.numericUpDownY2);
+ this.groupBoxFrom.Location = new System.Drawing.Point(12, 104);
+ this.groupBoxFrom.Name = "groupBoxFrom";
+ this.groupBoxFrom.Size = new System.Drawing.Size(960, 60);
+ this.groupBoxFrom.TabIndex = 2;
+ this.groupBoxFrom.TabStop = false;
+ this.groupBoxFrom.Text = "Region, in map tiles";
+ //
// label1
- //
- this.label1.AutoSize = true;
- this.label1.Location = new System.Drawing.Point(63, 24);
- this.label1.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
+ //
+ this.label1.Location = new System.Drawing.Point(16, 26);
this.label1.Name = "label1";
- this.label1.Size = new System.Drawing.Size(20, 15);
- this.label1.TabIndex = 5;
+ this.label1.Size = new System.Drawing.Size(24, 17);
+ this.label1.TabIndex = 0;
this.label1.Text = "X1";
- //
+ //
+ // numericUpDownX1
+ //
+ this.numericUpDownX1.Increment = new decimal(new int[] { 8, 0, 0, 0 });
+ this.numericUpDownX1.Location = new System.Drawing.Point(44, 23);
+ this.numericUpDownX1.Name = "numericUpDownX1";
+ this.numericUpDownX1.Size = new System.Drawing.Size(70, 23);
+ this.numericUpDownX1.TabIndex = 1;
+ this.numericUpDownX1.ValueChanged += new System.EventHandler(this.OnRegionChanged);
+ //
// label2
- //
- this.label2.AutoSize = true;
- this.label2.Location = new System.Drawing.Point(63, 54);
- this.label2.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
+ //
+ this.label2.Location = new System.Drawing.Point(124, 26);
this.label2.Name = "label2";
- this.label2.Size = new System.Drawing.Size(20, 15);
- this.label2.TabIndex = 7;
+ this.label2.Size = new System.Drawing.Size(24, 17);
+ this.label2.TabIndex = 2;
this.label2.Text = "Y1";
- //
+ //
// numericUpDownY1
- //
- this.numericUpDownY1.Increment = new decimal(new int[] {
- 8,
- 0,
- 0,
- 0});
- this.numericUpDownY1.Location = new System.Drawing.Point(99, 52);
- this.numericUpDownY1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ //
+ this.numericUpDownY1.Increment = new decimal(new int[] { 8, 0, 0, 0 });
+ this.numericUpDownY1.Location = new System.Drawing.Point(152, 23);
this.numericUpDownY1.Name = "numericUpDownY1";
- this.numericUpDownY1.Size = new System.Drawing.Size(63, 23);
- this.numericUpDownY1.TabIndex = 6;
- //
+ this.numericUpDownY1.Size = new System.Drawing.Size(70, 23);
+ this.numericUpDownY1.TabIndex = 3;
+ this.numericUpDownY1.ValueChanged += new System.EventHandler(this.OnRegionChanged);
+ //
// label3
- //
- this.label3.AutoSize = true;
- this.label3.Location = new System.Drawing.Point(192, 24);
- this.label3.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
+ //
+ this.label3.Location = new System.Drawing.Point(248, 26);
this.label3.Name = "label3";
- this.label3.Size = new System.Drawing.Size(20, 15);
- this.label3.TabIndex = 9;
+ this.label3.Size = new System.Drawing.Size(24, 17);
+ this.label3.TabIndex = 4;
this.label3.Text = "X2";
- //
+ //
// numericUpDownX2
- //
- this.numericUpDownX2.Increment = new decimal(new int[] {
- 8,
- 0,
- 0,
- 0});
- this.numericUpDownX2.Location = new System.Drawing.Point(229, 22);
- this.numericUpDownX2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ //
+ this.numericUpDownX2.Increment = new decimal(new int[] { 8, 0, 0, 0 });
+ this.numericUpDownX2.Location = new System.Drawing.Point(276, 23);
this.numericUpDownX2.Name = "numericUpDownX2";
- this.numericUpDownX2.Size = new System.Drawing.Size(63, 23);
- this.numericUpDownX2.TabIndex = 8;
- //
+ this.numericUpDownX2.Size = new System.Drawing.Size(70, 23);
+ this.numericUpDownX2.TabIndex = 5;
+ this.numericUpDownX2.ValueChanged += new System.EventHandler(this.OnRegionChanged);
+ //
// label4
- //
- this.label4.AutoSize = true;
- this.label4.Location = new System.Drawing.Point(192, 57);
- this.label4.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
+ //
+ this.label4.Location = new System.Drawing.Point(356, 26);
this.label4.Name = "label4";
- this.label4.Size = new System.Drawing.Size(20, 15);
- this.label4.TabIndex = 11;
+ this.label4.Size = new System.Drawing.Size(24, 17);
+ this.label4.TabIndex = 6;
this.label4.Text = "Y2";
- //
+ //
// numericUpDownY2
- //
- this.numericUpDownY2.Increment = new decimal(new int[] {
- 8,
- 0,
- 0,
- 0});
- this.numericUpDownY2.Location = new System.Drawing.Point(229, 54);
- this.numericUpDownY2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ //
+ this.numericUpDownY2.Increment = new decimal(new int[] { 8, 0, 0, 0 });
+ this.numericUpDownY2.Location = new System.Drawing.Point(384, 23);
this.numericUpDownY2.Name = "numericUpDownY2";
- this.numericUpDownY2.Size = new System.Drawing.Size(63, 23);
- this.numericUpDownY2.TabIndex = 10;
- //
- // button2
- //
- this.button2.Location = new System.Drawing.Point(147, 188);
- this.button2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.button2.Name = "button2";
- this.button2.Size = new System.Drawing.Size(88, 27);
- this.button2.TabIndex = 12;
- this.button2.Text = "Insert";
- this.button2.UseVisualStyleBackColor = true;
- this.button2.Click += new System.EventHandler(this.OnClickCopy);
- //
+ this.numericUpDownY2.Size = new System.Drawing.Size(70, 23);
+ this.numericUpDownY2.TabIndex = 7;
+ this.numericUpDownY2.ValueChanged += new System.EventHandler(this.OnRegionChanged);
+ //
+ // groupBoxPreview
+ //
+ this.groupBoxPreview.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
+ this.groupBoxPreview.Controls.Add(this.preview);
+ this.groupBoxPreview.Controls.Add(this.checkBoxPreviewStatics);
+ this.groupBoxPreview.Controls.Add(this.checkBoxPreviewPatched);
+ this.groupBoxPreview.Controls.Add(this.textBoxPreview);
+ this.groupBoxPreview.Location = new System.Drawing.Point(12, 170);
+ this.groupBoxPreview.Name = "groupBoxPreview";
+ this.groupBoxPreview.Size = new System.Drawing.Size(960, 548);
+ this.groupBoxPreview.TabIndex = 4;
+ this.groupBoxPreview.TabStop = false;
+ this.groupBoxPreview.Text = "What will be inserted - drag to choose the region";
+ //
+ // preview
+ //
+ this.preview.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
+ this.preview.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
+ this.preview.Location = new System.Drawing.Point(16, 22);
+ this.preview.Name = "preview";
+ this.preview.Size = new System.Drawing.Size(928, 428);
+ this.preview.TabIndex = 0;
+ //
+ // checkBoxPreviewStatics
+ //
+ this.checkBoxPreviewStatics.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
+ this.checkBoxPreviewStatics.Location = new System.Drawing.Point(16, 458);
+ this.checkBoxPreviewStatics.Name = "checkBoxPreviewStatics";
+ this.checkBoxPreviewStatics.Size = new System.Drawing.Size(120, 21);
+ this.checkBoxPreviewStatics.TabIndex = 1;
+ this.checkBoxPreviewStatics.Text = "Show statics";
+ this.checkBoxPreviewStatics.UseVisualStyleBackColor = true;
+ this.checkBoxPreviewStatics.CheckedChanged += new System.EventHandler(this.OnPreviewOptionChanged);
+ //
+ // checkBoxPreviewPatched
+ //
+ this.checkBoxPreviewPatched.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
+ this.checkBoxPreviewPatched.Location = new System.Drawing.Point(144, 458);
+ this.checkBoxPreviewPatched.Name = "checkBoxPreviewPatched";
+ this.checkBoxPreviewPatched.Size = new System.Drawing.Size(230, 21);
+ this.checkBoxPreviewPatched.TabIndex = 2;
+ this.checkBoxPreviewPatched.Text = "Mark the blocks the diff covers";
+ this.checkBoxPreviewPatched.UseVisualStyleBackColor = true;
+ this.checkBoxPreviewPatched.CheckedChanged += new System.EventHandler(this.OnPreviewOptionChanged);
+ //
+ // textBoxPreview
+ //
+ this.textBoxPreview.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
+ this.textBoxPreview.BackColor = System.Drawing.SystemColors.Control;
+ this.textBoxPreview.BorderStyle = System.Windows.Forms.BorderStyle.None;
+ this.textBoxPreview.Location = new System.Drawing.Point(16, 484);
+ this.textBoxPreview.Multiline = true;
+ this.textBoxPreview.Name = "textBoxPreview";
+ this.textBoxPreview.ReadOnly = true;
+ this.textBoxPreview.ScrollBars = System.Windows.Forms.ScrollBars.None;
+ this.textBoxPreview.Size = new System.Drawing.Size(928, 52);
+ this.textBoxPreview.TabIndex = 3;
+ this.textBoxPreview.TabStop = false;
+ //
// progressBar1
- //
- this.progressBar1.Dock = System.Windows.Forms.DockStyle.Bottom;
- this.progressBar1.Location = new System.Drawing.Point(0, 234);
- this.progressBar1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ //
+ this.progressBar1.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
+ this.progressBar1.Location = new System.Drawing.Point(12, 726);
this.progressBar1.Name = "progressBar1";
- this.progressBar1.Size = new System.Drawing.Size(384, 27);
- this.progressBar1.TabIndex = 13;
- //
- // RemoveDupl
- //
- this.RemoveDupl.AutoSize = true;
- this.RemoveDupl.Location = new System.Drawing.Point(13, 48);
- this.RemoveDupl.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.RemoveDupl.Name = "RemoveDupl";
- this.RemoveDupl.Size = new System.Drawing.Size(127, 19);
- this.RemoveDupl.TabIndex = 17;
- this.RemoveDupl.Text = "Remove Duplicates";
- this.RemoveDupl.UseVisualStyleBackColor = true;
- //
- // groupBox1
- //
- this.groupBox1.Controls.Add(this.checkBoxMap);
- this.groupBox1.Location = new System.Drawing.Point(14, 13);
- this.groupBox1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.groupBox1.Name = "groupBox1";
- this.groupBox1.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.groupBox1.Size = new System.Drawing.Size(169, 75);
- this.groupBox1.TabIndex = 19;
- this.groupBox1.TabStop = false;
- this.groupBox1.Text = "Map";
- //
- // groupBox2
- //
- this.groupBox2.Controls.Add(this.checkBoxStatics);
- this.groupBox2.Controls.Add(this.RemoveDupl);
- this.groupBox2.Location = new System.Drawing.Point(198, 13);
- this.groupBox2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.groupBox2.Name = "groupBox2";
- this.groupBox2.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.groupBox2.Size = new System.Drawing.Size(169, 75);
- this.groupBox2.TabIndex = 20;
- this.groupBox2.TabStop = false;
- this.groupBox2.Text = "Statics";
- //
- // groupBox3
- //
- this.groupBox3.Controls.Add(this.label1);
- this.groupBox3.Controls.Add(this.numericUpDownX1);
- this.groupBox3.Controls.Add(this.numericUpDownY1);
- this.groupBox3.Controls.Add(this.label2);
- this.groupBox3.Controls.Add(this.numericUpDownX2);
- this.groupBox3.Controls.Add(this.label4);
- this.groupBox3.Controls.Add(this.label3);
- this.groupBox3.Controls.Add(this.numericUpDownY2);
- this.groupBox3.Location = new System.Drawing.Point(13, 96);
- this.groupBox3.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.groupBox3.Name = "groupBox3";
- this.groupBox3.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.groupBox3.Size = new System.Drawing.Size(355, 85);
- this.groupBox3.TabIndex = 21;
- this.groupBox3.TabStop = false;
- this.groupBox3.Text = "Region";
- //
- // MapDiffInsertForm
- //
+ this.progressBar1.Size = new System.Drawing.Size(960, 18);
+ this.progressBar1.TabIndex = 5;
+ //
+ // labelStatus
+ //
+ this.labelStatus.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
+ this.labelStatus.AutoEllipsis = true;
+ this.labelStatus.Location = new System.Drawing.Point(12, 749);
+ this.labelStatus.Name = "labelStatus";
+ this.labelStatus.Size = new System.Drawing.Size(960, 17);
+ this.labelStatus.TabIndex = 6;
+ //
+ // buttonCopy
+ //
+ this.buttonCopy.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
+ this.buttonCopy.Location = new System.Drawing.Point(716, 774);
+ this.buttonCopy.Name = "buttonCopy";
+ this.buttonCopy.Size = new System.Drawing.Size(80, 28);
+ this.buttonCopy.TabIndex = 7;
+ this.buttonCopy.Text = "Insert";
+ this.buttonCopy.UseVisualStyleBackColor = true;
+ this.buttonCopy.Click += new System.EventHandler(this.OnClickCopy);
+ //
+ // buttonCancel
+ //
+ this.buttonCancel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
+ this.buttonCancel.Enabled = false;
+ this.buttonCancel.Location = new System.Drawing.Point(804, 774);
+ this.buttonCancel.Name = "buttonCancel";
+ this.buttonCancel.Size = new System.Drawing.Size(72, 28);
+ this.buttonCancel.TabIndex = 8;
+ this.buttonCancel.Text = "Cancel";
+ this.buttonCancel.UseVisualStyleBackColor = true;
+ this.buttonCancel.Click += new System.EventHandler(this.OnClickCancel);
+ //
+ // buttonClose
+ //
+ this.buttonClose.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
+ this.buttonClose.DialogResult = System.Windows.Forms.DialogResult.Cancel;
+ this.buttonClose.Location = new System.Drawing.Point(892, 774);
+ this.buttonClose.Name = "buttonClose";
+ this.buttonClose.Size = new System.Drawing.Size(80, 28);
+ this.buttonClose.TabIndex = 9;
+ this.buttonClose.Text = "Close";
+ this.buttonClose.UseVisualStyleBackColor = true;
+ this.buttonClose.Click += new System.EventHandler(this.OnClickClose);
+ //
+ // worker
+ //
+ this.worker.WorkerSupportsCancellation = true;
+ this.worker.DoWork += new System.ComponentModel.DoWorkEventHandler(this.OnWorkerDoWork);
+ this.worker.RunWorkerCompleted += new System.ComponentModel.RunWorkerCompletedEventHandler(this.OnWorkerCompleted);
+ //
+ // MapReplaceForm
+ //
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
- this.ClientSize = new System.Drawing.Size(384, 261);
- this.Controls.Add(this.groupBox3);
- this.Controls.Add(this.groupBox2);
- this.Controls.Add(this.groupBox1);
+ this.CancelButton = this.buttonClose;
+ this.ClientSize = new System.Drawing.Size(984, 814);
+ this.Controls.Add(this.groupBoxWhat);
+ this.Controls.Add(this.groupBoxFrom);
+ this.Controls.Add(this.groupBoxPreview);
this.Controls.Add(this.progressBar1);
- this.Controls.Add(this.button2);
+ this.Controls.Add(this.labelStatus);
+ this.Controls.Add(this.buttonCopy);
+ this.Controls.Add(this.buttonCancel);
+ this.Controls.Add(this.buttonClose);
this.DoubleBuffered = true;
- this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow;
- this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.MaximizeBox = false;
- this.MinimumSize = new System.Drawing.Size(388, 285);
+ this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.Sizable;
+ this.MinimizeBox = false;
+ this.MinimumSize = new System.Drawing.Size(700, 640);
this.Name = "MapDiffInsertForm";
- this.Text = "Map Diff Insert";
+ this.ShowInTaskbar = false;
+ this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
+ this.Text = "Diff to Map Copy";
((System.ComponentModel.ISupportInitialize)(this.numericUpDownX1)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numericUpDownY1)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numericUpDownX2)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numericUpDownY2)).EndInit();
- this.groupBox1.ResumeLayout(false);
- this.groupBox1.PerformLayout();
- this.groupBox2.ResumeLayout(false);
- this.groupBox2.PerformLayout();
- this.groupBox3.ResumeLayout(false);
- this.groupBox3.PerformLayout();
+ this.groupBoxWhat.ResumeLayout(false);
+ this.groupBoxFrom.ResumeLayout(false);
+ this.groupBoxPreview.ResumeLayout(false);
+ this.groupBoxPreview.PerformLayout();
this.ResumeLayout(false);
}
#endregion
- private System.Windows.Forms.Button button2;
+ private System.ComponentModel.BackgroundWorker worker;
+ private System.Windows.Forms.Button buttonCancel;
+ private System.Windows.Forms.Button buttonClose;
+ private System.Windows.Forms.Button buttonCopy;
+ private System.Windows.Forms.CheckBox RemoveDupl;
+ private System.Windows.Forms.CheckBox checkBoxDuplicatesHue;
private System.Windows.Forms.CheckBox checkBoxMap;
private System.Windows.Forms.CheckBox checkBoxStatics;
- private System.Windows.Forms.GroupBox groupBox1;
- private System.Windows.Forms.GroupBox groupBox2;
- private System.Windows.Forms.GroupBox groupBox3;
+ private System.Windows.Forms.ComboBox comboBoxMapFormat;
+ private System.Windows.Forms.GroupBox groupBoxFrom;
+ private System.Windows.Forms.GroupBox groupBoxPreview;
+ private System.Windows.Forms.GroupBox groupBoxWhat;
private System.Windows.Forms.Label label1;
private System.Windows.Forms.Label label2;
private System.Windows.Forms.Label label3;
private System.Windows.Forms.Label label4;
+ private System.Windows.Forms.Label labelMapFormat;
+ private System.Windows.Forms.Label labelStatus;
private System.Windows.Forms.NumericUpDown numericUpDownX1;
private System.Windows.Forms.NumericUpDown numericUpDownX2;
private System.Windows.Forms.NumericUpDown numericUpDownY1;
private System.Windows.Forms.NumericUpDown numericUpDownY2;
private System.Windows.Forms.ProgressBar progressBar1;
- private System.Windows.Forms.CheckBox RemoveDupl;
+ private System.Windows.Forms.CheckBox checkBoxPreviewPatched;
+ private System.Windows.Forms.CheckBox checkBoxPreviewStatics;
+ private System.Windows.Forms.TextBox textBoxPreview;
+ private UoFiddler.Controls.UserControls.MapRegionPreview preview;
}
}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/MapDiffInsertForm.cs b/UoFiddler.Controls/Forms/MapDiffInsertForm.cs
index a48d98ed..65b60a83 100644
--- a/UoFiddler.Controls/Forms/MapDiffInsertForm.cs
+++ b/UoFiddler.Controls/Forms/MapDiffInsertForm.cs
@@ -10,10 +10,19 @@
***************************************************************************/
using System;
-using System.IO;
+using System.ComponentModel;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using System.Threading;
using System.Windows.Forms;
+using Microsoft.Extensions.Logging;
using Ultima;
+using Ultima.Helpers;
+using Ultima.Maps;
+using Ultima.Statics;
using UoFiddler.Controls.Classes;
+using UoFiddler.Controls.UserControls;
namespace UoFiddler.Controls.Forms
{
@@ -21,473 +30,373 @@ public partial class MapDiffInsertForm : Form
{
private readonly Map _workingMap;
+ private CancellationTokenSource _cancellation;
+
+ /// Guards the round trip between a drag on the panel and the spinners it writes to.
+ private bool _syncingPreview;
+
public MapDiffInsertForm(Map currentMap)
{
InitializeComponent();
+
Icon = Options.GetFiddlerIcon();
- _workingMap = currentMap;
- numericUpDownX1.Maximum = _workingMap.Width;
- numericUpDownX2.Maximum = _workingMap.Width;
- numericUpDownY1.Maximum = _workingMap.Height;
- numericUpDownY2.Maximum = _workingMap.Height;
- Text = $"Map Diff Insert ID:{_workingMap.FileIndex}";
+
+ _workingMap = currentMap ?? throw new ArgumentNullException(nameof(currentMap));
+
+ Text = $"Diff to Map Copy - map {_workingMap.FileIndex}";
+
+ bool uop = _workingMap.Tiles.IsUOPFormat;
+
+ comboBoxMapFormat.Items.Add(uop
+ ? "the same format as this client (.uop)"
+ : "the same format as this client (.mul)");
+ comboBoxMapFormat.Items.Add($"map{_workingMap.FileIndex}.mul");
+ comboBoxMapFormat.Items.Add($"map{_workingMap.FileIndex}LegacyMUL.uop");
+ comboBoxMapFormat.SelectedIndex = ClientFileSaveFormats.DefaultIndex(Options.SaveFormat);
+
+ checkBoxMap.Text = "Map";
+ checkBoxStatics.Text = "Statics";
+ checkBoxMap.Checked = true;
+ checkBoxStatics.Checked = true;
+
+ // Exclusive bounds: a map of width W has tiles 0..W-1, and the old check let W through,
+ // which becomes a block index one past the end once it is shifted.
+ numericUpDownX1.Maximum = Math.Max(0, _workingMap.Width - 1);
+ numericUpDownX2.Maximum = Math.Max(0, _workingMap.Width - 1);
+ numericUpDownY1.Maximum = Math.Max(0, _workingMap.Height - 1);
+ numericUpDownY2.Maximum = Math.Max(0, _workingMap.Height - 1);
+ numericUpDownX2.Value = numericUpDownX2.Maximum;
+ numericUpDownY2.Value = numericUpDownY2.Maximum;
+
+ checkBoxPreviewStatics.Checked = true;
+ checkBoxPreviewPatched.Checked = true;
+
+ preview.Mode = MapPreviewMode.Rectangle;
+ preview.Map = _workingMap;
+ preview.MapSize = new MapSize(_workingMap.Width, _workingMap.Height);
+ preview.SelectionChanged += OnPreviewSelectionChanged;
+
+ OnPreviewOptionChanged(this, EventArgs.Empty);
+ OnOptionChanged(this, EventArgs.Empty);
+
+ ActiveControl = buttonCopy;
}
- private void OnClickCopy(object sender, EventArgs e)
+ private void OnOptionChanged(object sender, EventArgs e)
+ {
+ comboBoxMapFormat.Enabled = checkBoxMap.Checked;
+ labelMapFormat.Enabled = checkBoxMap.Checked;
+ RemoveDupl.Enabled = checkBoxStatics.Checked;
+ checkBoxDuplicatesHue.Enabled = checkBoxStatics.Checked && RemoveDupl.Checked;
+
+ UpdatePreview();
+ }
+
+ private void OnRegionChanged(object sender, EventArgs e)
+ {
+ UpdatePreview();
+ }
+
+ ///
+ /// Shows the block-snapped rectangle that will really be patched, and how much diff data
+ /// there is to patch with. The tile to block conversion rounds out to whole 8-tile blocks,
+ /// which used to happen silently.
+ ///
+ private void UpdatePreview()
{
int x1 = (int)numericUpDownX1.Value;
- int x2 = (int)numericUpDownX2.Value;
int y1 = (int)numericUpDownY1.Value;
+ int x2 = (int)numericUpDownX2.Value;
int y2 = (int)numericUpDownY2.Value;
- if (x1 < 0 || x1 > _workingMap.Width)
+ if (x1 > x2)
{
- MessageBox.Show("Invalid X1 coordinate!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
- return;
+ (x1, x2) = (x2, x1);
+ }
+
+ if (y1 > y2)
+ {
+ (y1, y2) = (y2, y1);
}
- if (x2 < 0 || x2 > _workingMap.Width)
+ var region = new BlockRectangle(x1 >> 3, y1 >> 3, x2 >> 3, y2 >> 3);
+
+ TileMatrixPatch patch = _workingMap.Tiles.Patch;
+
+ var sb = new StringBuilder();
+
+ sb.AppendLine($"region {region}");
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
+ "diff data loaded: {0:N0} land blocks, {1:N0} static blocks",
+ patch.LandBlocksCount, patch.StaticBlocksCount));
+
+ if (region.TileX1 != x1 || region.TileY1 != y1 || region.TileX2 != x2 || region.TileY2 != y2)
+ {
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
+ "the request {0},{1} - {2},{3} was widened to whole 8-tile blocks", x1, y1, x2, y2));
+ }
+
+ textBoxPreview.Text = sb.ToString();
+
+ if (_syncingPreview)
{
- MessageBox.Show("Invalid X2 coordinate!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
return;
}
- if (y1 < 0 || y1 > _workingMap.Height)
+ _syncingPreview = true;
+
+ try
+ {
+ preview.Selection = region;
+ }
+ finally
+ {
+ _syncingPreview = false;
+ }
+ }
+
+ private void OnPreviewOptionChanged(object sender, EventArgs e)
+ {
+ preview.ShowStatics = checkBoxPreviewStatics.Checked;
+
+ // Tinting the blocks the diff lists turns a count into something a region can be aimed at.
+ TileMatrixPatch patch = _workingMap.Tiles.Patch;
+
+ preview.BlockHighlight = checkBoxPreviewPatched.Checked
+ ? (x, y) => patch.IsLandBlockPatched(x, y) || patch.IsStaticBlockPatched(x, y)
+ : null;
+
+ preview.Invalidate();
+ }
+
+ /// A drag on the panel writes the region back into the spinners.
+ private void OnPreviewSelectionChanged(object sender, EventArgs e)
+ {
+ if (_syncingPreview)
{
- MessageBox.Show("Invalid Y1 coordinate!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
return;
}
- if (y2 < 0 || y2 > _workingMap.Height)
+ BlockRectangle selection = preview.Selection;
+
+ _syncingPreview = true;
+
+ try
+ {
+ numericUpDownX1.Value = Clamp(numericUpDownX1, selection.TileX1);
+ numericUpDownY1.Value = Clamp(numericUpDownY1, selection.TileY1);
+ numericUpDownX2.Value = Clamp(numericUpDownX2, selection.TileX2);
+ numericUpDownY2.Value = Clamp(numericUpDownY2, selection.TileY2);
+ }
+ finally
+ {
+ _syncingPreview = false;
+ }
+
+ UpdatePreview();
+ }
+
+ private static decimal Clamp(NumericUpDown control, int value)
+ {
+ return Math.Clamp(value, (int)control.Minimum, (int)control.Maximum);
+ }
+
+ private void OnClickCopy(object sender, EventArgs e)
+ {
+ if (worker.IsBusy)
{
- MessageBox.Show("Invalid Y2 coordinate!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
return;
}
- if (x1 > x2 || y1 > y2)
+ if (!checkBoxMap.Checked && !checkBoxStatics.Checked)
{
- MessageBox.Show("X1 and Y1 cannot be bigger than X2 and Y2!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
+ MessageBox.Show(this, "Nothing is selected to insert.", "Diff to Map Copy",
+ MessageBoxButtons.OK, MessageBoxIcon.Error);
+
return;
}
- x1 >>= 3;
- x2 >>= 3;
- y1 >>= 3;
- y2 >>= 3;
+ var options = new MapDiffApplyOptions
+ {
+ Map = _workingMap,
+ X1 = (int)numericUpDownX1.Value,
+ Y1 = (int)numericUpDownY1.Value,
+ X2 = (int)numericUpDownX2.Value,
+ Y2 = (int)numericUpDownY2.Value,
+ ApplyLand = checkBoxMap.Checked,
+ ApplyStatics = checkBoxStatics.Checked,
+ MapFormat = ResolveFormat(),
+ OutputDirectory = Options.OutputPath
+ };
+
+ if (checkBoxStatics.Checked)
+ {
+ options.StaticsFilter = new StaticsTileFilter
+ {
+ // The rules this feature has always applied, so its output keeps its old shape.
+ DropInvalidItemIds = true,
+ MaxItemId = Art.GetMaxItemId(),
+ OutOfBlockTiles = OutOfBlockAction.Keep,
+ DropInvalidZ = false,
+ NormalizeNegativeHue = true,
+ RemoveDuplicates = RemoveDupl.Checked,
+ DuplicatesCompareHue = RemoveDupl.Checked && checkBoxDuplicatesHue.Checked
+ };
+ }
- int blockY = _workingMap.Height >> 3;
- int blockX = _workingMap.Width >> 3;
+ _cancellation?.Dispose();
+ _cancellation = new CancellationTokenSource();
+ options.CancellationToken = _cancellation.Token;
+ options.Progress = new Progress(OnProgress);
- progressBar1.Step = 1;
+ SetRunning(true);
progressBar1.Value = 0;
- progressBar1.Maximum = 0;
+ labelStatus.Text = "Inserting...";
+
+ worker.RunWorkerAsync(options);
+ }
- if (checkBoxMap.Checked)
+ private MapOutputFormat ResolveFormat()
+ {
+ switch (comboBoxMapFormat.SelectedIndex)
{
- progressBar1.Maximum += blockY * blockX;
+ case 1: return MapOutputFormat.Mul;
+ case 2: return MapOutputFormat.Uop;
+ default: return _workingMap.Tiles.IsUOPFormat ? MapOutputFormat.Uop : MapOutputFormat.Mul;
}
+ }
- if (checkBoxStatics.Checked)
+ private void OnProgress(MapCopyProgress progress)
+ {
+ if (progress.BlocksTotal <= 0)
{
- progressBar1.Maximum += blockY * blockX;
+ return;
}
- if (checkBoxMap.Checked)
+ progressBar1.Value = Math.Min(100, Math.Max(0, (int)(progress.BlocksDone * 100L / progress.BlocksTotal)));
+ labelStatus.Text = string.Format(CultureInfo.InvariantCulture, "{0}: {1:N0} of {2:N0} blocks",
+ progress.Stage, progress.BlocksDone, progress.BlocksTotal);
+ }
+
+ private void OnWorkerDoWork(object sender, DoWorkEventArgs e)
+ {
+ e.Result = MapDiffApplier.Run((MapDiffApplyOptions)e.Argument);
+ }
+
+ private void OnWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
+ {
+ SetRunning(false);
+
+ if (e.Error is OperationCanceledException)
{
- string mapPath = Files.GetFilePath($"map{_workingMap.FileIndex}.mul");
- BinaryReader mMapReader;
+ progressBar1.Value = 0;
+ labelStatus.Text = "Cancelled. Nothing was written.";
- if (mapPath != null)
- {
- var mMap = new FileStream(mapPath, FileMode.Open, FileAccess.Read, FileShare.Read);
- mMapReader = new BinaryReader(mMap);
- }
- else
- {
- MessageBox.Show("Map file not found!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
- return;
- }
+ return;
+ }
- string mul = Path.Combine(Options.OutputPath, $"map{_workingMap.FileIndex}.mul");
- using (FileStream fsmul = new FileStream(mul, FileMode.Create, FileAccess.Write, FileShare.Write))
- {
- using (BinaryWriter binmul = new BinaryWriter(fsmul))
- {
- for (int x = 0; x < blockX; ++x)
- {
- for (int y = 0; y < blockY; ++y)
- {
- mMapReader.BaseStream.Seek(((x * blockY) + y) * 196, SeekOrigin.Begin);
- int header = mMapReader.ReadInt32();
- binmul.Write(header);
- ushort tileid;
- sbyte z;
- bool patched = false;
- if (x1 <= x && x <= x2 && y1 <= y && y <= y2)
- {
- if (_workingMap.Tiles.Patch.IsLandBlockPatched(x, y))
- {
- patched = true;
- Tile[] patchtile = _workingMap.Tiles.Patch.GetLandBlock(x, y);
- for (int i = 0; i < 64; ++i)
- {
- tileid = patchtile[i].Id;
- z = (sbyte)patchtile[i].Z;
- tileid = Art.GetLegalItemId(tileid);
- if (z < -128)
- {
- z = -128;
- }
-
- if (z > 127)
- {
- z = 127;
- }
-
- binmul.Write(tileid);
- binmul.Write(z);
- }
- }
- }
-
- if (!patched)
- {
- for (int i = 0; i < 64; ++i)
- {
- tileid = mMapReader.ReadUInt16();
- z = mMapReader.ReadSByte();
- tileid = Art.GetLegalItemId(tileid);
- if (z < -128)
- {
- z = -128;
- }
-
- if (z > 127)
- {
- z = 127;
- }
-
- binmul.Write(tileid);
- binmul.Write(z);
- }
- }
-
- progressBar1.PerformStep();
- }
- }
- }
- }
+ if (e.Error != null)
+ {
+ progressBar1.Value = 0;
+ labelStatus.Text = "Failed.";
+
+ ShowError("Diff to Map Copy", e.Error);
- mMapReader.Close();
+ return;
}
- if (checkBoxStatics.Checked)
+
+ var result = (MapDiffApplyResult)e.Result;
+
+ progressBar1.Value = 100;
+ labelStatus.Text = "Done.";
+
+ using (var form = new MapDiffApplyResultForm(result))
{
- string indexPath = Files.GetFilePath($"staidx{_workingMap.FileIndex}.mul");
- BinaryReader mIndexReader;
- if (indexPath != null)
- {
- var mIndex = new FileStream(indexPath, FileMode.Open, FileAccess.Read, FileShare.Read);
- mIndexReader = new BinaryReader(mIndex);
- }
- else
- {
- MessageBox.Show("Static file not found!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
- return;
- }
+ form.ShowDialog(this);
+ }
+ }
- string staticsPath = Files.GetFilePath($"statics{_workingMap.FileIndex}.mul");
+ ///
+ /// Shows what actually went wrong. A bare "Object reference not set to an instance of an
+ /// object" tells a user nothing and tells whoever gets the bug report even less, so the
+ /// exception type and the place it came from go in the dialog and the whole thing goes to
+ /// the log.
+ ///
+ private void ShowError(string title, Exception error)
+ {
+ AppLog.For(GetType()).LogError(error, "{Title} failed.", title);
- FileStream mStatics;
- BinaryReader mStaticsReader;
- if (staticsPath != null)
- {
- mStatics = new FileStream(staticsPath, FileMode.Open, FileAccess.Read, FileShare.Read);
- mStaticsReader = new BinaryReader(mStatics);
- }
- else
- {
- MessageBox.Show("Static file not found!", "Map Diff Insert", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
- return;
- }
+ var sb = new StringBuilder();
+
+ for (Exception current = error; current != null; current = current.InnerException)
+ {
+ sb.AppendLine(current.Message);
- string idx = Path.Combine(Options.OutputPath, $"staidx{_workingMap.FileIndex}.mul");
- string mul = Path.Combine(Options.OutputPath, $"statics{_workingMap.FileIndex}.mul");
- using (FileStream fsidx = new FileStream(idx, FileMode.Create, FileAccess.Write, FileShare.Write),
- fsmul = new FileStream(mul, FileMode.Create, FileAccess.Write, FileShare.Write))
+ if (current.InnerException != null)
{
- using (BinaryWriter binidx = new BinaryWriter(fsidx),
- binmul = new BinaryWriter(fsmul))
- {
- for (int x = 0; x < blockX; ++x)
- {
- for (int y = 0; y < blockY; ++y)
- {
- mIndexReader.BaseStream.Seek(((x * blockY) + y) * 12, SeekOrigin.Begin);
- var lookup = mIndexReader.ReadInt32();
- var length = mIndexReader.ReadInt32();
- var extra = mIndexReader.ReadInt32();
- bool patched = false;
- if (x1 <= x && x <= x2 && y1 <= y && y <= y2)
- {
- if (_workingMap.Tiles.Patch.IsStaticBlockPatched(x, y))
- {
- patched = true;
- }
- }
-
- if (patched)
- {
- HuedTile[][][] patchstat = _workingMap.Tiles.Patch.GetStaticBlock(x, y);
- int count = 0;
- for (int i = 0; i < 8; ++i)
- {
- for (int j = 0; j < 8; ++j)
- {
- if (patchstat[i][j] != null)
- {
- count += patchstat[i][j].Length;
- }
- }
- }
-
- if (count == 0)
- {
- binidx.Write(-1); // lookup
- binidx.Write(-1); // length
- binidx.Write(-1); // extra
- }
- else
- {
- int fsmullength = (int)fsmul.Position;
- if (RemoveDupl.Checked)
- {
- StaticTile[] tilelist = new StaticTile[count];
- int m = 0;
- for (int i = 0; i < 8; ++i)
- {
- for (int j = 0; j < 8; ++j)
- {
- foreach (HuedTile htile in patchstat[i][j])
- {
- StaticTile tile = new StaticTile
- {
- Id = htile.Id,
- Z = (sbyte)htile.Z,
- X = (byte)i,
- Y = (byte)j,
- Hue = (short)htile.Hue
- };
-
- if (tile.Id > Art.GetMaxItemId())
- {
- continue;
- }
-
- if (tile.Hue < 0)
- {
- tile.Hue = 0;
- }
-
- bool first = true;
- for (int k = 0; k < m; ++k)
- {
- if (tilelist[k].Id == tile.Id && tilelist[k].X == tile.X && tilelist[k].Y == tile.Y && tilelist[k].Z == tile.Z && tilelist[k].Hue == tile.Hue)
- {
- first = false;
- break;
- }
- }
- if (first)
- {
- tilelist[m] = tile;
- ++m;
- }
- }
- }
- }
- if (m > 0)
- {
- binidx.Write((int)fsmul.Position); //lookup
- for (int i = 0; i < m; ++i)
- {
- binmul.Write(tilelist[i].Id);
- binmul.Write(tilelist[i].X);
- binmul.Write(tilelist[i].Y);
- binmul.Write(tilelist[i].Z);
- binmul.Write(tilelist[i].Hue);
- }
- }
- }
- else
- {
- bool firstItem = true;
- for (int i = 0; i < 8; ++i)
- {
- for (int j = 0; j < 8; ++j)
- {
- foreach (HuedTile tile in patchstat[i][j])
- {
- ushort graphic = tile.Id;
- sbyte sz = (sbyte)tile.Z;
- short sHue = (short)tile.Hue;
-
- if (graphic > Art.GetMaxItemId())
- {
- continue;
- }
-
- if (sHue < 0)
- {
- sHue = 0;
- }
-
- if (firstItem)
- {
- binidx.Write((int)fsmul.Position); //lookup
- firstItem = false;
- }
- binmul.Write(graphic);
- binmul.Write((byte)i); //x
- binmul.Write((byte)j); //y
- binmul.Write(sz);
- binmul.Write(sHue);
- }
- }
- }
- }
- fsmullength = (int)fsmul.Position - fsmullength;
- if (fsmullength > 0)
- {
- binidx.Write(fsmullength); //length
- binidx.Write(extra); //extra
- }
- else
- {
- binidx.Write(-1); //lookup
- binidx.Write(-1); //length
- binidx.Write(-1); //extra
- }
- }
- }
- else
- {
- if (lookup < 0 || length <= 0)
- {
- binidx.Write(-1); // lookup
- binidx.Write(-1); // length
- binidx.Write(-1); // extra
- }
- else
- {
- mStatics.Seek(lookup, SeekOrigin.Begin);
- int fsmullength = (int)fsmul.Position;
- int count = length / 7;
-
- if (RemoveDupl.Checked)
- {
- StaticTile[] tilelist = new StaticTile[count];
- int j = 0;
- for (int i = 0; i < count; ++i)
- {
- StaticTile tile = new StaticTile
- {
- Id = mStaticsReader.ReadUInt16(),
- X = mStaticsReader.ReadByte(),
- Y = mStaticsReader.ReadByte(),
- Z = mStaticsReader.ReadSByte(),
- Hue = mStaticsReader.ReadInt16()
- };
-
- if (tile.Id <= Art.GetMaxItemId())
- {
- if (tile.Hue < 0)
- {
- tile.Hue = 0;
- }
-
- bool first = true;
- for (int k = 0; k < j; ++k)
- {
- if (tilelist[k].Id == tile.Id && tilelist[k].X == tile.X && tilelist[k].Y == tile.Y && tilelist[k].Z == tile.Z && tilelist[k].Hue == tile.Hue)
- {
- first = false;
- break;
- }
- }
- if (first)
- {
- tilelist[j++] = tile;
- }
- }
- }
- if (j > 0)
- {
- binidx.Write((int)fsmul.Position); //lookup
- for (int i = 0; i < j; ++i)
- {
- binmul.Write(tilelist[i].Id);
- binmul.Write(tilelist[i].X);
- binmul.Write(tilelist[i].Y);
- binmul.Write(tilelist[i].Z);
- binmul.Write(tilelist[i].Hue);
- }
- }
- }
- else
- {
- bool firstItem = true;
- for (int i = 0; i < count; ++i)
- {
- var graphic = mStaticsReader.ReadUInt16();
- var sx = mStaticsReader.ReadByte();
- var sy = mStaticsReader.ReadByte();
- var sz = mStaticsReader.ReadSByte();
- var shue = mStaticsReader.ReadInt16();
-
- if (graphic <= Art.GetMaxItemId())
- {
- if (shue < 0)
- {
- shue = 0;
- }
-
- if (firstItem)
- {
- binidx.Write((int)fsmul.Position); //lookup
- firstItem = false;
- }
- binmul.Write(graphic);
- binmul.Write(sx);
- binmul.Write(sy);
- binmul.Write(sz);
- binmul.Write(shue);
- }
- }
- }
- fsmullength = (int)fsmul.Position - fsmullength;
- if (fsmullength > 0)
- {
- binidx.Write(fsmullength); //length
- binidx.Write(extra); //extra
- }
- else
- {
- binidx.Write(-1); //lookup
- binidx.Write(-1); //length
- binidx.Write(-1); //extra
- }
- }
- }
- progressBar1.PerformStep();
- }
- }
- }
+ sb.AppendLine();
}
- mIndexReader.Close();
- mStaticsReader.Close();
}
- FileSavedDialog.Show(FindForm(), Options.OutputPath, "Files saved successfully.");
+ sb.AppendLine();
+ sb.AppendLine(error.GetType().FullName);
+
+ string where = error.StackTrace?.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim();
+
+ if (!string.IsNullOrEmpty(where))
+ {
+ sb.AppendLine(where);
+ }
+
+ MessageBox.Show(this, sb.ToString(), title, MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+
+ private void OnClickCancel(object sender, EventArgs e)
+ {
+ _cancellation?.Cancel();
+ labelStatus.Text = "Cancelling...";
+ }
+
+ private void OnClickClose(object sender, EventArgs e)
+ {
+ Close();
+ }
+
+ private void SetRunning(bool running)
+ {
+ buttonCopy.Enabled = !running;
+ buttonCancel.Enabled = running;
+ buttonClose.Enabled = !running;
+ groupBoxWhat.Enabled = !running;
+ groupBoxFrom.Enabled = !running;
+ }
+
+ protected override void OnLoad(EventArgs e)
+ {
+ base.OnLoad(e);
+
+ FormLayout.FitToScreen(this);
+ }
+
+ protected override void OnFormClosing(FormClosingEventArgs e)
+ {
+ if (worker.IsBusy)
+ {
+ _cancellation?.Cancel();
+ e.Cancel = true;
+
+ return;
+ }
+
+ base.OnFormClosing(e);
+ }
+
+ protected override void OnFormClosed(FormClosedEventArgs e)
+ {
+ _cancellation?.Dispose();
+ _cancellation = null;
+
+ base.OnFormClosed(e);
}
}
}
diff --git a/UoFiddler.Controls/Forms/MapRegionCopyResultForm.Designer.cs b/UoFiddler.Controls/Forms/MapRegionCopyResultForm.Designer.cs
new file mode 100644
index 00000000..65c77615
--- /dev/null
+++ b/UoFiddler.Controls/Forms/MapRegionCopyResultForm.Designer.cs
@@ -0,0 +1,151 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+namespace UoFiddler.Controls.Forms
+{
+ partial class MapRegionCopyResultForm
+ {
+ ///
+ /// Required designer variable.
+ ///
+ private System.ComponentModel.IContainer components = null;
+
+ ///
+ /// Clean up any resources being used.
+ ///
+ /// true if managed resources should be disposed; otherwise, false.
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && (components != null))
+ {
+ components.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ private void InitializeComponent()
+ {
+ this.reportTextBox = new System.Windows.Forms.TextBox();
+ this.buttonVerify = new System.Windows.Forms.Button();
+ this.buttonSave = new System.Windows.Forms.Button();
+ this.buttonCopy = new System.Windows.Forms.Button();
+ this.buttonOpenFolder = new System.Windows.Forms.Button();
+ this.buttonClose = new System.Windows.Forms.Button();
+ this.SuspendLayout();
+ //
+ // reportTextBox
+ //
+ this.reportTextBox.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
+ | System.Windows.Forms.AnchorStyles.Left)
+ | System.Windows.Forms.AnchorStyles.Right)));
+ this.reportTextBox.Font = new System.Drawing.Font("Consolas", 9F);
+ this.reportTextBox.Location = new System.Drawing.Point(12, 12);
+ this.reportTextBox.Multiline = true;
+ this.reportTextBox.Name = "reportTextBox";
+ this.reportTextBox.ReadOnly = true;
+ this.reportTextBox.ScrollBars = System.Windows.Forms.ScrollBars.Both;
+ this.reportTextBox.Size = new System.Drawing.Size(660, 420);
+ this.reportTextBox.TabIndex = 0;
+ this.reportTextBox.WordWrap = false;
+ //
+ // buttonVerify
+ //
+ this.buttonVerify.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
+ this.buttonVerify.Location = new System.Drawing.Point(12, 442);
+ this.buttonVerify.Name = "buttonVerify";
+ this.buttonVerify.Size = new System.Drawing.Size(110, 27);
+ this.buttonVerify.TabIndex = 1;
+ this.buttonVerify.Text = "Verify output";
+ this.buttonVerify.UseVisualStyleBackColor = true;
+ this.buttonVerify.Click += new System.EventHandler(this.OnClickVerify);
+ //
+ // buttonSave
+ //
+ this.buttonSave.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
+ this.buttonSave.Location = new System.Drawing.Point(128, 442);
+ this.buttonSave.Name = "buttonSave";
+ this.buttonSave.Size = new System.Drawing.Size(110, 27);
+ this.buttonSave.TabIndex = 2;
+ this.buttonSave.Text = "Save report...";
+ this.buttonSave.UseVisualStyleBackColor = true;
+ this.buttonSave.Click += new System.EventHandler(this.OnClickSave);
+ //
+ // buttonCopy
+ //
+ this.buttonCopy.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
+ this.buttonCopy.Location = new System.Drawing.Point(244, 442);
+ this.buttonCopy.Name = "buttonCopy";
+ this.buttonCopy.Size = new System.Drawing.Size(80, 27);
+ this.buttonCopy.TabIndex = 3;
+ this.buttonCopy.Text = "Copy";
+ this.buttonCopy.UseVisualStyleBackColor = true;
+ this.buttonCopy.Click += new System.EventHandler(this.OnClickCopy);
+ //
+ // buttonOpenFolder
+ //
+ this.buttonOpenFolder.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
+ this.buttonOpenFolder.Location = new System.Drawing.Point(452, 442);
+ this.buttonOpenFolder.Name = "buttonOpenFolder";
+ this.buttonOpenFolder.Size = new System.Drawing.Size(130, 27);
+ this.buttonOpenFolder.TabIndex = 4;
+ this.buttonOpenFolder.Text = "Open output folder";
+ this.buttonOpenFolder.UseVisualStyleBackColor = true;
+ this.buttonOpenFolder.Click += new System.EventHandler(this.OnClickOpenFolder);
+ //
+ // buttonClose
+ //
+ this.buttonClose.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
+ this.buttonClose.DialogResult = System.Windows.Forms.DialogResult.OK;
+ this.buttonClose.Location = new System.Drawing.Point(588, 442);
+ this.buttonClose.Name = "buttonClose";
+ this.buttonClose.Size = new System.Drawing.Size(84, 27);
+ this.buttonClose.TabIndex = 5;
+ this.buttonClose.Text = "Close";
+ this.buttonClose.UseVisualStyleBackColor = true;
+ //
+ // MapRegionCopyResultForm
+ //
+ this.AcceptButton = this.buttonClose;
+ this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
+ this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
+ this.CancelButton = this.buttonClose;
+ this.ClientSize = new System.Drawing.Size(684, 481);
+ this.Controls.Add(this.buttonClose);
+ this.Controls.Add(this.buttonOpenFolder);
+ this.Controls.Add(this.buttonCopy);
+ this.Controls.Add(this.buttonSave);
+ this.Controls.Add(this.buttonVerify);
+ this.Controls.Add(this.reportTextBox);
+ this.DoubleBuffered = true;
+ this.MinimizeBox = false;
+ this.MinimumSize = new System.Drawing.Size(520, 320);
+ this.Name = "MapRegionCopyResultForm";
+ this.ShowInTaskbar = false;
+ this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
+ this.Text = "Map and Statics Copy - Result";
+ this.ResumeLayout(false);
+ this.PerformLayout();
+
+ }
+
+ #endregion
+
+ private System.Windows.Forms.Button buttonClose;
+ private System.Windows.Forms.Button buttonCopy;
+ private System.Windows.Forms.Button buttonOpenFolder;
+ private System.Windows.Forms.Button buttonSave;
+ private System.Windows.Forms.Button buttonVerify;
+ private System.Windows.Forms.TextBox reportTextBox;
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/MapRegionCopyResultForm.cs b/UoFiddler.Controls/Forms/MapRegionCopyResultForm.cs
new file mode 100644
index 00000000..2ec9e964
--- /dev/null
+++ b/UoFiddler.Controls/Forms/MapRegionCopyResultForm.cs
@@ -0,0 +1,238 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using System.Windows.Forms;
+using Ultima;
+using Ultima.Maps;
+using UoFiddler.Controls.Classes;
+
+namespace UoFiddler.Controls.Forms
+{
+ public sealed partial class MapRegionCopyResultForm : Form
+ {
+ private readonly MapRegionCopyResult _result;
+
+ public MapRegionCopyResultForm(MapRegionCopyResult result)
+ {
+ InitializeComponent();
+
+ Icon = Options.GetFiddlerIcon();
+
+ _result = result;
+
+ reportTextBox.Text = result.ToReport();
+
+ buttonVerify.Enabled = result.OutputMapPath != null;
+ buttonOpenFolder.Enabled = result.OutputMapPath != null || result.OutputIndexPath != null;
+ }
+
+ ///
+ /// Reads the written map back and checks it block by block: inside the pasted rectangle it
+ /// has to match the source, everywhere else the map it was made from.
+ ///
+ private void OnClickVerify(object sender, EventArgs e)
+ {
+ using (new WaitCursorScope(this))
+ {
+ try
+ {
+ reportTextBox.Text = Verify() + Environment.NewLine +
+ new string('-', 60) + Environment.NewLine + _result.ToReport();
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, "Verify failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+ }
+
+ private string Verify()
+ {
+ var sb = new StringBuilder();
+
+ MapSize destination = _result.DestinationSize;
+ MapSize source = _result.SourceSize;
+
+ string outputDirectory = Path.GetDirectoryName(_result.OutputMapPath);
+
+ // Three views: what was written, where the region came from, and the map it replaced.
+ var written = new TileMatrix(_result.DestinationFileIndex, _result.DestinationFileIndex,
+ destination.Width, destination.Height, outputDirectory);
+ var from = new TileMatrix(_result.SourceFileIndex, _result.SourceFileIndex,
+ source.Width, source.Height, _result.SourceDirectory);
+ var original = new TileMatrix(_result.DestinationFileIndex, _result.DestinationFileIndex,
+ destination.Width, destination.Height, null);
+
+ try
+ {
+ var actual = new byte[TileMatrix.MapBlockSize];
+ var expected = new byte[TileMatrix.MapBlockSize];
+
+ long inRegion = 0;
+ long carried = 0;
+ long mismatches = 0;
+ string firstMismatch = null;
+
+ for (int x = 0; x < destination.BlockWidth; ++x)
+ {
+ for (int y = 0; y < destination.BlockHeight; ++y)
+ {
+ bool copied = x >= _result.DestinationRegion.BlockX1 && x <= _result.DestinationRegion.BlockX2 &&
+ y >= _result.DestinationRegion.BlockY1 && y <= _result.DestinationRegion.BlockY2;
+
+ written.ReadLandBlockBytes(x, y, actual);
+
+ if (copied)
+ {
+ ++inRegion;
+ from.ReadLandBlockBytes(
+ x - _result.DestinationRegion.BlockX1 + _result.Source.BlockX1,
+ y - _result.DestinationRegion.BlockY1 + _result.Source.BlockY1,
+ expected);
+
+ // The region was asked to move in z, so the source block is not what
+ // should have been written - the source block plus that shift is.
+ if (_result.ZAdjust != 0)
+ {
+ ShiftZ(expected, _result.ZAdjust);
+ }
+ }
+ else
+ {
+ ++carried;
+ original.ReadLandBlockBytes(x, y, expected);
+ }
+
+ if (actual.AsSpan().SequenceEqual(expected))
+ {
+ continue;
+ }
+
+ ++mismatches;
+
+ firstMismatch ??= Line("block {0},{1} (world {2},{3}) {4}",
+ x, y, x << 3, y << 3, copied ? "does not match the source" : "does not match the original map");
+ }
+ }
+
+ bool countsAgree = inRegion == _result.LandBlocksCopied && carried == _result.LandBlocksCarried;
+
+ sb.AppendLine(mismatches == 0 && countsAgree ? "PASSED" : "FAILED");
+ sb.AppendLine();
+ sb.AppendLine(Line("Read back : {0}", _result.OutputMapPath));
+ sb.AppendLine(Line("Blocks compared : {0:N0}", inRegion + carried));
+ sb.AppendLine(Line(" from the source: {0:N0}", inRegion));
+ sb.AppendLine(Line(" carried over : {0:N0}", carried));
+
+ if (_result.ZAdjust != 0)
+ {
+ sb.AppendLine(Line(" compared with : the source shifted by {0:+#;-#;0} in z", _result.ZAdjust));
+ }
+ sb.AppendLine(Line("Blocks differing : {0:N0}", mismatches));
+
+ if (firstMismatch != null)
+ {
+ sb.AppendLine(Line("First difference : {0}", firstMismatch));
+ }
+
+ if (!countsAgree)
+ {
+ sb.AppendLine(Line("Counts disagree with the copy report: it says {0:N0} copied and {1:N0} carried.",
+ _result.LandBlocksCopied, _result.LandBlocksCarried));
+ }
+ }
+ finally
+ {
+ written.CloseStreams();
+ from.CloseStreams();
+ original.CloseStreams();
+ }
+
+ return sb.ToString();
+ }
+
+ /// Moves a land block's 64 z values, the way the copier did on the way out.
+ private static void ShiftZ(byte[] block, int adjust)
+ {
+ for (int i = 0; i < 64; ++i)
+ {
+ int at = TileMatrix.BlockHeaderSize + (i * 3) + 2;
+
+ block[at] = (byte)(sbyte)Math.Clamp((sbyte)block[at] + adjust,
+ ZHistogram.MinZ, ZHistogram.MaxZ);
+ }
+ }
+
+ private void OnClickSave(object sender, EventArgs e)
+ {
+ using (var dialog = new SaveFileDialog
+ {
+ Title = "Save the copy report",
+ Filter = "Text files (*.txt)|*.txt|All files (*.*)|*.*",
+ FileName = "map-copy.txt",
+ InitialDirectory = Options.OutputPath
+ })
+ {
+ if (dialog.ShowDialog(this) != DialogResult.OK)
+ {
+ return;
+ }
+
+ try
+ {
+ File.WriteAllText(dialog.FileName, reportTextBox.Text);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, "Save failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+ }
+
+ private void OnClickCopy(object sender, EventArgs e)
+ {
+ if (reportTextBox.TextLength > 0)
+ {
+ Clipboard.SetText(reportTextBox.Text);
+ }
+ }
+
+ private void OnClickOpenFolder(object sender, EventArgs e)
+ {
+ string folder = Path.GetDirectoryName(_result.OutputMapPath ?? _result.OutputIndexPath);
+
+ if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder))
+ {
+ return;
+ }
+
+ try
+ {
+ Process.Start(new ProcessStartInfo { FileName = folder, UseShellExecute = true });
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, $"Unable to open folder: {ex.Message}", "Error",
+ MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+
+ private static string Line(string format, params object[] args)
+ {
+ return string.Format(CultureInfo.InvariantCulture, format, args);
+ }
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/MapRegionCopyResultForm.resx b/UoFiddler.Controls/Forms/MapRegionCopyResultForm.resx
new file mode 100644
index 00000000..6dae11dd
--- /dev/null
+++ b/UoFiddler.Controls/Forms/MapRegionCopyResultForm.resx
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/MapReplaceForm.Designer.cs b/UoFiddler.Controls/Forms/MapReplaceForm.Designer.cs
index f9c028b3..6383be9e 100644
--- a/UoFiddler.Controls/Forms/MapReplaceForm.Designer.cs
+++ b/UoFiddler.Controls/Forms/MapReplaceForm.Designer.cs
@@ -1,9 +1,9 @@
/***************************************************************************
*
* $Author: Turley
- *
+ *
* "THE BEER-WARE LICENSE"
- * As long as you retain this notice you can do whatever you want with
+ * As long as you retain this notice you can do whatever you want with
* this stuff. If we meet some day, and you think this stuff is worth it,
* you can buy me a beer in return.
*
@@ -28,416 +28,613 @@ protected override void Dispose(bool disposing)
{
components.Dispose();
}
+
base.Dispose(disposing);
}
#region Windows Form Designer generated code
- ///
- /// Required method for Designer support - do not modify
- /// the contents of this method with the code editor.
- ///
private void InitializeComponent()
{
- this.textBox1 = new System.Windows.Forms.TextBox();
- this.button1 = new System.Windows.Forms.Button();
+ this.components = new System.ComponentModel.Container();
+ this.groupBoxSource = new System.Windows.Forms.GroupBox();
+ this.labelFolder = new System.Windows.Forms.Label();
+ this.textBoxFolder = new System.Windows.Forms.TextBox();
+ this.buttonBrowse = new System.Windows.Forms.Button();
+ this.labelMap = new System.Windows.Forms.Label();
+ this.comboBoxMapID = new System.Windows.Forms.ComboBox();
+ this.labelDetected = new System.Windows.Forms.Label();
+ this.labelSizeWarning = new System.Windows.Forms.Label();
+ this.groupBoxWhat = new System.Windows.Forms.GroupBox();
this.checkBoxMap = new System.Windows.Forms.CheckBox();
+ this.labelMapFormat = new System.Windows.Forms.Label();
+ this.comboBoxMapFormat = new System.Windows.Forms.ComboBox();
this.checkBoxStatics = new System.Windows.Forms.CheckBox();
- this.numericUpDownX1 = new System.Windows.Forms.NumericUpDown();
+ this.RemoveDupl = new System.Windows.Forms.CheckBox();
+ this.checkBoxDuplicatesHue = new System.Windows.Forms.CheckBox();
+ this.groupBoxFrom = new System.Windows.Forms.GroupBox();
this.label1 = new System.Windows.Forms.Label();
+ this.numericUpDownX1 = new System.Windows.Forms.NumericUpDown();
this.label2 = new System.Windows.Forms.Label();
this.numericUpDownY1 = new System.Windows.Forms.NumericUpDown();
this.label3 = new System.Windows.Forms.Label();
this.numericUpDownX2 = new System.Windows.Forms.NumericUpDown();
this.label4 = new System.Windows.Forms.Label();
this.numericUpDownY2 = new System.Windows.Forms.NumericUpDown();
- this.button2 = new System.Windows.Forms.Button();
- this.progressBar1 = new System.Windows.Forms.ProgressBar();
- this.label5 = new System.Windows.Forms.Label();
- this.RemoveDupl = new System.Windows.Forms.CheckBox();
- this.groupBox1 = new System.Windows.Forms.GroupBox();
- this.groupBox2 = new System.Windows.Forms.GroupBox();
- this.groupBox3 = new System.Windows.Forms.GroupBox();
- this.groupBox4 = new System.Windows.Forms.GroupBox();
+ this.groupBoxTo = new System.Windows.Forms.GroupBox();
this.label6 = new System.Windows.Forms.Label();
this.numericUpDownToX1 = new System.Windows.Forms.NumericUpDown();
- this.numericUpDownToY1 = new System.Windows.Forms.NumericUpDown();
this.label7 = new System.Windows.Forms.Label();
- this.comboBoxMapID = new System.Windows.Forms.ComboBox();
- this.label8 = new System.Windows.Forms.Label();
+ this.numericUpDownToY1 = new System.Windows.Forms.NumericUpDown();
+ this.labelZAdjust = new System.Windows.Forms.Label();
+ this.numericUpDownZ = new System.Windows.Forms.NumericUpDown();
+ this.checkBoxZClamp = new System.Windows.Forms.CheckBox();
+ this.labelZRange = new System.Windows.Forms.Label();
+ this.groupBoxPreview = new System.Windows.Forms.GroupBox();
+ this.labelSourcePreview = new System.Windows.Forms.Label();
+ this.previewSource = new UoFiddler.Controls.UserControls.MapRegionPreview();
+ this.labelTargetPreview = new System.Windows.Forms.Label();
+ this.previewTarget = new UoFiddler.Controls.UserControls.MapRegionPreview();
+ this.checkBoxPreviewStatics = new System.Windows.Forms.CheckBox();
+ this.checkBoxPreviewOverlay = new System.Windows.Forms.CheckBox();
+ this.textBoxPreview = new System.Windows.Forms.TextBox();
+ this.progressBar1 = new System.Windows.Forms.ProgressBar();
+ this.labelStatus = new System.Windows.Forms.Label();
+ this.buttonCopy = new System.Windows.Forms.Button();
+ this.buttonCancel = new System.Windows.Forms.Button();
+ this.buttonClose = new System.Windows.Forms.Button();
+ this.worker = new System.ComponentModel.BackgroundWorker();
+ this.components.Add(this.worker);
((System.ComponentModel.ISupportInitialize)(this.numericUpDownX1)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.numericUpDownY1)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.numericUpDownX2)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.numericUpDownY2)).BeginInit();
- this.groupBox1.SuspendLayout();
- this.groupBox2.SuspendLayout();
- this.groupBox3.SuspendLayout();
- this.groupBox4.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.numericUpDownToX1)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.numericUpDownToY1)).BeginInit();
+ ((System.ComponentModel.ISupportInitialize)(this.numericUpDownZ)).BeginInit();
+ this.groupBoxSource.SuspendLayout();
+ this.groupBoxWhat.SuspendLayout();
+ this.groupBoxFrom.SuspendLayout();
+ this.groupBoxTo.SuspendLayout();
+ this.groupBoxPreview.SuspendLayout();
this.SuspendLayout();
- //
- // textBox1
- //
- this.textBox1.Location = new System.Drawing.Point(105, 14);
- this.textBox1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.textBox1.Name = "textBox1";
- this.textBox1.Size = new System.Drawing.Size(227, 23);
- this.textBox1.TabIndex = 0;
- //
- // button1
- //
- this.button1.AutoSize = true;
- this.button1.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink;
- this.button1.Location = new System.Drawing.Point(340, 12);
- this.button1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.button1.Name = "button1";
- this.button1.Size = new System.Drawing.Size(26, 25);
- this.button1.TabIndex = 1;
- this.button1.Text = "...";
- this.button1.UseVisualStyleBackColor = true;
- this.button1.Click += new System.EventHandler(this.OnClickBrowse);
- //
+ //
+ // groupBoxSource
+ //
+ this.groupBoxSource.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
+ this.groupBoxSource.Controls.Add(this.labelFolder);
+ this.groupBoxSource.Controls.Add(this.textBoxFolder);
+ this.groupBoxSource.Controls.Add(this.buttonBrowse);
+ this.groupBoxSource.Controls.Add(this.labelMap);
+ this.groupBoxSource.Controls.Add(this.comboBoxMapID);
+ this.groupBoxSource.Controls.Add(this.labelDetected);
+ this.groupBoxSource.Controls.Add(this.labelSizeWarning);
+ this.groupBoxSource.Location = new System.Drawing.Point(12, 12);
+ this.groupBoxSource.Name = "groupBoxSource";
+ this.groupBoxSource.Size = new System.Drawing.Size(1440, 122);
+ this.groupBoxSource.TabIndex = 0;
+ this.groupBoxSource.TabStop = false;
+ this.groupBoxSource.Text = "Copy from";
+ //
+ // labelFolder
+ //
+ this.labelFolder.Location = new System.Drawing.Point(12, 26);
+ this.labelFolder.Name = "labelFolder";
+ this.labelFolder.Size = new System.Drawing.Size(60, 17);
+ this.labelFolder.TabIndex = 0;
+ this.labelFolder.Text = "Folder:";
+ //
+ // textBoxFolder
+ //
+ this.textBoxFolder.Location = new System.Drawing.Point(76, 23);
+ this.textBoxFolder.Name = "textBoxFolder";
+ this.textBoxFolder.Size = new System.Drawing.Size(320, 23);
+ this.textBoxFolder.TabIndex = 1;
+ //
+ // buttonBrowse
+ //
+ this.buttonBrowse.Location = new System.Drawing.Point(404, 22);
+ this.buttonBrowse.Name = "buttonBrowse";
+ this.buttonBrowse.Size = new System.Drawing.Size(80, 25);
+ this.buttonBrowse.TabIndex = 2;
+ this.buttonBrowse.Text = "Browse...";
+ this.buttonBrowse.UseVisualStyleBackColor = true;
+ this.buttonBrowse.Click += new System.EventHandler(this.OnClickBrowse);
+ //
+ // labelMap
+ //
+ this.labelMap.Location = new System.Drawing.Point(12, 58);
+ this.labelMap.Name = "labelMap";
+ this.labelMap.Size = new System.Drawing.Size(60, 17);
+ this.labelMap.TabIndex = 3;
+ this.labelMap.Text = "Map:";
+ //
+ // comboBoxMapID
+ //
+ this.comboBoxMapID.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
+ this.comboBoxMapID.Location = new System.Drawing.Point(76, 55);
+ this.comboBoxMapID.Name = "comboBoxMapID";
+ this.comboBoxMapID.Size = new System.Drawing.Size(220, 23);
+ this.comboBoxMapID.TabIndex = 4;
+ this.comboBoxMapID.SelectedIndexChanged += new System.EventHandler(this.OnSourceMapChanged);
+ //
+ // labelDetected
+ //
+ this.labelDetected.AutoEllipsis = true;
+ this.labelDetected.Location = new System.Drawing.Point(304, 58);
+ this.labelDetected.Name = "labelDetected";
+ this.labelDetected.Size = new System.Drawing.Size(180, 17);
+ this.labelDetected.TabIndex = 5;
+ //
+ // labelSizeWarning
+ //
+ this.labelSizeWarning.Location = new System.Drawing.Point(12, 80);
+ this.labelSizeWarning.Name = "labelSizeWarning";
+ this.labelSizeWarning.Size = new System.Drawing.Size(472, 36);
+ this.labelSizeWarning.TabIndex = 6;
+ //
+ // groupBoxWhat
+ //
+ this.groupBoxWhat.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
+ this.groupBoxWhat.Controls.Add(this.checkBoxMap);
+ this.groupBoxWhat.Controls.Add(this.labelMapFormat);
+ this.groupBoxWhat.Controls.Add(this.comboBoxMapFormat);
+ this.groupBoxWhat.Controls.Add(this.checkBoxStatics);
+ this.groupBoxWhat.Controls.Add(this.RemoveDupl);
+ this.groupBoxWhat.Controls.Add(this.checkBoxDuplicatesHue);
+ this.groupBoxWhat.Location = new System.Drawing.Point(12, 140);
+ this.groupBoxWhat.Name = "groupBoxWhat";
+ this.groupBoxWhat.Size = new System.Drawing.Size(1440, 86);
+ this.groupBoxWhat.TabIndex = 1;
+ this.groupBoxWhat.TabStop = false;
+ this.groupBoxWhat.Text = "Copy";
+ //
// checkBoxMap
- //
- this.checkBoxMap.AutoSize = true;
- this.checkBoxMap.Location = new System.Drawing.Point(7, 22);
- this.checkBoxMap.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ //
+ this.checkBoxMap.Location = new System.Drawing.Point(16, 22);
this.checkBoxMap.Name = "checkBoxMap";
- this.checkBoxMap.Size = new System.Drawing.Size(81, 19);
- this.checkBoxMap.TabIndex = 2;
- this.checkBoxMap.Text = "Copy Map";
+ this.checkBoxMap.Size = new System.Drawing.Size(110, 21);
+ this.checkBoxMap.TabIndex = 0;
+ this.checkBoxMap.Text = "Map";
this.checkBoxMap.UseVisualStyleBackColor = true;
- //
+ this.checkBoxMap.CheckedChanged += new System.EventHandler(this.OnOptionChanged);
+ //
+ // labelMapFormat
+ //
+ this.labelMapFormat.Location = new System.Drawing.Point(140, 25);
+ this.labelMapFormat.Name = "labelMapFormat";
+ this.labelMapFormat.Size = new System.Drawing.Size(80, 17);
+ this.labelMapFormat.TabIndex = 1;
+ this.labelMapFormat.Text = "written as:";
+ //
+ // comboBoxMapFormat
+ //
+ this.comboBoxMapFormat.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
+ this.comboBoxMapFormat.Location = new System.Drawing.Point(224, 21);
+ this.comboBoxMapFormat.Name = "comboBoxMapFormat";
+ this.comboBoxMapFormat.Size = new System.Drawing.Size(256, 23);
+ this.comboBoxMapFormat.TabIndex = 2;
+ //
// checkBoxStatics
- //
- this.checkBoxStatics.AutoSize = true;
- this.checkBoxStatics.Location = new System.Drawing.Point(13, 22);
- this.checkBoxStatics.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ //
+ this.checkBoxStatics.Location = new System.Drawing.Point(16, 52);
this.checkBoxStatics.Name = "checkBoxStatics";
- this.checkBoxStatics.Size = new System.Drawing.Size(91, 19);
+ this.checkBoxStatics.Size = new System.Drawing.Size(110, 21);
this.checkBoxStatics.TabIndex = 3;
- this.checkBoxStatics.Text = "Copy Statics";
+ this.checkBoxStatics.Text = "Statics";
this.checkBoxStatics.UseVisualStyleBackColor = true;
- //
- // numericUpDownX1
- //
- this.numericUpDownX1.Increment = new decimal(new int[] {
- 8,
- 0,
- 0,
- 0});
- this.numericUpDownX1.Location = new System.Drawing.Point(99, 22);
- this.numericUpDownX1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.numericUpDownX1.Name = "numericUpDownX1";
- this.numericUpDownX1.Size = new System.Drawing.Size(63, 23);
- this.numericUpDownX1.TabIndex = 4;
- //
+ this.checkBoxStatics.CheckedChanged += new System.EventHandler(this.OnOptionChanged);
+ //
+ // RemoveDupl
+ //
+ this.RemoveDupl.Location = new System.Drawing.Point(140, 52);
+ this.RemoveDupl.Name = "RemoveDupl";
+ this.RemoveDupl.Size = new System.Drawing.Size(150, 21);
+ this.RemoveDupl.TabIndex = 4;
+ this.RemoveDupl.Text = "remove duplicates";
+ this.RemoveDupl.UseVisualStyleBackColor = true;
+ this.RemoveDupl.CheckedChanged += new System.EventHandler(this.OnOptionChanged);
+ //
+ // checkBoxDuplicatesHue
+ //
+ this.checkBoxDuplicatesHue.Location = new System.Drawing.Point(296, 52);
+ this.checkBoxDuplicatesHue.Name = "checkBoxDuplicatesHue";
+ this.checkBoxDuplicatesHue.Size = new System.Drawing.Size(184, 21);
+ this.checkBoxDuplicatesHue.TabIndex = 5;
+ this.checkBoxDuplicatesHue.Text = "comparing hue too (legacy)";
+ this.checkBoxDuplicatesHue.UseVisualStyleBackColor = true;
+ //
+ // groupBoxFrom
+ //
+ this.groupBoxFrom.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
+ this.groupBoxFrom.Controls.Add(this.label1);
+ this.groupBoxFrom.Controls.Add(this.numericUpDownX1);
+ this.groupBoxFrom.Controls.Add(this.label2);
+ this.groupBoxFrom.Controls.Add(this.numericUpDownY1);
+ this.groupBoxFrom.Controls.Add(this.label3);
+ this.groupBoxFrom.Controls.Add(this.numericUpDownX2);
+ this.groupBoxFrom.Controls.Add(this.label4);
+ this.groupBoxFrom.Controls.Add(this.numericUpDownY2);
+ this.groupBoxFrom.Location = new System.Drawing.Point(12, 232);
+ this.groupBoxFrom.Name = "groupBoxFrom";
+ this.groupBoxFrom.Size = new System.Drawing.Size(1440, 60);
+ this.groupBoxFrom.TabIndex = 2;
+ this.groupBoxFrom.TabStop = false;
+ this.groupBoxFrom.Text = "From region, in source map tiles";
+ //
// label1
- //
- this.label1.AutoSize = true;
- this.label1.Location = new System.Drawing.Point(63, 24);
- this.label1.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
+ //
+ this.label1.Location = new System.Drawing.Point(16, 26);
this.label1.Name = "label1";
- this.label1.Size = new System.Drawing.Size(20, 15);
- this.label1.TabIndex = 5;
+ this.label1.Size = new System.Drawing.Size(24, 17);
+ this.label1.TabIndex = 0;
this.label1.Text = "X1";
- //
+ //
+ // numericUpDownX1
+ //
+ this.numericUpDownX1.Increment = new decimal(new int[] { 8, 0, 0, 0 });
+ this.numericUpDownX1.Location = new System.Drawing.Point(44, 23);
+ this.numericUpDownX1.Name = "numericUpDownX1";
+ this.numericUpDownX1.Size = new System.Drawing.Size(70, 23);
+ this.numericUpDownX1.TabIndex = 1;
+ this.numericUpDownX1.ValueChanged += new System.EventHandler(this.OnRegionChanged);
+ //
// label2
- //
- this.label2.AutoSize = true;
- this.label2.Location = new System.Drawing.Point(63, 54);
- this.label2.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
+ //
+ this.label2.Location = new System.Drawing.Point(124, 26);
this.label2.Name = "label2";
- this.label2.Size = new System.Drawing.Size(20, 15);
- this.label2.TabIndex = 7;
+ this.label2.Size = new System.Drawing.Size(24, 17);
+ this.label2.TabIndex = 2;
this.label2.Text = "Y1";
- //
+ //
// numericUpDownY1
- //
- this.numericUpDownY1.Increment = new decimal(new int[] {
- 8,
- 0,
- 0,
- 0});
- this.numericUpDownY1.Location = new System.Drawing.Point(99, 52);
- this.numericUpDownY1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ //
+ this.numericUpDownY1.Increment = new decimal(new int[] { 8, 0, 0, 0 });
+ this.numericUpDownY1.Location = new System.Drawing.Point(152, 23);
this.numericUpDownY1.Name = "numericUpDownY1";
- this.numericUpDownY1.Size = new System.Drawing.Size(63, 23);
- this.numericUpDownY1.TabIndex = 6;
- //
+ this.numericUpDownY1.Size = new System.Drawing.Size(70, 23);
+ this.numericUpDownY1.TabIndex = 3;
+ this.numericUpDownY1.ValueChanged += new System.EventHandler(this.OnRegionChanged);
+ //
// label3
- //
- this.label3.AutoSize = true;
- this.label3.Location = new System.Drawing.Point(192, 24);
- this.label3.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
+ //
+ this.label3.Location = new System.Drawing.Point(248, 26);
this.label3.Name = "label3";
- this.label3.Size = new System.Drawing.Size(20, 15);
- this.label3.TabIndex = 9;
+ this.label3.Size = new System.Drawing.Size(24, 17);
+ this.label3.TabIndex = 4;
this.label3.Text = "X2";
- //
+ //
// numericUpDownX2
- //
- this.numericUpDownX2.Increment = new decimal(new int[] {
- 8,
- 0,
- 0,
- 0});
- this.numericUpDownX2.Location = new System.Drawing.Point(229, 22);
- this.numericUpDownX2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ //
+ this.numericUpDownX2.Increment = new decimal(new int[] { 8, 0, 0, 0 });
+ this.numericUpDownX2.Location = new System.Drawing.Point(276, 23);
this.numericUpDownX2.Name = "numericUpDownX2";
- this.numericUpDownX2.Size = new System.Drawing.Size(63, 23);
- this.numericUpDownX2.TabIndex = 8;
- //
+ this.numericUpDownX2.Size = new System.Drawing.Size(70, 23);
+ this.numericUpDownX2.TabIndex = 5;
+ this.numericUpDownX2.ValueChanged += new System.EventHandler(this.OnRegionChanged);
+ //
// label4
- //
- this.label4.AutoSize = true;
- this.label4.Location = new System.Drawing.Point(192, 57);
- this.label4.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
+ //
+ this.label4.Location = new System.Drawing.Point(356, 26);
this.label4.Name = "label4";
- this.label4.Size = new System.Drawing.Size(20, 15);
- this.label4.TabIndex = 11;
+ this.label4.Size = new System.Drawing.Size(24, 17);
+ this.label4.TabIndex = 6;
this.label4.Text = "Y2";
- //
+ //
// numericUpDownY2
- //
- this.numericUpDownY2.Increment = new decimal(new int[] {
- 8,
- 0,
- 0,
- 0});
- this.numericUpDownY2.Location = new System.Drawing.Point(229, 54);
- this.numericUpDownY2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ //
+ this.numericUpDownY2.Increment = new decimal(new int[] { 8, 0, 0, 0 });
+ this.numericUpDownY2.Location = new System.Drawing.Point(384, 23);
this.numericUpDownY2.Name = "numericUpDownY2";
- this.numericUpDownY2.Size = new System.Drawing.Size(63, 23);
- this.numericUpDownY2.TabIndex = 10;
- //
- // button2
- //
- this.button2.Location = new System.Drawing.Point(144, 318);
- this.button2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.button2.Name = "button2";
- this.button2.Size = new System.Drawing.Size(88, 27);
- this.button2.TabIndex = 12;
- this.button2.Text = "Replace";
- this.button2.UseVisualStyleBackColor = true;
- this.button2.Click += new System.EventHandler(this.OnClickCopy);
- //
- // progressBar1
- //
- this.progressBar1.Dock = System.Windows.Forms.DockStyle.Bottom;
- this.progressBar1.Location = new System.Drawing.Point(0, 354);
- this.progressBar1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.progressBar1.Name = "progressBar1";
- this.progressBar1.Size = new System.Drawing.Size(384, 27);
- this.progressBar1.TabIndex = 13;
- //
- // label5
- //
- this.label5.AutoSize = true;
- this.label5.Location = new System.Drawing.Point(13, 17);
- this.label5.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
- this.label5.Name = "label5";
- this.label5.Size = new System.Drawing.Size(79, 15);
- this.label5.TabIndex = 14;
- this.label5.Text = "Replace From";
- //
- // RemoveDupl
- //
- this.RemoveDupl.AutoSize = true;
- this.RemoveDupl.Location = new System.Drawing.Point(13, 48);
- this.RemoveDupl.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.RemoveDupl.Name = "RemoveDupl";
- this.RemoveDupl.Size = new System.Drawing.Size(127, 19);
- this.RemoveDupl.TabIndex = 17;
- this.RemoveDupl.Text = "Remove Duplicates";
- this.RemoveDupl.UseVisualStyleBackColor = true;
- //
- // groupBox1
- //
- this.groupBox1.Controls.Add(this.checkBoxMap);
- this.groupBox1.Location = new System.Drawing.Point(15, 75);
- this.groupBox1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.groupBox1.Name = "groupBox1";
- this.groupBox1.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.groupBox1.Size = new System.Drawing.Size(169, 75);
- this.groupBox1.TabIndex = 19;
- this.groupBox1.TabStop = false;
- this.groupBox1.Text = "Map";
- //
- // groupBox2
- //
- this.groupBox2.Controls.Add(this.checkBoxStatics);
- this.groupBox2.Controls.Add(this.RemoveDupl);
- this.groupBox2.Location = new System.Drawing.Point(201, 75);
- this.groupBox2.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.groupBox2.Name = "groupBox2";
- this.groupBox2.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.groupBox2.Size = new System.Drawing.Size(169, 75);
- this.groupBox2.TabIndex = 20;
- this.groupBox2.TabStop = false;
- this.groupBox2.Text = "Statics";
- //
- // groupBox3
- //
- this.groupBox3.Controls.Add(this.label1);
- this.groupBox3.Controls.Add(this.numericUpDownX1);
- this.groupBox3.Controls.Add(this.numericUpDownY1);
- this.groupBox3.Controls.Add(this.label2);
- this.groupBox3.Controls.Add(this.numericUpDownX2);
- this.groupBox3.Controls.Add(this.label4);
- this.groupBox3.Controls.Add(this.label3);
- this.groupBox3.Controls.Add(this.numericUpDownY2);
- this.groupBox3.Location = new System.Drawing.Point(15, 157);
- this.groupBox3.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.groupBox3.Name = "groupBox3";
- this.groupBox3.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.groupBox3.Size = new System.Drawing.Size(355, 85);
- this.groupBox3.TabIndex = 21;
- this.groupBox3.TabStop = false;
- this.groupBox3.Text = "From Region";
- //
- // groupBox4
- //
- this.groupBox4.Controls.Add(this.label6);
- this.groupBox4.Controls.Add(this.numericUpDownToX1);
- this.groupBox4.Controls.Add(this.numericUpDownToY1);
- this.groupBox4.Controls.Add(this.label7);
- this.groupBox4.Location = new System.Drawing.Point(15, 249);
- this.groupBox4.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.groupBox4.Name = "groupBox4";
- this.groupBox4.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.groupBox4.Size = new System.Drawing.Size(355, 62);
- this.groupBox4.TabIndex = 22;
- this.groupBox4.TabStop = false;
- this.groupBox4.Text = "To Region";
- //
+ this.numericUpDownY2.Size = new System.Drawing.Size(70, 23);
+ this.numericUpDownY2.TabIndex = 7;
+ this.numericUpDownY2.ValueChanged += new System.EventHandler(this.OnRegionChanged);
+ //
+ // groupBoxTo
+ //
+ this.groupBoxTo.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
+ this.groupBoxTo.Controls.Add(this.label6);
+ this.groupBoxTo.Controls.Add(this.numericUpDownToX1);
+ this.groupBoxTo.Controls.Add(this.label7);
+ this.groupBoxTo.Controls.Add(this.numericUpDownToY1);
+ this.groupBoxTo.Controls.Add(this.labelZAdjust);
+ this.groupBoxTo.Controls.Add(this.numericUpDownZ);
+ this.groupBoxTo.Controls.Add(this.checkBoxZClamp);
+ this.groupBoxTo.Controls.Add(this.labelZRange);
+ this.groupBoxTo.Location = new System.Drawing.Point(12, 298);
+ this.groupBoxTo.Name = "groupBoxTo";
+ this.groupBoxTo.Size = new System.Drawing.Size(1440, 60);
+ this.groupBoxTo.TabIndex = 3;
+ this.groupBoxTo.TabStop = false;
+ this.groupBoxTo.Text = "To position, in this map\'s tiles";
+ //
// label6
- //
- this.label6.AutoSize = true;
- this.label6.Location = new System.Drawing.Point(63, 24);
- this.label6.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
+ //
+ this.label6.Location = new System.Drawing.Point(16, 26);
this.label6.Name = "label6";
- this.label6.Size = new System.Drawing.Size(20, 15);
- this.label6.TabIndex = 5;
- this.label6.Text = "X1";
- //
+ this.label6.Size = new System.Drawing.Size(24, 17);
+ this.label6.TabIndex = 0;
+ this.label6.Text = "X";
+ //
// numericUpDownToX1
- //
- this.numericUpDownToX1.Increment = new decimal(new int[] {
- 8,
- 0,
- 0,
- 0});
- this.numericUpDownToX1.Location = new System.Drawing.Point(99, 22);
- this.numericUpDownToX1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ //
+ this.numericUpDownToX1.Increment = new decimal(new int[] { 8, 0, 0, 0 });
+ this.numericUpDownToX1.Location = new System.Drawing.Point(44, 23);
this.numericUpDownToX1.Name = "numericUpDownToX1";
- this.numericUpDownToX1.Size = new System.Drawing.Size(63, 23);
- this.numericUpDownToX1.TabIndex = 4;
- //
- // numericUpDownToY1
- //
- this.numericUpDownToY1.Increment = new decimal(new int[] {
- 8,
- 0,
- 0,
- 0});
- this.numericUpDownToY1.Location = new System.Drawing.Point(229, 22);
- this.numericUpDownToY1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.numericUpDownToY1.Name = "numericUpDownToY1";
- this.numericUpDownToY1.Size = new System.Drawing.Size(63, 23);
- this.numericUpDownToY1.TabIndex = 6;
- //
+ this.numericUpDownToX1.Size = new System.Drawing.Size(70, 23);
+ this.numericUpDownToX1.TabIndex = 1;
+ this.numericUpDownToX1.ValueChanged += new System.EventHandler(this.OnRegionChanged);
+ //
// label7
- //
- this.label7.AutoSize = true;
- this.label7.Location = new System.Drawing.Point(192, 24);
- this.label7.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
+ //
+ this.label7.Location = new System.Drawing.Point(124, 26);
this.label7.Name = "label7";
- this.label7.Size = new System.Drawing.Size(20, 15);
- this.label7.TabIndex = 7;
- this.label7.Text = "Y1";
- //
- // comboBoxMapID
- //
- this.comboBoxMapID.FormattingEnabled = true;
- this.comboBoxMapID.Location = new System.Drawing.Point(105, 44);
- this.comboBoxMapID.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.comboBoxMapID.Name = "comboBoxMapID";
- this.comboBoxMapID.Size = new System.Drawing.Size(227, 23);
- this.comboBoxMapID.TabIndex = 23;
- //
- // label8
- //
- this.label8.AutoSize = true;
- this.label8.Location = new System.Drawing.Point(13, 47);
- this.label8.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
- this.label8.Name = "label8";
- this.label8.Size = new System.Drawing.Size(45, 15);
- this.label8.TabIndex = 24;
- this.label8.Text = "Map ID";
- //
+ this.label7.Size = new System.Drawing.Size(24, 17);
+ this.label7.TabIndex = 2;
+ this.label7.Text = "Y";
+ //
+ // numericUpDownToY1
+ //
+ this.numericUpDownToY1.Increment = new decimal(new int[] { 8, 0, 0, 0 });
+ this.numericUpDownToY1.Location = new System.Drawing.Point(152, 23);
+ this.numericUpDownToY1.Name = "numericUpDownToY1";
+ this.numericUpDownToY1.Size = new System.Drawing.Size(70, 23);
+ this.numericUpDownToY1.TabIndex = 3;
+ this.numericUpDownToY1.ValueChanged += new System.EventHandler(this.OnRegionChanged);
+ //
+ // labelZAdjust
+ //
+ this.labelZAdjust.Location = new System.Drawing.Point(248, 26);
+ this.labelZAdjust.Name = "labelZAdjust";
+ this.labelZAdjust.Size = new System.Drawing.Size(64, 17);
+ this.labelZAdjust.TabIndex = 4;
+ this.labelZAdjust.Text = "Z adjust";
+ //
+ // numericUpDownZ
+ //
+ this.numericUpDownZ.Location = new System.Drawing.Point(316, 23);
+ this.numericUpDownZ.Maximum = new decimal(new int[] { 255, 0, 0, 0 });
+ this.numericUpDownZ.Minimum = new decimal(new int[] { 255, 0, 0, -2147483648 });
+ this.numericUpDownZ.Name = "numericUpDownZ";
+ this.numericUpDownZ.Size = new System.Drawing.Size(70, 23);
+ this.numericUpDownZ.TabIndex = 5;
+ this.numericUpDownZ.ValueChanged += new System.EventHandler(this.OnRegionChanged);
+ //
+ // checkBoxZClamp
+ //
+ this.checkBoxZClamp.Location = new System.Drawing.Point(396, 24);
+ this.checkBoxZClamp.Name = "checkBoxZClamp";
+ this.checkBoxZClamp.Size = new System.Drawing.Size(206, 21);
+ this.checkBoxZClamp.TabIndex = 6;
+ this.checkBoxZClamp.Text = "hold what passes the limit";
+ this.checkBoxZClamp.UseVisualStyleBackColor = true;
+ this.checkBoxZClamp.CheckedChanged += new System.EventHandler(this.OnRegionChanged);
+ //
+ // labelZRange
+ //
+ this.labelZRange.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
+ this.labelZRange.AutoEllipsis = true;
+ this.labelZRange.Location = new System.Drawing.Point(612, 26);
+ this.labelZRange.Name = "labelZRange";
+ this.labelZRange.Size = new System.Drawing.Size(812, 17);
+ this.labelZRange.TabIndex = 7;
+ //
+ // groupBoxPreview
+ //
+ this.groupBoxPreview.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
+ this.groupBoxPreview.Controls.Add(this.labelSourcePreview);
+ this.groupBoxPreview.Controls.Add(this.previewSource);
+ this.groupBoxPreview.Controls.Add(this.labelTargetPreview);
+ this.groupBoxPreview.Controls.Add(this.previewTarget);
+ this.groupBoxPreview.Controls.Add(this.checkBoxPreviewStatics);
+ this.groupBoxPreview.Controls.Add(this.checkBoxPreviewOverlay);
+ this.groupBoxPreview.Controls.Add(this.textBoxPreview);
+ this.groupBoxPreview.Location = new System.Drawing.Point(12, 364);
+ this.groupBoxPreview.Name = "groupBoxPreview";
+ this.groupBoxPreview.Size = new System.Drawing.Size(1440, 512);
+ this.groupBoxPreview.TabIndex = 4;
+ this.groupBoxPreview.TabStop = false;
+ this.groupBoxPreview.Text = "What will be copied";
+ //
+ // labelSourcePreview
+ //
+ this.labelSourcePreview.Location = new System.Drawing.Point(16, 20);
+ this.labelSourcePreview.Name = "labelSourcePreview";
+ this.labelSourcePreview.Size = new System.Drawing.Size(696, 17);
+ this.labelSourcePreview.TabIndex = 0;
+ this.labelSourcePreview.Text = "From - drag to choose the region";
+ //
+ // previewSource
+ //
+ this.previewSource.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
+ this.previewSource.Location = new System.Drawing.Point(16, 40);
+ this.previewSource.Name = "previewSource";
+ this.previewSource.Size = new System.Drawing.Size(696, 400);
+ this.previewSource.TabIndex = 1;
+ //
+ // labelTargetPreview
+ //
+ this.labelTargetPreview.Location = new System.Drawing.Point(728, 20);
+ this.labelTargetPreview.Name = "labelTargetPreview";
+ this.labelTargetPreview.Size = new System.Drawing.Size(696, 17);
+ this.labelTargetPreview.TabIndex = 2;
+ this.labelTargetPreview.Text = "To - drag to place it";
+ //
+ // previewTarget
+ //
+ this.previewTarget.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
+ this.previewTarget.Location = new System.Drawing.Point(728, 40);
+ this.previewTarget.Name = "previewTarget";
+ this.previewTarget.Size = new System.Drawing.Size(696, 400);
+ this.previewTarget.TabIndex = 3;
+ //
+ // checkBoxPreviewStatics
+ //
+ this.checkBoxPreviewStatics.Location = new System.Drawing.Point(16, 448);
+ this.checkBoxPreviewStatics.Name = "checkBoxPreviewStatics";
+ this.checkBoxPreviewStatics.Size = new System.Drawing.Size(120, 21);
+ this.checkBoxPreviewStatics.TabIndex = 4;
+ this.checkBoxPreviewStatics.Text = "Show statics";
+ this.checkBoxPreviewStatics.UseVisualStyleBackColor = true;
+ this.checkBoxPreviewStatics.CheckedChanged += new System.EventHandler(this.OnPreviewOptionChanged);
+ //
+ // checkBoxPreviewOverlay
+ //
+ this.checkBoxPreviewOverlay.Location = new System.Drawing.Point(144, 448);
+ this.checkBoxPreviewOverlay.Name = "checkBoxPreviewOverlay";
+ this.checkBoxPreviewOverlay.Size = new System.Drawing.Size(230, 21);
+ this.checkBoxPreviewOverlay.TabIndex = 5;
+ this.checkBoxPreviewOverlay.Text = "Show the piece in place";
+ this.checkBoxPreviewOverlay.UseVisualStyleBackColor = true;
+ this.checkBoxPreviewOverlay.CheckedChanged += new System.EventHandler(this.OnPreviewOptionChanged);
+ //
+ // textBoxPreview
+ //
+ this.textBoxPreview.BackColor = System.Drawing.SystemColors.Control;
+ this.textBoxPreview.BorderStyle = System.Windows.Forms.BorderStyle.None;
+ this.textBoxPreview.Location = new System.Drawing.Point(728, 444);
+ this.textBoxPreview.Multiline = true;
+ this.textBoxPreview.Name = "textBoxPreview";
+ this.textBoxPreview.ReadOnly = true;
+ this.textBoxPreview.ScrollBars = System.Windows.Forms.ScrollBars.None;
+ this.textBoxPreview.Size = new System.Drawing.Size(696, 56);
+ this.textBoxPreview.TabIndex = 6;
+ this.textBoxPreview.TabStop = false;
+ //
+ // progressBar1
+ //
+ this.progressBar1.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
+ this.progressBar1.Location = new System.Drawing.Point(12, 884);
+ this.progressBar1.Name = "progressBar1";
+ this.progressBar1.Size = new System.Drawing.Size(1440, 18);
+ this.progressBar1.TabIndex = 5;
+ //
+ // labelStatus
+ //
+ this.labelStatus.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
+ this.labelStatus.AutoEllipsis = true;
+ this.labelStatus.Location = new System.Drawing.Point(12, 907);
+ this.labelStatus.Name = "labelStatus";
+ this.labelStatus.Size = new System.Drawing.Size(1440, 17);
+ this.labelStatus.TabIndex = 6;
+ //
+ // buttonCopy
+ //
+ this.buttonCopy.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
+ this.buttonCopy.Location = new System.Drawing.Point(1204, 932);
+ this.buttonCopy.Name = "buttonCopy";
+ this.buttonCopy.Size = new System.Drawing.Size(80, 28);
+ this.buttonCopy.TabIndex = 7;
+ this.buttonCopy.Text = "Copy";
+ this.buttonCopy.UseVisualStyleBackColor = true;
+ this.buttonCopy.Click += new System.EventHandler(this.OnClickCopy);
+ //
+ // buttonCancel
+ //
+ this.buttonCancel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
+ this.buttonCancel.Enabled = false;
+ this.buttonCancel.Location = new System.Drawing.Point(1292, 932);
+ this.buttonCancel.Name = "buttonCancel";
+ this.buttonCancel.Size = new System.Drawing.Size(72, 28);
+ this.buttonCancel.TabIndex = 8;
+ this.buttonCancel.Text = "Cancel";
+ this.buttonCancel.UseVisualStyleBackColor = true;
+ this.buttonCancel.Click += new System.EventHandler(this.OnClickCancel);
+ //
+ // buttonClose
+ //
+ this.buttonClose.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
+ this.buttonClose.DialogResult = System.Windows.Forms.DialogResult.Cancel;
+ this.buttonClose.Location = new System.Drawing.Point(1380, 932);
+ this.buttonClose.Name = "buttonClose";
+ this.buttonClose.Size = new System.Drawing.Size(80, 28);
+ this.buttonClose.TabIndex = 9;
+ this.buttonClose.Text = "Close";
+ this.buttonClose.UseVisualStyleBackColor = true;
+ this.buttonClose.Click += new System.EventHandler(this.OnClickClose);
+ //
+ // worker
+ //
+ this.worker.WorkerSupportsCancellation = true;
+ this.worker.DoWork += new System.ComponentModel.DoWorkEventHandler(this.OnWorkerDoWork);
+ this.worker.RunWorkerCompleted += new System.ComponentModel.RunWorkerCompletedEventHandler(this.OnWorkerCompleted);
+ //
// MapReplaceForm
- //
+ //
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
- this.ClientSize = new System.Drawing.Size(384, 381);
- this.Controls.Add(this.label8);
- this.Controls.Add(this.comboBoxMapID);
- this.Controls.Add(this.groupBox4);
- this.Controls.Add(this.groupBox3);
- this.Controls.Add(this.groupBox2);
- this.Controls.Add(this.groupBox1);
- this.Controls.Add(this.label5);
+ this.CancelButton = this.buttonClose;
+ this.ClientSize = new System.Drawing.Size(1464, 972);
+ this.Controls.Add(this.groupBoxSource);
+ this.Controls.Add(this.groupBoxWhat);
+ this.Controls.Add(this.groupBoxFrom);
+ this.Controls.Add(this.groupBoxTo);
+ this.Controls.Add(this.groupBoxPreview);
this.Controls.Add(this.progressBar1);
- this.Controls.Add(this.button2);
- this.Controls.Add(this.button1);
- this.Controls.Add(this.textBox1);
+ this.Controls.Add(this.labelStatus);
+ this.Controls.Add(this.buttonCopy);
+ this.Controls.Add(this.buttonCancel);
+ this.Controls.Add(this.buttonClose);
this.DoubleBuffered = true;
- this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow;
- this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
- this.MaximizeBox = false;
- this.MinimumSize = new System.Drawing.Size(388, 285);
+ this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.Sizable;
+ this.MinimizeBox = false;
+ this.MinimumSize = new System.Drawing.Size(900, 760);
this.Name = "MapReplaceForm";
- this.Text = "MapReplace";
+ this.ShowInTaskbar = false;
+ this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
+ this.Text = "Map and Statics Copy";
((System.ComponentModel.ISupportInitialize)(this.numericUpDownX1)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numericUpDownY1)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numericUpDownX2)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numericUpDownY2)).EndInit();
- this.groupBox1.ResumeLayout(false);
- this.groupBox1.PerformLayout();
- this.groupBox2.ResumeLayout(false);
- this.groupBox2.PerformLayout();
- this.groupBox3.ResumeLayout(false);
- this.groupBox3.PerformLayout();
- this.groupBox4.ResumeLayout(false);
- this.groupBox4.PerformLayout();
((System.ComponentModel.ISupportInitialize)(this.numericUpDownToX1)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numericUpDownToY1)).EndInit();
+ ((System.ComponentModel.ISupportInitialize)(this.numericUpDownZ)).EndInit();
+ this.groupBoxSource.ResumeLayout(false);
+ this.groupBoxSource.PerformLayout();
+ this.groupBoxWhat.ResumeLayout(false);
+ this.groupBoxFrom.ResumeLayout(false);
+ this.groupBoxTo.ResumeLayout(false);
+ this.groupBoxPreview.ResumeLayout(false);
+ this.groupBoxPreview.PerformLayout();
this.ResumeLayout(false);
- this.PerformLayout();
}
#endregion
- private System.Windows.Forms.Button button1;
- private System.Windows.Forms.Button button2;
+ private System.ComponentModel.BackgroundWorker worker;
+ private System.Windows.Forms.Button buttonBrowse;
+ private System.Windows.Forms.Button buttonCancel;
+ private System.Windows.Forms.Button buttonClose;
+ private System.Windows.Forms.Button buttonCopy;
+ private System.Windows.Forms.CheckBox RemoveDupl;
+ private System.Windows.Forms.CheckBox checkBoxDuplicatesHue;
private System.Windows.Forms.CheckBox checkBoxMap;
private System.Windows.Forms.CheckBox checkBoxStatics;
+ private System.Windows.Forms.ComboBox comboBoxMapFormat;
private System.Windows.Forms.ComboBox comboBoxMapID;
- private System.Windows.Forms.GroupBox groupBox1;
- private System.Windows.Forms.GroupBox groupBox2;
- private System.Windows.Forms.GroupBox groupBox3;
- private System.Windows.Forms.GroupBox groupBox4;
+ private System.Windows.Forms.GroupBox groupBoxFrom;
+ private System.Windows.Forms.Label labelZAdjust;
+ private System.Windows.Forms.NumericUpDown numericUpDownZ;
+ private System.Windows.Forms.CheckBox checkBoxZClamp;
+ private System.Windows.Forms.Label labelZRange;
+ private System.Windows.Forms.GroupBox groupBoxPreview;
+ private System.Windows.Forms.GroupBox groupBoxSource;
+ private System.Windows.Forms.GroupBox groupBoxTo;
+ private System.Windows.Forms.GroupBox groupBoxWhat;
private System.Windows.Forms.Label label1;
private System.Windows.Forms.Label label2;
private System.Windows.Forms.Label label3;
private System.Windows.Forms.Label label4;
- private System.Windows.Forms.Label label5;
private System.Windows.Forms.Label label6;
private System.Windows.Forms.Label label7;
- private System.Windows.Forms.Label label8;
+ private System.Windows.Forms.Label labelDetected;
+ private System.Windows.Forms.Label labelFolder;
+ private System.Windows.Forms.Label labelMap;
+ private System.Windows.Forms.Label labelMapFormat;
+ private System.Windows.Forms.Label labelSizeWarning;
+ private System.Windows.Forms.Label labelStatus;
private System.Windows.Forms.NumericUpDown numericUpDownToX1;
private System.Windows.Forms.NumericUpDown numericUpDownToY1;
private System.Windows.Forms.NumericUpDown numericUpDownX1;
@@ -445,7 +642,13 @@ private void InitializeComponent()
private System.Windows.Forms.NumericUpDown numericUpDownY1;
private System.Windows.Forms.NumericUpDown numericUpDownY2;
private System.Windows.Forms.ProgressBar progressBar1;
- private System.Windows.Forms.CheckBox RemoveDupl;
- private System.Windows.Forms.TextBox textBox1;
+ private System.Windows.Forms.TextBox textBoxFolder;
+ private System.Windows.Forms.CheckBox checkBoxPreviewOverlay;
+ private System.Windows.Forms.CheckBox checkBoxPreviewStatics;
+ private System.Windows.Forms.Label labelSourcePreview;
+ private System.Windows.Forms.Label labelTargetPreview;
+ private System.Windows.Forms.TextBox textBoxPreview;
+ private UoFiddler.Controls.UserControls.MapRegionPreview previewSource;
+ private UoFiddler.Controls.UserControls.MapRegionPreview previewTarget;
}
}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/MapReplaceForm.cs b/UoFiddler.Controls/Forms/MapReplaceForm.cs
index abe2f51a..4c6c7674 100644
--- a/UoFiddler.Controls/Forms/MapReplaceForm.cs
+++ b/UoFiddler.Controls/Forms/MapReplaceForm.cs
@@ -1,19 +1,30 @@
/***************************************************************************
*
* $Author: Turley
- *
+ *
* "THE BEER-WARE LICENSE"
- * As long as you retain this notice you can do whatever you want with
+ * As long as you retain this notice you can do whatever you want with
* this stuff. If we meet some day, and you think this stuff is worth it,
* you can buy me a beer in return.
*
***************************************************************************/
using System;
+using System.ComponentModel;
+using System.Drawing;
+using System.Globalization;
+using System.Linq;
using System.IO;
+using System.Text;
+using System.Threading;
using System.Windows.Forms;
+using Microsoft.Extensions.Logging;
using Ultima;
+using Ultima.Helpers;
+using Ultima.Maps;
+using Ultima.Statics;
using UoFiddler.Controls.Classes;
+using UoFiddler.Controls.UserControls;
namespace UoFiddler.Controls.Forms
{
@@ -21,497 +32,921 @@ public partial class MapReplaceForm : Form
{
private readonly Map _workingMap;
+ private CancellationTokenSource _cancellation;
+ private MapSize _detectedSize;
+ private bool _detectionKnown;
+ private string _detectionEvidence;
+
+ /// Built on the browsed folder so the source panel renders that install, not this one.
+ private Map _sourceMap;
+
+ /// Guards the round trip between a drag on a panel and the spinners it writes to.
+ private bool _syncingPreview;
+
+ ///
+ /// Reads the z of the region so the dialog can say what a z adjustment would do to it before
+ /// the copy runs. The tally is per region, not per adjustment, so turning the spinner
+ /// re-answers the question without touching the files again.
+ ///
+ private readonly BackgroundWorker _zWorker = new BackgroundWorker();
+
+ private readonly System.Windows.Forms.Timer _zDebounce =
+ new System.Windows.Forms.Timer { Interval = 350 };
+
+ private MapRegionZSurvey _zSurvey;
+ private string _zSurveyKey;
+ private ZSurveyRequest _zPending;
+ private CancellationTokenSource _zCancellation;
+
public MapReplaceForm(Map currentMap)
{
InitializeComponent();
+
Icon = Options.GetFiddlerIcon();
- _workingMap = currentMap;
- numericUpDownX1.Maximum = _workingMap.Width;
- numericUpDownX2.Maximum = _workingMap.Width;
- numericUpDownY1.Maximum = _workingMap.Height;
- numericUpDownY2.Maximum = _workingMap.Height;
- numericUpDownToX1.Maximum = _workingMap.Width;
- numericUpDownToY1.Maximum = _workingMap.Height;
- Text = $"MapReplace ID:{_workingMap.FileIndex}";
+
+ _workingMap = currentMap ?? throw new ArgumentNullException(nameof(currentMap));
+
+ Text = $"Map and Statics Copy - into map {_workingMap.FileIndex}";
+
comboBoxMapID.BeginUpdate();
- comboBoxMapID.Items.Add(new RFeluccaOld());
- comboBoxMapID.Items.Add(new RFelucca());
- comboBoxMapID.Items.Add(new RTrammel());
- comboBoxMapID.Items.Add(new RIlshenar());
- comboBoxMapID.Items.Add(new RMalas());
- comboBoxMapID.Items.Add(new RTokuno());
- comboBoxMapID.Items.Add(new RTerMur());
+ comboBoxMapID.Items.Add(new SupportedMap(0, Options.MapNames[0] + " (old)", 6144, 4096));
+ comboBoxMapID.Items.Add(new SupportedMap(0, Options.MapNames[0], 7168, 4096));
+ comboBoxMapID.Items.Add(new SupportedMap(1, Options.MapNames[1] + " (old)", 6144, 4096));
+ comboBoxMapID.Items.Add(new SupportedMap(1, Options.MapNames[1], 7168, 4096));
+ comboBoxMapID.Items.Add(new SupportedMap(2, Options.MapNames[2], 2304, 1600));
+ comboBoxMapID.Items.Add(new SupportedMap(3, Options.MapNames[3], 2560, 2048));
+ comboBoxMapID.Items.Add(new SupportedMap(4, Options.MapNames[4], 1448, 1448));
+ comboBoxMapID.Items.Add(new SupportedMap(5, Options.MapNames[5], 1280, 4096));
comboBoxMapID.EndUpdate();
comboBoxMapID.SelectedIndex = 0;
+
+ bool uop = _workingMap.Tiles.IsUOPFormat;
+
+ comboBoxMapFormat.Items.Add(uop
+ ? "the same format as this client (.uop)"
+ : "the same format as this client (.mul)");
+ comboBoxMapFormat.Items.Add($"map{_workingMap.FileIndex}.mul");
+ comboBoxMapFormat.Items.Add($"map{_workingMap.FileIndex}LegacyMUL.uop");
+ comboBoxMapFormat.SelectedIndex = ClientFileSaveFormats.DefaultIndex(Options.SaveFormat);
+
+ checkBoxMap.Checked = true;
+ checkBoxStatics.Checked = true;
+
+ numericUpDownToX1.Maximum = Math.Max(0, _workingMap.Width - 1);
+ numericUpDownToY1.Maximum = Math.Max(0, _workingMap.Height - 1);
+
+ textBoxFolder.TextChanged += OnFolderChanged;
+
+ checkBoxPreviewStatics.Checked = true;
+ checkBoxPreviewOverlay.Checked = true;
+
+ previewSource.Mode = MapPreviewMode.Rectangle;
+ previewSource.SelectionChanged += OnSourcePreviewChanged;
+
+ previewTarget.Mode = MapPreviewMode.MoveFixedSize;
+ previewTarget.Map = _workingMap;
+ previewTarget.MapSize = new MapSize(_workingMap.Width, _workingMap.Height);
+ previewTarget.SelectionChanged += OnTargetPreviewChanged;
+
+ groupBoxPreview.SizeChanged += (sender, e) => LayoutPreviewPanels();
+ LayoutPreviewPanels();
+
+ _zDebounce.Tick += OnZDebounceTick;
+ _zWorker.DoWork += OnZWorkerDoWork;
+ _zWorker.RunWorkerCompleted += OnZWorkerCompleted;
+
+ OnSourceMapChanged(this, EventArgs.Empty);
+ OnOptionChanged(this, EventArgs.Empty);
+
+ ActiveControl = buttonBrowse;
+ }
+
+ private SupportedMap SelectedMap => comboBoxMapID.SelectedItem as SupportedMap;
+
+ protected override void OnLoad(EventArgs e)
+ {
+ base.OnLoad(e);
+
+ FormLayout.FitToScreen(this);
+ }
+
+ ///
+ /// The two panels split the group box between them. Anchors cannot express that - they would
+ /// grow one panel and leave the other - so the split is arithmetic, redone whenever the group
+ /// box resizes with the form.
+ ///
+ private void LayoutPreviewPanels()
+ {
+ const int margin = 16;
+ const int gap = 16;
+
+ int width = (groupBoxPreview.ClientSize.Width - (margin * 2) - gap) / 2;
+ // The checkbox row and the summary sit below the panels, and the group box needs a
+ // bottom margin of its own.
+ int height = groupBoxPreview.ClientSize.Height - previewSource.Top - 72;
+
+ if (width < 40 || height < 40)
+ {
+ return;
+ }
+
+ int right = margin + width + gap;
+
+ labelSourcePreview.Width = width;
+ labelTargetPreview.Left = right;
+ labelTargetPreview.Width = width;
+
+ previewSource.Size = new Size(width, height);
+ previewTarget.Location = new Point(right, previewTarget.Top);
+ previewTarget.Size = new Size(width, height);
+
+ int below = previewSource.Bottom + 8;
+
+ checkBoxPreviewStatics.Top = below;
+ checkBoxPreviewOverlay.Top = below;
+
+ textBoxPreview.Location = new Point(right, below - 4);
+ textBoxPreview.Width = width;
}
private void OnClickBrowse(object sender, EventArgs e)
{
- FolderBrowserDialog dialog = new FolderBrowserDialog
+ using (var dialog = new FolderBrowserDialog
{
- Description = "Select directory containing the map files",
- ShowNewFolderButton = false
- };
+ Description = "Select the folder holding the map files to copy from",
+ ShowNewFolderButton = false,
+ SelectedPath = Directory.Exists(textBoxFolder.Text) ? textBoxFolder.Text : string.Empty
+ })
+ {
+ if (dialog.ShowDialog(this) == DialogResult.OK)
+ {
+ textBoxFolder.Text = dialog.SelectedPath;
+ }
+ }
+ }
+
+ private void OnFolderChanged(object sender, EventArgs e)
+ {
+ Detect();
+ }
- if (dialog.ShowDialog() == DialogResult.OK)
+ ///
+ /// Rebinds the from-region spinners to the selected source map. They used to be bound to the
+ /// destination map, which is a different size, so a valid source coordinate could be
+ /// unreachable or a reachable one rejected.
+ ///
+ private void OnSourceMapChanged(object sender, EventArgs e)
+ {
+ SupportedMap map = SelectedMap;
+
+ if (map == null)
{
- textBox1.Text = dialog.SelectedPath;
+ return;
}
- dialog.Dispose();
+ SetMaximum(numericUpDownX1, map.Width - 1);
+ SetMaximum(numericUpDownX2, map.Width - 1);
+ SetMaximum(numericUpDownY1, map.Height - 1);
+ SetMaximum(numericUpDownY2, map.Height - 1);
+
+ Detect();
}
- private void OnClickCopy(object sender, EventArgs e)
+ private static void SetMaximum(NumericUpDown control, int maximum)
{
- string path = textBox1.Text;
- if (!Directory.Exists(path))
+ control.Maximum = Math.Max(0, maximum);
+
+ if (control.Value > control.Maximum)
{
- MessageBox.Show("Path not found!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
+ control.Value = control.Maximum;
+ }
+ }
+
+ ///
+ /// Measures the chosen folder and says so, refusing later if it disagrees with the picked
+ /// entry. Nothing used to check the two against each other, so a wrong guess read the wrong
+ /// blocks or ran off the end of the index.
+ ///
+ private void Detect()
+ {
+ SupportedMap map = SelectedMap;
+
+ _detectionKnown = false;
+ _detectionEvidence = null;
+ labelDetected.Text = string.Empty;
+ labelSizeWarning.Text = string.Empty;
+
+ if (map == null || !Directory.Exists(textBoxFolder.Text))
+ {
+ RebuildSourceMap(null);
+ UpdatePreview();
+
return;
}
- if (!(comboBoxMapID.SelectedItem is SupportedMaps replaceMap))
+ _detectionKnown = MapSizes.TryDetect(textBoxFolder.Text, map.Id, out _detectedSize, out _detectionEvidence);
+
+ RebuildSourceMap(map);
+
+ labelDetected.Text = _detectionKnown ? $"folder holds {_detectedSize}" : "size not recognised";
+
+ if (_detectionKnown && _detectedSize.Width == map.Width && _detectedSize.Height == map.Height)
+ {
+ labelSizeWarning.ForeColor = SystemColors.ControlText;
+ labelSizeWarning.Text = _detectionEvidence;
+ }
+ else
+ {
+ labelSizeWarning.ForeColor = Options.DarkMode ? Color.OrangeRed : Color.Red;
+ labelSizeWarning.Text = _detectionKnown
+ ? $"This folder holds {_detectedSize} for map {map.Id}, not the {map.Width}x{map.Height} selected.{Environment.NewLine}Pick the entry that matches."
+ : _detectionEvidence;
+ }
+
+ UpdatePreview();
+ }
+
+ private void OnOptionChanged(object sender, EventArgs e)
+ {
+ comboBoxMapFormat.Enabled = checkBoxMap.Checked;
+ labelMapFormat.Enabled = checkBoxMap.Checked;
+ RemoveDupl.Enabled = checkBoxStatics.Checked;
+ checkBoxDuplicatesHue.Enabled = checkBoxStatics.Checked && RemoveDupl.Checked;
+
+ UpdatePreview();
+ }
+
+ private void OnRegionChanged(object sender, EventArgs e)
+ {
+ UpdatePreview();
+ }
+
+ private sealed class ZSurveyRequest
+ {
+ public string Directory { get; init; }
+
+ public int FileIndex { get; init; }
+
+ public MapSize Size { get; init; }
+
+ public BlockRectangle Region { get; init; }
+
+ public bool Land { get; init; }
+
+ public bool Statics { get; init; }
+
+ public CancellationToken CancellationToken { get; set; }
+
+ /// Everything the answer depends on. The adjustment is deliberately not part of it.
+ public string Key => string.Format(CultureInfo.InvariantCulture, "{0}|{1}|{2},{3}-{4},{5}|{6}{7}",
+ Directory, FileIndex, Region.BlockX1, Region.BlockY1, Region.BlockX2, Region.BlockY2,
+ Land ? "L" : string.Empty, Statics ? "S" : string.Empty);
+ }
+
+ private sealed class ZSurveyAnswer
+ {
+ public string Key { get; init; }
+
+ public MapRegionZSurvey Survey { get; init; }
+ }
+
+ private void UpdatePreview()
+ {
+ UpdatePreviewRectangles();
+ UpdateZ();
+ }
+
+ ///
+ /// Shows the block-snapped rectangles that will really be copied. The tile to block
+ /// conversion rounds out to whole 8-tile blocks, which used to happen silently.
+ ///
+ private void UpdatePreviewRectangles()
+ {
+ SupportedMap map = SelectedMap;
+
+ if (map == null)
{
- MessageBox.Show("Invalid Map ID!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
+ textBoxPreview.Text = "Choose a folder and a map.";
+
return;
}
int x1 = (int)numericUpDownX1.Value;
- int x2 = (int)numericUpDownX2.Value;
int y1 = (int)numericUpDownY1.Value;
+ int x2 = (int)numericUpDownX2.Value;
int y2 = (int)numericUpDownY2.Value;
- int tox = (int)numericUpDownToX1.Value;
- int toy = (int)numericUpDownToY1.Value;
- if (x1 < 0 || x1 > replaceMap.Width)
+ if (x1 > x2)
+ {
+ (x1, x2) = (x2, x1);
+ }
+
+ if (y1 > y2)
+ {
+ (y1, y2) = (y2, y1);
+ }
+
+ int toX = (int)numericUpDownToX1.Value;
+ int toY = (int)numericUpDownToY1.Value;
+
+ var source = new BlockRectangle(x1 >> 3, y1 >> 3, x2 >> 3, y2 >> 3);
+ var destination = new BlockRectangle(toX >> 3, toY >> 3,
+ (toX >> 3) + source.BlockWidth - 1, (toY >> 3) + source.BlockHeight - 1);
+
+ var sb = new StringBuilder();
+
+ // The panels carry the shape now, so this is only the numbers.
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "from {0},{1} - {2},{3} {4} x {5} blocks",
+ source.TileX1, source.TileY1, source.TileX2, source.TileY2, source.BlockWidth, source.BlockHeight));
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "to {0},{1} - {2},{3}",
+ destination.TileX1, destination.TileY1, destination.TileX2, destination.TileY2));
+
+ bool snapped = source.TileX1 != x1 || source.TileY1 != y1 || source.TileX2 != x2 || source.TileY2 != y2;
+
+ if (snapped)
+ {
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
+ "{0},{1} - {2},{3} widened to whole blocks", x1, y1, x2, y2));
+ }
+
+ textBoxPreview.Text = sb.ToString();
+
+ if (_syncingPreview)
{
- MessageBox.Show("Invalid X1 coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
return;
}
- if (x2 < 0 || x2 > replaceMap.Width)
+ _syncingPreview = true;
+
+ try
+ {
+ previewSource.Selection = source;
+ previewTarget.Selection = destination;
+ previewTarget.OverlaySelection = source;
+ }
+ finally
+ {
+ _syncingPreview = false;
+ }
+ }
+
+ ///
+ /// Points the source panel at the browsed folder. The panel renders through a Map of its
+ /// own so it shows that install rather than the one loaded in the app.
+ ///
+ private void RebuildSourceMap(SupportedMap map)
+ {
+ _sourceMap?.Tiles.CloseStreams();
+ _sourceMap = null;
+
+ if (map != null && Directory.Exists(textBoxFolder.Text))
+ {
+ _sourceMap = new Map(textBoxFolder.Text, map.Id, map.Id, map.Width, map.Height);
+ }
+
+ previewSource.Map = _sourceMap;
+ previewSource.MapSize = map == null ? default : new MapSize(map.Width, map.Height);
+ previewSource.Message = _sourceMap == null ? "Choose a folder to copy from" : null;
+
+ previewTarget.OverlayMap = checkBoxPreviewOverlay.Checked ? _sourceMap : null;
+ }
+
+ private void OnPreviewOptionChanged(object sender, EventArgs e)
+ {
+ previewSource.ShowStatics = checkBoxPreviewStatics.Checked;
+ previewTarget.ShowStatics = checkBoxPreviewStatics.Checked;
+ previewTarget.OverlayMap = checkBoxPreviewOverlay.Checked ? _sourceMap : null;
+
+ UpdatePreview();
+ }
+
+ /// A drag on the source panel writes the region back into the spinners.
+ private void OnSourcePreviewChanged(object sender, EventArgs e)
+ {
+ if (_syncingPreview)
{
- MessageBox.Show("Invalid X2 coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
return;
}
- if (y1 < 0 || y1 > replaceMap.Height)
+ BlockRectangle selection = previewSource.Selection;
+
+ _syncingPreview = true;
+
+ try
+ {
+ numericUpDownX1.Value = Clamp(numericUpDownX1, selection.TileX1);
+ numericUpDownY1.Value = Clamp(numericUpDownY1, selection.TileY1);
+ numericUpDownX2.Value = Clamp(numericUpDownX2, selection.TileX2);
+ numericUpDownY2.Value = Clamp(numericUpDownY2, selection.TileY2);
+ }
+ finally
+ {
+ _syncingPreview = false;
+ }
+
+ UpdatePreview();
+ }
+
+ /// A drag on the destination panel writes the paste position back.
+ private void OnTargetPreviewChanged(object sender, EventArgs e)
+ {
+ if (_syncingPreview)
{
- MessageBox.Show("Invalid Y1 coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
return;
}
- if (y2 < 0 || y2 > replaceMap.Height)
+ BlockRectangle selection = previewTarget.Selection;
+
+ _syncingPreview = true;
+
+ try
+ {
+ numericUpDownToX1.Value = Clamp(numericUpDownToX1, selection.TileX1);
+ numericUpDownToY1.Value = Clamp(numericUpDownToY1, selection.TileY1);
+ }
+ finally
{
- MessageBox.Show("Invalid Y2 coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
+ _syncingPreview = false;
+ }
+
+ UpdatePreview();
+ }
+
+ private int ZAdjust => (int)numericUpDownZ.Value;
+
+ ///
+ /// Works out what the region's z is, and says whether the adjustment asked for still fits in
+ /// the -128..127 a map or statics file can hold.
+ ///
+ private void UpdateZ()
+ {
+ SupportedMap map = SelectedMap;
+
+ if (map == null || !_detectionKnown || !Directory.Exists(textBoxFolder.Text) ||
+ (!checkBoxMap.Checked && !checkBoxStatics.Checked))
+ {
+ _zSurvey = null;
+ _zSurveyKey = null;
+ _zDebounce.Stop();
+ labelZRange.Text = string.Empty;
+
return;
}
- if (x1 > x2 || y1 > y2)
+ var region = new BlockRectangle(
+ (int)numericUpDownX1.Value >> 3, (int)numericUpDownY1.Value >> 3,
+ (int)numericUpDownX2.Value >> 3, (int)numericUpDownY2.Value >> 3);
+
+ var request = new ZSurveyRequest
{
- MessageBox.Show("X1 and Y1 cannot be bigger than X2 and Y2!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
+ Directory = textBoxFolder.Text,
+ FileIndex = map.Id,
+ Size = new MapSize(map.Width, map.Height),
+ Region = Normalise(region),
+ Land = checkBoxMap.Checked,
+ Statics = checkBoxStatics.Checked
+ };
+
+ if (_zSurveyKey == request.Key)
+ {
+ ShowZ();
+
return;
}
- if (tox < 0 || tox > _workingMap.Width || tox + (x2 - x1) > _workingMap.Width)
+ _zSurvey = null;
+ _zSurveyKey = null;
+ _zPending = request;
+
+ labelZRange.ForeColor = SystemColors.ControlText;
+ labelZRange.Text = "reading the heights in this region...";
+
+ _zDebounce.Stop();
+ _zDebounce.Start();
+ }
+
+ private static BlockRectangle Normalise(BlockRectangle region)
+ {
+ return new BlockRectangle(
+ Math.Min(region.BlockX1, region.BlockX2), Math.Min(region.BlockY1, region.BlockY2),
+ Math.Max(region.BlockX1, region.BlockX2), Math.Max(region.BlockY1, region.BlockY2));
+ }
+
+ private void OnZDebounceTick(object sender, EventArgs e)
+ {
+ _zDebounce.Stop();
+
+ if (_zPending == null || IsDisposed)
{
- MessageBox.Show("Invalid toX coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
return;
}
- if (toy < 0 || toy > _workingMap.Height || toy + (y2 - y1) > _workingMap.Height)
+ if (_zWorker.IsBusy)
{
- MessageBox.Show("Invalid toX coordinate!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
+ // Whatever is running is for a region nobody is asking about any more.
+ _zCancellation?.Cancel();
+ _zDebounce.Start();
+
return;
}
- x1 >>= 3;
- x2 >>= 3;
- y1 >>= 3;
- y2 >>= 3;
+ _zCancellation?.Dispose();
+ _zCancellation = new CancellationTokenSource();
+ _zPending.CancellationToken = _zCancellation.Token;
- tox >>= 3;
- toy >>= 3;
+ _zWorker.RunWorkerAsync(_zPending);
+ }
- int tox2 = x2 - x1 + tox;
- int toy2 = y2 - y1 + toy;
+ private static void OnZWorkerDoWork(object sender, DoWorkEventArgs e)
+ {
+ var request = (ZSurveyRequest)e.Argument;
- int blockY = _workingMap.Height >> 3;
- int blockX = _workingMap.Width >> 3;
- int blockYReplace = replaceMap.Height >> 3;
- // int blockxreplace = replacemap.Width >> 3; // TODO: unused variable?
+ e.Result = new ZSurveyAnswer
+ {
+ Key = request.Key,
+ Survey = MapRegionZSurvey.Survey(request.Directory, request.FileIndex, request.Size,
+ request.Region, request.Land, request.Statics, null, request.CancellationToken)
+ };
+ }
- progressBar1.Step = 1;
- progressBar1.Value = 0;
- progressBar1.Maximum = 0;
+ private void OnZWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
+ {
+ if (IsDisposed)
+ {
+ return;
+ }
- if (checkBoxMap.Checked)
+ if (e.Error is OperationCanceledException)
{
- progressBar1.Maximum += blockY * blockX;
+ // Cancelled because the region moved. Whatever replaced it is already queued.
+ _zDebounce.Start();
+
+ return;
}
- if (checkBoxStatics.Checked)
+ if (e.Error != null)
{
- progressBar1.Maximum += blockY * blockX;
+ labelZRange.ForeColor = Options.DarkMode ? Color.OrangeRed : Color.Red;
+ labelZRange.Text = "the heights in this region could not be read: " + e.Error.Message;
+
+ return;
}
- if (checkBoxMap.Checked)
+ if (e.Result is ZSurveyAnswer answer)
{
- string copyMapMul = Path.Combine(path, $"map{replaceMap.Id}.mul");
- string copyMapUop = Path.Combine(path, $"map{replaceMap.Id}LegacyMUL.uop");
- if (!File.Exists(copyMapMul) && !File.Exists(copyMapUop))
- {
- MessageBox.Show("Map file not found!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error,
- MessageBoxDefaultButton.Button1);
- return;
- }
+ _zSurvey = answer.Survey;
+ _zSurveyKey = answer.Key;
+ }
- string workingMapMul = Files.GetFilePath($"map{_workingMap.FileIndex}.mul");
- string workingMapUop = Files.GetFilePath($"map{_workingMap.FileIndex}LegacyMUL.uop");
- if (workingMapMul == null && workingMapUop == null)
- {
- MessageBox.Show("Map file not found!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error,
- MessageBoxDefaultButton.Button1);
- return;
- }
+ if (_zPending != null && _zPending.Key != _zSurveyKey)
+ {
+ // The region moved while that was in flight.
+ _zDebounce.Start();
- var copyTileMatrix = new TileMatrix(replaceMap.Id, replaceMap.Id, replaceMap.Width, replaceMap.Height, path);
- var workTileMatrix = new TileMatrix(_workingMap.FileIndex, _workingMap.FileIndex, _workingMap.Width, _workingMap.Height, null);
+ return;
+ }
- string mul = Path.Combine(Options.OutputPath, $"map{_workingMap.FileIndex}.mul");
- using (FileStream fsMul = new FileStream(mul, FileMode.Create, FileAccess.Write, FileShare.Write))
- {
- using (BinaryWriter binMul = new BinaryWriter(fsMul))
- {
- for (int x = 0; x < blockX; ++x)
- {
- for (int y = 0; y < blockY; ++y)
- {
- bool inRegion = tox <= x && x <= tox2 && toy <= y && y <= toy2;
- Tile[] tiles = inRegion
- ? copyTileMatrix.GetLandBlock(x - tox + x1, y - toy + y1, false)
- : workTileMatrix.GetLandBlock(x, y, false);
-
- binMul.Write(0); // 4-byte block header
- foreach (Tile tile in tiles)
- {
- ushort tileId = Art.GetLegalItemId(tile.Id);
- sbyte z = tile.Z;
-
- if (z < -128)
- {
- z = -128;
- }
-
- if (z > 127)
- {
- z = 127;
- }
-
- binMul.Write(tileId);
- binMul.Write(z);
- }
- progressBar1.PerformStep();
- }
- }
- }
- }
+ ShowZ();
+ }
- copyTileMatrix.CloseStreams();
- workTileMatrix.CloseStreams();
+ /// Says what the region's z is, and what the adjustment does to it.
+ private void ShowZ()
+ {
+ if (_zSurvey == null)
+ {
+ return;
}
- if (checkBoxStatics.Checked)
+ int adjust = ZAdjust;
+
+ var sb = new StringBuilder();
+
+ // Kept short: this sits on one line beside the spinners, and an ellipsis in the middle
+ // of the warning is worse than no warning at all.
+ sb.Append(CultureInfo.InvariantCulture,
+ $"region z land {Span(_zSurvey.Land, 0)}, statics {Span(_zSurvey.Statics, 0)}");
+
+ if (adjust != 0)
{
- string indexPath = Files.GetFilePath($"staidx{_workingMap.FileIndex}.mul");
- BinaryReader mIndexReader;
+ sb.Append(CultureInfo.InvariantCulture,
+ $" -> land {Span(_zSurvey.Land, adjust)}, statics {Span(_zSurvey.Statics, adjust)}");
+ }
- if (indexPath != null)
- {
- FileStream mIndex = new FileStream(indexPath, FileMode.Open, FileAccess.Read, FileShare.Read);
- mIndexReader = new BinaryReader(mIndex);
- }
- else
- {
- MessageBox.Show("Static file not found!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
- return;
- }
+ long past = _zSurvey.OutOfRange(adjust);
- string staticsPath = Files.GetFilePath($"statics{_workingMap.FileIndex}.mul");
- FileStream mStatics;
- BinaryReader mStaticsReader;
+ if (past == 0)
+ {
+ labelZRange.ForeColor = SystemColors.ControlText;
+ labelZRange.Text = sb.ToString();
- if (staticsPath != null)
- {
- mStatics = new FileStream(staticsPath, FileMode.Open, FileAccess.Read, FileShare.Read);
- mStaticsReader = new BinaryReader(mStatics);
- }
- else
- {
- MessageBox.Show("Static file not found!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
- return;
- }
+ return;
+ }
- string copyIndexPath = Path.Combine(path, $"staidx{replaceMap.Id}.mul");
- if (!File.Exists(copyIndexPath))
- {
- MessageBox.Show("Static file not found!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
- return;
- }
+ sb.Append(CultureInfo.InvariantCulture,
+ $" - {past:N0} past the limit, {(checkBoxZClamp.Checked ? "held there" : "refused")}");
+
+ labelZRange.ForeColor = Options.DarkMode ? Color.OrangeRed : Color.Red;
+ labelZRange.Text = sb.ToString();
+ }
- FileStream mIndexCopy = new FileStream(copyIndexPath, FileMode.Open, FileAccess.Read, FileShare.Read);
- BinaryReader mIndexReaderCopy = new BinaryReader(mIndexCopy);
+ private static string Span(ZHistogram z, int adjust)
+ {
+ if (!z.HasTiles)
+ {
+ return "none";
+ }
- string copyStaticsPath = Path.Combine(path, $"statics{replaceMap.Id}.mul");
- if (!File.Exists(copyStaticsPath))
- {
- MessageBox.Show("Static file not found!", "Map Replace", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
- return;
- }
+ return string.Format(CultureInfo.InvariantCulture, "{0}..{1}",
+ Math.Clamp(z.Min + adjust, ZHistogram.MinZ, ZHistogram.MaxZ),
+ Math.Clamp(z.Max + adjust, ZHistogram.MinZ, ZHistogram.MaxZ));
+ }
- FileStream mStaticsCopy = new FileStream(copyStaticsPath, FileMode.Open, FileAccess.Read, FileShare.Read);
- BinaryReader mStaticsReaderCopy = new BinaryReader(mStaticsCopy);
+ private static decimal Clamp(NumericUpDown control, int value)
+ {
+ return Math.Clamp(value, (int)control.Minimum, (int)control.Maximum);
+ }
- string idx = Path.Combine(Options.OutputPath, $"staidx{_workingMap.FileIndex}.mul");
- string mul = Path.Combine(Options.OutputPath, $"statics{_workingMap.FileIndex}.mul");
- using (FileStream fsIdx = new FileStream(idx, FileMode.Create, FileAccess.Write, FileShare.Write),
- fsMul = new FileStream(mul, FileMode.Create, FileAccess.Write, FileShare.Write))
+ private void OnClickCopy(object sender, EventArgs e)
+ {
+ if (worker.IsBusy)
+ {
+ return;
+ }
+
+ SupportedMap map = SelectedMap;
+
+ if (map == null)
+ {
+ return;
+ }
+
+ if (!Directory.Exists(textBoxFolder.Text))
+ {
+ Fail("Choose the folder holding the map files to copy from.");
+
+ return;
+ }
+
+ if (!checkBoxMap.Checked && !checkBoxStatics.Checked)
+ {
+ Fail("Nothing is selected to copy.");
+
+ return;
+ }
+
+ if (!_detectionKnown)
+ {
+ Fail($"The size of map {map.Id} in that folder could not be worked out.{Environment.NewLine}{Environment.NewLine}{_detectionEvidence}");
+
+ return;
+ }
+
+ if (_detectedSize.Width != map.Width || _detectedSize.Height != map.Height)
+ {
+ Fail($"That folder holds {_detectedSize} for map {map.Id}, but {map} is selected." +
+ $"{Environment.NewLine}{Environment.NewLine}{_detectionEvidence}" +
+ $"{Environment.NewLine}{Environment.NewLine}Pick the entry that matches, or the wrong blocks will be read.");
+
+ return;
+ }
+
+ MapRegionCopyOptions options = BuildOptions(map);
+
+ _cancellation?.Dispose();
+ _cancellation = new CancellationTokenSource();
+ options.CancellationToken = _cancellation.Token;
+ options.Progress = new Progress(OnProgress);
+
+ SetRunning(true);
+ progressBar1.Value = 0;
+ labelStatus.Text = "Copying...";
+
+ worker.RunWorkerAsync(options);
+ }
+
+ private MapRegionCopyOptions BuildOptions(SupportedMap map)
+ {
+ int x1 = (int)numericUpDownX1.Value;
+ int y1 = (int)numericUpDownY1.Value;
+ int x2 = (int)numericUpDownX2.Value;
+ int y2 = (int)numericUpDownY2.Value;
+
+ var options = new MapRegionCopyOptions
+ {
+ SourceDirectory = textBoxFolder.Text,
+ SourceFileIndex = map.Id,
+ SourceSize = new MapSize(map.Width, map.Height),
+ Destination = _workingMap,
+ SourceX1 = x1,
+ SourceY1 = y1,
+ SourceX2 = x2,
+ SourceY2 = y2,
+ DestinationX = (int)numericUpDownToX1.Value,
+ DestinationY = (int)numericUpDownToY1.Value,
+ CopyLand = checkBoxMap.Checked,
+ CopyStatics = checkBoxStatics.Checked,
+ MapFormat = ResolveFormat(),
+ ZAdjust = ZAdjust,
+ ZOverflow = checkBoxZClamp.Checked ? ZOverflowAction.Clamp : ZOverflowAction.Refuse,
+ OutputDirectory = Options.OutputPath
+ };
+
+ if (checkBoxStatics.Checked)
+ {
+ options.StaticsFilter = new StaticsTileFilter
{
- using (BinaryWriter binidx = new BinaryWriter(fsIdx),
- binmul = new BinaryWriter(fsMul))
- {
- for (int x = 0; x < blockX; ++x)
- {
- for (int y = 0; y < blockY; ++y)
- {
- int lookup, length, extra;
- if (tox <= x && x <= tox2 && toy <= y && y <= toy2)
- {
- mIndexReaderCopy.BaseStream.Seek((((x - tox + x1) * blockYReplace) + (y - toy) + y1) * 12, SeekOrigin.Begin);
- lookup = mIndexReaderCopy.ReadInt32();
- length = mIndexReaderCopy.ReadInt32();
- extra = mIndexReaderCopy.ReadInt32();
- }
- else
- {
- mIndexReader.BaseStream.Seek(((x * blockY) + y) * 12, SeekOrigin.Begin);
- lookup = mIndexReader.ReadInt32();
- length = mIndexReader.ReadInt32();
- extra = mIndexReader.ReadInt32();
- }
-
- if (lookup < 0 || length <= 0)
- {
- binidx.Write(-1); // lookup
- binidx.Write(-1); // length
- binidx.Write(-1); // extra
- }
- else
- {
- if (tox <= x && x <= tox2 && toy <= y && y <= toy2)
- {
- mStaticsCopy.Seek(lookup, SeekOrigin.Begin);
- }
- else
- {
- mStatics.Seek(lookup, SeekOrigin.Begin);
- }
-
- int fsMulLength = (int)fsMul.Position;
- int count = length / 7;
- if (RemoveDupl.Checked)
- {
- var tileList = new StaticTile[count];
- int j = 0;
- for (int i = 0; i < count; ++i)
- {
- StaticTile tile = new StaticTile();
- if (tox <= x && x <= tox2 && toy <= y && y <= toy2)
- {
- tile.Id = mStaticsReaderCopy.ReadUInt16();
- tile.X = mStaticsReaderCopy.ReadByte();
- tile.Y = mStaticsReaderCopy.ReadByte();
- tile.Z = mStaticsReaderCopy.ReadSByte();
- tile.Hue = mStaticsReaderCopy.ReadInt16();
- }
- else
- {
- tile.Id = mStaticsReader.ReadUInt16();
- tile.X = mStaticsReader.ReadByte();
- tile.Y = mStaticsReader.ReadByte();
- tile.Z = mStaticsReader.ReadSByte();
- tile.Hue = mStaticsReader.ReadInt16();
- }
-
- if (tile.Id > Art.GetMaxItemId())
- {
- continue;
- }
-
- if (tile.Hue < 0)
- {
- tile.Hue = 0;
- }
-
- bool first = true;
- for (int k = 0; k < j; ++k)
- {
- if (tileList[k].Id == tile.Id && tileList[k].X == tile.X && tileList[k].Y == tile.Y && tileList[k].Z == tile.Z && tileList[k].Hue == tile.Hue)
- {
- first = false;
- break;
- }
- }
- if (first)
- {
- tileList[j++] = tile;
- }
- }
- if (j > 0)
- {
- binidx.Write((int)fsMul.Position); //lookup
- for (int i = 0; i < j; ++i)
- {
- binmul.Write(tileList[i].Id);
- binmul.Write(tileList[i].X);
- binmul.Write(tileList[i].Y);
- binmul.Write(tileList[i].Z);
- binmul.Write(tileList[i].Hue);
- }
- }
- }
- else
- {
- bool firstItem = true;
- for (int i = 0; i < count; ++i)
- {
- ushort graphic;
- short sHue;
- byte sx, sy;
- sbyte sz;
- if (tox <= x && x <= tox2 && toy <= y && y <= toy2)
- {
- graphic = mStaticsReaderCopy.ReadUInt16();
- sx = mStaticsReaderCopy.ReadByte();
- sy = mStaticsReaderCopy.ReadByte();
- sz = mStaticsReaderCopy.ReadSByte();
- sHue = mStaticsReaderCopy.ReadInt16();
- }
- else
- {
- graphic = mStaticsReader.ReadUInt16();
- sx = mStaticsReader.ReadByte();
- sy = mStaticsReader.ReadByte();
- sz = mStaticsReader.ReadSByte();
- sHue = mStaticsReader.ReadInt16();
- }
-
- if (graphic > Art.GetMaxItemId())
- {
- continue;
- }
-
- if (sHue < 0)
- {
- sHue = 0;
- }
-
- if (firstItem)
- {
- binidx.Write((int)fsMul.Position); // lookup
- firstItem = false;
- }
- binmul.Write(graphic);
- binmul.Write(sx);
- binmul.Write(sy);
- binmul.Write(sz);
- binmul.Write(sHue);
- }
- }
-
- fsMulLength = (int)fsMul.Position - fsMulLength;
- if (fsMulLength > 0)
- {
- binidx.Write(fsMulLength); // length
- binidx.Write(extra); // extra
- }
- else
- {
- binidx.Write(-1); // lookup
- binidx.Write(-1); // length
- binidx.Write(-1); // extra
- }
- }
-
- progressBar1.PerformStep();
- }
- }
- }
- }
+ // The same rules this feature has always applied, so a copy keeps its old shape.
+ DropInvalidItemIds = true,
+ MaxItemId = Art.GetMaxItemId(),
+ OutOfBlockTiles = OutOfBlockAction.Keep,
+ DropInvalidZ = false,
+ NormalizeNegativeHue = true,
+ RemoveDuplicates = RemoveDupl.Checked,
+ DuplicatesCompareHue = RemoveDupl.Checked && checkBoxDuplicatesHue.Checked
+ };
+ }
+
+ return options;
+ }
- mIndexReader.Close();
- mStaticsReader.Close();
- mIndexCopy.Close();
- mStaticsReaderCopy.Close();
+ private MapOutputFormat ResolveFormat()
+ {
+ switch (comboBoxMapFormat.SelectedIndex)
+ {
+ case 1: return MapOutputFormat.Mul;
+ case 2: return MapOutputFormat.Uop;
+ default: return _workingMap.Tiles.IsUOPFormat ? MapOutputFormat.Uop : MapOutputFormat.Mul;
}
+ }
- FileSavedDialog.Show(FindForm(), Options.OutputPath, "Files saved successfully.");
+ private void OnProgress(MapCopyProgress progress)
+ {
+ if (progress.BlocksTotal <= 0)
+ {
+ return;
+ }
+
+ progressBar1.Value = Math.Min(100, Math.Max(0, (int)(progress.BlocksDone * 100L / progress.BlocksTotal)));
+ labelStatus.Text = string.Format(CultureInfo.InvariantCulture, "{0}: {1:N0} of {2:N0} blocks",
+ progress.Stage, progress.BlocksDone, progress.BlocksTotal);
}
- private class SupportedMaps
+ private void OnWorkerDoWork(object sender, DoWorkEventArgs e)
{
- public int Id { get; }
- private string Name { get; }
- public int Height { get; }
- public int Width { get; }
+ e.Result = MapRegionCopier.Run((MapRegionCopyOptions)e.Argument);
+ }
- protected SupportedMaps(int id, string name, int width, int height)
+ private void OnWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
+ {
+ SetRunning(false);
+
+ if (e.Error is OperationCanceledException)
{
- Id = id;
- Name = name;
- Width = width;
- Height = height;
+ progressBar1.Value = 0;
+ labelStatus.Text = "Cancelled. Nothing was written.";
+
+ return;
}
- public override string ToString()
+ if (e.Error != null)
{
- return $"{Id} - {Name} : {Width}x{Height}";
+ progressBar1.Value = 0;
+ labelStatus.Text = "Failed.";
+ ShowError("Map and Statics Copy", e.Error);
+
+ return;
}
+
+ var result = (MapRegionCopyResult)e.Result;
+
+ progressBar1.Value = 100;
+ labelStatus.Text = "Done.";
+
+ using (var form = new MapRegionCopyResultForm(result))
+ {
+ form.ShowDialog(this);
+ }
+ }
+
+ ///
+ /// Shows what actually went wrong. A bare "Object reference not set to an instance of an
+ /// object" tells a user nothing and tells whoever gets the bug report even less, so the
+ /// exception type and the place it came from go in the dialog and the whole thing goes to
+ /// the log.
+ ///
+ private void ShowError(string title, Exception error)
+ {
+ AppLog.For(GetType()).LogError(error, "{Title} failed.", title);
+
+ var sb = new StringBuilder();
+
+ for (Exception current = error; current != null; current = current.InnerException)
+ {
+ sb.AppendLine(current.Message);
+
+ if (current.InnerException != null)
+ {
+ sb.AppendLine();
+ }
+ }
+
+ sb.AppendLine();
+ sb.AppendLine(error.GetType().FullName);
+
+ string where = error.StackTrace?.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim();
+
+ if (!string.IsNullOrEmpty(where))
+ {
+ sb.AppendLine(where);
+ }
+
+ MessageBox.Show(this, sb.ToString(), title, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
- private class RFeluccaOld : SupportedMaps
+ private void OnClickCancel(object sender, EventArgs e)
{
- public RFeluccaOld() : base(0, Options.MapNames[0] + "Old", 6144, 4096) { }
+ _cancellation?.Cancel();
+ labelStatus.Text = "Cancelling...";
}
- private class RFelucca : SupportedMaps
+ private void OnClickClose(object sender, EventArgs e)
{
- public RFelucca() : base(0, Options.MapNames[0], 7168, 4096) { }
+ Close();
}
- private class RTrammel : SupportedMaps
+ private void SetRunning(bool running)
{
- public RTrammel() : base(1, Options.MapNames[1], 7168, 4096) { }
+ buttonCopy.Enabled = !running;
+ buttonCancel.Enabled = running;
+ buttonClose.Enabled = !running;
+ groupBoxSource.Enabled = !running;
+ groupBoxWhat.Enabled = !running;
+ groupBoxFrom.Enabled = !running;
+ groupBoxTo.Enabled = !running;
}
- private class RIlshenar : SupportedMaps
+ private void Fail(string message)
{
- public RIlshenar() : base(2, Options.MapNames[2], 2304, 1600) { }
+ MessageBox.Show(this, message, "Map and Statics Copy", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
- private class RMalas : SupportedMaps
+ protected override void OnFormClosing(FormClosingEventArgs e)
{
- public RMalas() : base(3, Options.MapNames[3], 2560, 2048) { }
+ if (worker.IsBusy)
+ {
+ _cancellation?.Cancel();
+ e.Cancel = true;
+
+ return;
+ }
+
+ base.OnFormClosing(e);
}
- private class RTokuno : SupportedMaps
+ protected override void OnFormClosed(FormClosedEventArgs e)
{
- public RTokuno() : base(4, Options.MapNames[4], 1448, 1448) { }
+ _cancellation?.Dispose();
+ _cancellation = null;
+
+ _zDebounce.Stop();
+ _zDebounce.Dispose();
+ _zCancellation?.Cancel();
+ _zCancellation?.Dispose();
+ _zCancellation = null;
+ _zWorker.Dispose();
+
+ _sourceMap?.Tiles.CloseStreams();
+ _sourceMap = null;
+
+ base.OnFormClosed(e);
}
- private class RTerMur : SupportedMaps
+ ///
+ /// One entry of the source-map dropdown. The sizes are the shapes a facet is known to ship
+ /// in; what the chosen folder actually holds is measured separately and has to agree.
+ ///
+ private sealed class SupportedMap
{
- public RTerMur() : base(5, Options.MapNames[5], 1280, 4096) { }
+ public SupportedMap(int id, string name, int width, int height)
+ {
+ Id = id;
+ Name = name;
+ Width = width;
+ Height = height;
+ }
+
+ public int Id { get; }
+
+ private string Name { get; }
+
+ public int Width { get; }
+
+ public int Height { get; }
+
+ public override string ToString()
+ {
+ return $"{Id} - {Name} : {Width}x{Height}";
+ }
}
}
-}
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/SaveFormatDialog.Designer.cs b/UoFiddler.Controls/Forms/SaveFormatDialog.Designer.cs
new file mode 100644
index 00000000..51246381
--- /dev/null
+++ b/UoFiddler.Controls/Forms/SaveFormatDialog.Designer.cs
@@ -0,0 +1,249 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+namespace UoFiddler.Controls.Forms
+{
+ partial class SaveFormatDialog
+ {
+ ///
+ /// Required designer variable.
+ ///
+ private System.ComponentModel.IContainer components = null;
+
+ ///
+ /// Clean up any resources being used.
+ ///
+ /// true if managed resources should be disposed; otherwise, false.
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && (components != null))
+ {
+ components.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ ///
+ /// Required method for Designer support - do not modify
+ /// the contents of this method with the code editor.
+ ///
+ private void InitializeComponent()
+ {
+ mainLayoutPanel = new System.Windows.Forms.TableLayoutPanel();
+ iconPictureBox = new System.Windows.Forms.PictureBox();
+ contentLayoutPanel = new System.Windows.Forms.TableLayoutPanel();
+ toLabel = new System.Windows.Forms.Label();
+ pathLabel = new System.Windows.Forms.Label();
+ formatLabel = new System.Windows.Forms.Label();
+ comboBoxFormat = new System.Windows.Forms.ComboBox();
+ willCreateLabel = new System.Windows.Forms.Label();
+ buttonsPanel = new System.Windows.Forms.TableLayoutPanel();
+ buttonSave = new System.Windows.Forms.Button();
+ buttonCancel = new System.Windows.Forms.Button();
+ mainLayoutPanel.SuspendLayout();
+ ((System.ComponentModel.ISupportInitialize)iconPictureBox).BeginInit();
+ contentLayoutPanel.SuspendLayout();
+ buttonsPanel.SuspendLayout();
+ SuspendLayout();
+ //
+ // mainLayoutPanel
+ //
+ mainLayoutPanel.AutoSize = true;
+ mainLayoutPanel.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink;
+ mainLayoutPanel.ColumnCount = 2;
+ mainLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
+ mainLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
+ mainLayoutPanel.Controls.Add(iconPictureBox, 0, 0);
+ mainLayoutPanel.Controls.Add(contentLayoutPanel, 1, 0);
+ mainLayoutPanel.Dock = System.Windows.Forms.DockStyle.Fill;
+ mainLayoutPanel.Location = new System.Drawing.Point(12, 12);
+ mainLayoutPanel.Margin = new System.Windows.Forms.Padding(0);
+ mainLayoutPanel.Name = "mainLayoutPanel";
+ mainLayoutPanel.RowCount = 1;
+ mainLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
+ mainLayoutPanel.Size = new System.Drawing.Size(516, 160);
+ mainLayoutPanel.TabIndex = 0;
+ //
+ // iconPictureBox
+ //
+ iconPictureBox.Location = new System.Drawing.Point(0, 0);
+ iconPictureBox.Margin = new System.Windows.Forms.Padding(0, 0, 12, 0);
+ iconPictureBox.Name = "iconPictureBox";
+ iconPictureBox.Size = new System.Drawing.Size(32, 32);
+ iconPictureBox.SizeMode = System.Windows.Forms.PictureBoxSizeMode.CenterImage;
+ iconPictureBox.TabIndex = 0;
+ iconPictureBox.TabStop = false;
+ //
+ // contentLayoutPanel
+ //
+ contentLayoutPanel.AutoSize = true;
+ contentLayoutPanel.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink;
+ contentLayoutPanel.ColumnCount = 1;
+ contentLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
+ contentLayoutPanel.Controls.Add(toLabel, 0, 0);
+ contentLayoutPanel.Controls.Add(pathLabel, 0, 1);
+ contentLayoutPanel.Controls.Add(formatLabel, 0, 2);
+ contentLayoutPanel.Controls.Add(comboBoxFormat, 0, 3);
+ contentLayoutPanel.Controls.Add(willCreateLabel, 0, 4);
+ contentLayoutPanel.Controls.Add(buttonsPanel, 0, 5);
+ contentLayoutPanel.Dock = System.Windows.Forms.DockStyle.Fill;
+ contentLayoutPanel.Location = new System.Drawing.Point(44, 0);
+ contentLayoutPanel.Margin = new System.Windows.Forms.Padding(0);
+ contentLayoutPanel.Name = "contentLayoutPanel";
+ contentLayoutPanel.RowCount = 6;
+ contentLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
+ contentLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
+ contentLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
+ contentLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
+ contentLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
+ contentLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
+ contentLayoutPanel.Size = new System.Drawing.Size(472, 160);
+ contentLayoutPanel.TabIndex = 1;
+ //
+ // toLabel
+ //
+ toLabel.AutoSize = true;
+ toLabel.Location = new System.Drawing.Point(0, 0);
+ toLabel.Margin = new System.Windows.Forms.Padding(0, 0, 0, 2);
+ toLabel.Name = "toLabel";
+ toLabel.Size = new System.Drawing.Size(70, 15);
+ toLabel.TabIndex = 0;
+ toLabel.Text = "Saving to:";
+ //
+ // pathLabel
+ //
+ pathLabel.AutoSize = true;
+ pathLabel.Location = new System.Drawing.Point(0, 17);
+ pathLabel.Margin = new System.Windows.Forms.Padding(0, 0, 0, 12);
+ pathLabel.MaximumSize = new System.Drawing.Size(472, 0);
+ pathLabel.Name = "pathLabel";
+ pathLabel.Size = new System.Drawing.Size(0, 15);
+ pathLabel.TabIndex = 1;
+ pathLabel.UseMnemonic = false;
+ //
+ // formatLabel
+ //
+ formatLabel.AutoSize = true;
+ formatLabel.Location = new System.Drawing.Point(0, 44);
+ formatLabel.Margin = new System.Windows.Forms.Padding(0, 0, 0, 2);
+ formatLabel.Name = "formatLabel";
+ formatLabel.Size = new System.Drawing.Size(60, 15);
+ formatLabel.TabIndex = 2;
+ formatLabel.Text = "Write as:";
+ //
+ // comboBoxFormat
+ //
+ comboBoxFormat.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
+ comboBoxFormat.Location = new System.Drawing.Point(0, 61);
+ comboBoxFormat.Margin = new System.Windows.Forms.Padding(0, 0, 0, 8);
+ comboBoxFormat.Name = "comboBoxFormat";
+ comboBoxFormat.Size = new System.Drawing.Size(472, 23);
+ comboBoxFormat.TabIndex = 3;
+ comboBoxFormat.SelectedIndexChanged += OnFormatChanged;
+ //
+ // willCreateLabel
+ //
+ willCreateLabel.AutoSize = true;
+ willCreateLabel.Location = new System.Drawing.Point(0, 92);
+ willCreateLabel.Margin = new System.Windows.Forms.Padding(0, 0, 0, 12);
+ willCreateLabel.MaximumSize = new System.Drawing.Size(472, 0);
+ willCreateLabel.Name = "willCreateLabel";
+ willCreateLabel.Size = new System.Drawing.Size(0, 15);
+ willCreateLabel.TabIndex = 4;
+ willCreateLabel.UseMnemonic = false;
+ //
+ // buttonsPanel
+ //
+ buttonsPanel.Anchor = System.Windows.Forms.AnchorStyles.Right;
+ buttonsPanel.AutoSize = true;
+ buttonsPanel.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink;
+ buttonsPanel.ColumnCount = 2;
+ buttonsPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
+ buttonsPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
+ buttonsPanel.Controls.Add(buttonSave, 0, 0);
+ buttonsPanel.Controls.Add(buttonCancel, 1, 0);
+ buttonsPanel.Location = new System.Drawing.Point(216, 119);
+ buttonsPanel.Margin = new System.Windows.Forms.Padding(0);
+ buttonsPanel.Name = "buttonsPanel";
+ buttonsPanel.RowCount = 1;
+ buttonsPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
+ buttonsPanel.Size = new System.Drawing.Size(256, 36);
+ buttonsPanel.TabIndex = 5;
+ //
+ // buttonSave
+ //
+ buttonSave.AutoSize = false;
+ buttonSave.DialogResult = System.Windows.Forms.DialogResult.OK;
+ buttonSave.Location = new System.Drawing.Point(0, 0);
+ buttonSave.Margin = new System.Windows.Forms.Padding(0, 0, 8, 0);
+ buttonSave.Name = "buttonSave";
+ buttonSave.Size = new System.Drawing.Size(120, 32);
+ buttonSave.TabIndex = 0;
+ buttonSave.Text = "Save";
+ buttonSave.UseVisualStyleBackColor = true;
+ //
+ // buttonCancel
+ //
+ buttonCancel.AutoSize = false;
+ buttonCancel.DialogResult = System.Windows.Forms.DialogResult.Cancel;
+ buttonCancel.Location = new System.Drawing.Point(128, 0);
+ buttonCancel.Margin = new System.Windows.Forms.Padding(0);
+ buttonCancel.Name = "buttonCancel";
+ buttonCancel.Size = new System.Drawing.Size(120, 32);
+ buttonCancel.TabIndex = 1;
+ buttonCancel.Text = "Cancel";
+ buttonCancel.UseVisualStyleBackColor = true;
+ //
+ // SaveFormatDialog
+ //
+ AcceptButton = buttonSave;
+ AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
+ AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
+ AutoSize = true;
+ AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink;
+ CancelButton = buttonCancel;
+ ClientSize = new System.Drawing.Size(540, 184);
+ Controls.Add(mainLayoutPanel);
+ FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
+ MaximizeBox = false;
+ MinimizeBox = false;
+ Name = "SaveFormatDialog";
+ Padding = new System.Windows.Forms.Padding(12);
+ ShowInTaskbar = false;
+ StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
+ Text = "Save";
+ mainLayoutPanel.ResumeLayout(false);
+ mainLayoutPanel.PerformLayout();
+ ((System.ComponentModel.ISupportInitialize)iconPictureBox).EndInit();
+ contentLayoutPanel.ResumeLayout(false);
+ contentLayoutPanel.PerformLayout();
+ buttonsPanel.ResumeLayout(false);
+ ResumeLayout(false);
+ PerformLayout();
+ }
+
+ #endregion
+
+ private System.Windows.Forms.TableLayoutPanel mainLayoutPanel;
+ private System.Windows.Forms.PictureBox iconPictureBox;
+ private System.Windows.Forms.TableLayoutPanel contentLayoutPanel;
+ private System.Windows.Forms.Label toLabel;
+ private System.Windows.Forms.Label pathLabel;
+ private System.Windows.Forms.Label formatLabel;
+ private System.Windows.Forms.ComboBox comboBoxFormat;
+ private System.Windows.Forms.Label willCreateLabel;
+ private System.Windows.Forms.TableLayoutPanel buttonsPanel;
+ private System.Windows.Forms.Button buttonSave;
+ private System.Windows.Forms.Button buttonCancel;
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/SaveFormatDialog.cs b/UoFiddler.Controls/Forms/SaveFormatDialog.cs
new file mode 100644
index 00000000..c802bf76
--- /dev/null
+++ b/UoFiddler.Controls/Forms/SaveFormatDialog.cs
@@ -0,0 +1,78 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Drawing;
+using System.Windows.Forms;
+using Ultima.Uop;
+
+namespace UoFiddler.Controls.Forms
+{
+ ///
+ /// Asks which container a save should write, for the file types the client ships in either.
+ /// Shown only when the save format option is set to ask every time.
+ ///
+ public sealed partial class SaveFormatDialog : Form
+ {
+ private readonly FileType _type;
+ private readonly int _mapIndex;
+ private readonly string _outputDirectory;
+
+ public SaveFormatDialog(FileType type, string outputDirectory, ContainerFormat suggested, int mapIndex = 0)
+ {
+ _type = type;
+ _mapIndex = mapIndex;
+ _outputDirectory = outputDirectory ?? string.Empty;
+
+ InitializeComponent();
+
+ iconPictureBox.Image = SystemIcons.Question.ToBitmap();
+
+ var (mulName, idxName, uopName) = UopFileNames.For(type, mapIndex);
+ bool clientUsesUop = ClientFileSaver.ClientUsesUop(type, mapIndex);
+
+ string mulItem = idxName == null ? mulName : $"{mulName} + {idxName}";
+
+ comboBoxFormat.Items.Add(Describe(mulItem, !clientUsesUop));
+ comboBoxFormat.Items.Add(Describe(uopName, clientUsesUop));
+ comboBoxFormat.SelectedIndex = suggested == ContainerFormat.Uop ? 1 : 0;
+
+ pathLabel.Text = _outputDirectory;
+
+ UpdateWillCreate();
+ }
+
+ /// The container the user picked. Only meaningful on .
+ public ContainerFormat SelectedFormat =>
+ comboBoxFormat.SelectedIndex == 1 ? ContainerFormat.Uop : ContainerFormat.Mul;
+
+ private static string Describe(string files, bool isWhatTheClientUses)
+ {
+ return isWhatTheClientUses ? $"{files} (the same format as this client)" : files;
+ }
+
+ private void OnFormatChanged(object sender, EventArgs e)
+ {
+ UpdateWillCreate();
+ }
+
+ private void UpdateWillCreate()
+ {
+ var (mulName, idxName, uopName) = UopFileNames.For(_type, _mapIndex);
+
+ string files = SelectedFormat == ContainerFormat.Uop
+ ? uopName
+ : idxName == null ? mulName : $"{mulName}, {idxName}";
+
+ willCreateLabel.Text = "Will create: " + files;
+ }
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/SaveFormatDialog.resx b/UoFiddler.Controls/Forms/SaveFormatDialog.resx
new file mode 100644
index 00000000..1af7de15
--- /dev/null
+++ b/UoFiddler.Controls/Forms/SaveFormatDialog.resx
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
\ No newline at end of file
diff --git a/UoFiddler.Controls/Forms/TileDataHelpForm.cs b/UoFiddler.Controls/Forms/TileDataHelpForm.cs
index 90f7981e..dc4bbf60 100644
--- a/UoFiddler.Controls/Forms/TileDataHelpForm.cs
+++ b/UoFiddler.Controls/Forms/TileDataHelpForm.cs
@@ -26,6 +26,31 @@ public TileDataHelpForm()
private void PopulateFields()
{
+ AddHeader("Editing many entries");
+ Add("Multi-select",
+ "Ctrl+click and Shift+click select several entries at once. The right hand pane keeps showing the entry "
+ + "you picked last, and 'Save Changes' writes to every selected entry.",
+ "multi");
+ Add("Empty boxes",
+ "With more than one entry selected, a box the entries disagree on comes up empty, and an empty box is "
+ + "left alone when you save - the entries keep their own values. Fill it in to give them all the same "
+ + "value. Because empty means 'leave alone', a name cannot be cleared across a multi-selection; select "
+ + "the entry on its own to do that.",
+ "multi");
+ Add("Greyed flags",
+ "A flag that is set on some of the selected entries but not all shows greyed, and a greyed flag is left "
+ + "alone when you save. Clicking it cycles leave alone -> set on all -> clear on all. A flag they all "
+ + "already agree on just toggles.",
+ "multi");
+ Add("Copy / Paste special",
+ "Right-click an entry and choose 'Copy tile data' to remember it, then select any number of entries and "
+ + "choose 'Paste special...' to pick which of the copied fields and flags to write onto them.",
+ "multi");
+ Add("Undo",
+ "Misc -> 'Undo last bulk apply' puts back the values the entries had before the last apply. Only the "
+ + "most recent apply is kept, and it is forgotten when tiledata is reloaded.",
+ "multi");
+
AddHeader("Items");
Add("Name", "This field is for the name of the item, which can be a maximum of 20 characters.", "items");
Add("Animation", "This field is for the animation ID associated with the item.", "items");
diff --git a/UoFiddler.Controls/Forms/TileDataPasteSpecialForm.Designer.cs b/UoFiddler.Controls/Forms/TileDataPasteSpecialForm.Designer.cs
new file mode 100644
index 00000000..fe58bc57
--- /dev/null
+++ b/UoFiddler.Controls/Forms/TileDataPasteSpecialForm.Designer.cs
@@ -0,0 +1,371 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+namespace UoFiddler.Controls.Forms
+{
+ partial class TileDataPasteSpecialForm
+ {
+ ///
+ /// Required designer variable.
+ ///
+ private System.ComponentModel.IContainer components = null;
+
+ ///
+ /// Clean up any resources being used.
+ ///
+ /// true if managed resources should be disposed; otherwise, false.
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && (components != null))
+ {
+ components.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ ///
+ /// Required method for Designer support - do not modify
+ /// the contents of this method with the code editor.
+ ///
+ private void InitializeComponent()
+ {
+ mainTableLayoutPanel = new System.Windows.Forms.TableLayoutPanel();
+ headerLabel = new System.Windows.Forms.Label();
+ contentSplitContainer = new System.Windows.Forms.SplitContainer();
+ fieldsTableLayoutPanel = new System.Windows.Forms.TableLayoutPanel();
+ fieldsLabel = new System.Windows.Forms.Label();
+ fieldsCheckedListBox = new System.Windows.Forms.CheckedListBox();
+ flagsGroupBox = new System.Windows.Forms.GroupBox();
+ flagsTableLayoutPanel = new System.Windows.Forms.TableLayoutPanel();
+ flagsModeFlowLayoutPanel = new System.Windows.Forms.FlowLayoutPanel();
+ flagsLeaveRadioButton = new System.Windows.Forms.RadioButton();
+ flagsReplaceRadioButton = new System.Windows.Forms.RadioButton();
+ flagsSetCheckedRadioButton = new System.Windows.Forms.RadioButton();
+ flagsClearCheckedRadioButton = new System.Windows.Forms.RadioButton();
+ flagsCheckedListBox = new System.Windows.Forms.CheckedListBox();
+ buttonsFlowLayoutPanel = new System.Windows.Forms.FlowLayoutPanel();
+ cancelButton = new System.Windows.Forms.Button();
+ applyButton = new System.Windows.Forms.Button();
+ uncheckAllButton = new System.Windows.Forms.Button();
+ checkAllButton = new System.Windows.Forms.Button();
+ mainTableLayoutPanel.SuspendLayout();
+ ((System.ComponentModel.ISupportInitialize)contentSplitContainer).BeginInit();
+ contentSplitContainer.Panel1.SuspendLayout();
+ contentSplitContainer.Panel2.SuspendLayout();
+ contentSplitContainer.SuspendLayout();
+ fieldsTableLayoutPanel.SuspendLayout();
+ flagsGroupBox.SuspendLayout();
+ flagsTableLayoutPanel.SuspendLayout();
+ flagsModeFlowLayoutPanel.SuspendLayout();
+ buttonsFlowLayoutPanel.SuspendLayout();
+ SuspendLayout();
+ //
+ // mainTableLayoutPanel
+ //
+ mainTableLayoutPanel.ColumnCount = 1;
+ mainTableLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
+ mainTableLayoutPanel.Controls.Add(headerLabel, 0, 0);
+ mainTableLayoutPanel.Controls.Add(contentSplitContainer, 0, 1);
+ mainTableLayoutPanel.Controls.Add(buttonsFlowLayoutPanel, 0, 2);
+ mainTableLayoutPanel.Dock = System.Windows.Forms.DockStyle.Fill;
+ mainTableLayoutPanel.Location = new System.Drawing.Point(0, 0);
+ mainTableLayoutPanel.Name = "mainTableLayoutPanel";
+ mainTableLayoutPanel.Padding = new System.Windows.Forms.Padding(8);
+ mainTableLayoutPanel.RowCount = 3;
+ mainTableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
+ mainTableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F));
+ mainTableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
+ mainTableLayoutPanel.Size = new System.Drawing.Size(624, 441);
+ mainTableLayoutPanel.TabIndex = 0;
+ //
+ // headerLabel
+ //
+ headerLabel.AutoSize = true;
+ headerLabel.Dock = System.Windows.Forms.DockStyle.Fill;
+ headerLabel.Location = new System.Drawing.Point(11, 8);
+ headerLabel.Name = "headerLabel";
+ headerLabel.Padding = new System.Windows.Forms.Padding(0, 0, 0, 6);
+ headerLabel.Size = new System.Drawing.Size(602, 21);
+ headerLabel.TabIndex = 0;
+ headerLabel.Text = "Paste special";
+ //
+ // contentSplitContainer
+ //
+ contentSplitContainer.Dock = System.Windows.Forms.DockStyle.Fill;
+ contentSplitContainer.Location = new System.Drawing.Point(11, 32);
+ contentSplitContainer.Name = "contentSplitContainer";
+ //
+ // contentSplitContainer.Panel1
+ //
+ contentSplitContainer.Panel1.Controls.Add(fieldsTableLayoutPanel);
+ contentSplitContainer.Panel1MinSize = 160;
+ //
+ // contentSplitContainer.Panel2
+ //
+ contentSplitContainer.Panel2.Controls.Add(flagsGroupBox);
+ contentSplitContainer.Panel2MinSize = 200;
+ contentSplitContainer.Size = new System.Drawing.Size(602, 359);
+ contentSplitContainer.SplitterDistance = 240;
+ contentSplitContainer.TabIndex = 1;
+ //
+ // fieldsTableLayoutPanel
+ //
+ fieldsTableLayoutPanel.ColumnCount = 1;
+ fieldsTableLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
+ fieldsTableLayoutPanel.Controls.Add(fieldsLabel, 0, 0);
+ fieldsTableLayoutPanel.Controls.Add(fieldsCheckedListBox, 0, 1);
+ fieldsTableLayoutPanel.Dock = System.Windows.Forms.DockStyle.Fill;
+ fieldsTableLayoutPanel.Location = new System.Drawing.Point(0, 0);
+ fieldsTableLayoutPanel.Name = "fieldsTableLayoutPanel";
+ fieldsTableLayoutPanel.RowCount = 2;
+ fieldsTableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
+ fieldsTableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F));
+ fieldsTableLayoutPanel.Size = new System.Drawing.Size(240, 359);
+ fieldsTableLayoutPanel.TabIndex = 0;
+ //
+ // fieldsLabel
+ //
+ fieldsLabel.AutoSize = true;
+ fieldsLabel.Dock = System.Windows.Forms.DockStyle.Fill;
+ fieldsLabel.Location = new System.Drawing.Point(3, 0);
+ fieldsLabel.Name = "fieldsLabel";
+ fieldsLabel.Padding = new System.Windows.Forms.Padding(0, 0, 0, 4);
+ fieldsLabel.Size = new System.Drawing.Size(234, 19);
+ fieldsLabel.TabIndex = 0;
+ fieldsLabel.Text = "Fields to paste";
+ //
+ // fieldsCheckedListBox
+ //
+ fieldsCheckedListBox.CheckOnClick = true;
+ fieldsCheckedListBox.Dock = System.Windows.Forms.DockStyle.Fill;
+ fieldsCheckedListBox.FormattingEnabled = true;
+ fieldsCheckedListBox.IntegralHeight = false;
+ fieldsCheckedListBox.Location = new System.Drawing.Point(3, 22);
+ fieldsCheckedListBox.Name = "fieldsCheckedListBox";
+ fieldsCheckedListBox.Size = new System.Drawing.Size(234, 334);
+ fieldsCheckedListBox.TabIndex = 1;
+ //
+ // flagsGroupBox
+ //
+ flagsGroupBox.Controls.Add(flagsTableLayoutPanel);
+ flagsGroupBox.Dock = System.Windows.Forms.DockStyle.Fill;
+ flagsGroupBox.Location = new System.Drawing.Point(0, 0);
+ flagsGroupBox.Name = "flagsGroupBox";
+ flagsGroupBox.Padding = new System.Windows.Forms.Padding(6);
+ flagsGroupBox.Size = new System.Drawing.Size(358, 359);
+ flagsGroupBox.TabIndex = 0;
+ flagsGroupBox.TabStop = false;
+ flagsGroupBox.Text = "Flags";
+ //
+ // flagsTableLayoutPanel
+ //
+ flagsTableLayoutPanel.ColumnCount = 1;
+ flagsTableLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
+ flagsTableLayoutPanel.Controls.Add(flagsModeFlowLayoutPanel, 0, 0);
+ flagsTableLayoutPanel.Controls.Add(flagsCheckedListBox, 0, 1);
+ flagsTableLayoutPanel.Dock = System.Windows.Forms.DockStyle.Fill;
+ flagsTableLayoutPanel.Location = new System.Drawing.Point(6, 22);
+ flagsTableLayoutPanel.Name = "flagsTableLayoutPanel";
+ flagsTableLayoutPanel.RowCount = 2;
+ flagsTableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
+ flagsTableLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F));
+ flagsTableLayoutPanel.Size = new System.Drawing.Size(346, 331);
+ flagsTableLayoutPanel.TabIndex = 0;
+ //
+ // flagsModeFlowLayoutPanel
+ //
+ flagsModeFlowLayoutPanel.AutoSize = true;
+ flagsModeFlowLayoutPanel.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink;
+ flagsModeFlowLayoutPanel.Controls.Add(flagsLeaveRadioButton);
+ flagsModeFlowLayoutPanel.Controls.Add(flagsReplaceRadioButton);
+ flagsModeFlowLayoutPanel.Controls.Add(flagsSetCheckedRadioButton);
+ flagsModeFlowLayoutPanel.Controls.Add(flagsClearCheckedRadioButton);
+ flagsModeFlowLayoutPanel.Dock = System.Windows.Forms.DockStyle.Fill;
+ flagsModeFlowLayoutPanel.FlowDirection = System.Windows.Forms.FlowDirection.TopDown;
+ flagsModeFlowLayoutPanel.Location = new System.Drawing.Point(3, 3);
+ flagsModeFlowLayoutPanel.Name = "flagsModeFlowLayoutPanel";
+ flagsModeFlowLayoutPanel.Size = new System.Drawing.Size(340, 92);
+ flagsModeFlowLayoutPanel.TabIndex = 0;
+ flagsModeFlowLayoutPanel.WrapContents = false;
+ //
+ // flagsLeaveRadioButton
+ //
+ flagsLeaveRadioButton.AutoSize = true;
+ flagsLeaveRadioButton.Location = new System.Drawing.Point(3, 3);
+ flagsLeaveRadioButton.Name = "flagsLeaveRadioButton";
+ flagsLeaveRadioButton.Size = new System.Drawing.Size(122, 19);
+ flagsLeaveRadioButton.TabIndex = 0;
+ flagsLeaveRadioButton.Text = "Don't change flags";
+ flagsLeaveRadioButton.UseVisualStyleBackColor = true;
+ flagsLeaveRadioButton.CheckedChanged += OnFlagModeChanged;
+ //
+ // flagsReplaceRadioButton
+ //
+ flagsReplaceRadioButton.AutoSize = true;
+ flagsReplaceRadioButton.Checked = true;
+ flagsReplaceRadioButton.Location = new System.Drawing.Point(3, 28);
+ flagsReplaceRadioButton.Name = "flagsReplaceRadioButton";
+ flagsReplaceRadioButton.Size = new System.Drawing.Size(178, 19);
+ flagsReplaceRadioButton.TabIndex = 1;
+ flagsReplaceRadioButton.TabStop = true;
+ flagsReplaceRadioButton.Text = "Replace all flags with source";
+ flagsReplaceRadioButton.UseVisualStyleBackColor = true;
+ flagsReplaceRadioButton.CheckedChanged += OnFlagModeChanged;
+ //
+ // flagsSetCheckedRadioButton
+ //
+ flagsSetCheckedRadioButton.AutoSize = true;
+ flagsSetCheckedRadioButton.Location = new System.Drawing.Point(3, 53);
+ flagsSetCheckedRadioButton.Name = "flagsSetCheckedRadioButton";
+ flagsSetCheckedRadioButton.Size = new System.Drawing.Size(148, 19);
+ flagsSetCheckedRadioButton.TabIndex = 2;
+ flagsSetCheckedRadioButton.Text = "Set only the checked flags";
+ flagsSetCheckedRadioButton.UseVisualStyleBackColor = true;
+ flagsSetCheckedRadioButton.CheckedChanged += OnFlagModeChanged;
+ //
+ // flagsClearCheckedRadioButton
+ //
+ flagsClearCheckedRadioButton.AutoSize = true;
+ flagsClearCheckedRadioButton.Location = new System.Drawing.Point(3, 78);
+ flagsClearCheckedRadioButton.Name = "flagsClearCheckedRadioButton";
+ flagsClearCheckedRadioButton.Size = new System.Drawing.Size(163, 19);
+ flagsClearCheckedRadioButton.TabIndex = 3;
+ flagsClearCheckedRadioButton.Text = "Clear only the checked flags";
+ flagsClearCheckedRadioButton.UseVisualStyleBackColor = true;
+ flagsClearCheckedRadioButton.CheckedChanged += OnFlagModeChanged;
+ //
+ // flagsCheckedListBox
+ //
+ flagsCheckedListBox.CheckOnClick = true;
+ flagsCheckedListBox.Dock = System.Windows.Forms.DockStyle.Fill;
+ flagsCheckedListBox.FormattingEnabled = true;
+ flagsCheckedListBox.IntegralHeight = false;
+ flagsCheckedListBox.Location = new System.Drawing.Point(3, 101);
+ flagsCheckedListBox.MultiColumn = true;
+ flagsCheckedListBox.Name = "flagsCheckedListBox";
+ flagsCheckedListBox.Size = new System.Drawing.Size(340, 227);
+ flagsCheckedListBox.TabIndex = 1;
+ //
+ // buttonsFlowLayoutPanel
+ //
+ buttonsFlowLayoutPanel.AutoSize = true;
+ buttonsFlowLayoutPanel.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink;
+ buttonsFlowLayoutPanel.Controls.Add(cancelButton);
+ buttonsFlowLayoutPanel.Controls.Add(applyButton);
+ buttonsFlowLayoutPanel.Controls.Add(uncheckAllButton);
+ buttonsFlowLayoutPanel.Controls.Add(checkAllButton);
+ buttonsFlowLayoutPanel.Dock = System.Windows.Forms.DockStyle.Fill;
+ buttonsFlowLayoutPanel.FlowDirection = System.Windows.Forms.FlowDirection.RightToLeft;
+ buttonsFlowLayoutPanel.Location = new System.Drawing.Point(11, 397);
+ buttonsFlowLayoutPanel.Name = "buttonsFlowLayoutPanel";
+ buttonsFlowLayoutPanel.Padding = new System.Windows.Forms.Padding(0, 6, 0, 0);
+ buttonsFlowLayoutPanel.Size = new System.Drawing.Size(602, 35);
+ buttonsFlowLayoutPanel.TabIndex = 2;
+ buttonsFlowLayoutPanel.WrapContents = false;
+ //
+ // cancelButton
+ //
+ cancelButton.DialogResult = System.Windows.Forms.DialogResult.Cancel;
+ cancelButton.Location = new System.Drawing.Point(524, 9);
+ cancelButton.Name = "cancelButton";
+ cancelButton.Size = new System.Drawing.Size(75, 23);
+ cancelButton.TabIndex = 0;
+ cancelButton.Text = "Cancel";
+ cancelButton.UseVisualStyleBackColor = true;
+ //
+ // applyButton
+ //
+ applyButton.Location = new System.Drawing.Point(443, 9);
+ applyButton.Name = "applyButton";
+ applyButton.Size = new System.Drawing.Size(75, 23);
+ applyButton.TabIndex = 1;
+ applyButton.Text = "Apply";
+ applyButton.UseVisualStyleBackColor = true;
+ applyButton.Click += OnClickApply;
+ //
+ // uncheckAllButton
+ //
+ uncheckAllButton.Location = new System.Drawing.Point(348, 9);
+ uncheckAllButton.Name = "uncheckAllButton";
+ uncheckAllButton.Size = new System.Drawing.Size(89, 23);
+ uncheckAllButton.TabIndex = 2;
+ uncheckAllButton.Text = "Uncheck all";
+ uncheckAllButton.UseVisualStyleBackColor = true;
+ uncheckAllButton.Click += OnClickUncheckAll;
+ //
+ // checkAllButton
+ //
+ checkAllButton.Location = new System.Drawing.Point(253, 9);
+ checkAllButton.Name = "checkAllButton";
+ checkAllButton.Size = new System.Drawing.Size(89, 23);
+ checkAllButton.TabIndex = 3;
+ checkAllButton.Text = "Check all";
+ checkAllButton.UseVisualStyleBackColor = true;
+ checkAllButton.Click += OnClickCheckAll;
+ //
+ // TileDataPasteSpecialForm
+ //
+ AcceptButton = applyButton;
+ AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
+ AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
+ CancelButton = cancelButton;
+ ClientSize = new System.Drawing.Size(624, 441);
+ Controls.Add(mainTableLayoutPanel);
+ MinimizeBox = false;
+ MaximizeBox = false;
+ MinimumSize = new System.Drawing.Size(560, 400);
+ Name = "TileDataPasteSpecialForm";
+ ShowIcon = false;
+ ShowInTaskbar = false;
+ StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
+ Text = "Paste special";
+ mainTableLayoutPanel.ResumeLayout(false);
+ mainTableLayoutPanel.PerformLayout();
+ contentSplitContainer.Panel1.ResumeLayout(false);
+ contentSplitContainer.Panel2.ResumeLayout(false);
+ ((System.ComponentModel.ISupportInitialize)contentSplitContainer).EndInit();
+ contentSplitContainer.ResumeLayout(false);
+ fieldsTableLayoutPanel.ResumeLayout(false);
+ fieldsTableLayoutPanel.PerformLayout();
+ flagsGroupBox.ResumeLayout(false);
+ flagsTableLayoutPanel.ResumeLayout(false);
+ flagsTableLayoutPanel.PerformLayout();
+ flagsModeFlowLayoutPanel.ResumeLayout(false);
+ flagsModeFlowLayoutPanel.PerformLayout();
+ buttonsFlowLayoutPanel.ResumeLayout(false);
+ ResumeLayout(false);
+ }
+
+ #endregion
+
+ private System.Windows.Forms.TableLayoutPanel mainTableLayoutPanel;
+ private System.Windows.Forms.Label headerLabel;
+ private System.Windows.Forms.SplitContainer contentSplitContainer;
+ private System.Windows.Forms.TableLayoutPanel fieldsTableLayoutPanel;
+ private System.Windows.Forms.Label fieldsLabel;
+ private System.Windows.Forms.CheckedListBox fieldsCheckedListBox;
+ private System.Windows.Forms.GroupBox flagsGroupBox;
+ private System.Windows.Forms.TableLayoutPanel flagsTableLayoutPanel;
+ private System.Windows.Forms.FlowLayoutPanel flagsModeFlowLayoutPanel;
+ private System.Windows.Forms.RadioButton flagsLeaveRadioButton;
+ private System.Windows.Forms.RadioButton flagsReplaceRadioButton;
+ private System.Windows.Forms.RadioButton flagsSetCheckedRadioButton;
+ private System.Windows.Forms.RadioButton flagsClearCheckedRadioButton;
+ private System.Windows.Forms.CheckedListBox flagsCheckedListBox;
+ private System.Windows.Forms.FlowLayoutPanel buttonsFlowLayoutPanel;
+ private System.Windows.Forms.Button cancelButton;
+ private System.Windows.Forms.Button applyButton;
+ private System.Windows.Forms.Button uncheckAllButton;
+ private System.Windows.Forms.Button checkAllButton;
+ }
+}
diff --git a/UoFiddler.Controls/Forms/TileDataPasteSpecialForm.cs b/UoFiddler.Controls/Forms/TileDataPasteSpecialForm.cs
new file mode 100644
index 00000000..6c42fec6
--- /dev/null
+++ b/UoFiddler.Controls/Forms/TileDataPasteSpecialForm.cs
@@ -0,0 +1,428 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.Windows.Forms;
+using Ultima;
+using UoFiddler.Controls.Classes;
+
+namespace UoFiddler.Controls.Forms
+{
+ ///
+ /// Picks which parts of a copied tiledata entry get pasted onto the selected
+ /// entries. Produces an / ,
+ /// so the paste lands through the same apply path as a multi-selection
+ /// "Save Changes".
+ ///
+ public partial class TileDataPasteSpecialForm : Form
+ {
+ private enum FlagMode
+ {
+ Leave,
+ Replace,
+ SetChecked,
+ ClearChecked
+ }
+
+ private enum ItemField
+ {
+ Name,
+ Animation,
+ Weight,
+ Quality,
+ Quantity,
+ Hue,
+ StackingOffset,
+ Value,
+ Height,
+ MiscData,
+ Unk2,
+ Unk3
+ }
+
+ private enum LandField
+ {
+ Name,
+ TextureId
+ }
+
+ // Remembered for the session so pasting the same subset onto batch after batch
+ // does not mean re-ticking the same boxes every time.
+ private static readonly HashSet _rememberedItemFields = new HashSet();
+ private static readonly HashSet _rememberedLandFields = new HashSet();
+ private static bool _hasRememberedItemFields;
+ private static bool _hasRememberedLandFields;
+ private static FlagMode _rememberedFlagMode = FlagMode.Replace;
+
+ private readonly bool _land;
+ private readonly ItemData _sourceItem;
+ private readonly LandData _sourceLand;
+ private readonly List _fieldKeys = new List();
+ private readonly List _flagValues = new List();
+
+ public TileDataPasteSpecialForm(ItemData source, int sourceGraphic, int targetCount)
+ : this(false, sourceGraphic, targetCount)
+ {
+ _sourceItem = source;
+ BuildItemFields();
+ BuildFlagList(source.Flags);
+ RestoreRemembered(_rememberedItemFields, _hasRememberedItemFields);
+ }
+
+ public TileDataPasteSpecialForm(LandData source, int sourceGraphic, int targetCount)
+ : this(true, sourceGraphic, targetCount)
+ {
+ _sourceLand = source;
+ BuildLandFields();
+ BuildFlagList(source.Flags);
+ RestoreRemembered(_rememberedLandFields, _hasRememberedLandFields);
+ }
+
+ private TileDataPasteSpecialForm(bool land, int sourceGraphic, int targetCount)
+ {
+ InitializeComponent();
+
+ _land = land;
+
+ string sourceName = land
+ ? TileData.LandTable[sourceGraphic].Name
+ : TileData.ItemTable[sourceGraphic].Name;
+
+ headerLabel.Text = targetCount == 1
+ ? $"Paste from 0x{sourceGraphic:X4} ({sourceGraphic}) \"{sourceName}\" onto 1 selected entry."
+ : $"Paste from 0x{sourceGraphic:X4} ({sourceGraphic}) \"{sourceName}\" onto {targetCount} selected entries.";
+
+ switch (_rememberedFlagMode)
+ {
+ case FlagMode.Leave:
+ flagsLeaveRadioButton.Checked = true;
+ break;
+
+ case FlagMode.SetChecked:
+ flagsSetCheckedRadioButton.Checked = true;
+ break;
+
+ case FlagMode.ClearChecked:
+ flagsClearCheckedRadioButton.Checked = true;
+ break;
+
+ default:
+ flagsReplaceRadioButton.Checked = true;
+ break;
+ }
+
+ UpdateFlagListEnabled();
+ }
+
+ private void BuildItemFields()
+ {
+ AddField((int)ItemField.Name, "Name", _sourceItem.Name);
+ AddField((int)ItemField.Animation, "Anim", _sourceItem.Animation.ToString());
+ AddField((int)ItemField.Weight, "Weight", _sourceItem.Weight.ToString());
+ AddField((int)ItemField.Quality, "Layer", _sourceItem.Quality.ToString());
+ AddField((int)ItemField.Quantity, "Quantity", _sourceItem.Quantity.ToString());
+ AddField((int)ItemField.Hue, "Hue", _sourceItem.Hue.ToString());
+ AddField((int)ItemField.StackingOffset, "StackOff", _sourceItem.StackingOffset.ToString());
+ AddField((int)ItemField.Value, "Value", _sourceItem.Value.ToString());
+ AddField((int)ItemField.Height, "Height", _sourceItem.Height.ToString());
+ AddField((int)ItemField.MiscData, "MiscData", _sourceItem.MiscData.ToString());
+ AddField((int)ItemField.Unk2, "Unk2", _sourceItem.Unk2.ToString());
+ AddField((int)ItemField.Unk3, "Unk3", _sourceItem.Unk3.ToString());
+ }
+
+ private void BuildLandFields()
+ {
+ AddField((int)LandField.Name, "Name", _sourceLand.Name);
+ AddField((int)LandField.TextureId, "TexID", _sourceLand.TextureId.ToString());
+ }
+
+ private void AddField(int key, string label, string value)
+ {
+ _fieldKeys.Add(key);
+ fieldsCheckedListBox.Items.Add($"{label} = {value}", true);
+ }
+
+ private void BuildFlagList(TileFlag sourceFlags)
+ {
+ string[] enumNames = Enum.GetNames(typeof(TileFlag));
+ Array enumValues = Enum.GetValues(typeof(TileFlag));
+
+ // Same "which half of the enum is valid for this client" rule the tiledata
+ // editor and the CSV export use.
+ int maxLength = Art.IsUOAHS() ? enumNames.Length : (enumNames.Length / 2) + 1;
+
+ flagsCheckedListBox.BeginUpdate();
+ try
+ {
+ for (int i = 1; i < maxLength; ++i)
+ {
+ var flag = (TileFlag)enumValues.GetValue(i);
+ _flagValues.Add(flag);
+ flagsCheckedListBox.Items.Add(enumNames[i], (sourceFlags & flag) != 0);
+ }
+ }
+ finally
+ {
+ flagsCheckedListBox.EndUpdate();
+ }
+ }
+
+ private void RestoreRemembered(HashSet remembered, bool hasRemembered)
+ {
+ if (!hasRemembered)
+ {
+ return;
+ }
+
+ for (int i = 0; i < _fieldKeys.Count; ++i)
+ {
+ fieldsCheckedListBox.SetItemChecked(i, remembered.Contains(_fieldKeys[i]));
+ }
+ }
+
+ private FlagMode SelectedFlagMode
+ {
+ get
+ {
+ if (flagsLeaveRadioButton.Checked)
+ {
+ return FlagMode.Leave;
+ }
+
+ if (flagsSetCheckedRadioButton.Checked)
+ {
+ return FlagMode.SetChecked;
+ }
+
+ if (flagsClearCheckedRadioButton.Checked)
+ {
+ return FlagMode.ClearChecked;
+ }
+
+ return FlagMode.Replace;
+ }
+ }
+
+ /// The item edit the chosen boxes describe. Only valid for an item paste.
+ public ItemDataEdit BuildItemEdit()
+ {
+ var edit = new ItemDataEdit();
+
+ for (int i = 0; i < _fieldKeys.Count; ++i)
+ {
+ if (!fieldsCheckedListBox.GetItemChecked(i))
+ {
+ continue;
+ }
+
+ switch ((ItemField)_fieldKeys[i])
+ {
+ case ItemField.Name:
+ edit.Name = _sourceItem.Name ?? string.Empty;
+ break;
+
+ case ItemField.Animation:
+ edit.Animation = _sourceItem.Animation;
+ break;
+
+ case ItemField.Weight:
+ edit.Weight = _sourceItem.Weight;
+ break;
+
+ case ItemField.Quality:
+ edit.Quality = _sourceItem.Quality;
+ break;
+
+ case ItemField.Quantity:
+ edit.Quantity = _sourceItem.Quantity;
+ break;
+
+ case ItemField.Hue:
+ edit.Hue = _sourceItem.Hue;
+ break;
+
+ case ItemField.StackingOffset:
+ edit.StackingOffset = _sourceItem.StackingOffset;
+ break;
+
+ case ItemField.Value:
+ edit.Value = _sourceItem.Value;
+ break;
+
+ case ItemField.Height:
+ edit.Height = _sourceItem.Height;
+ break;
+
+ case ItemField.MiscData:
+ edit.MiscData = _sourceItem.MiscData;
+ break;
+
+ case ItemField.Unk2:
+ edit.Unk2 = _sourceItem.Unk2;
+ break;
+
+ case ItemField.Unk3:
+ edit.Unk3 = _sourceItem.Unk3;
+ break;
+ }
+ }
+
+ ApplyFlagMode(_sourceItem.Flags, out TileFlag setFlags, out TileFlag clearFlags);
+ edit.SetFlags = setFlags;
+ edit.ClearFlags = clearFlags;
+
+ return edit;
+ }
+
+ /// The land edit the chosen boxes describe. Only valid for a land paste.
+ public LandDataEdit BuildLandEdit()
+ {
+ var edit = new LandDataEdit();
+
+ for (int i = 0; i < _fieldKeys.Count; ++i)
+ {
+ if (!fieldsCheckedListBox.GetItemChecked(i))
+ {
+ continue;
+ }
+
+ switch ((LandField)_fieldKeys[i])
+ {
+ case LandField.Name:
+ edit.Name = _sourceLand.Name ?? string.Empty;
+ break;
+
+ case LandField.TextureId:
+ edit.TextureId = _sourceLand.TextureId;
+ break;
+ }
+ }
+
+ ApplyFlagMode(_sourceLand.Flags, out TileFlag setFlags, out TileFlag clearFlags);
+ edit.SetFlags = setFlags;
+ edit.ClearFlags = clearFlags;
+
+ return edit;
+ }
+
+ private void ApplyFlagMode(TileFlag sourceFlags, out TileFlag setFlags, out TileFlag clearFlags)
+ {
+ setFlags = TileFlag.None;
+ clearFlags = TileFlag.None;
+
+ switch (SelectedFlagMode)
+ {
+ case FlagMode.Leave:
+ return;
+
+ case FlagMode.Replace:
+ // Set what the source has and clear every other flag this client
+ // knows about, so the target ends up with exactly the source flags.
+ foreach (TileFlag flag in _flagValues)
+ {
+ if ((sourceFlags & flag) != 0)
+ {
+ setFlags |= flag;
+ }
+ else
+ {
+ clearFlags |= flag;
+ }
+ }
+
+ return;
+
+ case FlagMode.SetChecked:
+ setFlags = GetCheckedFlags();
+ return;
+
+ case FlagMode.ClearChecked:
+ clearFlags = GetCheckedFlags();
+ return;
+ }
+ }
+
+ private TileFlag GetCheckedFlags()
+ {
+ TileFlag flags = TileFlag.None;
+ for (int i = 0; i < _flagValues.Count; ++i)
+ {
+ if (flagsCheckedListBox.GetItemChecked(i))
+ {
+ flags |= _flagValues[i];
+ }
+ }
+
+ return flags;
+ }
+
+ private void UpdateFlagListEnabled()
+ {
+ FlagMode mode = SelectedFlagMode;
+ flagsCheckedListBox.Enabled = mode == FlagMode.SetChecked || mode == FlagMode.ClearChecked;
+ }
+
+ private void OnFlagModeChanged(object sender, EventArgs e)
+ {
+ UpdateFlagListEnabled();
+ }
+
+ private void OnClickCheckAll(object sender, EventArgs e)
+ {
+ SetAllFieldsChecked(true);
+ }
+
+ private void OnClickUncheckAll(object sender, EventArgs e)
+ {
+ SetAllFieldsChecked(false);
+ }
+
+ private void SetAllFieldsChecked(bool value)
+ {
+ for (int i = 0; i < fieldsCheckedListBox.Items.Count; ++i)
+ {
+ fieldsCheckedListBox.SetItemChecked(i, value);
+ }
+ }
+
+ private void OnClickApply(object sender, EventArgs e)
+ {
+ Remember();
+ DialogResult = DialogResult.OK;
+ Close();
+ }
+
+ private void Remember()
+ {
+ _rememberedFlagMode = SelectedFlagMode;
+
+ HashSet remembered = _land ? _rememberedLandFields : _rememberedItemFields;
+ remembered.Clear();
+ for (int i = 0; i < _fieldKeys.Count; ++i)
+ {
+ if (fieldsCheckedListBox.GetItemChecked(i))
+ {
+ remembered.Add(_fieldKeys[i]);
+ }
+ }
+
+ if (_land)
+ {
+ _hasRememberedLandFields = true;
+ }
+ else
+ {
+ _hasRememberedItemFields = true;
+ }
+ }
+ }
+}
diff --git a/UoFiddler.Controls/Plugin/PluginBase.cs b/UoFiddler.Controls/Plugin/PluginBase.cs
index 44321854..d653ed01 100644
--- a/UoFiddler.Controls/Plugin/PluginBase.cs
+++ b/UoFiddler.Controls/Plugin/PluginBase.cs
@@ -13,6 +13,7 @@
using Microsoft.Extensions.Logging;
using UoFiddler.Controls.Classes;
using UoFiddler.Controls.Plugin.Interfaces;
+using Ultima.Helpers;
namespace UoFiddler.Controls.Plugin
{
@@ -47,4 +48,3 @@ public virtual void ModifyPluginToolStrip(ToolStripDropDownButton toolStrip) { }
public virtual void ModifyTabPages(TabControl tabControl) { }
}
}
-
diff --git a/UoFiddler.Controls/Plugin/PluginServices.cs b/UoFiddler.Controls/Plugin/PluginServices.cs
index d21c4199..c42178d1 100644
--- a/UoFiddler.Controls/Plugin/PluginServices.cs
+++ b/UoFiddler.Controls/Plugin/PluginServices.cs
@@ -17,6 +17,7 @@
using UoFiddler.Controls.Plugin.Interfaces;
using UoFiddler.Controls.UserControls;
using UoFiddler.Controls.UserControls.TileView;
+using Ultima.Helpers;
namespace UoFiddler.Controls.Plugin
{
diff --git a/UoFiddler.Controls/UserControls/GumpControl.Designer.cs b/UoFiddler.Controls/UserControls/GumpControl.Designer.cs
index b99d5b44..37afc50d 100644
--- a/UoFiddler.Controls/UserControls/GumpControl.Designer.cs
+++ b/UoFiddler.Controls/UserControls/GumpControl.Designer.cs
@@ -57,6 +57,9 @@ private void InitializeComponent()
jumpToMaleFemale = new System.Windows.Forms.ToolStripMenuItem();
toolStripSeparator5 = new System.Windows.Forms.ToolStripSeparator();
replaceGumpToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ copyImageToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ pasteImageToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ clipboardToolStripSeparator = new System.Windows.Forms.ToolStripSeparator();
insertToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
InsertText = new System.Windows.Forms.ToolStripTextBox();
toolStripMenuItem1 = new System.Windows.Forms.ToolStripMenuItem();
@@ -148,8 +151,9 @@ private void InitializeComponent()
//
// contextMenuStrip
//
- contextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { showFreeSlotsToolStripMenuItem, findNextFreeSlotToolStripMenuItem, changeBackgroundColorToolStripMenuItem, toolStripSeparator2, extractImageToolStripMenuItem, toolStripSeparator6, jumpToMaleFemale, toolStripSeparator5, replaceGumpToolStripMenuItem, insertToolStripMenuItem, toolStripMenuItem1, removeToolStripMenuItem, toolStripSeparator1, saveToolStripMenuItem });
+ contextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { showFreeSlotsToolStripMenuItem, findNextFreeSlotToolStripMenuItem, changeBackgroundColorToolStripMenuItem, toolStripSeparator2, extractImageToolStripMenuItem, toolStripSeparator6, jumpToMaleFemale, toolStripSeparator5, copyImageToolStripMenuItem, pasteImageToolStripMenuItem, clipboardToolStripSeparator, replaceGumpToolStripMenuItem, insertToolStripMenuItem, toolStripMenuItem1, removeToolStripMenuItem, toolStripSeparator1, saveToolStripMenuItem });
contextMenuStrip.Name = "contextMenuStrip1";
+ contextMenuStrip.Opening += ContextMenuStrip_Opening;
contextMenuStrip.Size = new System.Drawing.Size(190, 226);
//
// showFreeSlotsToolStripMenuItem
@@ -230,6 +234,27 @@ private void InitializeComponent()
toolStripSeparator5.Name = "toolStripSeparator5";
toolStripSeparator5.Size = new System.Drawing.Size(186, 6);
//
+ // copyImageToolStripMenuItem
+ //
+ copyImageToolStripMenuItem.Name = "copyImageToolStripMenuItem";
+ copyImageToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+C";
+ copyImageToolStripMenuItem.Size = new System.Drawing.Size(189, 22);
+ copyImageToolStripMenuItem.Text = "Copy Image";
+ copyImageToolStripMenuItem.Click += OnClickCopyImage;
+ //
+ // pasteImageToolStripMenuItem
+ //
+ pasteImageToolStripMenuItem.Name = "pasteImageToolStripMenuItem";
+ pasteImageToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+V";
+ pasteImageToolStripMenuItem.Size = new System.Drawing.Size(189, 22);
+ pasteImageToolStripMenuItem.Text = "Paste Image";
+ pasteImageToolStripMenuItem.Click += OnClickPasteImage;
+ //
+ // clipboardToolStripSeparator
+ //
+ clipboardToolStripSeparator.Name = "clipboardToolStripSeparator";
+ clipboardToolStripSeparator.Size = new System.Drawing.Size(186, 6);
+ //
// replaceGumpToolStripMenuItem
//
replaceGumpToolStripMenuItem.Name = "replaceGumpToolStripMenuItem";
@@ -541,6 +566,9 @@ private void InitializeComponent()
private System.Windows.Forms.ToolStripProgressBar ProgressBar;
private System.Windows.Forms.ToolStripMenuItem removeToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem replaceGumpToolStripMenuItem;
+ private System.Windows.Forms.ToolStripMenuItem copyImageToolStripMenuItem;
+ private System.Windows.Forms.ToolStripMenuItem pasteImageToolStripMenuItem;
+ private System.Windows.Forms.ToolStripSeparator clipboardToolStripSeparator;
private System.Windows.Forms.ToolStripMenuItem saveToolStripMenuItem;
private System.Windows.Forms.ToolStripLabel SizeLabel;
private System.Windows.Forms.SplitContainer splitContainer1;
diff --git a/UoFiddler.Controls/UserControls/GumpControl.cs b/UoFiddler.Controls/UserControls/GumpControl.cs
index 8cd8f5c5..64b76262 100644
--- a/UoFiddler.Controls/UserControls/GumpControl.cs
+++ b/UoFiddler.Controls/UserControls/GumpControl.cs
@@ -19,6 +19,7 @@
using System.Windows.Forms;
using System.Xml;
using Ultima;
+using Ultima.Uop;
using UoFiddler.Controls.Classes;
using UoFiddler.Controls.Forms;
using UoFiddler.Controls.Helpers;
@@ -497,6 +498,11 @@ private void ListView_DrawItem(object sender, DrawListViewItemEventArgs e)
e.Graphics.FillRectangle(Brushes.LightCoral, e.Bounds.X, e.Bounds.Y, 105, e.Bounds.Height);
}
e.Graphics.DrawImage(bmp, new Rectangle(e.Bounds.X + 3, e.Bounds.Y + 3, width, height));
+
+ if (Gumps.IsModified(i))
+ {
+ ModifiedMarker.Draw(e.Graphics, new Rectangle(e.Bounds.X, e.Bounds.Y, 105, e.Bounds.Height));
+ }
}
else
{
@@ -601,6 +607,82 @@ private void JumpToMaleFemaleInvalidate()
}
}
+ private void ContextMenuStrip_Opening(object sender, System.ComponentModel.CancelEventArgs e)
+ {
+ int id = SelectedGumpId;
+ copyImageToolStripMenuItem.Enabled = id >= 0 && Gumps.IsValidIndex(id);
+ pasteImageToolStripMenuItem.Enabled = id >= 0 && ImageClipboard.ContainsImage();
+ }
+
+ private void OnClickCopyImage(object sender, EventArgs e)
+ {
+ int id = SelectedGumpId;
+ if (id < 0 || !Gumps.IsValidIndex(id))
+ {
+ return;
+ }
+
+ if (!ImageClipboard.TryCopy(Gumps.GetGump(id), out string error))
+ {
+ MessageBox.Show(error, "Copy Image", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ }
+ }
+
+ private void OnClickPasteImage(object sender, EventArgs e)
+ {
+ int id = SelectedGumpId;
+ if (id < 0)
+ {
+ return;
+ }
+
+ using Bitmap pasted = ImageClipboard.TryPaste(out string error);
+ if (pasted == null)
+ {
+ MessageBox.Show(error, "Paste Image", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return;
+ }
+
+ // Gumps have no fixed size, but the idx packs width and height into one int and the
+ // decoder refuses anything past 0xFFFF on either axis.
+ if (pasted.Width == 0 || pasted.Height == 0 || pasted.Width > 0xFFFF || pasted.Height > 0xFFFF)
+ {
+ MessageBox.Show(
+ $"Invalid gump dimensions!\n\n" +
+ $"Clipboard image: {pasted.Width}x{pasted.Height}\n" +
+ $"Gumps may be up to 65535x65535 pixels.\n\n" +
+ "No changes made.",
+ "Invalid Size", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return;
+ }
+
+ Gumps.ReplaceGump(id, Utils.ToUoBitmap(pasted));
+ ControlEvents.FireGumpChangeEvent(this, id);
+ listView.Invalidate();
+ ListView_SelectedIndexChanged(this, EventArgs.Empty);
+ Options.ChangedUltimaClass["Gumps"] = true;
+ }
+
+ protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
+ {
+ // Copy/paste is handled here rather than as menu ShortcutKeys: a shortcut on a
+ // ContextMenuStrip is processed for the whole form, which would swallow Ctrl+C/Ctrl+V in
+ // every text box on every tab.
+ if (keyData == (Keys.Control | Keys.C) && listView.Focused)
+ {
+ OnClickCopyImage(this, EventArgs.Empty);
+ return true;
+ }
+
+ if (keyData == (Keys.Control | Keys.V) && listView.Focused)
+ {
+ OnClickPasteImage(this, EventArgs.Empty);
+ return true;
+ }
+
+ return base.ProcessCmdKey(ref msg, keyData);
+ }
+
private void OnClickReplace(object sender, EventArgs e)
{
if (SelectedGumpId < 0)
@@ -651,15 +733,8 @@ private void OnClickSave(object sender, EventArgs e)
return;
}
- using (new WaitCursorScope(this))
- {
- ProgressBarDialog barDialog = new ProgressBarDialog(Gumps.GetCount(), "Save");
- Gumps.Save(Options.OutputPath);
- barDialog.Dispose();
- }
-
- Options.ChangedUltimaClass["Gumps"] = false;
- FileSavedDialog.Show(FindForm(), Options.OutputPath, "Files saved successfully.");
+ ClientFileSaveCommand.Run(this, FileType.GumpartLegacyMul, Gumps.Save, "Gumps",
+ createProgress: () => new ProgressBarDialog(Gumps.GetCount(), "Save"));
}
private void OnClickRemove(object sender, EventArgs e)
diff --git a/UoFiddler.Controls/UserControls/ItemsControl.Designer.cs b/UoFiddler.Controls/UserControls/ItemsControl.Designer.cs
index 4773dcfc..de21dc8a 100644
--- a/UoFiddler.Controls/UserControls/ItemsControl.Designer.cs
+++ b/UoFiddler.Controls/UserControls/ItemsControl.Designer.cs
@@ -67,6 +67,12 @@ private void InitializeComponent()
selectInGumpsTabFemaleToolStripMenuItem = new ToolStripMenuItem();
toolStripSeparator2 = new ToolStripSeparator();
replaceToolStripMenuItem = new ToolStripMenuItem();
+ copyImageToolStripMenuItem = new ToolStripMenuItem();
+ pasteImageToolStripMenuItem = new ToolStripMenuItem();
+ clipboardToolStripSeparator = new ToolStripSeparator();
+ copyImageToolStripMenuItemDetail = new ToolStripMenuItem();
+ pasteImageToolStripMenuItemDetail = new ToolStripMenuItem();
+ clipboardToolStripSeparatorDetail = new ToolStripSeparator();
replaceStartingFromToolStripMenuItem = new ToolStripMenuItem();
ReplaceStartingFromText = new ToolStripTextBox();
replaceFromFolderToolStripMenuItem = new ToolStripMenuItem();
@@ -146,8 +152,9 @@ private void InitializeComponent()
//
// DetailPictureBoxContextMenuStrip
//
- DetailPictureBoxContextMenuStrip.Items.AddRange(new ToolStripItem[] { changeBackgroundColorToolStripMenuItemDetail });
+ DetailPictureBoxContextMenuStrip.Items.AddRange(new ToolStripItem[] { copyImageToolStripMenuItemDetail, pasteImageToolStripMenuItemDetail, clipboardToolStripSeparatorDetail, changeBackgroundColorToolStripMenuItemDetail });
DetailPictureBoxContextMenuStrip.Name = "contextMenuStrip2";
+ DetailPictureBoxContextMenuStrip.Opening += DetailPictureBoxContextMenuStrip_Opening;
DetailPictureBoxContextMenuStrip.Size = new System.Drawing.Size(213, 26);
//
// changeBackgroundColorToolStripMenuItemDetail
@@ -218,7 +225,7 @@ private void InitializeComponent()
//
// TileViewContextMenuStrip
//
- TileViewContextMenuStrip.Items.AddRange(new ToolStripItem[] { showFreeSlotsToolStripMenuItem, findNextFreeSlotToolStripMenuItem, ChangeBackgroundColorToolStripMenuItem, toolStripSeparator3, extractToolStripMenuItem, toolStripSeparator7, selectInTileDataTabToolStripMenuItem, selectInRadarColorTabToolStripMenuItem, selectInGumpsTabMaleToolStripMenuItem, selectInGumpsTabFemaleToolStripMenuItem, toolStripSeparator2, replaceToolStripMenuItem, replaceStartingFromToolStripMenuItem, replaceFromFolderToolStripMenuItem, insertAtToolStripMenuItem, removeToolStripMenuItem, toolStripSeparator1, saveToolStripMenuItem });
+ TileViewContextMenuStrip.Items.AddRange(new ToolStripItem[] { showFreeSlotsToolStripMenuItem, findNextFreeSlotToolStripMenuItem, ChangeBackgroundColorToolStripMenuItem, toolStripSeparator3, extractToolStripMenuItem, toolStripSeparator7, selectInTileDataTabToolStripMenuItem, selectInRadarColorTabToolStripMenuItem, selectInGumpsTabMaleToolStripMenuItem, selectInGumpsTabFemaleToolStripMenuItem, toolStripSeparator2, copyImageToolStripMenuItem, pasteImageToolStripMenuItem, clipboardToolStripSeparator, replaceToolStripMenuItem, replaceStartingFromToolStripMenuItem, replaceFromFolderToolStripMenuItem, insertAtToolStripMenuItem, removeToolStripMenuItem, toolStripSeparator1, saveToolStripMenuItem });
TileViewContextMenuStrip.Name = "contextMenuStrip1";
TileViewContextMenuStrip.Size = new System.Drawing.Size(213, 314);
TileViewContextMenuStrip.Opening += TileViewContextMenuStrip_Opening;
@@ -325,6 +332,48 @@ private void InitializeComponent()
toolStripSeparator2.Name = "toolStripSeparator2";
toolStripSeparator2.Size = new System.Drawing.Size(209, 6);
//
+ // copyImageToolStripMenuItem
+ //
+ copyImageToolStripMenuItem.Name = "copyImageToolStripMenuItem";
+ copyImageToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+C";
+ copyImageToolStripMenuItem.Size = new System.Drawing.Size(212, 22);
+ copyImageToolStripMenuItem.Text = "Copy Image";
+ copyImageToolStripMenuItem.Click += OnClickCopyImage;
+ //
+ // pasteImageToolStripMenuItem
+ //
+ pasteImageToolStripMenuItem.Name = "pasteImageToolStripMenuItem";
+ pasteImageToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+V";
+ pasteImageToolStripMenuItem.Size = new System.Drawing.Size(212, 22);
+ pasteImageToolStripMenuItem.Text = "Paste Image";
+ pasteImageToolStripMenuItem.Click += OnClickPasteImage;
+ //
+ // clipboardToolStripSeparator
+ //
+ clipboardToolStripSeparator.Name = "clipboardToolStripSeparator";
+ clipboardToolStripSeparator.Size = new System.Drawing.Size(209, 6);
+ //
+ // copyImageToolStripMenuItemDetail
+ //
+ copyImageToolStripMenuItemDetail.Name = "copyImageToolStripMenuItemDetail";
+ copyImageToolStripMenuItemDetail.ShortcutKeyDisplayString = "Ctrl+C";
+ copyImageToolStripMenuItemDetail.Size = new System.Drawing.Size(212, 22);
+ copyImageToolStripMenuItemDetail.Text = "Copy Image";
+ copyImageToolStripMenuItemDetail.Click += OnClickCopyImage;
+ //
+ // pasteImageToolStripMenuItemDetail
+ //
+ pasteImageToolStripMenuItemDetail.Name = "pasteImageToolStripMenuItemDetail";
+ pasteImageToolStripMenuItemDetail.ShortcutKeyDisplayString = "Ctrl+V";
+ pasteImageToolStripMenuItemDetail.Size = new System.Drawing.Size(212, 22);
+ pasteImageToolStripMenuItemDetail.Text = "Paste Image";
+ pasteImageToolStripMenuItemDetail.Click += OnClickPasteImage;
+ //
+ // clipboardToolStripSeparatorDetail
+ //
+ clipboardToolStripSeparatorDetail.Name = "clipboardToolStripSeparatorDetail";
+ clipboardToolStripSeparatorDetail.Size = new System.Drawing.Size(209, 6);
+ //
// replaceToolStripMenuItem
//
replaceToolStripMenuItem.Name = "replaceToolStripMenuItem";
@@ -619,6 +668,12 @@ private void InitializeComponent()
private ToolStripProgressBar ProgressBar;
private ToolStripMenuItem removeToolStripMenuItem;
private ToolStripMenuItem replaceToolStripMenuItem;
+ private ToolStripMenuItem copyImageToolStripMenuItem;
+ private ToolStripMenuItem pasteImageToolStripMenuItem;
+ private ToolStripSeparator clipboardToolStripSeparator;
+ private ToolStripMenuItem copyImageToolStripMenuItemDetail;
+ private ToolStripMenuItem pasteImageToolStripMenuItemDetail;
+ private ToolStripSeparator clipboardToolStripSeparatorDetail;
private ToolStripMenuItem saveToolStripMenuItem;
private ToolStripMenuItem selectInRadarColorTabToolStripMenuItem;
private ToolStripMenuItem selectInTileDataTabToolStripMenuItem;
diff --git a/UoFiddler.Controls/UserControls/ItemsControl.cs b/UoFiddler.Controls/UserControls/ItemsControl.cs
index b5a4069b..3f9b1cf7 100644
--- a/UoFiddler.Controls/UserControls/ItemsControl.cs
+++ b/UoFiddler.Controls/UserControls/ItemsControl.cs
@@ -20,6 +20,7 @@
using System.Text.RegularExpressions;
using System.Windows.Forms;
using Ultima;
+using Ultima.Uop;
using UoFiddler.Controls.Classes;
using UoFiddler.Controls.Forms;
using UoFiddler.Controls.Helpers;
@@ -507,6 +508,93 @@ private void OnClickFindFree(object sender, EventArgs e)
}
}
+ private void OnClickCopyImage(object sender, EventArgs e)
+ {
+ // The clipboard holds one image, so a multi selection copies the focused tile.
+ int id = SelectedGraphicId;
+ if (id < 0 || !Art.IsValidStatic(id))
+ {
+ return;
+ }
+
+ if (!ImageClipboard.TryCopy(Art.GetStatic(id), out string error))
+ {
+ MessageBox.Show(error, "Copy Image", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ }
+ }
+
+ private void OnClickPasteImage(object sender, EventArgs e)
+ {
+ var ids = GetSelectedGraphicIds();
+ if (ids.Count == 0)
+ {
+ if (SelectedGraphicId < 0)
+ {
+ return;
+ }
+
+ ids.Add(SelectedGraphicId);
+ }
+
+ using Bitmap pasted = ImageClipboard.TryPaste(out string error);
+ if (pasted == null)
+ {
+ MessageBox.Show(error, "Paste Image", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return;
+ }
+
+ if (ids.Count > 1)
+ {
+ DialogResult confirm = MessageBox.Show(
+ $"Paste this {pasted.Width}x{pasted.Height} image into {ids.Count} selected items?",
+ "Paste Image", MessageBoxButtons.YesNo, MessageBoxIcon.Question,
+ MessageBoxDefaultButton.Button2);
+
+ if (confirm != DialogResult.Yes)
+ {
+ return;
+ }
+ }
+
+ Bitmap converted = Utils.ToUoBitmap(pasted);
+
+ if (!Art.ValidateStaticSize(converted, out int estimatedSize))
+ {
+ converted.Dispose();
+
+ MessageBox.Show(
+ $"Image is too large for MUL format!\n\n" +
+ $"Image dimensions: {pasted.Width}x{pasted.Height}\n" +
+ $"Encoded size: {estimatedSize:N0} ushorts\n" +
+ $"Maximum allowed: 65,535 ushorts\n\n" +
+ "Try a smaller image or one with more transparent pixels.",
+ "Image Too Large", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return;
+ }
+
+ foreach (int id in ids)
+ {
+ // Each slot needs its own bitmap: the SDK keeps the instance it is handed.
+ Art.ReplaceStatic(id, ids.Count == 1 ? converted : (Bitmap)converted.Clone());
+ ControlEvents.FireItemChangeEvent(this, id);
+ }
+
+ if (ids.Count > 1)
+ {
+ converted.Dispose();
+ }
+
+ ItemsTileView.Invalidate();
+
+ if (SelectedGraphicId >= 0)
+ {
+ UpdateToolStripLabels(SelectedGraphicId);
+ UpdateDetail(SelectedGraphicId);
+ }
+
+ Options.ChangedUltimaClass["Art"] = true;
+ }
+
private void OnClickReplace(object sender, EventArgs e)
{
if (ItemsTileView.SelectedIndices.Count > 1)
@@ -781,16 +869,8 @@ private void OnClickSave(object sender, EventArgs e)
return;
}
- using (new WaitCursorScope(this))
- {
- ProgressBarDialog barDialog = new ProgressBarDialog(Art.GetIdxLength(), "Save");
- Art.Save(Options.OutputPath);
- barDialog.Dispose();
- }
-
- Options.ChangedUltimaClass["Art"] = false;
-
- FileSavedDialog.Show(FindForm(), Options.OutputPath, "Files saved successfully.");
+ ClientFileSaveCommand.Run(this, FileType.ArtLegacyMul, Art.Save, "Art",
+ createProgress: () => new ProgressBarDialog(Art.GetIdxLength(), "Save"));
}
private void OnClickShowFreeSlots(object sender, EventArgs e)
@@ -910,7 +990,14 @@ private static void ExportItemImage(int index, ImageFormat imageFormat)
private void OnClickSelectTiledata(object sender, EventArgs e)
{
- if (_selectedGraphicId >= 0)
+ // Carry the whole selection over, so a set of tiles picked here can be
+ // edited in one go on the TileData tab.
+ List ids = GetSelectedGraphicIds();
+ if (ids.Count > 0)
+ {
+ TileDataControl.Select(ids, false);
+ }
+ else if (_selectedGraphicId >= 0)
{
TileDataControl.Select(_selectedGraphicId, false);
}
@@ -1099,6 +1186,11 @@ private void ItemsTileView_DrawItem(object sender, TileViewControl.DrawTileListI
e.Graphics.DrawImage(bitmap, new Rectangle(itemPoint, new Size(width, height)));
}
+ if (Art.IsStaticModified(_itemList[e.Index]))
+ {
+ ModifiedMarker.Draw(e.Graphics, rect);
+ }
+
e.Graphics.Clip = previousClip;
}
}
@@ -1221,12 +1313,24 @@ private void SelectInGumpsTabFemaleToolStripMenuItem_Click(object sender, EventA
SelectInGumpsTab(SelectedGraphicId, true);
}
+ private void DetailPictureBoxContextMenuStrip_Opening(object sender, CancelEventArgs e)
+ {
+ copyImageToolStripMenuItemDetail.Enabled = SelectedGraphicId >= 0 && Art.IsValidStatic(SelectedGraphicId);
+ pasteImageToolStripMenuItemDetail.Enabled = SelectedGraphicId >= 0 && ImageClipboard.ContainsImage();
+ }
+
private void TileViewContextMenuStrip_Opening(object sender, CancelEventArgs e)
{
int selectedCount = ItemsTileView.SelectedIndices.Count;
+ copyImageToolStripMenuItem.Enabled = SelectedGraphicId >= 0 && Art.IsValidStatic(SelectedGraphicId);
+ pasteImageToolStripMenuItem.Enabled = selectedCount > 0 && ImageClipboard.ContainsImage();
+ pasteImageToolStripMenuItem.Text = selectedCount > 1 ? $"Paste Image into {selectedCount}" : "Paste Image";
removeToolStripMenuItem.Text = selectedCount > 1 ? $"Remove {selectedCount}" : "Remove";
extractToolStripMenuItem.Text = selectedCount > 1 ? $"Export {selectedCount} Images..." : "Export Image..";
replaceToolStripMenuItem.Text = selectedCount > 1 ? $"Replace {selectedCount}..." : "Replace...";
+ selectInTileDataTabToolStripMenuItem.Text = selectedCount > 1
+ ? $"Select {selectedCount} in TileData tab"
+ : "Select in TileData tab";
if (SelectedGraphicId <= 0)
{
@@ -1485,6 +1589,21 @@ private void SearchByIdToolStripTextBox_KeyUp(object sender, KeyEventArgs e)
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
+ // Copy/paste is handled here rather than as menu ShortcutKeys: a shortcut on a
+ // ContextMenuStrip is processed for the whole form, which would swallow Ctrl+C/Ctrl+V in
+ // every text box on every tab.
+ if (keyData == (Keys.Control | Keys.C) && ItemsTileView.Focused)
+ {
+ OnClickCopyImage(this, EventArgs.Empty);
+ return true;
+ }
+
+ if (keyData == (Keys.Control | Keys.V) && ItemsTileView.Focused)
+ {
+ OnClickPasteImage(this, EventArgs.Empty);
+ return true;
+ }
+
if (keyData == Keys.F3 || keyData == (Keys.F3 | Keys.Shift))
{
if (searchByNameToolStripTextBox.TextBox.Focused)
diff --git a/UoFiddler.Controls/UserControls/LandTilesControl.Designer.cs b/UoFiddler.Controls/UserControls/LandTilesControl.Designer.cs
index 2dbea649..d70f5354 100644
--- a/UoFiddler.Controls/UserControls/LandTilesControl.Designer.cs
+++ b/UoFiddler.Controls/UserControls/LandTilesControl.Designer.cs
@@ -60,6 +60,9 @@ private void InitializeComponent()
selectInTexturesTabToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator();
replaceToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ copyImageToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ pasteImageToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ clipboardToolStripSeparator = new System.Windows.Forms.ToolStripSeparator();
replaceStartingFromToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
ReplaceStartingFromTb = new System.Windows.Forms.ToolStripTextBox();
replaceFromFolderToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
@@ -98,7 +101,7 @@ private void InitializeComponent()
//
// LandTilesContextMenuStrip
//
- LandTilesContextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { showFreeSlotsToolStripMenuItem, findNextFreeSlotToolStripMenuItem, changeBackgroundColorToolStripMenuItem, toolStripSeparator6, exportImageToolStripMenuItem, toolStripSeparator3, selectInTileDataTabToolStripMenuItem, selectInRadarColorTabToolStripMenuItem, selectInTexturesTabToolStripMenuItem, toolStripSeparator2, replaceToolStripMenuItem, replaceStartingFromToolStripMenuItem, replaceFromFolderToolStripMenuItem, insertAtToolStripMenuItem, removeToolStripMenuItem, toolStripSeparator1, saveToolStripMenuItem });
+ LandTilesContextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { showFreeSlotsToolStripMenuItem, findNextFreeSlotToolStripMenuItem, changeBackgroundColorToolStripMenuItem, toolStripSeparator6, exportImageToolStripMenuItem, toolStripSeparator3, selectInTileDataTabToolStripMenuItem, selectInRadarColorTabToolStripMenuItem, selectInTexturesTabToolStripMenuItem, toolStripSeparator2, copyImageToolStripMenuItem, pasteImageToolStripMenuItem, clipboardToolStripSeparator, replaceToolStripMenuItem, replaceStartingFromToolStripMenuItem, replaceFromFolderToolStripMenuItem, insertAtToolStripMenuItem, removeToolStripMenuItem, toolStripSeparator1, saveToolStripMenuItem });
LandTilesContextMenuStrip.Name = "contextMenuStrip1";
LandTilesContextMenuStrip.Size = new System.Drawing.Size(201, 270);
LandTilesContextMenuStrip.Opening += LandTilesContextMenuStrip_Opening;
@@ -196,6 +199,27 @@ private void InitializeComponent()
toolStripSeparator2.Name = "toolStripSeparator2";
toolStripSeparator2.Size = new System.Drawing.Size(197, 6);
//
+ // copyImageToolStripMenuItem
+ //
+ copyImageToolStripMenuItem.Name = "copyImageToolStripMenuItem";
+ copyImageToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+C";
+ copyImageToolStripMenuItem.Size = new System.Drawing.Size(200, 22);
+ copyImageToolStripMenuItem.Text = "Copy Image";
+ copyImageToolStripMenuItem.Click += OnClickCopyImage;
+ //
+ // pasteImageToolStripMenuItem
+ //
+ pasteImageToolStripMenuItem.Name = "pasteImageToolStripMenuItem";
+ pasteImageToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+V";
+ pasteImageToolStripMenuItem.Size = new System.Drawing.Size(200, 22);
+ pasteImageToolStripMenuItem.Text = "Paste Image";
+ pasteImageToolStripMenuItem.Click += OnClickPasteImage;
+ //
+ // clipboardToolStripSeparator
+ //
+ clipboardToolStripSeparator.Name = "clipboardToolStripSeparator";
+ clipboardToolStripSeparator.Size = new System.Drawing.Size(197, 6);
+ //
// replaceToolStripMenuItem
//
replaceToolStripMenuItem.Name = "replaceToolStripMenuItem";
@@ -473,6 +497,9 @@ private void InitializeComponent()
private System.Windows.Forms.ToolStripTextBox InsertText;
private System.Windows.Forms.ToolStripMenuItem removeToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem replaceToolStripMenuItem;
+ private System.Windows.Forms.ToolStripMenuItem copyImageToolStripMenuItem;
+ private System.Windows.Forms.ToolStripMenuItem pasteImageToolStripMenuItem;
+ private System.Windows.Forms.ToolStripSeparator clipboardToolStripSeparator;
private System.Windows.Forms.ToolStripButton SaveButton;
private System.Windows.Forms.ToolStripMenuItem selectInRadarColorTabToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem selectInTexturesTabToolStripMenuItem;
diff --git a/UoFiddler.Controls/UserControls/LandTilesControl.cs b/UoFiddler.Controls/UserControls/LandTilesControl.cs
index 8a16c8aa..db81f395 100644
--- a/UoFiddler.Controls/UserControls/LandTilesControl.cs
+++ b/UoFiddler.Controls/UserControls/LandTilesControl.cs
@@ -20,6 +20,7 @@
using System.Text.RegularExpressions;
using System.Windows.Forms;
using Ultima;
+using Ultima.Uop;
using UoFiddler.Controls.Classes;
using UoFiddler.Controls.Forms;
using UoFiddler.Controls.Helpers;
@@ -43,6 +44,11 @@ public LandTilesControl()
private const int _landTileMax = 0x4000;
+ ///
+ /// Land art is stored as a fixed size raw block, so every land bitmap is this wide and tall.
+ ///
+ private const int LandTileSize = 44;
+
private static LandTilesControl _refMarker;
private int _selectedGraphicId = -1;
private readonly List _tileList = new List();
@@ -383,6 +389,90 @@ private void OnClickRemove(object sender, EventArgs e)
Options.ChangedUltimaClass["Art"] = true;
}
+ private void OnClickCopyImage(object sender, EventArgs e)
+ {
+ // The clipboard holds one image, so a multi selection copies the focused tile.
+ int id = _selectedGraphicId;
+ if (id < 0 || !Art.IsValidLand(id))
+ {
+ return;
+ }
+
+ if (!ImageClipboard.TryCopy(Art.GetLand(id), out string error))
+ {
+ MessageBox.Show(error, "Copy Image", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ }
+ }
+
+ private void OnClickPasteImage(object sender, EventArgs e)
+ {
+ var ids = GetSelectedGraphicIds();
+ if (ids.Count == 0)
+ {
+ if (_selectedGraphicId < 0)
+ {
+ return;
+ }
+
+ ids.Add(_selectedGraphicId);
+ }
+
+ using Bitmap pasted = ImageClipboard.TryPaste(out string error);
+ if (pasted == null)
+ {
+ MessageBox.Show(error, "Paste Image", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return;
+ }
+
+ // Land art is a fixed 44x44 raw block - anything else would be written out as garbage.
+ if (pasted.Width != LandTileSize || pasted.Height != LandTileSize)
+ {
+ MessageBox.Show(
+ $"Invalid land tile dimensions!\n\n" +
+ $"Clipboard image: {pasted.Width}x{pasted.Height}\n" +
+ $"Land tiles must be {LandTileSize}x{LandTileSize} pixels.\n\n" +
+ "No changes made.",
+ "Invalid Size", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return;
+ }
+
+ if (ids.Count > 1)
+ {
+ DialogResult confirm = MessageBox.Show(
+ $"Paste this image into {ids.Count} selected land tiles?",
+ "Paste Image", MessageBoxButtons.YesNo, MessageBoxIcon.Question,
+ MessageBoxDefaultButton.Button2);
+
+ if (confirm != DialogResult.Yes)
+ {
+ return;
+ }
+ }
+
+ Bitmap converted = Utils.ToUoBitmap(pasted);
+
+ foreach (int id in ids)
+ {
+ // Each slot needs its own bitmap: the SDK keeps the instance it is handed.
+ Art.ReplaceLand(id, ids.Count == 1 ? converted : (Bitmap)converted.Clone());
+ ControlEvents.FireLandTileChangeEvent(this, id);
+ }
+
+ if (ids.Count > 1)
+ {
+ converted.Dispose();
+ }
+
+ LandTilesTileView.Invalidate();
+
+ if (_selectedGraphicId >= 0)
+ {
+ UpdateToolStripLabels(_selectedGraphicId);
+ }
+
+ Options.ChangedUltimaClass["Art"] = true;
+ }
+
private void OnClickReplace(object sender, EventArgs e)
{
if (LandTilesTileView.SelectedIndices.Count > 1)
@@ -636,12 +726,7 @@ private void OnClickSave(object sender, EventArgs e)
return;
}
- using (new WaitCursorScope(this))
- {
- Art.Save(Options.OutputPath);
- }
- Options.ChangedUltimaClass["Art"] = false;
- FileSavedDialog.Show(FindForm(), Options.OutputPath, "Files saved successfully.");
+ ClientFileSaveCommand.Run(this, FileType.ArtLegacyMul, Art.Save, "Art");
}
private void OnClickExportBmp(object sender, EventArgs e)
@@ -724,7 +809,14 @@ private static void ExportLandTileImage(int index, ImageFormat imageFormat)
private void OnClickSelectTiledata(object sender, EventArgs e)
{
- if (_selectedGraphicId >= 0)
+ // Carry the whole selection over, so a set of tiles picked here can be
+ // edited in one go on the TileData tab.
+ List ids = GetSelectedGraphicIds();
+ if (ids.Count > 0)
+ {
+ TileDataControl.Select(ids, true);
+ }
+ else if (_selectedGraphicId >= 0)
{
TileDataControl.Select(_selectedGraphicId, true);
}
@@ -755,9 +847,15 @@ private void OnClickSelectTexture(object sender, EventArgs e)
private void LandTilesContextMenuStrip_Opening(object sender, System.ComponentModel.CancelEventArgs e)
{
int selectedCount = LandTilesTileView.SelectedIndices.Count;
+ copyImageToolStripMenuItem.Enabled = _selectedGraphicId >= 0 && Art.IsValidLand(_selectedGraphicId);
+ pasteImageToolStripMenuItem.Enabled = selectedCount > 0 && ImageClipboard.ContainsImage();
+ pasteImageToolStripMenuItem.Text = selectedCount > 1 ? $"Paste Image into {selectedCount}" : "Paste Image";
removeToolStripMenuItem.Text = selectedCount > 1 ? $"Remove {selectedCount}" : "Remove";
exportImageToolStripMenuItem.Text = selectedCount > 1 ? $"Export {selectedCount} Images..." : "Export Image..";
replaceToolStripMenuItem.Text = selectedCount > 1 ? $"Replace {selectedCount}" : "Replace";
+ selectInTileDataTabToolStripMenuItem.Text = selectedCount > 1
+ ? $"Select {selectedCount} in TileData tab"
+ : "Select in TileData tab";
bool hasTexture = _selectedGraphicId >= 0
&& TileData.LandTable[_selectedGraphicId].TextureId != 0
@@ -844,8 +942,7 @@ private void LandTilesTileView_DrawItem(object sender, TileView.TileViewControl.
}
Point itemPoint = new Point(e.Bounds.X + LandTilesTileView.TilePadding.Left, e.Bounds.Y + LandTilesTileView.TilePadding.Top);
- const int fixedTileSize = 44;
- Size itemSize = new Size(fixedTileSize, fixedTileSize);
+ Size itemSize = new Size(LandTileSize, LandTileSize);
Rectangle itemRec = new Rectangle(itemPoint, itemSize);
using var previousClip = e.Graphics.Clip;
@@ -882,6 +979,11 @@ private void LandTilesTileView_DrawItem(object sender, TileView.TileViewControl.
e.Graphics.DrawImage(bitmap, itemRec);
+ if (Art.IsLandModified(_tileList[e.Index]))
+ {
+ ModifiedMarker.Draw(e.Graphics, itemRec);
+ }
+
e.Graphics.Clip = previousClip;
}
}
@@ -1194,6 +1296,21 @@ private void SearchByIdToolStripTextBox_KeyUp(object sender, KeyEventArgs e)
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
+ // Copy/paste is handled here rather than as menu ShortcutKeys: a shortcut on a
+ // ContextMenuStrip is processed for the whole form, which would swallow Ctrl+C/Ctrl+V in
+ // every text box on every tab.
+ if (keyData == (Keys.Control | Keys.C) && LandTilesTileView.Focused)
+ {
+ OnClickCopyImage(this, EventArgs.Empty);
+ return true;
+ }
+
+ if (keyData == (Keys.Control | Keys.V) && LandTilesTileView.Focused)
+ {
+ OnClickPasteImage(this, EventArgs.Empty);
+ return true;
+ }
+
if (keyData == Keys.F3 || keyData == (Keys.F3 | Keys.Shift))
{
if (searchByNameToolStripTextBox.TextBox.Focused)
diff --git a/UoFiddler.Controls/UserControls/MapControl.Designer.cs b/UoFiddler.Controls/UserControls/MapControl.Designer.cs
index 88379e20..6f23940e 100644
--- a/UoFiddler.Controls/UserControls/MapControl.Designer.cs
+++ b/UoFiddler.Controls/UserControls/MapControl.Designer.cs
@@ -108,7 +108,6 @@ private void InitializeComponent()
this.PreloadMap = new System.Windows.Forms.ToolStripButton();
this.toolStripDropDownButton3 = new System.Windows.Forms.ToolStripDropDownButton();
this.defragStaticsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
- this.defragAndRemoveDuplicatesStToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.importStaticsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.meltStaticsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.clearStaticsinMemoryToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
@@ -612,7 +611,6 @@ private void InitializeComponent()
this.toolStripDropDownButton3.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text;
this.toolStripDropDownButton3.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] {
this.defragStaticsToolStripMenuItem,
- this.defragAndRemoveDuplicatesStToolStripMenuItem,
this.importStaticsToolStripMenuItem,
this.meltStaticsToolStripMenuItem,
this.clearStaticsinMemoryToolStripMenuItem,
@@ -635,16 +633,11 @@ private void InitializeComponent()
//
this.defragStaticsToolStripMenuItem.Name = "defragStaticsToolStripMenuItem";
this.defragStaticsToolStripMenuItem.Size = new System.Drawing.Size(308, 22);
- this.defragStaticsToolStripMenuItem.Text = "Defrag Statics";
+ this.defragStaticsToolStripMenuItem.Text = "Defrag Statics...";
+ this.defragStaticsToolStripMenuItem.ToolTipText = "Rewrites staidx/statics for this map, compacting it and optionally filtering out " +
+ "statics the client cannot draw correctly. Reports what it removed.";
this.defragStaticsToolStripMenuItem.Click += new System.EventHandler(this.OnClickDefragStatics);
//
- // defragAndRemoveDuplicatesStToolStripMenuItem
- //
- this.defragAndRemoveDuplicatesStToolStripMenuItem.Name = "defragAndRemoveDuplicatesStToolStripMenuItem";
- this.defragAndRemoveDuplicatesStToolStripMenuItem.Size = new System.Drawing.Size(308, 22);
- this.defragAndRemoveDuplicatesStToolStripMenuItem.Text = "Defrag and Remove Duplicates Statics";
- this.defragAndRemoveDuplicatesStToolStripMenuItem.Click += new System.EventHandler(this.OnClickDefragRemoveStatics);
- //
// importStaticsToolStripMenuItem
//
this.importStaticsToolStripMenuItem.Name = "importStaticsToolStripMenuItem";
@@ -811,7 +804,6 @@ private void InitializeComponent()
private System.Windows.Forms.ContextMenuStrip contextMenuStrip2;
private System.Windows.Forms.ToolStripStatusLabel CoordsLabel;
private System.Windows.Forms.ToolStripMenuItem copyToolStripMenuItem;
- private System.Windows.Forms.ToolStripMenuItem defragAndRemoveDuplicatesStToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem defragStaticsToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem extractMapToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem feluccaToolStripMenuItem;
diff --git a/UoFiddler.Controls/UserControls/MapControl.cs b/UoFiddler.Controls/UserControls/MapControl.cs
index 18d1fca6..563a5b54 100644
--- a/UoFiddler.Controls/UserControls/MapControl.cs
+++ b/UoFiddler.Controls/UserControls/MapControl.cs
@@ -1304,23 +1304,10 @@ private void OnChangeView(object sender, EventArgs e)
private void OnClickDefragStatics(object sender, EventArgs e)
{
- using (new WaitCursorScope(this))
- {
- Map.DefragStatics(Options.OutputPath,
- CurrentMap, CurrentMap.Width, CurrentMap.Height, false);
- }
-
- FileSavedDialog.Show(FindForm(), Options.OutputPath, "Statics saved successfully.");
- }
-
- private void OnClickDefragRemoveStatics(object sender, EventArgs e)
- {
- using (new WaitCursorScope(this))
+ using (var form = new MapDefragStaticsForm(CurrentMap, Options.OutputPath))
{
- Map.DefragStatics(Options.OutputPath,
- CurrentMap, CurrentMap.Width, CurrentMap.Height, true);
+ form.ShowDialog(FindForm());
}
- FileSavedDialog.Show(FindForm(), Options.OutputPath, "Statics saved successfully.");
}
private void OnResizeMap(object sender, EventArgs e)
diff --git a/UoFiddler.Controls/UserControls/MapRegionPreview.Designer.cs b/UoFiddler.Controls/UserControls/MapRegionPreview.Designer.cs
new file mode 100644
index 00000000..5e29d495
--- /dev/null
+++ b/UoFiddler.Controls/UserControls/MapRegionPreview.Designer.cs
@@ -0,0 +1,43 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+namespace UoFiddler.Controls.UserControls
+{
+ partial class MapRegionPreview
+ {
+ ///
+ /// Required designer variable.
+ ///
+ private System.ComponentModel.IContainer components = null;
+
+ #region Component Designer generated code
+
+ ///
+ /// The control paints itself, so it owns no child controls.
+ ///
+ private void InitializeComponent()
+ {
+ this.SuspendLayout();
+ //
+ // MapRegionPreview
+ //
+ this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
+ this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
+ this.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
+ this.DoubleBuffered = true;
+ this.Name = "MapRegionPreview";
+ this.Size = new System.Drawing.Size(320, 240);
+ this.ResumeLayout(false);
+ }
+
+ #endregion
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/UserControls/MapRegionPreview.cs b/UoFiddler.Controls/UserControls/MapRegionPreview.cs
new file mode 100644
index 00000000..29f06f91
--- /dev/null
+++ b/UoFiddler.Controls/UserControls/MapRegionPreview.cs
@@ -0,0 +1,895 @@
+/***************************************************************************
+ *
+ * $Author: Turley
+ *
+ * "THE BEER-WARE LICENSE"
+ * As long as you retain this notice you can do whatever you want with
+ * this stuff. If we meet some day, and you think this stuff is worth it,
+ * you can buy me a beer in return.
+ *
+ ***************************************************************************/
+
+using System;
+using System.ComponentModel;
+using System.Drawing;
+using System.Drawing.Drawing2D;
+using System.Drawing.Imaging;
+using System.Windows.Forms;
+using Ultima;
+using Ultima.Maps;
+using UoFiddler.Controls.Classes;
+
+namespace UoFiddler.Controls.UserControls
+{
+ public enum MapPreviewMode
+ {
+ /// Shows the selection but does not let it be changed.
+ ReadOnly,
+
+ /// Dragging with the left button draws a new selection rectangle.
+ Rectangle,
+
+ /// Dragging with the left button moves the selection without changing its size.
+ MoveFixedSize
+ }
+
+ ///
+ /// Draws a window of a map facet with a block-aligned selection rectangle over it, so a region
+ /// can be seen and placed rather than only typed. The left button sets the region, the right
+ /// button pans, the wheel zooms and a double click re-fits.
+ ///
+ ///
+ /// Rendering reuses and its half and
+ /// quarter resolution siblings, which fill a caller-supplied bitmap from a block rectangle. The
+ /// rendered window is cached and rebuilt on a worker behind a short debounce, while painting maps
+ /// blocks to the control every frame - so a pan slides the cached bitmap straight away and the
+ /// fresh render simply replaces it when it lands.
+ ///
+ public sealed partial class MapRegionPreview : UserControl
+ {
+ /// Blocks of context left around the selection when the view is framed on it.
+ private const int DefaultMarginBlocks = 6;
+
+ /// How far in the wheel can go. Eight blocks across a panel is 64 tiles.
+ private const int MinViewBlocks = 8;
+
+ ///
+ /// Map rendering walks per-instance caches and file streams that are not safe to use from
+ /// two threads, and the source map is shared between a source panel and the overlay of a
+ /// destination panel, so every render in the process takes this.
+ ///
+ private static readonly object _renderLock = new object();
+
+ private readonly System.Windows.Forms.Timer _debounce = new System.Windows.Forms.Timer { Interval = 120 };
+ private readonly BackgroundWorker _worker = new BackgroundWorker();
+
+ private Bitmap _rendered;
+ private Bitmap _overlayRendered;
+ private RenderRequest _renderedRequest;
+ private RenderRequest _pending;
+ private bool _hasPending;
+
+ private Map _map;
+ private MapSize _mapSize;
+ private bool _showStatics = true;
+ private BlockRectangle _selection;
+ private Map _overlayMap;
+ private BlockRectangle _overlaySelection;
+ private string _message;
+
+ ///
+ /// The blocks on screen. Deliberately not derived from the selection: drawing a region must
+ /// not move the map out from under the hand drawing it.
+ ///
+ private int _viewX;
+ private int _viewY;
+ private int _viewWidth;
+ private int _viewHeight;
+ private bool _viewFramed;
+
+ private bool _dragging;
+ private Point _dragStartBlock;
+ private BlockRectangle _dragOrigin;
+
+ private bool _panning;
+ private Point _panStartMouse;
+ private int _panStartX;
+ private int _panStartY;
+
+ public MapRegionPreview()
+ {
+ InitializeComponent();
+
+ SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer |
+ ControlStyles.UserPaint | ControlStyles.ResizeRedraw, true);
+
+ BackColor = Options.PreviewBackgroundColor;
+
+ _debounce.Tick += OnDebounceTick;
+ _worker.DoWork += OnWorkerDoWork;
+ _worker.RunWorkerCompleted += OnWorkerCompleted;
+ }
+
+ /// Raised when a drag changed .
+ public event EventHandler SelectionChanged;
+
+ /// The facet to draw. May be built on a directory other than the loaded client.
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public Map Map
+ {
+ get => _map;
+ set
+ {
+ _map = value;
+ ResetView();
+ }
+ }
+
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public MapSize MapSize
+ {
+ get => _mapSize;
+ set
+ {
+ _mapSize = value;
+ ResetView();
+ }
+ }
+
+ [DefaultValue(true)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public bool ShowStatics
+ {
+ get => _showStatics;
+ set
+ {
+ if (_showStatics == value)
+ {
+ return;
+ }
+
+ _showStatics = value;
+ Rebuild();
+ }
+ }
+
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public MapPreviewMode Mode { get; set; } = MapPreviewMode.ReadOnly;
+
+ /// The block rectangle outlined on top of the map.
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public BlockRectangle Selection
+ {
+ get => _selection;
+ set
+ {
+ bool wasVisible = _viewFramed && Intersects(CurrentWindow(), value);
+
+ _selection = value;
+
+ // A selection that lands somewhere else entirely brings the view with it; one that is
+ // still on screen leaves the view where the user put it.
+ if (!wasVisible)
+ {
+ _viewFramed = false;
+ }
+
+ Rebuild();
+ }
+ }
+
+ ///
+ /// Optional second map whose is drawn inside
+ /// , so a destination panel can show the piece that will land there
+ /// rather than an empty outline.
+ ///
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public Map OverlayMap
+ {
+ get => _overlayMap;
+ set
+ {
+ _overlayMap = value;
+ Rebuild();
+ }
+ }
+
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public BlockRectangle OverlaySelection
+ {
+ get => _overlaySelection;
+ set
+ {
+ _overlaySelection = value;
+ Rebuild();
+ }
+ }
+
+ /// Tints the blocks it returns true for. Used to mark blocks a diff covers.
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public Func BlockHighlight { get; set; }
+
+ /// Shown instead of a render, for "choose a folder first" and the like.
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public string Message
+ {
+ get => _message;
+ set
+ {
+ _message = value;
+ Invalidate();
+ }
+ }
+
+ /// True when part of the selection lies outside the blocks on screen.
+ [Browsable(false)]
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public bool SelectionTruncated { get; private set; }
+
+ /// Queues a rebuild of the rendered window.
+ public void Rebuild()
+ {
+ _debounce.Stop();
+ _debounce.Start();
+ Invalidate();
+ }
+
+ /// The blocks the panel is showing, selection plus margin, panned and clamped.
+ [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
+ public BlockRectangle Window => CurrentWindow();
+
+ /// Drops the pan and zoom back to framing the selection.
+ public void ResetView()
+ {
+ _viewFramed = false;
+
+ Rebuild();
+ }
+
+ ///
+ /// The blocks currently on screen. Pure arithmetic, so painting can call it every frame.
+ ///
+ private BlockRectangle CurrentWindow()
+ {
+ if (_mapSize.IsEmpty)
+ {
+ SelectionTruncated = false;
+
+ return default;
+ }
+
+ if (!_viewFramed)
+ {
+ FrameSelection();
+ }
+
+ ClampView();
+
+ var window = new BlockRectangle(_viewX, _viewY,
+ _viewX + _viewWidth - 1, _viewY + _viewHeight - 1);
+
+ SelectionTruncated =
+ _selection.BlockX1 < window.BlockX1 || _selection.BlockX2 > window.BlockX2 ||
+ _selection.BlockY1 < window.BlockY1 || _selection.BlockY2 > window.BlockY2;
+
+ return window;
+ }
+
+ /// Puts the selection back on screen with a margin of context around it.
+ private void FrameSelection()
+ {
+ int width = Math.Max(1, _selection.BlockWidth) + (DefaultMarginBlocks * 2);
+ int height = Math.Max(1, _selection.BlockHeight) + (DefaultMarginBlocks * 2);
+
+ SetView(_selection.BlockX1 - DefaultMarginBlocks, _selection.BlockY1 - DefaultMarginBlocks,
+ width, height);
+
+ _viewFramed = true;
+ }
+
+ ///
+ /// Stores a view, grown to the shape of the panel so the map fills it rather than sitting in
+ /// a letterbox, then clamped to the facet. The growth is centred on what was asked for.
+ ///
+ private void SetView(int x, int y, int width, int height)
+ {
+ if (_mapSize.IsEmpty)
+ {
+ return;
+ }
+
+ int wantedWidth = Math.Clamp(width, MinViewBlocks, _mapSize.BlockWidth);
+ int wantedHeight = Math.Clamp(height, MinViewBlocks, _mapSize.BlockHeight);
+
+ width = wantedWidth;
+ height = wantedHeight;
+
+ if (ClientSize.Width > 0 && ClientSize.Height > 0)
+ {
+ double aspect = (double)ClientSize.Width / ClientSize.Height;
+
+ int neededWidth = (int)Math.Round(height * aspect);
+ int neededHeight = (int)Math.Round(width / aspect);
+
+ // Only ever grow, and only when a whole block is missing, so putting a view that
+ // already has the panel's shape back through here leaves it alone. Rounding up on
+ // every pass would inflate the view a block at a time.
+ if (neededWidth > width)
+ {
+ width = neededWidth;
+ }
+ else if (neededHeight > height)
+ {
+ height = neededHeight;
+ }
+ }
+
+ _viewWidth = width;
+ _viewHeight = height;
+
+ // Growing one axis must not shove the view sideways off what was asked for.
+ _viewX = x - ((_viewWidth - wantedWidth) / 2);
+ _viewY = y - ((_viewHeight - wantedHeight) / 2);
+
+ ClampView();
+ }
+
+ /// Pulls the stored view back onto the facet without reshaping it.
+ private void ClampView()
+ {
+ _viewWidth = Math.Clamp(_viewWidth, 1, _mapSize.BlockWidth);
+ _viewHeight = Math.Clamp(_viewHeight, 1, _mapSize.BlockHeight);
+
+ _viewX = Math.Clamp(_viewX, 0, Math.Max(0, _mapSize.BlockWidth - _viewWidth));
+ _viewY = Math.Clamp(_viewY, 0, Math.Max(0, _mapSize.BlockHeight - _viewHeight));
+ }
+
+ ///
+ /// The largest of the three native scales whose bitmap still fits a couple of panels worth
+ /// of pixels. Anything finer would be thrown away by the scale down to the panel.
+ ///
+ private int ChoosePixelsPerBlock(BlockRectangle window)
+ {
+ int budgetX = Math.Max(64, ClientSize.Width * 2);
+ int budgetY = Math.Max(64, ClientSize.Height * 2);
+
+ foreach (int candidate in new[] { 8, 4, 2 })
+ {
+ if (window.BlockWidth * candidate <= budgetX && window.BlockHeight * candidate <= budgetY)
+ {
+ return candidate;
+ }
+ }
+
+ return 2;
+ }
+
+ /// Pixels per block on screen for the window currently framed.
+ private float ScreenScale(BlockRectangle window)
+ {
+ if (window.BlockWidth <= 0 || window.BlockHeight <= 0)
+ {
+ return 0f;
+ }
+
+ return Math.Min((float)ClientSize.Width / window.BlockWidth, (float)ClientSize.Height / window.BlockHeight);
+ }
+
+ /// Top left of the framed window in control pixels, centred.
+ private PointF ScreenOrigin(BlockRectangle window, float scale)
+ {
+ return new PointF(
+ (ClientSize.Width - (window.BlockWidth * scale)) / 2f,
+ (ClientSize.Height - (window.BlockHeight * scale)) / 2f);
+ }
+
+ private static bool Intersects(BlockRectangle window, BlockRectangle selection)
+ {
+ return selection.BlockX2 >= window.BlockX1 && selection.BlockX1 <= window.BlockX2 &&
+ selection.BlockY2 >= window.BlockY1 && selection.BlockY1 <= window.BlockY2;
+ }
+
+ private void OnDebounceTick(object sender, EventArgs e)
+ {
+ _debounce.Stop();
+
+ if (_map == null || _mapSize.IsEmpty || ClientSize.Width < 8 || ClientSize.Height < 8)
+ {
+ return;
+ }
+
+ BlockRectangle window = CurrentWindow();
+
+ var request = new RenderRequest
+ {
+ Map = _map,
+ Window = window,
+ PixelsPerBlock = ChoosePixelsPerBlock(window),
+ Statics = _showStatics,
+ OverlayMap = _overlayMap,
+ OverlayWindow = _overlaySelection
+ };
+
+ if (request.Equals(_renderedRequest))
+ {
+ Invalidate();
+
+ return;
+ }
+
+ _pending = request;
+ _hasPending = true;
+
+ if (_worker.IsBusy)
+ {
+ return;
+ }
+
+ _hasPending = false;
+ _worker.RunWorkerAsync(request);
+ }
+
+ private void OnWorkerDoWork(object sender, DoWorkEventArgs e)
+ {
+ var request = (RenderRequest)e.Argument;
+
+ lock (_renderLock)
+ {
+ Bitmap map = Render(request.Map, request.Window, request.PixelsPerBlock, request.Statics);
+ Bitmap overlay = request.OverlayMap == null
+ ? null
+ : Render(request.OverlayMap, request.OverlayWindow, request.PixelsPerBlock, request.Statics);
+
+ e.Result = new RenderResult { Request = request, Map = map, Overlay = overlay };
+ }
+ }
+
+ private static Bitmap Render(Map map, BlockRectangle window, int pixelsPerBlock, bool statics)
+ {
+ if (map == null || window.BlockWidth <= 0 || window.BlockHeight <= 0)
+ {
+ return null;
+ }
+
+ var bitmap = new Bitmap(window.BlockWidth * pixelsPerBlock, window.BlockHeight * pixelsPerBlock,
+ PixelFormat.Format16bppRgb555);
+
+ switch (pixelsPerBlock)
+ {
+ case 8:
+ map.GetImage(window.BlockX1, window.BlockY1, window.BlockWidth, window.BlockHeight, bitmap, statics);
+ break;
+
+ case 4:
+ map.GetImageHalf(window.BlockX1, window.BlockY1, window.BlockWidth, window.BlockHeight, bitmap, statics);
+ break;
+
+ default:
+ map.GetImageQuarter(window.BlockX1, window.BlockY1, window.BlockWidth, window.BlockHeight, bitmap, statics);
+ break;
+ }
+
+ return bitmap;
+ }
+
+ private void OnWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
+ {
+ if (e.Error == null && e.Result is RenderResult result)
+ {
+ _rendered?.Dispose();
+ _overlayRendered?.Dispose();
+
+ _rendered = result.Map;
+ _overlayRendered = result.Overlay;
+ _renderedRequest = result.Request;
+
+ Invalidate();
+ }
+
+ if (!_hasPending || _map == null || IsDisposed)
+ {
+ return;
+ }
+
+ // Something changed while that render was in flight.
+ _hasPending = false;
+ _worker.RunWorkerAsync(_pending);
+ }
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ e.Graphics.Clear(BackColor);
+
+ if (!string.IsNullOrEmpty(_message))
+ {
+ DrawCentredText(e.Graphics, _message);
+
+ return;
+ }
+
+ BlockRectangle window = CurrentWindow();
+ float scale = ScreenScale(window);
+
+ if (_rendered == null || scale <= 0)
+ {
+ DrawCentredText(e.Graphics, "Rendering...");
+
+ return;
+ }
+
+ PointF origin = ScreenOrigin(window, scale);
+
+ e.Graphics.InterpolationMode = InterpolationMode.NearestNeighbor;
+ e.Graphics.PixelOffsetMode = PixelOffsetMode.Half;
+
+ // The cached bitmap covers whatever window it was rendered for, which is not necessarily
+ // the one framed now - during a pan it lags by the distance dragged. Drawing it at its
+ // own block position makes the stale image slide with the pan instead of sitting still.
+ e.Graphics.DrawImage(_rendered, BlockRect(origin, window, scale, _renderedRequest.Window));
+
+ DrawHighlights(e.Graphics, origin, window, scale);
+ DrawOverlay(e.Graphics, origin, window, scale);
+ DrawSelection(e.Graphics, origin, window, scale);
+
+ if (SelectionTruncated)
+ {
+ DrawNote(e.Graphics, "the region runs past the view - zoom out with the wheel to see all of it");
+ }
+ else if (_debounce.Enabled || _worker.IsBusy)
+ {
+ DrawNote(e.Graphics, "rendering...");
+ }
+ }
+
+ /// Where a block rectangle sits on screen, given the window currently framed.
+ private static RectangleF BlockRect(PointF origin, BlockRectangle window, float scale, BlockRectangle blocks)
+ {
+ return new RectangleF(
+ origin.X + ((blocks.BlockX1 - window.BlockX1) * scale),
+ origin.Y + ((blocks.BlockY1 - window.BlockY1) * scale),
+ Math.Max(1f, blocks.BlockWidth * scale),
+ Math.Max(1f, blocks.BlockHeight * scale));
+ }
+
+ private void DrawHighlights(Graphics g, PointF origin, BlockRectangle window, float scale)
+ {
+ if (BlockHighlight == null)
+ {
+ return;
+ }
+
+ using (var brush = new SolidBrush(Color.FromArgb(90, Color.Gold)))
+ {
+ for (int x = window.BlockX1; x <= window.BlockX2; ++x)
+ {
+ for (int y = window.BlockY1; y <= window.BlockY2; ++y)
+ {
+ if (!BlockHighlight(x, y))
+ {
+ continue;
+ }
+
+ g.FillRectangle(brush,
+ origin.X + ((x - window.BlockX1) * scale),
+ origin.Y + ((y - window.BlockY1) * scale),
+ Math.Max(1f, scale), Math.Max(1f, scale));
+ }
+ }
+ }
+ }
+
+ private void DrawOverlay(Graphics g, PointF origin, BlockRectangle window, float scale)
+ {
+ if (_overlayRendered == null)
+ {
+ return;
+ }
+
+ g.DrawImage(_overlayRendered, BlockRect(origin, window, scale, _selection));
+ }
+
+ private void DrawSelection(Graphics g, PointF origin, BlockRectangle window, float scale)
+ {
+ RectangleF rectangle = BlockRect(origin, window, scale, _selection);
+
+ using (var shadow = new Pen(Color.FromArgb(160, Color.Black), 3f))
+ using (var pen = new Pen(Options.DarkMode ? Color.OrangeRed : Color.Red, 1.5f))
+ {
+ g.DrawRectangle(shadow, rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height);
+ g.DrawRectangle(pen, rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height);
+ }
+ }
+
+ private void DrawCentredText(Graphics g, string text)
+ {
+ SizeF size = g.MeasureString(text, Font);
+
+ g.DrawString(text, Font, SystemBrushes.ControlText,
+ (ClientSize.Width - size.Width) / 2f, (ClientSize.Height - size.Height) / 2f);
+ }
+
+ private void DrawNote(Graphics g, string text)
+ {
+ SizeF size = g.MeasureString(text, Font);
+
+ using (var brush = new SolidBrush(Color.FromArgb(170, Color.Black)))
+ {
+ g.FillRectangle(brush, 0, ClientSize.Height - size.Height, ClientSize.Width, size.Height);
+ }
+
+ g.DrawString(text, Font, Brushes.White, 2, ClientSize.Height - size.Height);
+ }
+
+ private bool TryBlockAt(Point location, out int blockX, out int blockY)
+ {
+ blockX = 0;
+ blockY = 0;
+
+ if (_mapSize.IsEmpty)
+ {
+ return false;
+ }
+
+ BlockRectangle window = CurrentWindow();
+ float scale = ScreenScale(window);
+
+ if (scale <= 0)
+ {
+ return false;
+ }
+
+ PointF origin = ScreenOrigin(window, scale);
+
+ blockX = window.BlockX1 + (int)Math.Floor((location.X - origin.X) / scale);
+ blockY = window.BlockY1 + (int)Math.Floor((location.Y - origin.Y) / scale);
+
+ blockX = Math.Clamp(blockX, 0, _mapSize.BlockWidth - 1);
+ blockY = Math.Clamp(blockY, 0, _mapSize.BlockHeight - 1);
+
+ return true;
+ }
+
+ protected override void OnMouseDown(MouseEventArgs e)
+ {
+ base.OnMouseDown(e);
+
+ if (e.Button == MouseButtons.Right)
+ {
+ _panning = true;
+ _panStartMouse = e.Location;
+ _panStartX = _viewX;
+ _panStartY = _viewY;
+ Cursor = Cursors.SizeAll;
+
+ return;
+ }
+
+ if (e.Button != MouseButtons.Left || Mode == MapPreviewMode.ReadOnly ||
+ !TryBlockAt(e.Location, out int blockX, out int blockY))
+ {
+ return;
+ }
+
+ _dragging = true;
+ _dragStartBlock = new Point(blockX, blockY);
+ _dragOrigin = _selection;
+
+ if (Mode == MapPreviewMode.Rectangle)
+ {
+ SetSelection(new BlockRectangle(blockX, blockY, blockX, blockY));
+ }
+ else
+ {
+ MoveSelectionTo(blockX, blockY);
+ }
+ }
+
+ protected override void OnMouseMove(MouseEventArgs e)
+ {
+ base.OnMouseMove(e);
+
+ if (_panning)
+ {
+ Pan(e.Location);
+
+ return;
+ }
+
+ if (!_dragging || !TryBlockAt(e.Location, out int blockX, out int blockY))
+ {
+ return;
+ }
+
+ if (Mode == MapPreviewMode.Rectangle)
+ {
+ SetSelection(new BlockRectangle(
+ Math.Min(_dragStartBlock.X, blockX), Math.Min(_dragStartBlock.Y, blockY),
+ Math.Max(_dragStartBlock.X, blockX), Math.Max(_dragStartBlock.Y, blockY)));
+ }
+ else
+ {
+ MoveSelectionTo(blockX, blockY);
+ }
+ }
+
+ protected override void OnMouseUp(MouseEventArgs e)
+ {
+ base.OnMouseUp(e);
+
+ if (_panning && e.Button == MouseButtons.Right)
+ {
+ _panning = false;
+ Cursor = Cursors.Default;
+ }
+
+ if (e.Button == MouseButtons.Left)
+ {
+ _dragging = false;
+ }
+ }
+
+ /// Drags the view under the cursor, a block per block-sized step of the mouse.
+ private void Pan(Point location)
+ {
+ BlockRectangle window = CurrentWindow();
+ float scale = ScreenScale(window);
+
+ if (scale <= 0)
+ {
+ return;
+ }
+
+ _viewX = _panStartX - (int)Math.Round((location.X - _panStartMouse.X) / scale);
+ _viewY = _panStartY - (int)Math.Round((location.Y - _panStartMouse.Y) / scale);
+
+ ClampView();
+
+ Rebuild();
+ }
+
+ /// Centres the fixed-size selection on a block, clamped so it stays on the map.
+ private void MoveSelectionTo(int blockX, int blockY)
+ {
+ int width = _dragOrigin.BlockWidth;
+ int height = _dragOrigin.BlockHeight;
+
+ int x1 = Math.Clamp(blockX - (width / 2), 0, Math.Max(0, _mapSize.BlockWidth - width));
+ int y1 = Math.Clamp(blockY - (height / 2), 0, Math.Max(0, _mapSize.BlockHeight - height));
+
+ SetSelection(new BlockRectangle(x1, y1, x1 + width - 1, y1 + height - 1));
+ }
+
+ ///
+ /// Sets the selection from a drag. Unlike the property, this leaves the pan alone - the user
+ /// is working inside the view they arranged.
+ ///
+ private void SetSelection(BlockRectangle selection)
+ {
+ _selection = selection;
+
+ Invalidate();
+ SelectionChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ protected override void OnMouseWheel(MouseEventArgs e)
+ {
+ base.OnMouseWheel(e);
+
+ if (_mapSize.IsEmpty)
+ {
+ return;
+ }
+
+ BlockRectangle window = CurrentWindow();
+ float scale = ScreenScale(window);
+
+ if (scale <= 0)
+ {
+ return;
+ }
+
+ PointF origin = ScreenOrigin(window, scale);
+
+ // Where the cursor sits in the window, as a fraction of it. Keeping that fraction over
+ // the same block is what makes the wheel zoom towards what is under the pointer.
+ double acrossX = Math.Clamp((e.Location.X - origin.X) / (window.BlockWidth * scale), 0d, 1d);
+ double acrossY = Math.Clamp((e.Location.Y - origin.Y) / (window.BlockHeight * scale), 0d, 1d);
+
+ double anchorX = window.BlockX1 + (acrossX * window.BlockWidth);
+ double anchorY = window.BlockY1 + (acrossY * window.BlockHeight);
+
+ double factor = e.Delta > 0 ? 1d / 1.3d : 1.3d;
+
+ int width = (int)Math.Round(window.BlockWidth * factor);
+ int height = (int)Math.Round(window.BlockHeight * factor);
+
+ SetView((int)Math.Round(anchorX - (acrossX * width)),
+ (int)Math.Round(anchorY - (acrossY * height)), width, height);
+
+ Rebuild();
+ }
+
+ protected override void OnDoubleClick(EventArgs e)
+ {
+ base.OnDoubleClick(e);
+
+ ResetView();
+ }
+
+ protected override void OnResize(EventArgs e)
+ {
+ base.OnResize(e);
+
+ if (_viewFramed)
+ {
+ SetView(_viewX, _viewY, _viewWidth, _viewHeight);
+ }
+
+ Rebuild();
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _debounce.Stop();
+ _debounce.Dispose();
+ _worker.Dispose();
+ _rendered?.Dispose();
+ _overlayRendered?.Dispose();
+
+ components?.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ private struct RenderRequest : IEquatable
+ {
+ public Map Map;
+ public BlockRectangle Window;
+ public int PixelsPerBlock;
+ public bool Statics;
+ public Map OverlayMap;
+ public BlockRectangle OverlayWindow;
+
+ public bool Equals(RenderRequest other)
+ {
+ return ReferenceEquals(Map, other.Map) &&
+ PixelsPerBlock == other.PixelsPerBlock &&
+ Statics == other.Statics &&
+ Same(Window, other.Window) &&
+ ReferenceEquals(OverlayMap, other.OverlayMap) &&
+ Same(OverlayWindow, other.OverlayWindow);
+ }
+
+ public override bool Equals(object obj) => obj is RenderRequest other && Equals(other);
+
+ public override int GetHashCode() => HashCode.Combine(Map, Window.BlockX1, Window.BlockY1,
+ Window.BlockX2, Window.BlockY2, PixelsPerBlock, Statics, OverlayMap);
+
+ private static bool Same(BlockRectangle a, BlockRectangle b)
+ {
+ return a.BlockX1 == b.BlockX1 && a.BlockY1 == b.BlockY1 &&
+ a.BlockX2 == b.BlockX2 && a.BlockY2 == b.BlockY2;
+ }
+ }
+
+ private sealed class RenderResult
+ {
+ public RenderRequest Request { get; init; }
+
+ public Bitmap Map { get; init; }
+
+ public Bitmap Overlay { get; init; }
+ }
+ }
+}
\ No newline at end of file
diff --git a/UoFiddler.Controls/UserControls/MapRegionPreview.resx b/UoFiddler.Controls/UserControls/MapRegionPreview.resx
new file mode 100644
index 00000000..6dae11dd
--- /dev/null
+++ b/UoFiddler.Controls/UserControls/MapRegionPreview.resx
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
\ No newline at end of file
diff --git a/UoFiddler.Controls/UserControls/MultisControl.cs b/UoFiddler.Controls/UserControls/MultisControl.cs
index 3a26d859..8ca277cc 100644
--- a/UoFiddler.Controls/UserControls/MultisControl.cs
+++ b/UoFiddler.Controls/UserControls/MultisControl.cs
@@ -17,6 +17,7 @@
using System.Windows.Forms;
using System.Xml;
using Ultima;
+using Ultima.Uop;
using UoFiddler.Controls.Classes;
using UoFiddler.Controls.Forms;
using UoFiddler.Controls.Helpers;
@@ -621,10 +622,7 @@ private void OnExportUOAFile(object sender, EventArgs e)
private void OnClickSave(object sender, EventArgs e)
{
- Multis.Save(Options.OutputPath);
- Options.ChangedUltimaClass["Multis"] = false;
-
- FileSavedDialog.Show(FindForm(), Options.OutputPath, "Files saved successfully.");
+ ClientFileSaveCommand.Run(this, FileType.MultiCollection, Multis.Save, "Multis");
}
private void OnClickRemove(object sender, EventArgs e)
diff --git a/UoFiddler.Controls/UserControls/SoundsControl.cs b/UoFiddler.Controls/UserControls/SoundsControl.cs
index 612b3607..47555dc5 100644
--- a/UoFiddler.Controls/UserControls/SoundsControl.cs
+++ b/UoFiddler.Controls/UserControls/SoundsControl.cs
@@ -16,6 +16,7 @@
using System.Linq;
using System.Windows.Forms;
using Ultima;
+using Ultima.Uop;
using UoFiddler.Controls.Classes;
using UoFiddler.Controls.Forms;
using UoFiddler.Controls.Helpers;
@@ -441,14 +442,7 @@ private void OnClickExtract(object sender, EventArgs e)
private void OnClickSave(object sender, EventArgs e)
{
- using (new WaitCursorScope(this))
- {
- string path = Options.OutputPath;
- Sounds.Save(path);
- Options.ChangedUltimaClass["Sound"] = false;
- }
-
- FileSavedDialog.Show(FindForm(), Options.OutputPath, "Files saved successfully.");
+ ClientFileSaveCommand.Run(this, FileType.SoundLegacyMul, Sounds.Save, "Sound");
}
private void OnClickRemove(object sender, EventArgs e)
diff --git a/UoFiddler.Controls/UserControls/TexturesControl.Designer.cs b/UoFiddler.Controls/UserControls/TexturesControl.Designer.cs
index 20a4a8b8..5917885b 100644
--- a/UoFiddler.Controls/UserControls/TexturesControl.Designer.cs
+++ b/UoFiddler.Controls/UserControls/TexturesControl.Designer.cs
@@ -52,6 +52,9 @@ private void InitializeComponent()
selectInLandTilesTabToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator();
replaceToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ copyImageToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ pasteImageToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ clipboardToolStripSeparator = new System.Windows.Forms.ToolStripSeparator();
replaceStartingFromToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
ReplaceStartingFromTb = new System.Windows.Forms.ToolStripTextBox();
insertAtToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
@@ -84,7 +87,7 @@ private void InitializeComponent()
//
// contextMenuStrip
//
- contextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { showFreeSlotsToolStripMenuItem, findNextFreeSlotToolStripMenuItem, toolStripSeparator5, exportImageToolStripMenuItem, toolStripSeparator6, selectInLandTilesTabToolStripMenuItem, toolStripSeparator2, replaceToolStripMenuItem, replaceStartingFromToolStripMenuItem, insertAtToolStripMenuItem, removeToolStripMenuItem, toolStripSeparator3, saveToolStripMenuItem });
+ contextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { showFreeSlotsToolStripMenuItem, findNextFreeSlotToolStripMenuItem, toolStripSeparator5, exportImageToolStripMenuItem, toolStripSeparator6, selectInLandTilesTabToolStripMenuItem, toolStripSeparator2, copyImageToolStripMenuItem, pasteImageToolStripMenuItem, clipboardToolStripSeparator, replaceToolStripMenuItem, replaceStartingFromToolStripMenuItem, insertAtToolStripMenuItem, removeToolStripMenuItem, toolStripSeparator3, saveToolStripMenuItem });
contextMenuStrip.Name = "contextMenuStrip1";
contextMenuStrip.Size = new System.Drawing.Size(194, 248);
contextMenuStrip.Opening += contextMenuStrip_Opening;
@@ -156,6 +159,27 @@ private void InitializeComponent()
toolStripSeparator2.Name = "toolStripSeparator2";
toolStripSeparator2.Size = new System.Drawing.Size(190, 6);
//
+ // copyImageToolStripMenuItem
+ //
+ copyImageToolStripMenuItem.Name = "copyImageToolStripMenuItem";
+ copyImageToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+C";
+ copyImageToolStripMenuItem.Size = new System.Drawing.Size(193, 22);
+ copyImageToolStripMenuItem.Text = "Copy Image";
+ copyImageToolStripMenuItem.Click += OnClickCopyImage;
+ //
+ // pasteImageToolStripMenuItem
+ //
+ pasteImageToolStripMenuItem.Name = "pasteImageToolStripMenuItem";
+ pasteImageToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+V";
+ pasteImageToolStripMenuItem.Size = new System.Drawing.Size(193, 22);
+ pasteImageToolStripMenuItem.Text = "Paste Image";
+ pasteImageToolStripMenuItem.Click += OnClickPasteImage;
+ //
+ // clipboardToolStripSeparator
+ //
+ clipboardToolStripSeparator.Name = "clipboardToolStripSeparator";
+ clipboardToolStripSeparator.Size = new System.Drawing.Size(190, 6);
+ //
// replaceToolStripMenuItem
//
replaceToolStripMenuItem.Name = "replaceToolStripMenuItem";
@@ -387,6 +411,9 @@ private void InitializeComponent()
private System.Windows.Forms.ToolStripTextBox InsertText;
private System.Windows.Forms.ToolStripMenuItem removeToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem replaceToolStripMenuItem;
+ private System.Windows.Forms.ToolStripMenuItem copyImageToolStripMenuItem;
+ private System.Windows.Forms.ToolStripMenuItem pasteImageToolStripMenuItem;
+ private System.Windows.Forms.ToolStripSeparator clipboardToolStripSeparator;
private System.Windows.Forms.ToolStripButton SaveButton;
private System.Windows.Forms.ToolStrip topMenuToolStrip;
private System.Windows.Forms.ToolStripSeparator toolStripSeparator1;
diff --git a/UoFiddler.Controls/UserControls/TexturesControl.cs b/UoFiddler.Controls/UserControls/TexturesControl.cs
index 21ad3219..c02ba781 100644
--- a/UoFiddler.Controls/UserControls/TexturesControl.cs
+++ b/UoFiddler.Controls/UserControls/TexturesControl.cs
@@ -289,6 +289,93 @@ private void OnClickRemove(object sender, EventArgs e)
Options.ChangedUltimaClass["Texture"] = true;
}
+ private void OnClickCopyImage(object sender, EventArgs e)
+ {
+ // The clipboard holds one image, so a multi selection copies the focused texture.
+ int id = _selectedTextureId;
+ if (id < 0 || !Textures.TestTexture(id))
+ {
+ return;
+ }
+
+ if (!ImageClipboard.TryCopy(Textures.GetTexture(id), out string error))
+ {
+ MessageBox.Show(error, "Copy Image", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ }
+ }
+
+ private void OnClickPasteImage(object sender, EventArgs e)
+ {
+ var ids = GetSelectedTextureIds();
+ if (ids.Count == 0)
+ {
+ if (_selectedTextureId < 0)
+ {
+ return;
+ }
+
+ ids.Add(_selectedTextureId);
+ }
+
+ using Bitmap pasted = ImageClipboard.TryPaste(out string error);
+ if (pasted == null)
+ {
+ MessageBox.Show(error, "Paste Image", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return;
+ }
+
+ if (!IsValidTextureSize(pasted))
+ {
+ MessageBox.Show(
+ $"Invalid texture dimensions!\n\n" +
+ $"Clipboard image: {pasted.Width}x{pasted.Height}\n" +
+ $"Textures must be 64x64 or 128x128 pixels.\n\n" +
+ "No changes made.",
+ "Invalid Size", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return;
+ }
+
+ if (ids.Count > 1)
+ {
+ DialogResult confirm = MessageBox.Show(
+ $"Paste this {pasted.Width}x{pasted.Height} image into {ids.Count} selected textures?",
+ "Paste Image", MessageBoxButtons.YesNo, MessageBoxIcon.Question,
+ MessageBoxDefaultButton.Button2);
+
+ if (confirm != DialogResult.Yes)
+ {
+ return;
+ }
+ }
+
+ Bitmap converted = Utils.ToUoBitmap(pasted);
+
+ foreach (int id in ids)
+ {
+ // Each slot needs its own bitmap: the SDK keeps the instance it is handed.
+ Textures.Replace(id, ids.Count == 1 ? converted : (Bitmap)converted.Clone());
+ ControlEvents.FireTextureChangeEvent(this, id);
+ }
+
+ if (ids.Count > 1)
+ {
+ converted.Dispose();
+ }
+
+ TextureTileView.Invalidate();
+ Options.ChangedUltimaClass["Texture"] = true;
+ }
+
+ ///
+ /// Texidx stores no dimensions - the client derives them from the encoded length - so a
+ /// texture that is not 64x64 or 128x128 would be read back as the wrong size.
+ ///
+ private static bool IsValidTextureSize(Image image)
+ {
+ return (image.Width == 64 && image.Height == 64)
+ || (image.Width == 128 && image.Height == 128);
+ }
+
private void OnClickReplace(object sender, EventArgs e)
{
if (TextureTileView.SelectedIndices.Count > 1)
@@ -692,6 +779,11 @@ private void TextureTileView_DrawItem(object sender, TileView.TileViewControl.Dr
Rectangle textureRectangle = new Rectangle(itemPoint, new Size(bitmap.Width, bitmap.Height));
e.Graphics.DrawImage(bitmap, textureRectangle);
+ if (Textures.IsModified(_textureList[e.Index]))
+ {
+ ModifiedMarker.Draw(e.Graphics, tileRectangle);
+ }
+
e.Graphics.Clip = previousClip;
}
}
@@ -932,9 +1024,32 @@ private void OnClickSelectInLandTiles(object sender, EventArgs e)
}
}
+ protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
+ {
+ // Copy/paste is handled here rather than as menu ShortcutKeys: a shortcut on a
+ // ContextMenuStrip is processed for the whole form, which would swallow Ctrl+C/Ctrl+V in
+ // every text box on every tab.
+ if (keyData == (Keys.Control | Keys.C) && TextureTileView.Focused)
+ {
+ OnClickCopyImage(this, EventArgs.Empty);
+ return true;
+ }
+
+ if (keyData == (Keys.Control | Keys.V) && TextureTileView.Focused)
+ {
+ OnClickPasteImage(this, EventArgs.Empty);
+ return true;
+ }
+
+ return base.ProcessCmdKey(ref msg, keyData);
+ }
+
private void contextMenuStrip_Opening(object sender, System.ComponentModel.CancelEventArgs e)
{
int selectedCount = TextureTileView.SelectedIndices.Count;
+ copyImageToolStripMenuItem.Enabled = _selectedTextureId >= 0 && Textures.TestTexture(_selectedTextureId);
+ pasteImageToolStripMenuItem.Enabled = selectedCount > 0 && ImageClipboard.ContainsImage();
+ pasteImageToolStripMenuItem.Text = selectedCount > 1 ? $"Paste Image into {selectedCount}" : "Paste Image";
removeToolStripMenuItem.Text = selectedCount > 1 ? $"Remove {selectedCount}" : "Remove";
exportImageToolStripMenuItem.Text = selectedCount > 1 ? $"Export {selectedCount} Images..." : "Export Image..";
replaceToolStripMenuItem.Text = selectedCount > 1 ? $"Replace {selectedCount}" : "Replace";
diff --git a/UoFiddler.Controls/UserControls/TileDataControl.Designer.cs b/UoFiddler.Controls/UserControls/TileDataControl.Designer.cs
index 27f1276f..f410b3c3 100644
--- a/UoFiddler.Controls/UserControls/TileDataControl.Designer.cs
+++ b/UoFiddler.Controls/UserControls/TileDataControl.Designer.cs
@@ -48,6 +48,9 @@ private void InitializeComponent()
listViewItem = new System.Windows.Forms.ListView();
listViewItemColumn = new System.Windows.Forms.ColumnHeader();
ItemsContextMenuStrip = new System.Windows.Forms.ContextMenuStrip(components);
+ copyItemTileDataToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ pasteSpecialItemToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ toolStripSeparator7 = new System.Windows.Forms.ToolStripSeparator();
selectInItemsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
selectRadarColorToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator();
@@ -99,6 +102,9 @@ private void InitializeComponent()
listViewLand = new System.Windows.Forms.ListView();
listViewLandColumn = new System.Windows.Forms.ColumnHeader();
LandTilesContextMenuStrip = new System.Windows.Forms.ContextMenuStrip(components);
+ copyLandTileDataToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ pasteSpecialLandToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ toolStripSeparator8 = new System.Windows.Forms.ToolStripSeparator();
selectInLandtilesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
selToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
pictureBoxLand = new System.Windows.Forms.PictureBox();
@@ -118,6 +124,10 @@ private void InitializeComponent()
searchByNameToolStripButton = new System.Windows.Forms.ToolStripButton();
toolStripSeparator5 = new System.Windows.Forms.ToolStripSeparator();
toolStripDropDownButton1 = new System.Windows.Forms.ToolStripDropDownButton();
+ toolStripSeparator9 = new System.Windows.Forms.ToolStripSeparator();
+ undoBulkApplyToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ multiSelectItemInfoLabel = new System.Windows.Forms.Label();
+ multiSelectLandInfoLabel = new System.Windows.Forms.Label();
memorySaveWarningToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
saveDirectlyOnChangesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
setFilterToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
@@ -247,23 +257,47 @@ private void InitializeComponent()
listViewItem.View = System.Windows.Forms.View.Details;
listViewItem.VirtualMode = true;
listViewItem.FullRowSelect = true;
- listViewItem.MultiSelect = false;
+ listViewItem.MultiSelect = true;
listViewItem.HeaderStyle = System.Windows.Forms.ColumnHeaderStyle.None;
listViewItemColumn.Text = "Item";
listViewItemColumn.Width = 240;
listViewItem.Columns.Add(listViewItemColumn);
listViewItem.RetrieveVirtualItem += OnRetrieveItemVirtualItem;
listViewItem.SelectedIndexChanged += OnItemSelectedIndexChanged;
+ listViewItem.VirtualItemsSelectionRangeChanged += OnItemSelectionRangeChanged;
//
// ItemsContextMenuStrip
//
- ItemsContextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { selectInItemsToolStripMenuItem, selectRadarColorToolStripMenuItem, toolStripSeparator3, selectInGumpsTabMaleToolStripMenuItem, selectInGumpsTabFemaleToolStripMenuItem, selectInAnimDataTabToolStripMenuItem });
+ ItemsContextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { copyItemTileDataToolStripMenuItem, pasteSpecialItemToolStripMenuItem, toolStripSeparator7, selectInItemsToolStripMenuItem, selectRadarColorToolStripMenuItem, toolStripSeparator3, selectInGumpsTabMaleToolStripMenuItem, selectInGumpsTabFemaleToolStripMenuItem, selectInAnimDataTabToolStripMenuItem });
ItemsContextMenuStrip.Name = "contextMenuStrip1";
ItemsContextMenuStrip.Size = new System.Drawing.Size(201, 98);
ItemsContextMenuStrip.Opening += ItemsContextMenuStrip_Opening;
- //
+ //
+ // copyItemTileDataToolStripMenuItem
+ //
+ copyItemTileDataToolStripMenuItem.Name = "copyItemTileDataToolStripMenuItem";
+ copyItemTileDataToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+C";
+ copyItemTileDataToolStripMenuItem.ShowShortcutKeys = true;
+ copyItemTileDataToolStripMenuItem.Size = new System.Drawing.Size(200, 22);
+ copyItemTileDataToolStripMenuItem.Text = "Copy tile data";
+ copyItemTileDataToolStripMenuItem.Click += OnClickCopyItemTileData;
+ //
+ // pasteSpecialItemToolStripMenuItem
+ //
+ pasteSpecialItemToolStripMenuItem.Name = "pasteSpecialItemToolStripMenuItem";
+ pasteSpecialItemToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+V";
+ pasteSpecialItemToolStripMenuItem.ShowShortcutKeys = true;
+ pasteSpecialItemToolStripMenuItem.Size = new System.Drawing.Size(200, 22);
+ pasteSpecialItemToolStripMenuItem.Text = "Paste special...";
+ pasteSpecialItemToolStripMenuItem.Click += OnClickPasteSpecialItem;
+ //
+ // toolStripSeparator7
+ //
+ toolStripSeparator7.Name = "toolStripSeparator7";
+ toolStripSeparator7.Size = new System.Drawing.Size(197, 6);
+ //
// selectInItemsToolStripMenuItem
- //
+ //
selectInItemsToolStripMenuItem.Name = "selectInItemsToolStripMenuItem";
selectInItemsToolStripMenuItem.Size = new System.Drawing.Size(200, 22);
selectInItemsToolStripMenuItem.Text = "Select In Items tab";
@@ -378,6 +412,7 @@ private void InitializeComponent()
// splitContainer3.Panel2
//
splitContainer3.Panel2.Controls.Add(checkedListBox1);
+ splitContainer3.Panel2.Controls.Add(multiSelectItemInfoLabel);
splitContainer3.Size = new System.Drawing.Size(504, 341);
splitContainer3.SplitterDistance = 157;
splitContainer3.SplitterWidth = 2;
@@ -743,6 +778,17 @@ private void InitializeComponent()
checkedListBox1.Size = new System.Drawing.Size(504, 182);
checkedListBox1.TabIndex = 0;
checkedListBox1.ItemCheck += OnFlagItemCheckItems;
+ //
+ // multiSelectItemInfoLabel
+ //
+ multiSelectItemInfoLabel.AutoSize = false;
+ multiSelectItemInfoLabel.Dock = System.Windows.Forms.DockStyle.Top;
+ multiSelectItemInfoLabel.Location = new System.Drawing.Point(0, 0);
+ multiSelectItemInfoLabel.Name = "multiSelectItemInfoLabel";
+ multiSelectItemInfoLabel.Padding = new System.Windows.Forms.Padding(3, 3, 3, 3);
+ multiSelectItemInfoLabel.Size = new System.Drawing.Size(504, 42);
+ multiSelectItemInfoLabel.TabIndex = 1;
+ multiSelectItemInfoLabel.Visible = false;
//
// tabPageLand
//
@@ -810,19 +856,44 @@ private void InitializeComponent()
listViewLand.View = System.Windows.Forms.View.Details;
listViewLand.VirtualMode = true;
listViewLand.FullRowSelect = true;
- listViewLand.MultiSelect = false;
+ listViewLand.MultiSelect = true;
listViewLand.HeaderStyle = System.Windows.Forms.ColumnHeaderStyle.None;
listViewLandColumn.Text = "Land";
listViewLandColumn.Width = 240;
listViewLand.Columns.Add(listViewLandColumn);
listViewLand.RetrieveVirtualItem += OnRetrieveLandVirtualItem;
listViewLand.SelectedIndexChanged += OnLandSelectedIndexChanged;
+ listViewLand.VirtualItemsSelectionRangeChanged += OnLandSelectionRangeChanged;
//
// LandTilesContextMenuStrip
//
- LandTilesContextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { selectInLandtilesToolStripMenuItem, selToolStripMenuItem });
+ LandTilesContextMenuStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { copyLandTileDataToolStripMenuItem, pasteSpecialLandToolStripMenuItem, toolStripSeparator8, selectInLandtilesToolStripMenuItem, selToolStripMenuItem });
LandTilesContextMenuStrip.Name = "contextMenuStrip2";
LandTilesContextMenuStrip.Size = new System.Drawing.Size(201, 48);
+ LandTilesContextMenuStrip.Opening += LandTilesContextMenuStrip_Opening;
+ //
+ // copyLandTileDataToolStripMenuItem
+ //
+ copyLandTileDataToolStripMenuItem.Name = "copyLandTileDataToolStripMenuItem";
+ copyLandTileDataToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+C";
+ copyLandTileDataToolStripMenuItem.ShowShortcutKeys = true;
+ copyLandTileDataToolStripMenuItem.Size = new System.Drawing.Size(200, 22);
+ copyLandTileDataToolStripMenuItem.Text = "Copy tile data";
+ copyLandTileDataToolStripMenuItem.Click += OnClickCopyLandTileData;
+ //
+ // pasteSpecialLandToolStripMenuItem
+ //
+ pasteSpecialLandToolStripMenuItem.Name = "pasteSpecialLandToolStripMenuItem";
+ pasteSpecialLandToolStripMenuItem.ShortcutKeyDisplayString = "Ctrl+V";
+ pasteSpecialLandToolStripMenuItem.ShowShortcutKeys = true;
+ pasteSpecialLandToolStripMenuItem.Size = new System.Drawing.Size(200, 22);
+ pasteSpecialLandToolStripMenuItem.Text = "Paste special...";
+ pasteSpecialLandToolStripMenuItem.Click += OnClickPasteSpecialLand;
+ //
+ // toolStripSeparator8
+ //
+ toolStripSeparator8.Name = "toolStripSeparator8";
+ toolStripSeparator8.Size = new System.Drawing.Size(197, 6);
//
// selectInLandtilesToolStripMenuItem
//
@@ -889,6 +960,7 @@ private void InitializeComponent()
// splitContainer7.Panel2
//
splitContainer7.Panel2.Controls.Add(checkedListBox2);
+ splitContainer7.Panel2.Controls.Add(multiSelectLandInfoLabel);
splitContainer7.Size = new System.Drawing.Size(504, 341);
splitContainer7.SplitterDistance = 27;
splitContainer7.SplitterWidth = 2;
@@ -945,6 +1017,17 @@ private void InitializeComponent()
checkedListBox2.Size = new System.Drawing.Size(504, 312);
checkedListBox2.TabIndex = 0;
checkedListBox2.ItemCheck += OnFlagItemCheckLandTiles;
+ //
+ // multiSelectLandInfoLabel
+ //
+ multiSelectLandInfoLabel.AutoSize = false;
+ multiSelectLandInfoLabel.Dock = System.Windows.Forms.DockStyle.Top;
+ multiSelectLandInfoLabel.Location = new System.Drawing.Point(0, 0);
+ multiSelectLandInfoLabel.Name = "multiSelectLandInfoLabel";
+ multiSelectLandInfoLabel.Padding = new System.Windows.Forms.Padding(3, 3, 3, 3);
+ multiSelectLandInfoLabel.Size = new System.Drawing.Size(504, 42);
+ multiSelectLandInfoLabel.TabIndex = 1;
+ multiSelectLandInfoLabel.Visible = false;
//
// MainToolStrip
//
@@ -999,11 +1082,25 @@ private void InitializeComponent()
// toolStripDropDownButton1
//
toolStripDropDownButton1.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text;
- toolStripDropDownButton1.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { memorySaveWarningToolStripMenuItem, saveDirectlyOnChangesToolStripMenuItem, setFilterToolStripMenuItem, toolStripSeparator4, setTextureOnDoubleClickToolStripMenuItem, setTexturesToolStripMenuItem });
+ toolStripDropDownButton1.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { memorySaveWarningToolStripMenuItem, saveDirectlyOnChangesToolStripMenuItem, setFilterToolStripMenuItem, toolStripSeparator9, undoBulkApplyToolStripMenuItem, toolStripSeparator4, setTextureOnDoubleClickToolStripMenuItem, setTexturesToolStripMenuItem });
+ toolStripDropDownButton1.DropDownOpening += MiscToolStripDropDownButton_DropDownOpening;
toolStripDropDownButton1.ImageTransparentColor = System.Drawing.Color.Magenta;
toolStripDropDownButton1.Name = "toolStripDropDownButton1";
toolStripDropDownButton1.Size = new System.Drawing.Size(45, 22);
toolStripDropDownButton1.Text = "Misc";
+ //
+ // toolStripSeparator9
+ //
+ toolStripSeparator9.Name = "toolStripSeparator9";
+ toolStripSeparator9.Size = new System.Drawing.Size(202, 6);
+ //
+ // undoBulkApplyToolStripMenuItem
+ //
+ undoBulkApplyToolStripMenuItem.Enabled = false;
+ undoBulkApplyToolStripMenuItem.Name = "undoBulkApplyToolStripMenuItem";
+ undoBulkApplyToolStripMenuItem.Size = new System.Drawing.Size(205, 22);
+ undoBulkApplyToolStripMenuItem.Text = "Undo last bulk apply";
+ undoBulkApplyToolStripMenuItem.Click += OnClickUndoBulkApply;
//
// memorySaveWarningToolStripMenuItem
//
@@ -1188,6 +1285,16 @@ private void InitializeComponent()
private System.Windows.Forms.ToolStripMenuItem saveDirectlyOnChangesToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem selectInItemsToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem selectInLandtilesToolStripMenuItem;
+ private System.Windows.Forms.ToolStripMenuItem copyItemTileDataToolStripMenuItem;
+ private System.Windows.Forms.ToolStripMenuItem pasteSpecialItemToolStripMenuItem;
+ private System.Windows.Forms.ToolStripMenuItem copyLandTileDataToolStripMenuItem;
+ private System.Windows.Forms.ToolStripMenuItem pasteSpecialLandToolStripMenuItem;
+ private System.Windows.Forms.ToolStripMenuItem undoBulkApplyToolStripMenuItem;
+ private System.Windows.Forms.ToolStripSeparator toolStripSeparator7;
+ private System.Windows.Forms.ToolStripSeparator toolStripSeparator8;
+ private System.Windows.Forms.ToolStripSeparator toolStripSeparator9;
+ private System.Windows.Forms.Label multiSelectItemInfoLabel;
+ private System.Windows.Forms.Label multiSelectLandInfoLabel;
private System.Windows.Forms.ToolStripMenuItem selectRadarColorToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem selToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem setFilterToolStripMenuItem;
diff --git a/UoFiddler.Controls/UserControls/TileDataControl.cs b/UoFiddler.Controls/UserControls/TileDataControl.cs
index 11422788..dfbba2c5 100644
--- a/UoFiddler.Controls/UserControls/TileDataControl.cs
+++ b/UoFiddler.Controls/UserControls/TileDataControl.cs
@@ -106,18 +106,69 @@ private void InitItemsFlagsCheckBoxes()
private static Color ModifiedColor => Options.DarkMode ? Color.OrangeRed : Color.Red;
+ // Snapshot of the entries the last bulk apply overwrote, so one misplaced
+ // "apply to everything selected" can be taken back. Only one level is kept.
+ private TileDataBulkUndo _lastBulkUndo;
+
private int GetSelectedItemGraphic()
{
- return listViewItem.SelectedIndices.Count > 0
- ? _itemIndices[listViewItem.SelectedIndices[0]]
- : -1;
+ return GetPrimaryGraphic(listViewItem, _itemIndices);
}
private int GetSelectedLandGraphic()
{
- return listViewLand.SelectedIndices.Count > 0
- ? _landIndices[listViewLand.SelectedIndices[0]]
- : -1;
+ return GetPrimaryGraphic(listViewLand, _landIndices);
+ }
+
+ ///
+ /// The entry the editor pane shows - the focused row while it is part of the
+ /// selection, which is the one the user picked last, otherwise the first
+ /// selected row.
+ ///
+ private static int GetPrimaryGraphic(ListView listView, int[] indices)
+ {
+ if (listView.SelectedIndices.Count == 0)
+ {
+ return -1;
+ }
+
+ int row = listView.FocusedItem?.Index ?? -1;
+ if (row < 0 || !listView.SelectedIndices.Contains(row))
+ {
+ row = listView.SelectedIndices[0];
+ }
+
+ return (uint)row < (uint)indices.Length ? indices[row] : -1;
+ }
+
+ ///
+ /// Every selected graphic id in ascending order. Rows are mapped through the
+ /// filter projection, so this is ids, not row positions.
+ ///
+ private static int[] GetSelectedGraphics(ListView listView, int[] indices)
+ {
+ ListView.SelectedIndexCollection selected = listView.SelectedIndices;
+ var graphics = new List(selected.Count);
+ foreach (int row in selected)
+ {
+ if ((uint)row < (uint)indices.Length)
+ {
+ graphics.Add(indices[row]);
+ }
+ }
+
+ graphics.Sort();
+ return graphics.ToArray();
+ }
+
+ private int[] GetSelectedItemGraphics()
+ {
+ return GetSelectedGraphics(listViewItem, _itemIndices);
+ }
+
+ private int[] GetSelectedLandGraphics()
+ {
+ return GetSelectedGraphics(listViewLand, _landIndices);
}
private static string FormatItemRow(int graphic, string name)
@@ -218,6 +269,52 @@ private void SelectLandRow(int rowPos)
}
}
+ ///
+ /// Selects several rows at once, focusing the last one so the editor pane
+ /// shows it. Batched - a virtual ListView raises a selection event per row.
+ ///
+ private static void SelectRows(ListView listView, IReadOnlyList rowPositions, int rowCount)
+ {
+ listView.BeginUpdate();
+ try
+ {
+ listView.SelectedIndices.Clear();
+ foreach (int rowPos in rowPositions)
+ {
+ if ((uint)rowPos < (uint)rowCount)
+ {
+ listView.SelectedIndices.Add(rowPos);
+ }
+ }
+ }
+ finally
+ {
+ listView.EndUpdate();
+ }
+
+ if (rowPositions.Count == 0)
+ {
+ return;
+ }
+
+ int last = rowPositions[rowPositions.Count - 1];
+ if ((uint)last < (uint)rowCount)
+ {
+ listView.EnsureVisible(last);
+ listView.FocusedItem = listView.Items[last];
+ }
+ }
+
+ private void SelectItemRows(IReadOnlyList rowPositions)
+ {
+ SelectRows(listViewItem, rowPositions, _itemIndices.Length);
+ }
+
+ private void SelectLandRows(IReadOnlyList rowPositions)
+ {
+ SelectRows(listViewLand, rowPositions, _landIndices.Length);
+ }
+
private static int[] BuildIdentity(int length)
{
var array = new int[length];
@@ -295,6 +392,109 @@ public static bool SearchGraphic(int graphic, bool land)
}
}
+ ///
+ /// Cross-tab entry point carrying a whole selection over, e.g. from the Items
+ /// tab's "Select in TileData tab". See for why
+ /// the tab has to be activated before the selection is set.
+ ///
+ public static void Select(IReadOnlyList graphics, bool land)
+ {
+ if (_refMarker == null || graphics == null || graphics.Count == 0)
+ {
+ return;
+ }
+
+ if (graphics.Count == 1)
+ {
+ Select(graphics[0], land);
+ return;
+ }
+
+ TabPageNavigator.ActivateOwningTabPage(_refMarker);
+
+ if (_refMarker.IsHandleCreated)
+ {
+ _refMarker.BeginInvoke(new Action(() => SearchGraphics(graphics, land)));
+ }
+ else
+ {
+ SearchGraphics(graphics, land);
+ }
+ }
+
+ ///
+ /// Selects every given graphic. Returns false only when none of them exist at
+ /// all; ids the current filter hides are reached by resetting the view once,
+ /// the same way does for a single id.
+ ///
+ public static bool SearchGraphics(IReadOnlyList graphics, bool land)
+ {
+ if (_refMarker == null || graphics == null || graphics.Count == 0)
+ {
+ return false;
+ }
+
+ int[] indices = land ? _refMarker._landIndices : _refMarker._itemIndices;
+ List rows = MapGraphicsToRows(graphics, indices);
+
+ if (rows.Count < graphics.Count)
+ {
+ // At least one target is filtered out of the view - drop the filter so
+ // the navigation always lands on the full selection.
+ if (land)
+ {
+ _refMarker.ResetLandView();
+ indices = _refMarker._landIndices;
+ }
+ else
+ {
+ _refMarker.ResetItemView();
+ indices = _refMarker._itemIndices;
+ }
+
+ rows = MapGraphicsToRows(graphics, indices);
+ }
+
+ if (rows.Count == 0)
+ {
+ return false;
+ }
+
+ if (land)
+ {
+ _refMarker.tabcontrol.SelectTab(1);
+ _refMarker.SelectLandRows(rows);
+ }
+ else
+ {
+ _refMarker.tabcontrol.SelectTab(0);
+ _refMarker.SelectItemRows(rows);
+ }
+
+ return true;
+ }
+
+ ///
+ /// Row lookup for a batch of ids. The projection arrays are always ascending -
+ /// identity, or filter matches appended in order - so this can binary search
+ /// instead of scanning the array once per id.
+ ///
+ private static List MapGraphicsToRows(IReadOnlyList graphics, int[] indices)
+ {
+ var rows = new List(graphics.Count);
+ foreach (int graphic in graphics)
+ {
+ int pos = Array.BinarySearch(indices, graphic);
+ if (pos >= 0)
+ {
+ rows.Add(pos);
+ }
+ }
+
+ rows.Sort();
+ return rows;
+ }
+
private void ResetItemView()
{
int total = TileData.ItemTable?.Length ?? 0;
@@ -313,6 +513,38 @@ private void ResetLandView()
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
+ // Copy/paste is handled here rather than as menu ShortcutKeys: a shortcut on
+ // a ContextMenuStrip is processed for the whole form, which would swallow
+ // Ctrl+C/Ctrl+V in every text box on every tab.
+ if (keyData == (Keys.Control | Keys.C))
+ {
+ if (listViewItem.Focused)
+ {
+ OnClickCopyItemTileData(this, EventArgs.Empty);
+ return true;
+ }
+
+ if (listViewLand.Focused)
+ {
+ OnClickCopyLandTileData(this, EventArgs.Empty);
+ return true;
+ }
+ }
+ else if (keyData == (Keys.Control | Keys.V))
+ {
+ if (listViewItem.Focused && _copiedItem != null)
+ {
+ OnClickPasteSpecialItem(this, EventArgs.Empty);
+ return true;
+ }
+
+ if (listViewLand.Focused && _copiedLand != null)
+ {
+ OnClickPasteSpecialLand(this, EventArgs.Empty);
+ return true;
+ }
+ }
+
if (keyData == Keys.F3 || keyData == (Keys.F3 | Keys.Shift))
{
if (searchByNameToolStripTextBox.TextBox.Focused)
@@ -560,6 +792,9 @@ public void OnLoad(object sender, EventArgs e)
_modifiedItems.Clear();
_modifiedLand.Clear();
+ // The snapshot refers to entries that have just been replaced wholesale.
+ _lastBulkUndo = null;
+
ResetItemView();
ResetLandView();
@@ -587,17 +822,8 @@ private void OnPreviewBackgroundColorChanged()
pictureBoxItem.BackColor = Options.PreviewBackgroundColor;
pictureBoxLand.BackColor = Options.PreviewBackgroundColor;
- int itemGraphic = GetSelectedItemGraphic();
- if (itemGraphic >= 0)
- {
- UpdateSelectedItemPreview(itemGraphic);
- }
-
- int landGraphic = GetSelectedLandGraphic();
- if (landGraphic >= 0)
- {
- UpdateSelectedLandPreview(landGraphic);
- }
+ RefreshItemEditor();
+ RefreshLandEditor();
}
private void OnTileDataChangeEvent(object sender, int index)
@@ -618,7 +844,7 @@ private void OnTileDataChangeEvent(object sender, int index)
MarkItemModified(graphic);
if (GetSelectedItemGraphic() == graphic)
{
- UpdateSelectedItemPreview(graphic);
+ QueueItemEditorRefresh();
}
}
else
@@ -626,31 +852,309 @@ private void OnTileDataChangeEvent(object sender, int index)
MarkLandModified(index);
if (GetSelectedLandGraphic() == index)
{
- UpdateSelectedLandPreview(index);
+ QueueLandEditorRefresh();
}
}
}
private void OnItemSelectedIndexChanged(object sender, EventArgs e)
{
- int graphic = GetSelectedItemGraphic();
- if (graphic < 0)
+ QueueItemEditorRefresh();
+ }
+
+ private void OnLandSelectedIndexChanged(object sender, EventArgs e)
+ {
+ QueueLandEditorRefresh();
+ }
+
+ private void OnItemSelectionRangeChanged(object sender, ListViewVirtualItemsSelectionRangeChangedEventArgs e)
+ {
+ QueueItemEditorRefresh();
+ }
+
+ private void OnLandSelectionRangeChanged(object sender, ListViewVirtualItemsSelectionRangeChangedEventArgs e)
+ {
+ QueueLandEditorRefresh();
+ }
+
+ // SelectedIndexChanged fires once per row, so rubber-banding a few thousand
+ // rows would otherwise repopulate the whole editor pane - art decode included -
+ // once per row. Coalesce everything raised in one message-loop turn.
+ private bool _itemRefreshPending;
+ private bool _landRefreshPending;
+
+ private void QueueItemEditorRefresh()
+ {
+ if (_itemRefreshPending || !IsHandleCreated || IsDisposed)
{
return;
}
- UpdateSelectedItemPreview(graphic);
+ _itemRefreshPending = true;
+ BeginInvoke(new Action(() =>
+ {
+ _itemRefreshPending = false;
+ if (!IsDisposed)
+ {
+ RefreshItemEditor();
+ }
+ }));
}
- private void OnLandSelectedIndexChanged(object sender, EventArgs e)
+ private void QueueLandEditorRefresh()
{
- int graphic = GetSelectedLandGraphic();
- if (graphic < 0)
+ if (_landRefreshPending || !IsHandleCreated || IsDisposed)
+ {
+ return;
+ }
+
+ _landRefreshPending = true;
+ BeginInvoke(new Action(() =>
+ {
+ _landRefreshPending = false;
+ if (!IsDisposed)
+ {
+ RefreshLandEditor();
+ }
+ }));
+ }
+
+ private void RefreshItemEditor()
+ {
+ int[] selection = GetSelectedItemGraphics();
+ UpdateMultiSelectInfoLabel(multiSelectItemInfoLabel, selection.Length);
+
+ int primary = GetSelectedItemGraphic();
+ if (primary < 0)
+ {
+ return;
+ }
+
+ UpdateSelectedItemPreview(primary);
+
+ if (selection.Length > 1)
+ {
+ ApplyItemMixedState(selection);
+ _itemMultiBaseline = BuildItemEditFromEditor();
+ }
+ else
+ {
+ _itemMultiBaseline = null;
+ }
+ }
+
+ private void RefreshLandEditor()
+ {
+ int[] selection = GetSelectedLandGraphics();
+ UpdateMultiSelectInfoLabel(multiSelectLandInfoLabel, selection.Length);
+
+ int primary = GetSelectedLandGraphic();
+ if (primary < 0)
+ {
+ return;
+ }
+
+ UpdateSelectedLandPreview(primary);
+
+ if (selection.Length > 1)
+ {
+ ApplyLandMixedState(selection);
+ _landMultiBaseline = BuildLandEditFromEditor();
+ }
+ else
+ {
+ _landMultiBaseline = null;
+ }
+ }
+
+ private static void UpdateMultiSelectInfoLabel(Label label, int selectionCount)
+ {
+ if (selectionCount > 1)
+ {
+ label.Text =
+ $"{selectionCount} entries selected - 'Save Changes' writes what you edit to all of them."
+ + " Empty boxes and greyed flags are left unchanged.";
+ label.Visible = true;
+ }
+ else
+ {
+ label.Visible = false;
+ }
+ }
+
+ ///
+ /// With more than one entry selected the pane shows the primary entry's values,
+ /// then this blanks every box the selection disagrees on and greys every flag
+ /// that is not uniformly set or clear. Blank and grey both read as "leave
+ /// alone" when the edit is applied.
+ ///
+ private void ApplyItemMixedState(int[] selection)
+ {
+ ref readonly ItemData first = ref TileData.ItemTable[selection[0]];
+
+ bool sameName = true;
+ bool sameAnim = true;
+ bool sameWeight = true;
+ bool sameQuality = true;
+ bool sameQuantity = true;
+ bool sameHue = true;
+ bool sameStackOff = true;
+ bool sameValue = true;
+ bool sameHeight = true;
+ bool sameMisc = true;
+ bool sameUnk2 = true;
+ bool sameUnk3 = true;
+
+ TileFlag inAll = first.Flags;
+ TileFlag inAny = first.Flags;
+
+ for (int i = 1; i < selection.Length; ++i)
+ {
+ ref readonly ItemData row = ref TileData.ItemTable[selection[i]];
+
+ sameName &= string.Equals(row.Name, first.Name, StringComparison.Ordinal);
+ sameAnim &= row.Animation == first.Animation;
+ sameWeight &= row.Weight == first.Weight;
+ sameQuality &= row.Quality == first.Quality;
+ sameQuantity &= row.Quantity == first.Quantity;
+ sameHue &= row.Hue == first.Hue;
+ sameStackOff &= row.StackingOffset == first.StackingOffset;
+ sameValue &= row.Value == first.Value;
+ sameHeight &= row.Height == first.Height;
+ sameMisc &= row.MiscData == first.MiscData;
+ sameUnk2 &= row.Unk2 == first.Unk2;
+ sameUnk3 &= row.Unk3 == first.Unk3;
+
+ inAll &= row.Flags;
+ inAny |= row.Flags;
+ }
+
+ _changingIndex = true;
+ try
+ {
+ BlankIfMixed(textBoxName, sameName);
+ BlankIfMixed(textBoxAnim, sameAnim);
+ BlankIfMixed(textBoxWeight, sameWeight);
+ BlankIfMixed(textBoxQuality, sameQuality);
+ BlankIfMixed(textBoxQuantity, sameQuantity);
+ BlankIfMixed(textBoxHue, sameHue);
+ BlankIfMixed(textBoxStackOff, sameStackOff);
+ BlankIfMixed(textBoxValue, sameValue);
+ BlankIfMixed(textBoxHeigth, sameHeight);
+ BlankIfMixed(textBoxUnk1, sameMisc);
+ BlankIfMixed(textBoxUnk2, sameUnk2);
+ BlankIfMixed(textBoxUnk3, sameUnk3);
+
+ // Set somewhere but not everywhere.
+ MarkMixedFlags(checkedListBox1, inAny & ~inAll);
+ }
+ finally
+ {
+ _changingIndex = false;
+ }
+ }
+
+ private void ApplyLandMixedState(int[] selection)
+ {
+ ref readonly LandData first = ref TileData.LandTable[selection[0]];
+
+ bool sameName = true;
+ bool sameTexture = true;
+
+ TileFlag inAll = first.Flags;
+ TileFlag inAny = first.Flags;
+
+ for (int i = 1; i < selection.Length; ++i)
+ {
+ ref readonly LandData row = ref TileData.LandTable[selection[i]];
+
+ sameName &= string.Equals(row.Name, first.Name, StringComparison.Ordinal);
+ sameTexture &= row.TextureId == first.TextureId;
+
+ inAll &= row.Flags;
+ inAny |= row.Flags;
+ }
+
+ _changingIndex = true;
+ try
+ {
+ BlankIfMixed(textBoxNameLand, sameName);
+ BlankIfMixed(textBoxTexID, sameTexture);
+
+ MarkMixedFlags(checkedListBox2, inAny & ~inAll);
+ }
+ finally
+ {
+ _changingIndex = false;
+ }
+ }
+
+ private static void BlankIfMixed(TextBox textBox, bool allAgree)
+ {
+ if (!allAgree)
+ {
+ textBox.Text = string.Empty;
+ }
+ }
+
+ private static void MarkMixedFlags(CheckedListBox checkedListBox, TileFlag mixed)
+ {
+ if (mixed == TileFlag.None)
+ {
+ return;
+ }
+
+ Array enumValues = Enum.GetValues(typeof(TileFlag));
+ for (int i = 0; i < checkedListBox.Items.Count; ++i)
+ {
+ if ((mixed & (TileFlag)enumValues.GetValue(i + 1)) != 0)
+ {
+ checkedListBox.SetItemCheckState(i, CheckState.Indeterminate);
+ }
+ }
+ }
+
+ ///
+ /// Multi-selection flag cycling, which CheckedListBox will not do on its own.
+ /// A flag the selection disagreed on cycles leave alone -> set on all -> clear
+ /// on all, so the "don't touch it" state stays reachable. A flag they all
+ /// already agreed on just toggles: leaving it at the value it came up with is
+ /// already a no-op, so it has no need of a third state.
+ ///
+ private static CheckState NextMultiSelectFlagState(CheckState current, bool wasMixed)
+ {
+ if (!wasMixed)
+ {
+ return current == CheckState.Checked ? CheckState.Unchecked : CheckState.Checked;
+ }
+
+ switch (current)
+ {
+ case CheckState.Indeterminate:
+ return CheckState.Checked;
+
+ case CheckState.Checked:
+ return CheckState.Unchecked;
+
+ default:
+ return CheckState.Indeterminate;
+ }
+ }
+
+ ///
+ /// True when the selection disagreed on this flag at the time the pane was
+ /// populated - i.e. the baseline left it out of both masks.
+ ///
+ private static bool FlagWasMixed(TileFlag baselineSet, TileFlag baselineClear, int flagIndex)
+ {
+ Array enumValues = Enum.GetValues(typeof(TileFlag));
+ if ((uint)(flagIndex + 1) >= (uint)enumValues.Length)
{
- return;
+ return false;
}
- UpdateSelectedLandPreview(graphic);
+ var flag = (TileFlag)enumValues.GetValue(flagIndex + 1);
+ return (baselineSet & flag) == 0 && (baselineClear & flag) == 0;
}
private void UpdateSelectedItemPreview(int index)
@@ -744,6 +1248,12 @@ private void OnClickSaveChanges(object sender, EventArgs e)
{
if (tabcontrol.SelectedIndex == 0) // items
{
+ if (listViewItem.SelectedIndices.Count > 1)
+ {
+ ApplyItemEditToSelection(GetSelectedItemGraphics(), BuildItemEditForSelection());
+ return;
+ }
+
int index = GetSelectedItemGraphic();
if (index < 0)
{
@@ -837,6 +1347,12 @@ private void OnClickSaveChanges(object sender, EventArgs e)
}
else // land
{
+ if (listViewLand.SelectedIndices.Count > 1)
+ {
+ ApplyLandEditToSelection(GetSelectedLandGraphics(), BuildLandEditForSelection());
+ return;
+ }
+
int index = GetSelectedLandGraphic();
if (index < 0)
{
@@ -880,14 +1396,338 @@ private void OnClickSaveChanges(object sender, EventArgs e)
}
}
+ ///
+ /// Reads the editor pane into a sparse edit. An empty or unparseable box and an
+ /// indeterminate flag are both left out, so applying it across a selection only
+ /// touches what the user actually filled in.
+ ///
+ private ItemDataEdit BuildItemEditFromEditor()
+ {
+ var edit = new ItemDataEdit
+ {
+ Name = string.IsNullOrEmpty(textBoxName.Text) ? null : TileDataBulkEdit.TruncateName(textBoxName.Text),
+ Animation = ParseShort(textBoxAnim.Text),
+ Weight = ParseByte(textBoxWeight.Text),
+ Quality = ParseByte(textBoxQuality.Text),
+ Quantity = ParseByte(textBoxQuantity.Text),
+ Hue = ParseByte(textBoxHue.Text),
+ StackingOffset = ParseByte(textBoxStackOff.Text),
+ Value = ParseByte(textBoxValue.Text),
+ Height = ParseByte(textBoxHeigth.Text),
+ MiscData = ParseShort(textBoxUnk1.Text),
+ Unk2 = ParseByte(textBoxUnk2.Text),
+ Unk3 = ParseByte(textBoxUnk3.Text)
+ };
+
+ ReadFlagMasks(checkedListBox1, out TileFlag setFlags, out TileFlag clearFlags);
+ edit.SetFlags = setFlags;
+ edit.ClearFlags = clearFlags;
+
+ return edit;
+ }
+
+ private LandDataEdit BuildLandEditFromEditor()
+ {
+ var edit = new LandDataEdit
+ {
+ Name = string.IsNullOrEmpty(textBoxNameLand.Text)
+ ? null
+ : TileDataBulkEdit.TruncateName(textBoxNameLand.Text),
+ TextureId = ushort.TryParse(textBoxTexID.Text, out ushort textureId) ? textureId : (ushort?)null
+ };
+
+ ReadFlagMasks(checkedListBox2, out TileFlag setFlags, out TileFlag clearFlags);
+ edit.SetFlags = setFlags;
+ edit.ClearFlags = clearFlags;
+
+ return edit;
+ }
+
+ private static void ReadFlagMasks(CheckedListBox checkedListBox, out TileFlag setFlags, out TileFlag clearFlags)
+ {
+ setFlags = TileFlag.None;
+ clearFlags = TileFlag.None;
+
+ Array enumValues = Enum.GetValues(typeof(TileFlag));
+ for (int i = 0; i < checkedListBox.Items.Count; ++i)
+ {
+ CheckState state = checkedListBox.GetItemCheckState(i);
+ if (state == CheckState.Indeterminate)
+ {
+ continue;
+ }
+
+ var flag = (TileFlag)enumValues.GetValue(i + 1);
+ if (state == CheckState.Checked)
+ {
+ setFlags |= flag;
+ }
+ else
+ {
+ clearFlags |= flag;
+ }
+ }
+ }
+
+ private static byte? ParseByte(string text)
+ {
+ return byte.TryParse(text, out byte value) ? value : (byte?)null;
+ }
+
+ private static short? ParseShort(string text)
+ {
+ return short.TryParse(text, out short value) ? value : (short?)null;
+ }
+
+ // The pane as it stood when it was populated for the current multi-selection.
+ // A bulk apply writes the difference against this, so boxes and flags the user
+ // left alone are neither listed in the confirmation nor written back over
+ // entries that already agree.
+ private ItemDataEdit _itemMultiBaseline;
+ private LandDataEdit _landMultiBaseline;
+
+ ///
+ /// What the user actually changed in the pane since it was populated for this
+ /// selection.
+ ///
+ private ItemDataEdit BuildItemEditForSelection()
+ {
+ ItemDataEdit current = BuildItemEditFromEditor();
+ ItemDataEdit baseline = _itemMultiBaseline;
+ if (baseline == null)
+ {
+ return current;
+ }
+
+ return new ItemDataEdit
+ {
+ Name = OnlyIfChanged(current.Name, baseline.Name),
+ Animation = OnlyIfChanged(current.Animation, baseline.Animation),
+ Weight = OnlyIfChanged(current.Weight, baseline.Weight),
+ Quality = OnlyIfChanged(current.Quality, baseline.Quality),
+ Quantity = OnlyIfChanged(current.Quantity, baseline.Quantity),
+ Hue = OnlyIfChanged(current.Hue, baseline.Hue),
+ StackingOffset = OnlyIfChanged(current.StackingOffset, baseline.StackingOffset),
+ Value = OnlyIfChanged(current.Value, baseline.Value),
+ Height = OnlyIfChanged(current.Height, baseline.Height),
+ MiscData = OnlyIfChanged(current.MiscData, baseline.MiscData),
+ Unk2 = OnlyIfChanged(current.Unk2, baseline.Unk2),
+ Unk3 = OnlyIfChanged(current.Unk3, baseline.Unk3),
+
+ // Only flags the user moved to checked / unchecked from something else.
+ SetFlags = current.SetFlags & ~baseline.SetFlags,
+ ClearFlags = current.ClearFlags & ~baseline.ClearFlags
+ };
+ }
+
+ private LandDataEdit BuildLandEditForSelection()
+ {
+ LandDataEdit current = BuildLandEditFromEditor();
+ LandDataEdit baseline = _landMultiBaseline;
+ if (baseline == null)
+ {
+ return current;
+ }
+
+ return new LandDataEdit
+ {
+ Name = OnlyIfChanged(current.Name, baseline.Name),
+ TextureId = OnlyIfChanged(current.TextureId, baseline.TextureId),
+ SetFlags = current.SetFlags & ~baseline.SetFlags,
+ ClearFlags = current.ClearFlags & ~baseline.ClearFlags
+ };
+ }
+
+ private static T? OnlyIfChanged(T? current, T? baseline) where T : struct
+ {
+ return current.HasValue && !EqualityComparer.Default.Equals(current, baseline)
+ ? current
+ : null;
+ }
+
+ private static string OnlyIfChanged(string current, string baseline)
+ {
+ return current != null && !string.Equals(current, baseline, StringComparison.Ordinal)
+ ? current
+ : null;
+ }
+
+ ///
+ /// Writes one sparse edit to every selected item entry, after confirming what is
+ /// about to change and snapshotting the old values for a single level of undo.
+ ///
+ private void ApplyItemEditToSelection(int[] graphics, ItemDataEdit edit)
+ {
+ if (graphics.Length == 0)
+ {
+ return;
+ }
+
+ string what = TileDataBulkEdit.Describe(edit);
+ if (!ConfirmBulkApply(what, graphics.Length))
+ {
+ return;
+ }
+
+ _lastBulkUndo = TileDataBulkUndo.ForItems(graphics, what);
+
+ using (new WaitCursorScope(this))
+ {
+ foreach (int graphic in graphics)
+ {
+ TileData.ItemTable[graphic] = TileDataBulkEdit.Apply(TileData.ItemTable[graphic], edit);
+
+ // Mark without RedrawItemRow - that scans the projection array per
+ // call, which would be quadratic over a large selection. One
+ // Invalidate below repaints the lot.
+ _modifiedItems.Add(graphic);
+ ControlEvents.FireTileDataChangeEvent(this, graphic + 0x4000);
+ }
+ }
+
+ Options.ChangedUltimaClass["TileData"] = true;
+ listViewItem.Invalidate();
+ QueueItemEditorRefresh();
+
+ ReportBulkApply(what, graphics.Length);
+ }
+
+ private void ApplyLandEditToSelection(int[] graphics, LandDataEdit edit)
+ {
+ if (graphics.Length == 0)
+ {
+ return;
+ }
+
+ string what = TileDataBulkEdit.Describe(edit);
+ if (!ConfirmBulkApply(what, graphics.Length))
+ {
+ return;
+ }
+
+ _lastBulkUndo = TileDataBulkUndo.ForLand(graphics, what);
+
+ using (new WaitCursorScope(this))
+ {
+ foreach (int graphic in graphics)
+ {
+ TileData.LandTable[graphic] = TileDataBulkEdit.Apply(TileData.LandTable[graphic], edit);
+ _modifiedLand.Add(graphic);
+ ControlEvents.FireTileDataChangeEvent(this, graphic);
+ }
+ }
+
+ Options.ChangedUltimaClass["TileData"] = true;
+ listViewLand.Invalidate();
+ QueueLandEditorRefresh();
+
+ ReportBulkApply(what, graphics.Length);
+ }
+
+ private bool ConfirmBulkApply(string what, int count)
+ {
+ if (string.IsNullOrEmpty(what))
+ {
+ MessageBox.Show(
+ "Nothing to apply - every box is empty and every flag is left unchanged.",
+ "Apply to selection", MessageBoxButtons.OK, MessageBoxIcon.Information);
+ return false;
+ }
+
+ return MessageBox.Show(
+ $"Apply {what} to {count} entries?",
+ "Apply to selection", MessageBoxButtons.YesNo, MessageBoxIcon.Question,
+ MessageBoxDefaultButton.Button2) == DialogResult.Yes;
+ }
+
+ private void ReportBulkApply(string what, int count)
+ {
+ if (!memorySaveWarningToolStripMenuItem.Checked)
+ {
+ return;
+ }
+
+ MessageBox.Show(
+ $"Applied {what} to {count} entries in memory.\r\n\r\nClick 'Save Tiledata' to write to file.",
+ "Saved", MessageBoxButtons.OK, MessageBoxIcon.Information, MessageBoxDefaultButton.Button1);
+ }
+
+ private void MiscToolStripDropDownButton_DropDownOpening(object sender, EventArgs e)
+ {
+ undoBulkApplyToolStripMenuItem.Enabled = _lastBulkUndo != null;
+ undoBulkApplyToolStripMenuItem.Text = _lastBulkUndo == null
+ ? "Undo last bulk apply"
+ : $"Undo last bulk apply ({_lastBulkUndo.Count} entries)";
+ }
+
+ private void OnClickUndoBulkApply(object sender, EventArgs e)
+ {
+ TileDataBulkUndo undo = _lastBulkUndo;
+ if (undo == null)
+ {
+ return;
+ }
+
+ if (MessageBox.Show(
+ $"Restore {undo.Count} entries to the values they had before '{undo.Description}' was applied?",
+ "Undo bulk apply", MessageBoxButtons.YesNo, MessageBoxIcon.Question,
+ MessageBoxDefaultButton.Button1) != DialogResult.Yes)
+ {
+ return;
+ }
+
+ using (new WaitCursorScope(this))
+ {
+ for (int i = 0; i < undo.Ids.Length; ++i)
+ {
+ int graphic = undo.Ids[i];
+ if (undo.Land)
+ {
+ TileData.LandTable[graphic] = undo.Lands[i];
+ ControlEvents.FireTileDataChangeEvent(this, graphic);
+ }
+ else
+ {
+ TileData.ItemTable[graphic] = undo.Items[i];
+ ControlEvents.FireTileDataChangeEvent(this, graphic + 0x4000);
+ }
+ }
+ }
+
+ // The entries keep their modified marker on purpose: undoing restores what
+ // was in memory before this apply, which is not necessarily what is on disk.
+ _lastBulkUndo = null;
+
+ if (undo.Land)
+ {
+ listViewLand.Invalidate();
+ QueueLandEditorRefresh();
+ }
+ else
+ {
+ listViewItem.Invalidate();
+ QueueItemEditorRefresh();
+ }
+ }
+
private void SaveDirectlyOnChangesToolStripMenuItemOnCheckedChanged(object sender, EventArgs eventArgs)
{
Options.TileDataDirectlySaveOnChange = saveDirectlyOnChangesToolStripMenuItem.Checked;
}
+ // "Save directly on changes" writes on every keystroke, which has no sensible
+ // meaning across a multi-selection - half-typed values would land on every
+ // selected entry. Bulk edits go through Save Changes instead, so the
+ // per-keystroke path only runs while exactly one entry is selected.
+ private bool DirectSaveItemEnabled =>
+ saveDirectlyOnChangesToolStripMenuItem.Checked && listViewItem.SelectedIndices.Count == 1;
+
+ private bool DirectSaveLandEnabled =>
+ saveDirectlyOnChangesToolStripMenuItem.Checked && listViewLand.SelectedIndices.Count == 1;
+
private void OnTextChangedItemAnim(object sender, EventArgs e)
{
- if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
+ if (!DirectSaveItemEnabled)
{
return;
}
@@ -918,7 +1758,7 @@ private void OnTextChangedItemAnim(object sender, EventArgs e)
private void OnTextChangedItemName(object sender, EventArgs e)
{
- if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
+ if (!DirectSaveItemEnabled)
{
return;
}
@@ -956,7 +1796,7 @@ private void OnTextChangedItemName(object sender, EventArgs e)
private void OnTextChangedItemWeight(object sender, EventArgs e)
{
- if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
+ if (!DirectSaveItemEnabled)
{
return;
}
@@ -987,7 +1827,7 @@ private void OnTextChangedItemWeight(object sender, EventArgs e)
private void OnTextChangedItemQuality(object sender, EventArgs e)
{
- if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
+ if (!DirectSaveItemEnabled)
{
return;
}
@@ -1018,7 +1858,7 @@ private void OnTextChangedItemQuality(object sender, EventArgs e)
private void OnTextChangedItemQuantity(object sender, EventArgs e)
{
- if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
+ if (!DirectSaveItemEnabled)
{
return;
}
@@ -1049,7 +1889,7 @@ private void OnTextChangedItemQuantity(object sender, EventArgs e)
private void OnTextChangedItemHue(object sender, EventArgs e)
{
- if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
+ if (!DirectSaveItemEnabled)
{
return;
}
@@ -1080,7 +1920,7 @@ private void OnTextChangedItemHue(object sender, EventArgs e)
private void OnTextChangedItemStackOff(object sender, EventArgs e)
{
- if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
+ if (!DirectSaveItemEnabled)
{
return;
}
@@ -1111,7 +1951,7 @@ private void OnTextChangedItemStackOff(object sender, EventArgs e)
private void OnTextChangedItemValue(object sender, EventArgs e)
{
- if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
+ if (!DirectSaveItemEnabled)
{
return;
}
@@ -1142,7 +1982,7 @@ private void OnTextChangedItemValue(object sender, EventArgs e)
private void OnTextChangedItemHeight(object sender, EventArgs e)
{
- if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
+ if (!DirectSaveItemEnabled)
{
return;
}
@@ -1173,7 +2013,7 @@ private void OnTextChangedItemHeight(object sender, EventArgs e)
private void OnTextChangedItemMiscData(object sender, EventArgs e)
{
- if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
+ if (!DirectSaveItemEnabled)
{
return;
}
@@ -1204,7 +2044,7 @@ private void OnTextChangedItemMiscData(object sender, EventArgs e)
private void OnTextChangedItemUnk2(object sender, EventArgs e)
{
- if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
+ if (!DirectSaveItemEnabled)
{
return;
}
@@ -1235,7 +2075,7 @@ private void OnTextChangedItemUnk2(object sender, EventArgs e)
private void OnTextChangedItemUnk3(object sender, EventArgs e)
{
- if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
+ if (!DirectSaveItemEnabled)
{
return;
}
@@ -1266,7 +2106,7 @@ private void OnTextChangedItemUnk3(object sender, EventArgs e)
private void OnTextChangedLandName(object sender, EventArgs e)
{
- if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
+ if (!DirectSaveLandEnabled)
{
return;
}
@@ -1303,7 +2143,7 @@ private void OnTextChangedLandName(object sender, EventArgs e)
private void OnTextChangedLandTexID(object sender, EventArgs e)
{
- if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
+ if (!DirectSaveLandEnabled)
{
return;
}
@@ -1334,12 +2174,20 @@ private void OnTextChangedLandTexID(object sender, EventArgs e)
private void OnFlagItemCheckItems(object sender, ItemCheckEventArgs e)
{
- if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
+ if (_changingIndex)
{
return;
}
- if (_changingIndex)
+ if (listViewItem.SelectedIndices.Count > 1)
+ {
+ bool wasMixed = _itemMultiBaseline != null
+ && FlagWasMixed(_itemMultiBaseline.SetFlags, _itemMultiBaseline.ClearFlags, e.Index);
+ e.NewValue = NextMultiSelectFlagState(e.CurrentValue, wasMixed);
+ return;
+ }
+
+ if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
{
return;
}
@@ -1390,12 +2238,20 @@ private void OnFlagItemCheckItems(object sender, ItemCheckEventArgs e)
private void OnFlagItemCheckLandTiles(object sender, ItemCheckEventArgs e)
{
- if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
+ if (_changingIndex)
{
return;
}
- if (_changingIndex)
+ if (listViewLand.SelectedIndices.Count > 1)
+ {
+ bool wasMixed = _landMultiBaseline != null
+ && FlagWasMixed(_landMultiBaseline.SetFlags, _landMultiBaseline.ClearFlags, e.Index);
+ e.NewValue = NextMultiSelectFlagState(e.CurrentValue, wasMixed);
+ return;
+ }
+
+ if (!saveDirectlyOnChangesToolStripMenuItem.Checked)
{
return;
}
@@ -1412,33 +2268,13 @@ private void OnFlagItemCheckLandTiles(object sender, ItemCheckEventArgs e)
}
LandData land = TileData.LandTable[index];
- TileFlag changeFlag;
- switch (e.Index)
- {
- case 0:
- changeFlag = TileFlag.Damaging;
- break;
-
- case 1:
- changeFlag = TileFlag.Wet;
- break;
-
- case 2:
- changeFlag = TileFlag.Impassable;
- break;
- case 3:
- changeFlag = TileFlag.Wall;
- break;
-
- case 4:
- changeFlag = TileFlag.NoDiagonal;
- break;
-
- default:
- changeFlag = TileFlag.None;
- break;
- }
+ // The list holds every TileFlag in enum order (index 0 is None and is not
+ // listed), the same mapping OnClickSaveChanges uses. It used to be a
+ // hardcoded switch over five flags, which toggled the wrong bit for the
+ // first five entries and did nothing at all past them.
+ Array enumValues = Enum.GetValues(typeof(TileFlag));
+ var changeFlag = (TileFlag)enumValues.GetValue(e.Index + 1);
if ((land.Flags & changeFlag) != 0)
{
@@ -1616,8 +2452,107 @@ private void SelectInGumpsTabFemaleToolStripMenuItem_Click(object sender, EventA
SelectInGumpsTab(graphic, true);
}
+ // In-process clipboard for tiledata entries, one slot per table so item data can
+ // never land on a land tile. Not the Windows clipboard - there is no sensible
+ // text form of a tiledata entry to hand to other applications.
+ private static ItemData? _copiedItem;
+ private static int _copiedItemGraphic = -1;
+ private static LandData? _copiedLand;
+ private static int _copiedLandGraphic = -1;
+
+ private void OnClickCopyItemTileData(object sender, EventArgs e)
+ {
+ int graphic = GetSelectedItemGraphic();
+ if (graphic < 0)
+ {
+ return;
+ }
+
+ _copiedItem = TileData.ItemTable[graphic];
+ _copiedItemGraphic = graphic;
+ }
+
+ private void OnClickCopyLandTileData(object sender, EventArgs e)
+ {
+ int graphic = GetSelectedLandGraphic();
+ if (graphic < 0)
+ {
+ return;
+ }
+
+ _copiedLand = TileData.LandTable[graphic];
+ _copiedLandGraphic = graphic;
+ }
+
+ private void OnClickPasteSpecialItem(object sender, EventArgs e)
+ {
+ if (_copiedItem == null)
+ {
+ return;
+ }
+
+ int[] graphics = GetSelectedItemGraphics();
+ if (graphics.Length == 0)
+ {
+ return;
+ }
+
+ using (var dialog = new TileDataPasteSpecialForm(_copiedItem.Value, _copiedItemGraphic, graphics.Length))
+ {
+ if (dialog.ShowDialog(this) != DialogResult.OK)
+ {
+ return;
+ }
+
+ ApplyItemEditToSelection(graphics, dialog.BuildItemEdit());
+ }
+ }
+
+ private void OnClickPasteSpecialLand(object sender, EventArgs e)
+ {
+ if (_copiedLand == null)
+ {
+ return;
+ }
+
+ int[] graphics = GetSelectedLandGraphics();
+ if (graphics.Length == 0)
+ {
+ return;
+ }
+
+ using (var dialog = new TileDataPasteSpecialForm(_copiedLand.Value, _copiedLandGraphic, graphics.Length))
+ {
+ if (dialog.ShowDialog(this) != DialogResult.OK)
+ {
+ return;
+ }
+
+ ApplyLandEditToSelection(graphics, dialog.BuildLandEdit());
+ }
+ }
+
+ private void LandTilesContextMenuStrip_Opening(object sender, System.ComponentModel.CancelEventArgs e)
+ {
+ int selectedCount = listViewLand.SelectedIndices.Count;
+
+ copyLandTileDataToolStripMenuItem.Enabled = selectedCount == 1;
+ pasteSpecialLandToolStripMenuItem.Enabled = _copiedLand != null && selectedCount > 0;
+ pasteSpecialLandToolStripMenuItem.Text = selectedCount > 1
+ ? $"Paste special onto {selectedCount}..."
+ : "Paste special...";
+ }
+
private void ItemsContextMenuStrip_Opening(object sender, System.ComponentModel.CancelEventArgs e)
{
+ int selectedCount = listViewItem.SelectedIndices.Count;
+
+ copyItemTileDataToolStripMenuItem.Enabled = selectedCount == 1;
+ pasteSpecialItemToolStripMenuItem.Enabled = _copiedItem != null && selectedCount > 0;
+ pasteSpecialItemToolStripMenuItem.Text = selectedCount > 1
+ ? $"Paste special onto {selectedCount}..."
+ : "Paste special...";
+
int graphic = GetSelectedItemGraphic();
if (graphic <= 0)
{
diff --git a/UoFiddler.Plugin.MassImport/Forms/MassImportForm.cs b/UoFiddler.Plugin.MassImport/Forms/MassImportForm.cs
index 951a7b6f..4176accf 100644
--- a/UoFiddler.Plugin.MassImport/Forms/MassImportForm.cs
+++ b/UoFiddler.Plugin.MassImport/Forms/MassImportForm.cs
@@ -15,6 +15,7 @@
using System.IO;
using System.Windows.Forms;
using System.Xml;
+using Ultima.Uop;
using UoFiddler.Controls.Classes;
using UoFiddler.Controls.Forms;
using UoFiddler.Plugin.MassImport.Imports;
@@ -322,7 +323,7 @@ private void StartOnClick(object sender, EventArgs e)
if (changedUltimaClass["Art"])
{
OutputBox.AppendText($"Saving Items/LandTiles..{Environment.NewLine}");
- Ultima.Art.Save(Options.OutputPath);
+ SaveInChosenFormat(FileType.ArtLegacyMul, Ultima.Art.Save);
}
if (changedUltimaClass["Texture"])
@@ -334,7 +335,7 @@ private void StartOnClick(object sender, EventArgs e)
if (changedUltimaClass["Gumps"])
{
OutputBox.AppendText($"Saving Gumps..{Environment.NewLine}");
- Ultima.Gumps.Save(Options.OutputPath);
+ SaveInChosenFormat(FileType.GumpartLegacyMul, Ultima.Gumps.Save);
}
if (changedUltimaClass["TileData"])
@@ -352,12 +353,41 @@ private void StartOnClick(object sender, EventArgs e)
if (changedUltimaClass["Multis"])
{
OutputBox.AppendText($"Saving Multis..{Environment.NewLine}");
- Ultima.Multis.Save(Options.OutputPath);
+ SaveInChosenFormat(FileType.MultiCollection, Ultima.Multis.Save);
}
OutputBox.AppendText($"Done{Environment.NewLine}");
}
}
}
+
+ ///
+ /// Writes one file type in whatever container the save format option asks for. A batch cannot
+ /// stop on a dialog, so "ask every time" is taken here to mean the format the client already uses.
+ ///
+ private void SaveInChosenFormat(FileType type, Action writeMul)
+ {
+ ContainerFormat format = SaveFormatResolver.Resolve(type);
+
+ try
+ {
+ foreach (string concern in ClientFileSaver.Preflight(type, format))
+ {
+ OutputBox.AppendText($"{concern}{Environment.NewLine}");
+ }
+
+ ClientFileSaveResult result = ClientFileSaver.Save(type, Options.OutputPath, format, writeMul);
+
+ foreach (string warning in result.Warnings)
+ {
+ OutputBox.AppendText($"{warning}{Environment.NewLine}");
+ }
+ }
+ catch (Exception ex)
+ {
+ // Losing one type's output should not abandon the rest of the batch.
+ OutputBox.AppendText($"Could not save {type}: {ex.Message}{Environment.NewLine}");
+ }
+ }
}
}
\ No newline at end of file
diff --git a/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs b/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs
index 2c568e39..111783be 100644
--- a/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs
+++ b/UoFiddler.Plugin.UopPacker/UserControls/UopPackerControl.cs
@@ -18,7 +18,8 @@
using Ultima;
using UoFiddler.Controls.Classes;
using UoFiddler.Controls.Forms;
-using UoFiddler.Plugin.UopPacker.Classes;
+using Ultima.Uop;
+using Ultima.Helpers;
namespace UoFiddler.Plugin.UopPacker.UserControls
{
@@ -116,19 +117,6 @@ private void UpdatePackAllCompressionVisibility()
private const string _batchMultiIdxName = "multi.idx";
- private static (string mul, string idx, string uop) GetConventionalNames(FileType type, int mapIndex)
- {
- return type switch
- {
- FileType.ArtLegacyMul => ("art.mul", "artidx.mul", "artLegacyMUL.uop"),
- FileType.GumpartLegacyMul => ("gumpart.mul", "gumpidx.mul", "gumpartLegacyMUL.uop"),
- FileType.MapLegacyMul => ($"map{mapIndex}.mul", null, $"map{mapIndex}LegacyMUL.uop"),
- FileType.SoundLegacyMul => ("sound.mul", "soundidx.mul", "soundLegacyMUL.uop"),
- FileType.MultiCollection => ("multi.mul", "multi.idx", "MultiCollection.uop"),
- _ => ("", "", "")
- };
- }
-
private void OnMulTypeChanged(object sender, EventArgs e) => RefreshMulTypeUi();
private void OnUopTypeChanged(object sender, EventArgs e) => RefreshUopTypeUi();
@@ -146,21 +134,15 @@ private void RefreshMulTypeUi()
inidx.Enabled = inidxbtn.Enabled = !isMap;
mulMapIndex.Enabled = isMap;
- // Every entry of every shipped MultiCollection.uop is zlib compressed. Packing it uncompressed
- // produces a file several times larger than the original, and Mythic is not a valid compression
- // for this type at all, so the choice is fixed rather than merely defaulted.
- if (isMulti)
- {
- compressionBox.SelectedItem = nameof(CompressionFlag.Zlib);
- }
- else if (type == FileType.ArtLegacyMul || type == FileType.MapLegacyMul || type == FileType.SoundLegacyMul)
+ // What the shipped files use, and whether the type has any other valid choice, is recorded
+ // once in UopFileNames. Gumpart is the one type the client also accepts zlib and Mythic for,
+ // so its selection is left wherever the user put it rather than reset on every type change.
+ if (type != FileType.GumpartLegacyMul)
{
- // Every art, map and sound entry of every shipped client is stored uncompressed, and
- // UOFiddler's own map reader can only address stored entries. Default accordingly.
- compressionBox.SelectedItem = nameof(CompressionFlag.None);
+ compressionBox.SelectedItem = UopFileNames.DefaultCompression(type).ToString();
}
- compressionBox.Enabled = !isMulti;
+ compressionBox.Enabled = !UopFileNames.IsCompressionFixed(type);
inhousingbin.Visible = inhousingbinbtn.Visible = labelHousingBin.Visible = isMulti;
@@ -170,7 +152,7 @@ private void RefreshMulTypeUi()
inidx.Text = string.Empty;
inhousingbin.Text = string.Empty;
- var (mulName, idxName, uopName) = GetConventionalNames(type, (int)mulMapIndex.Value);
+ var (mulName, idxName, uopName) = UopFileNames.For(type, (int)mulMapIndex.Value);
inmul.PlaceholderText = mulName;
inidx.PlaceholderText = idxName ?? string.Empty;
inhousingbin.PlaceholderText = "housing.bin";
@@ -192,7 +174,7 @@ private void RefreshUopTypeUi()
inuop.Text = string.Empty;
- var (mulName, idxName, uopName) = GetConventionalNames(type, (int)uopMapIndex.Value);
+ var (mulName, idxName, uopName) = UopFileNames.For(type, (int)uopMapIndex.Value);
inuop.PlaceholderText = uopName;
// Preview what will be written under the output folder.
@@ -304,7 +286,7 @@ private async void ToUop(object sender, EventArgs e)
}
}
- var (_, _, uopName) = GetConventionalNames(fileType, (int)mulMapIndex.Value);
+ var (_, _, uopName) = UopFileNames.For(fileType, (int)mulMapIndex.Value);
string outUopPath = Path.Combine(outuopfolder.Text, uopName);
string inIdxPath = fileType == FileType.MapLegacyMul ? null : inidx.Text;
@@ -449,7 +431,7 @@ private async void ToMul(object sender, EventArgs e)
}
int mapIdx = (int)uopMapIndex.Value;
- var (mulName, idxName, _) = GetConventionalNames(fileType, mapIdx);
+ var (mulName, idxName, _) = UopFileNames.For(fileType, mapIdx);
string outMulPath = Path.Combine(outfolder.Text, mulName);
string outIdxPath = idxName != null ? Path.Combine(outfolder.Text, idxName) : null;
diff --git a/UoFiddler/Classes/FiddlerOptions.cs b/UoFiddler/Classes/FiddlerOptions.cs
index ee361531..7ac36bec 100644
--- a/UoFiddler/Classes/FiddlerOptions.cs
+++ b/UoFiddler/Classes/FiddlerOptions.cs
@@ -131,6 +131,12 @@ public static void SaveProfile()
elem = dom.CreateElement("CacheData");
elem.SetAttribute("active", Files.CacheData.ToString());
sr.AppendChild(elem);
+ comment = dom.CreateComment(
+ "SaveFormat for art, gumpart, sound, multis and maps: FollowSource, Mul, Uop or Ask");
+ sr.AppendChild(comment);
+ elem = dom.CreateElement("SaveFormat");
+ elem.SetAttribute("value", Options.SaveFormat.ToString());
+ sr.AppendChild(elem);
// + Colors
comment = dom.CreateComment("Focus tile color for tile views");
sr.AppendChild(comment);
@@ -324,6 +330,12 @@ public static void LoadProfile(string filename)
Options.ArtItemClip = bool.Parse(elem.GetAttribute("active"));
}
+ elem = (XmlElement)xOptions.SelectSingleNode("SaveFormat");
+ if (elem != null && Enum.TryParse(elem.GetAttribute("value"), out ClientFileSaveFormat saveFormat))
+ {
+ Options.SaveFormat = saveFormat;
+ }
+
elem = (XmlElement)xOptions.SelectSingleNode("CacheData");
if (elem != null)
{
diff --git a/UoFiddler/Classes/UpdateRunner.cs b/UoFiddler/Classes/UpdateRunner.cs
index 7abb810a..5973a1a5 100644
--- a/UoFiddler/Classes/UpdateRunner.cs
+++ b/UoFiddler/Classes/UpdateRunner.cs
@@ -4,6 +4,7 @@
using System.Windows.Forms;
using Microsoft.Extensions.Logging;
using UoFiddler.Controls.Classes;
+using Ultima.Helpers;
namespace UoFiddler.Classes
{
diff --git a/UoFiddler/FiddlerAppContext.cs b/UoFiddler/FiddlerAppContext.cs
index dc62d5c8..0a8309dd 100644
--- a/UoFiddler/FiddlerAppContext.cs
+++ b/UoFiddler/FiddlerAppContext.cs
@@ -17,6 +17,7 @@
using UoFiddler.Controls.Classes;
using UoFiddler.Controls.UserControls;
using UoFiddler.Forms;
+using Ultima.Helpers;
namespace UoFiddler
{
diff --git a/UoFiddler/Forms/AboutBoxForm.resx b/UoFiddler/Forms/AboutBoxForm.resx
index 3cf41a32..dbab37ce 100644
--- a/UoFiddler/Forms/AboutBoxForm.resx
+++ b/UoFiddler/Forms/AboutBoxForm.resx
@@ -118,7 +118,21 @@
System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
- Version 4.23.0
+ Version 4.24.0
+- Statics defrag rewritten with explicit filters (item id ceiling, out-of-block offsets, invalid z, duplicates, stacked statics), a report naming everything removed, and a Verify button that reads the result back and reconciles it against the source
+- Defrag now runs on a background worker with a working Cancel, and no longer reports success when the source files were missing and nothing was written
+- Map region copy and map diff insert work on UOP-only clients, copy land blocks byte-exactly, and can write either `map{N}.mul` or `map{N}LegacyMUL.uop`
+- Both map dialogs gain a draggable region preview with pan and zoom, so a region is picked on the map instead of typed as numbers
+- A copied region can be shifted in z, with the dialog saying up front how far it can actually move before the signed byte runs out
+- New per-profile save format option: art, gumpart, sound, MultiCollection and maps can be written as mul, as uop, following the loaded client, or chosen at save time
+- Fixed the land block sentinel size, diff patch bounds on TerMur, and facet 5 diff files never being found
+- Fixed textures on older clients throwing from a paint handler: unused idx rows were being read as verdata patches
+- Fixed hued gumps taking the process down on UOP clients
+- Fixed AnimationSequence group records being skipped or walked at the wrong stride, so bodies such as 666 and 1253 now resolve the action aliases the file declares
+- TileData can be edited for many entries at once, with Copy tile data / Paste special and a one-level undo of the last bulk apply
+- Items, Land Tiles, Gumps and Textures copy and paste images through the clipboard, and mark the entries edited since load
+
+Version 4.23.0
- Multi tile flags survive a UOP <> mul round trip: the visibility word maps onto both `multi.mul` int32s instead of one boolean (8207 of 186695 shipped tiles used to lose a bit)
- Per-tile component ids are kept in a `multi-components.txt` sidecar, so repacking no longer strips a boat's tiller man, hatch and planks or a house's doors
- Loading a multi no longer deletes invisible tiles or reorders the tile list (this dropped 122 tiles and reshuffled 119 multis on shipped data)
diff --git a/UoFiddler/Forms/LoadProfileForm.cs b/UoFiddler/Forms/LoadProfileForm.cs
index 295a490f..38e74810 100644
--- a/UoFiddler/Forms/LoadProfileForm.cs
+++ b/UoFiddler/Forms/LoadProfileForm.cs
@@ -15,6 +15,7 @@
using Microsoft.Extensions.Logging;
using UoFiddler.Controls.Classes;
using UoFiddler.Classes;
+using Ultima.Helpers;
namespace UoFiddler.Forms
{
diff --git a/UoFiddler/Forms/ManagePluginsForm.cs b/UoFiddler/Forms/ManagePluginsForm.cs
index a1d2cb39..7738ddea 100644
--- a/UoFiddler/Forms/ManagePluginsForm.cs
+++ b/UoFiddler/Forms/ManagePluginsForm.cs
@@ -15,6 +15,7 @@
using Microsoft.Extensions.Logging;
using UoFiddler.Controls.Classes;
using UoFiddler.Controls.Plugin;
+using Ultima.Helpers;
namespace UoFiddler.Forms
{
diff --git a/UoFiddler/Forms/OptionsForm.Designer.cs b/UoFiddler/Forms/OptionsForm.Designer.cs
index 093a2e35..d5212629 100644
--- a/UoFiddler/Forms/OptionsForm.Designer.cs
+++ b/UoFiddler/Forms/OptionsForm.Designer.cs
@@ -78,6 +78,8 @@ private void InitializeComponent()
button2 = new System.Windows.Forms.Button();
textBoxOutputPath = new System.Windows.Forms.TextBox();
label10 = new System.Windows.Forms.Label();
+ comboBoxSaveFormat = new System.Windows.Forms.ComboBox();
+ labelSaveFormat = new System.Windows.Forms.Label();
ColorsGroupBox = new System.Windows.Forms.GroupBox();
checkboxRemoveTileBorder = new System.Windows.Forms.CheckBox();
RestoreDefaultsButton = new System.Windows.Forms.Button();
@@ -224,7 +226,7 @@ private void InitializeComponent()
//
// buttonApply
//
- buttonApply.Location = new System.Drawing.Point(318, 551);
+ buttonApply.Location = new System.Drawing.Point(318, 586);
buttonApply.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
buttonApply.Name = "buttonApply";
buttonApply.Size = new System.Drawing.Size(88, 27);
@@ -478,14 +480,16 @@ private void InitializeComponent()
groupBox4.Controls.Add(button2);
groupBox4.Controls.Add(textBoxOutputPath);
groupBox4.Controls.Add(label10);
+ groupBox4.Controls.Add(comboBoxSaveFormat);
+ groupBox4.Controls.Add(labelSaveFormat);
groupBox4.Location = new System.Drawing.Point(16, 419);
groupBox4.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
groupBox4.Name = "groupBox4";
groupBox4.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3);
- groupBox4.Size = new System.Drawing.Size(486, 51);
+ groupBox4.Size = new System.Drawing.Size(486, 86);
groupBox4.TabIndex = 6;
groupBox4.TabStop = false;
- groupBox4.Text = "Path";
+ groupBox4.Text = "Output";
//
// button2
//
@@ -516,6 +520,26 @@ private void InitializeComponent()
label10.TabIndex = 0;
label10.Text = "Output Path";
//
+ // comboBoxSaveFormat
+ //
+ comboBoxSaveFormat.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
+ comboBoxSaveFormat.Location = new System.Drawing.Point(87, 51);
+ comboBoxSaveFormat.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
+ comboBoxSaveFormat.Name = "comboBoxSaveFormat";
+ comboBoxSaveFormat.Size = new System.Drawing.Size(383, 23);
+ comboBoxSaveFormat.TabIndex = 4;
+ toolTip1.SetToolTip(comboBoxSaveFormat, "Which container a save writes for the files the client ships as either. Affects art, gumpart, sound, multis and maps; everything else has only one format.");
+ //
+ // labelSaveFormat
+ //
+ labelSaveFormat.AutoSize = true;
+ labelSaveFormat.Location = new System.Drawing.Point(7, 55);
+ labelSaveFormat.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
+ labelSaveFormat.Name = "labelSaveFormat";
+ labelSaveFormat.Size = new System.Drawing.Size(72, 15);
+ labelSaveFormat.TabIndex = 3;
+ labelSaveFormat.Text = "Save Format";
+ //
// ColorsGroupBox
//
ColorsGroupBox.Controls.Add(checkboxRemoveTileBorder);
@@ -598,7 +622,7 @@ private void InitializeComponent()
//
// buttonClose
//
- buttonClose.Location = new System.Drawing.Point(414, 551);
+ buttonClose.Location = new System.Drawing.Point(414, 586);
buttonClose.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
buttonClose.Name = "buttonClose";
buttonClose.Size = new System.Drawing.Size(88, 27);
@@ -612,7 +636,7 @@ private void InitializeComponent()
ExportFilenamesGroupBox.Controls.Add(radioExportFilenameHex);
ExportFilenamesGroupBox.Controls.Add(radioExportFilenameDec);
ExportFilenamesGroupBox.Controls.Add(checkBoxExportFilenameDecPad);
- ExportFilenamesGroupBox.Location = new System.Drawing.Point(16, 476);
+ ExportFilenamesGroupBox.Location = new System.Drawing.Point(16, 511);
ExportFilenamesGroupBox.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
ExportFilenamesGroupBox.Name = "ExportFilenamesGroupBox";
ExportFilenamesGroupBox.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3);
@@ -625,7 +649,7 @@ private void InitializeComponent()
//
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
- ClientSize = new System.Drawing.Size(518, 590);
+ ClientSize = new System.Drawing.Size(518, 625);
Controls.Add(buttonClose);
Controls.Add(ExportFilenamesGroupBox);
Controls.Add(ColorsGroupBox);
@@ -672,6 +696,8 @@ private void InitializeComponent()
private System.Windows.Forms.GroupBox groupBox2;
private System.Windows.Forms.GroupBox groupBox3;
private System.Windows.Forms.GroupBox groupBox4;
+ private System.Windows.Forms.ComboBox comboBoxSaveFormat;
+ private System.Windows.Forms.Label labelSaveFormat;
private System.Windows.Forms.Label label1;
private System.Windows.Forms.Label label10;
private System.Windows.Forms.Label label2;
diff --git a/UoFiddler/Forms/OptionsForm.cs b/UoFiddler/Forms/OptionsForm.cs
index 6c4edb4f..44a68aa2 100644
--- a/UoFiddler/Forms/OptionsForm.cs
+++ b/UoFiddler/Forms/OptionsForm.cs
@@ -90,6 +90,13 @@ public OptionsForm(Action updateAllTileViewsAction,
cmdtext.Text = Options.MapCmd;
argstext.Text = Options.MapArgs;
textBoxOutputPath.Text = Options.OutputPath;
+
+ foreach (ClientFileSaveFormat format in ClientFileSaveFormats.All)
+ {
+ comboBoxSaveFormat.Items.Add(ClientFileSaveFormats.DisplayName(format));
+ }
+
+ comboBoxSaveFormat.SelectedIndex = ClientFileSaveFormats.IndexOf(Options.SaveFormat);
}
private void OnClickApply(object sender, EventArgs e)
@@ -192,6 +199,11 @@ private void OnClickApply(object sender, EventArgs e)
Options.OutputPath = textBoxOutputPath.Text;
}
+ if (comboBoxSaveFormat.SelectedIndex >= 0)
+ {
+ Options.SaveFormat = ClientFileSaveFormats.All[comboBoxSaveFormat.SelectedIndex];
+ }
+
bool newHex = radioExportFilenameHex.Checked;
bool newPad = checkBoxExportFilenameDecPad.Checked;
if (newHex != AppSettings.ExportFilenameInHex || newPad != AppSettings.ExportFilenameDecimalPadded)
diff --git a/UoFiddler/Options_default.xml b/UoFiddler/Options_default.xml
index 571bfa4d..f689a8b3 100644
--- a/UoFiddler/Options_default.xml
+++ b/UoFiddler/Options_default.xml
@@ -6,6 +6,8 @@
+
+
diff --git a/UoFiddler/UoFiddler.csproj b/UoFiddler/UoFiddler.csproj
index ad555456..01b63757 100644
--- a/UoFiddler/UoFiddler.csproj
+++ b/UoFiddler/UoFiddler.csproj
@@ -9,9 +9,9 @@
UoFiddler
UoFiddler
Copyright © 2026
- 4.23.0
- 4.23.0
- 4.23.0
+ 4.24.0
+ 4.24.0
+ 4.24.0
true