diff --git a/Ultima/ASCIIFont.cs b/Ultima/ASCIIFont.cs
index 6b46a83a..0b4c753d 100644
--- a/Ultima/ASCIIFont.cs
+++ b/Ultima/ASCIIFont.cs
@@ -81,6 +81,8 @@ public static unsafe void Initialize()
string path = Files.GetFilePath("fonts.mul");
if (path == null)
{
+ // No fonts in this client: drop the previous client's fonts.
+ System.Array.Clear(Fonts);
return;
}
diff --git a/Ultima/AnimationEdit.cs b/Ultima/AnimationEdit.cs
index a9966dc8..227cbede 100644
--- a/Ultima/AnimationEdit.cs
+++ b/Ultima/AnimationEdit.cs
@@ -285,10 +285,32 @@ public static void LoadFromVD(int fileType, int body, BinaryReader bin)
}
}
+ ///
+ /// Throws before anything is written when a frame of can't be stored.
+ ///
+ /// A frame does not fit its run headers.
+ private static void EnsureFramesFit(AnimIdx anim, string where)
+ {
+ int frame = anim?.FindFrameOutsideRunHeaders() ?? -1;
+ if (frame >= 0)
+ {
+ throw new InvalidOperationException(
+ $"{where}, frame {frame}: pixels lie more than {FrameEdit.MaxCenterDistance} pixels from the frame center, " +
+ "which the animation format can't store. Move the center closer or crop the frame. Nothing was written.");
+ }
+ }
+
public static void ExportToVD(int fileType, int body, string file)
{
AnimIdx[] cache = GetCache(fileType);
GetFileIndex(body, fileType, 0, 0, out FileIndex fileIndex, out int index);
+
+ int bodyLength = Animations.GetAnimLength(body, fileType) * 5;
+ for (int i = 0; i < bodyLength && cache != null && index + i < cache.Length; ++i)
+ {
+ EnsureFramesFit(cache[index + i], $"Body {body}, action {i / 5}, direction {i % 5}");
+ }
+
using (var fs = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.Write))
using (var bin = new BinaryWriter(fs))
{
@@ -357,6 +379,11 @@ public static bool IsActionInRange(int fileType, int body, int action)
///
public static void ExportActionToVD(int fileType, int body, int action, string file)
{
+ for (int dir = 0; dir < 5; ++dir)
+ {
+ EnsureFramesFit(GetAnimation(fileType, body, action, dir), $"Body {body}, action {action}, direction {dir}");
+ }
+
using (var fs = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.Write))
using (var bin = new BinaryWriter(fs))
{
@@ -470,6 +497,12 @@ public static void Save(int fileType, string path)
string idx = Path.Combine(path, filename + ".idx");
string mul = Path.Combine(path, filename + ".mul");
+ // Checked first, so a frame that can't be stored leaves the files untouched.
+ for (int i = 0; cache != null && i < cache.Length; ++i)
+ {
+ EnsureFramesFit(cache[i], $"{filename} entry {i}");
+ }
+
using (var fsidx = new FileStream(idx, FileMode.Create, FileAccess.Write, FileShare.Write))
using (var fsmul = new FileStream(mul, FileMode.Create, FileAccess.Write, FileShare.Write))
using (var binidx = new BinaryWriter(fsidx))
@@ -1052,6 +1085,22 @@ private unsafe void SavePaletteImage(string filename, ImageFormat imageFormat)
}
}
+ ///
+ /// Index of the first frame that does not fit its run headers, or -1 when all of them do.
+ ///
+ public int FindFrameOutsideRunHeaders()
+ {
+ for (int i = 0; Frames != null && i < Frames.Count; ++i)
+ {
+ if (Frames[i]?.FitsRunHeaders == false)
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
public void ReplacePalette(ushort[] palette)
{
Palette = palette;
@@ -1295,8 +1344,57 @@ public unsafe FrameEdit(Bitmap bit, ushort[] palette, int centerX, int centerY)
bit.UnlockBits(bd);
}
+ // A run header stores each offset in 10 bits, biased by 512 (so -512..511 around the center),
+ // and the run length in 12 bits.
+ private const int _maxOffset = 0x3FF;
+ private const int _maxRun = 0xFFF;
+
+ ///
+ /// Most pixels a frame can have on either side of its center, per axis.
+ ///
+ public const int MaxCenterDistance = 512;
+
+ ///
+ /// True when every run fits a run header. A frame that does not would be saved with its
+ /// offsets wrapped into the neighbouring header fields.
+ ///
+ public bool FitsRunHeaders => CanCenterAt(Center.X, Center.Y);
+
+ ///
+ /// True when the frame can be moved to center (, )
+ /// and still fit its run headers.
+ ///
+ public bool CanCenterAt(int x, int y)
+ {
+ if (RawData == null)
+ {
+ return true;
+ }
+
+ int dx = Center.X - x;
+ int dy = Center.Y - y;
+ foreach (Raw raw in RawData)
+ {
+ int offsetX = raw.offsetX + dx;
+ int offsetY = raw.offsetY + dy;
+ if (offsetX < 0 || offsetX > _maxOffset || offsetY < 0 || offsetY > _maxOffset || raw.run < 0 || raw.run > _maxRun)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /// The frame would not fit its run headers at that center.
public void ChangeCenter(int x, int y)
{
+ if (!CanCenterAt(x, y))
+ {
+ throw new ArgumentOutOfRangeException(nameof(x),
+ $"Center {x},{y} puts pixels more than {MaxCenterDistance} pixels from the center, which a frame can't store.");
+ }
+
for (int i = 0; i < RawData.Length; i++)
{
RawData[i].offsetX += Center.X;
diff --git a/Ultima/BodyConverter.cs b/Ultima/BodyConverter.cs
index ecacdb25..2a166bf6 100644
--- a/Ultima/BodyConverter.cs
+++ b/Ultima/BodyConverter.cs
@@ -37,6 +37,9 @@ public static void Initialize()
string path = Files.GetFilePath("bodyconv.def");
if (path == null)
{
+ // No conversions in this client: drop the previous client's tables.
+ Table1 = Table2 = Table3 = Table4 = Table5 = null;
+ _reverse1 = _reverse2 = _reverse3 = _reverse4 = _reverse5 = null;
return;
}
diff --git a/Ultima/FileIndex.cs b/Ultima/FileIndex.cs
index 3604ae68..2b953249 100644
--- a/Ultima/FileIndex.cs
+++ b/Ultima/FileIndex.cs
@@ -311,8 +311,9 @@ public Stream Seek(int index, out int length, out int extra, out bool patched)
if (IsVerdataPatched(e))
{
patched = true;
- Verdata.Seek(e.Lookup);
- return Verdata.Stream;
+
+ // A private copy: the verdata stream is shared by every client file and every thread.
+ return Verdata.ReadPatch(e.Lookup, length);
}
if (e.Length < 0)
@@ -377,8 +378,9 @@ public Stream Seek(int index, ref IEntry entry, out bool patched)
if (IsVerdataPatched(e))
{
patched = true;
- Verdata.Seek(e.Lookup);
- return Verdata.Stream;
+
+ // A private copy: the verdata stream is shared by every client file and every thread.
+ return Verdata.ReadPatch(e.Lookup, length);
}
if (e.Length < 0)
diff --git a/Ultima/Helpers/TileDataHelpers.cs b/Ultima/Helpers/TileDataHelpers.cs
index c19c40ee..934aa6bd 100644
--- a/Ultima/Helpers/TileDataHelpers.cs
+++ b/Ultima/Helpers/TileDataHelpers.cs
@@ -42,6 +42,40 @@ public static string ReadNameString(byte[] buffer, int len)
return Encoding.ASCII.GetString(buffer, 0, count);
}
+ ///
+ /// Decodes a NUL-terminated tiledata name field. Shipped names are ASCII apart from a few in UTF-8
+ /// (item 0xA387 "lion’s_mane_jelly" from 7.0.98.1 on), so valid UTF-8 is read as UTF-8 and
+ /// anything else byte for byte as Latin-1.
+ ///
+ public static string DecodeName(ReadOnlySpan field)
+ {
+ int length = field.IndexOf((byte)0);
+ ReadOnlySpan text = length < 0 ? field : field[..length];
+
+ return System.Text.Unicode.Utf8.IsValid(text) ? Encoding.UTF8.GetString(text) : Encoding.Latin1.GetString(text);
+ }
+
+ ///
+ /// Encodes a name into a NUL-padded field as UTF-8, cut at a character boundary when too long.
+ ///
+ public static void EncodeName(string name, Span field)
+ {
+ field.Clear();
+ if (string.IsNullOrEmpty(name))
+ {
+ return;
+ }
+
+ byte[] bytes = Encoding.UTF8.GetBytes(name);
+ int length = Math.Min(bytes.Length, field.Length);
+ while (length > 0 && length < bytes.Length && (bytes[length] & 0xC0) == 0x80)
+ {
+ --length;
+ }
+
+ bytes.AsSpan(0, length).CopyTo(field);
+ }
+
///
/// Reads a NUL-padded ASCII name from the start of ,
/// up to 20 bytes. Lets callers avoid pinning + Marshal.PtrToStructure.
diff --git a/Ultima/Skills.cs b/Ultima/Skills.cs
index 9278889d..3b41580b 100644
--- a/Ultima/Skills.cs
+++ b/Ultima/Skills.cs
@@ -19,40 +19,41 @@ public static List SkillEntries
return _skillEntries;
}
- _skillEntries = new List();
- for (int i = 0; i < _fileIndex.IndexLength; ++i)
- {
- SkillInfo info = GetSkill(i);
- if (info == null)
- {
- break;
- }
-
- _skillEntries.Add(info);
- }
+ _skillEntries = LoadEntries();
return _skillEntries;
}
set { _skillEntries = value; }
}
///
- /// ReReads skills.mul
+ /// Reads every slot. The list position is the skill id, so an empty slot is kept as a
+ /// placeholder with no name (see ) instead of ending the list.
+ /// Empty slots after the last skill are left out; Save writes them back.
///
- public static void Reload()
+ private static List LoadEntries()
{
- _fileIndex?.Dispose();
- _fileIndex = new FileIndex("skills.idx", "skills.mul", 16);
- _skillEntries = new List();
+ var entries = new List();
for (int i = 0; i < _fileIndex.IndexLength; ++i)
{
- SkillInfo info = GetSkill(i);
- if (info == null)
- {
- break;
- }
+ entries.Add(GetSkill(i) ?? new SkillInfo(i, string.Empty, false, 0));
+ }
- _skillEntries.Add(info);
+ while (entries.Count > 0 && entries[^1].IsEmpty)
+ {
+ entries.RemoveAt(entries.Count - 1);
}
+
+ return entries;
+ }
+
+ ///
+ /// ReReads skills.mul
+ ///
+ public static void Reload()
+ {
+ _fileIndex?.Dispose();
+ _fileIndex = new FileIndex("skills.idx", "skills.mul", 16);
+ _skillEntries = LoadEntries();
}
///
@@ -73,7 +74,8 @@ public static SkillInfo GetSkill(int index)
return null;
}
- using (var bin = new BinaryReader(stream))
+ // leaveOpen: the stream is the index's shared file stream.
+ using (var bin = new BinaryReader(stream, Encoding.Latin1, leaveOpen: true))
{
bool action = bin.ReadBoolean();
string name = ReadNameString(bin, length - 1);
@@ -81,20 +83,19 @@ public static SkillInfo GetSkill(int index)
}
}
- private static readonly byte[] _stringBuffer = new byte[1024];
-
private static string ReadNameString(BinaryReader bin, int length)
{
- bin.Read(_stringBuffer, 0, length);
- int count;
- for (count = 0; count < length && _stringBuffer[count] != 0; ++count)
+ byte[] bytes = bin.ReadBytes(length);
+
+ // The name ends at the first null byte.
+ int count = System.Array.IndexOf(bytes, (byte)0);
+ if (count < 0)
{
- // TODO: this loop is weird
- //;
+ count = bytes.Length;
}
// Names are single bytes; Latin-1 keeps every byte as loaded so saving writes it back unchanged
- return Encoding.Latin1.GetString(_stringBuffer, 0, count);
+ return Encoding.Latin1.GetString(bytes, 0, count);
}
public static void Save(string path)
@@ -112,7 +113,7 @@ public static void Save(string path)
for (int i = 0; i < count; ++i)
{
SkillInfo skill = (i < entries.Count) ? entries[i] : null;
- if (skill == null)
+ if (skill == null || skill.IsEmpty)
{
binidx.Write(-1); // lookup
binidx.Write(0); // length
@@ -156,6 +157,12 @@ public string Name
public int Extra { get; }
+ ///
+ /// An empty slot: a skill without a name is saved as an unused entry.
+ ///
+ [System.ComponentModel.Browsable(false)]
+ public bool IsEmpty => string.IsNullOrEmpty(_name);
+
public SkillInfo(int nr, string name, bool action, int extra)
{
Index = nr;
diff --git a/Ultima/TileData.cs b/Ultima/TileData.cs
index 66d7ba19..89c4ef74 100644
--- a/Ultima/TileData.cs
+++ b/Ultima/TileData.cs
@@ -1,5 +1,6 @@
using System;
using System.Buffers.Binary;
+using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
@@ -21,16 +22,23 @@ public unsafe LandData(NewLandTileDataMul mulStruct)
{
TextureId = mulStruct.texID;
Flags = (TileFlag)mulStruct.flags;
- Name = TileDataHelpers.ReadNameString(mulStruct.name);
+ RawName = new ReadOnlySpan(mulStruct.name, 20).ToArray();
+ Name = RawNameText = TileDataHelpers.DecodeName(RawName);
}
public unsafe LandData(OldLandTileDataMul mulStruct)
{
TextureId = mulStruct.texID;
Flags = (TileFlag)mulStruct.flags;
- Name = TileDataHelpers.ReadNameString(mulStruct.name);
+ RawName = new ReadOnlySpan(mulStruct.name, 20).ToArray();
+ Name = RawNameText = TileDataHelpers.DecodeName(RawName);
}
+ // The name field as loaded and its decoded text. While Name is unchanged, Save writes these
+ // bytes back, so the encoding and any bytes after the NUL survive.
+ internal byte[] RawName;
+ internal string RawNameText;
+
///
/// Gets the name of this land tile.
///
@@ -46,12 +54,26 @@ public unsafe LandData(OldLandTileDataMul mulStruct)
///
public TileFlag Flags { get; set; }
- public void ReadData(string[] split)
+ ///
+ /// Reads a CSV row from .
+ ///
+ /// The row's columns.
+ ///
+ /// Flag columns found from the CSV header (). Null reads the
+ /// flags by position in the column order used before flag 39 was named.
+ ///
+ public void ReadData(string[] split, IReadOnlyDictionary flagColumns = null)
{
int i = 1;
Name = split[i++];
TextureId = (ushort)TileDataHelpers.ConvertStringToInt(split[i++]);
+ if (flagColumns != null)
+ {
+ Flags = TileData.ReadFlagColumns(split, flagColumns, Flags);
+ return;
+ }
+
Flags = 0;
int temp = Convert.ToByte(split[i++]);
if (temp != 0)
@@ -455,7 +477,8 @@ public struct ItemData
{
public unsafe ItemData(NewItemTileDataMul mulStruct)
{
- Name = TileDataHelpers.ReadNameString(mulStruct.name);
+ RawName = new ReadOnlySpan(mulStruct.name, 20).ToArray();
+ Name = RawNameText = TileDataHelpers.DecodeName(RawName);
Flags = (TileFlag)mulStruct.flags;
Weight = mulStruct.weight;
Quality = mulStruct.quality;
@@ -472,7 +495,8 @@ public unsafe ItemData(NewItemTileDataMul mulStruct)
public unsafe ItemData(OldItemTileDataMul mulStruct)
{
- Name = TileDataHelpers.ReadNameString(mulStruct.name);
+ RawName = new ReadOnlySpan(mulStruct.name, 20).ToArray();
+ Name = RawNameText = TileDataHelpers.DecodeName(RawName);
Flags = (TileFlag)mulStruct.flags;
Weight = mulStruct.weight;
Quality = mulStruct.quality;
@@ -487,6 +511,11 @@ public unsafe ItemData(OldItemTileDataMul mulStruct)
Unk3 = mulStruct.unk3;
}
+ // The name field as loaded and its decoded text. While Name is unchanged, Save writes these
+ // bytes back, so the encoding and any bytes after the NUL survive.
+ internal byte[] RawName;
+ internal string RawNameText;
+
///
/// Gets the name of this item.
///
@@ -615,8 +644,28 @@ public bool Wearable
get { return (Flags & TileFlag.Wearable) != 0; }
}
- public void ReadData(string[] split)
+ ///
+ /// Reads a CSV row from .
+ ///
+ /// The row's columns.
+ ///
+ /// Column of the Value field, found from the CSV header; it comes after the flags. -1 for a CSV
+ /// without one, which leaves unchanged.
+ ///
+ ///
+ /// Flag columns found from the CSV header (). Null reads the
+ /// flags by position in the column order used before flag 39 was named.
+ ///
+ public void ReadData(string[] split, int valueColumn = -1, IReadOnlyDictionary flagColumns = null)
{
+ if (valueColumn > 0 && valueColumn < split.Length)
+ {
+ Value = Convert.ToByte(split[valueColumn]);
+
+ // The flags end where the Value column starts.
+ split = split[..valueColumn];
+ }
+
int i = 1;
Name = split[i++];
Weight = Convert.ToByte(split[i++]);
@@ -630,6 +679,12 @@ public void ReadData(string[] split)
Unk2 = Convert.ToByte(split[i++]);
Unk3 = Convert.ToByte(split[i++]);
+ if (flagColumns != null)
+ {
+ Flags = TileData.ReadFlagColumns(split, flagColumns, Flags);
+ return;
+ }
+
Flags = 0;
int temp = Convert.ToByte(split[i++]);
if (temp != 0)
@@ -1193,6 +1248,11 @@ public enum TileFlag : ulong
///
PlayAnimOnce = 0x4000000000,
///
+ /// Read by the client when it builds a missile gump, to pick which animdata.mul field sets the
+ /// frame speed (client decompilation). Set on items from 7.0.65.4 on.
+ ///
+ MissileFrames = 0x8000000000,
+ ///
/// Movable multi? Cool ships and vehicles etc? Something related to the masts ???
///
MultiMovable = 0x10000000000,
@@ -1322,6 +1382,7 @@ public static unsafe void Initialize()
string filePath = Files.GetFilePath("tiledata.mul");
if (filePath == null)
{
+ InitializeEmpty();
return;
}
@@ -1393,6 +1454,29 @@ public static unsafe void Initialize()
}
}
+ ///
+ /// Blank tables for a client without tiledata.mul, so nothing of the previous client's is kept.
+ /// Items cover the art id range, rounded up to the sizes shipped files have (0x4000, 0x8000, 0x10000).
+ ///
+ private static void InitializeEmpty()
+ {
+ _landHeader = new int[0x4000 / 32];
+ LandTable = new LandData[0x4000];
+ for (int i = 0; i < LandTable.Length; ++i)
+ {
+ LandTable[i].Name = string.Empty;
+ }
+
+ int itemLength = Math.Max(0x4000, (Art.GetMaxItemId() + 1 + 0xFFF) & ~0xFFF);
+ _itemHeader = new int[itemLength / 32];
+ ItemTable = new ItemData[itemLength];
+ HeightTable = new int[itemLength];
+ for (int i = 0; i < ItemTable.Length; ++i)
+ {
+ ItemTable[i].Name = string.Empty;
+ }
+ }
+
///
/// Saves and to tiledata.mul
///
@@ -1421,19 +1505,7 @@ public static void SaveTileData(string fileName)
}
bin.Write(LandTable[i].TextureId);
- var b = new byte[20];
- if (LandTable[i].Name != null)
- {
- byte[] bb = Encoding.ASCII.GetBytes(LandTable[i].Name);
- if (bb.Length > 20)
- {
- Array.Resize(ref bb, 20);
- }
-
- bb.CopyTo(b, 0);
- }
-
- bin.Write(b);
+ WriteName(bin, LandTable[i].Name, LandTable[i].RawName, LandTable[i].RawNameText);
}
j = 0;
@@ -1465,24 +1537,27 @@ public static void SaveTileData(string fileName)
bin.Write(ItemTable[i].StackingOffset); // unk4
bin.Write(ItemTable[i].Value); // unk5
bin.Write(ItemTable[i].Height);
-
- var b = new byte[20];
- if (ItemTable[i].Name != null)
- {
- byte[] bb = Encoding.ASCII.GetBytes(ItemTable[i].Name);
- if (bb.Length > 20)
- {
- Array.Resize(ref bb, 20);
- }
-
- bb.CopyTo(b, 0);
- }
-
- bin.Write(b);
+ WriteName(bin, ItemTable[i].Name, ItemTable[i].RawName, ItemTable[i].RawNameText);
}
}
}
+ ///
+ /// Writes the 20-byte name field: the loaded bytes while the name is unchanged, else the name encoded.
+ ///
+ private static void WriteName(BinaryWriter bin, string name, byte[] rawName, string rawNameText)
+ {
+ if (rawName?.Length == 20 && name == rawNameText)
+ {
+ bin.Write(rawName);
+ return;
+ }
+
+ Span field = stackalloc byte[20];
+ TileDataHelpers.EncodeName(name, field);
+ bin.Write(field);
+ }
+
///
/// Exports to csv file
///
@@ -1493,8 +1568,9 @@ public static void ExportItemDataToCsv(string fileName)
{
tex.Write("ID;Name;Weight/Quantity;Layer/Quality;Gump/AnimID;Height;Hue;Class/Quantity;StackingOffset;miscData;Unknown2;Unknown3");
+ // Value goes after the flags, so CSVs and readers that count columns by position still work.
string columnNames = GetTileFlagColumnNames();
- tex.Write($"{columnNames}\r\n");
+ tex.Write($"{columnNames};{ItemValueColumnName}\r\n");
for (int i = 0; i < ItemTable.Length; ++i)
{
@@ -1518,11 +1594,98 @@ public static void ExportItemDataToCsv(string fileName)
{
tex.Write($";{((tile.Flags & (TileFlag)enumValues.GetValue(t)) != 0 ? "1" : "0")}");
}
+ tex.Write($";{tile.Value}");
tex.Write("\r\n");
}
}
}
+ ///
+ /// Opens a tiledata CSV in the encoding the exports write (Windows-1252), unless the file starts
+ /// with a byte order mark, as one saved from a spreadsheet as UTF-8 does.
+ ///
+ public static StreamReader OpenCsv(string fileName)
+ {
+ return new StreamReader(fileName, Encoding.GetEncoding(1252), detectEncodingFromByteOrderMarks: true);
+ }
+
+ ///
+ /// Flag columns of a CSV header row, matched by flag name: column index to flag. Null when the
+ /// header names no flag, so the caller falls back to reading flags by position.
+ ///
+ public static Dictionary FindFlagColumns(string headerLine)
+ {
+ string[] flagNames = Enum.GetNames();
+ TileFlag[] flagValues = Enum.GetValues();
+ var byName = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (int i = 0; i < flagNames.Length; ++i)
+ {
+ if (flagValues[i] != TileFlag.None)
+ {
+ byName[flagNames[i]] = flagValues[i];
+ }
+ }
+
+ var columns = new Dictionary();
+ string[] names = headerLine.Split(';');
+ for (int i = 1; i < names.Length; ++i)
+ {
+ if (byName.TryGetValue(names[i].Trim(), out TileFlag flag))
+ {
+ columns[i] = flag;
+ }
+ }
+
+ return columns.Count > 0 ? columns : null;
+ }
+
+ ///
+ /// Flags of a CSV row read by header. A flag without a column, as in a CSV from before it was
+ /// named, keeps its value from .
+ ///
+ internal static TileFlag ReadFlagColumns(string[] split, IReadOnlyDictionary columns, TileFlag current)
+ {
+ TileFlag covered = TileFlag.None;
+ TileFlag set = TileFlag.None;
+ foreach (KeyValuePair column in columns)
+ {
+ if (column.Key >= split.Length)
+ {
+ continue;
+ }
+
+ covered |= column.Value;
+ if (Convert.ToByte(split[column.Key]) != 0)
+ {
+ set |= column.Value;
+ }
+ }
+
+ return (current & ~covered) | set;
+ }
+
+ ///
+ /// Header name of the item CSV column that holds .
+ ///
+ public const string ItemValueColumnName = "Value";
+
+ ///
+ /// Column of in an item CSV header row, or -1 when it has none.
+ ///
+ public static int FindItemValueColumn(string headerLine)
+ {
+ string[] names = headerLine.Split(';');
+ for (int i = 1; i < names.Length; ++i)
+ {
+ if (string.Equals(names[i].Trim(), ItemValueColumnName, StringComparison.OrdinalIgnoreCase))
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
private static string GetTileFlagColumnNames()
{
string[] enumNames = Enum.GetNames(typeof(TileFlag));
@@ -1579,8 +1742,10 @@ public static void ImportItemDataFromCsv(string fileName)
return;
}
- using (var sr = new StreamReader(fileName))
+ using (StreamReader sr = OpenCsv(fileName))
{
+ int valueColumn = -1;
+ Dictionary flagColumns = null;
while (sr.ReadLine() is { } line)
{
line = line.Trim();
@@ -1592,6 +1757,8 @@ public static void ImportItemDataFromCsv(string fileName)
if (line.StartsWith("ID;"))
{
+ valueColumn = FindItemValueColumn(line);
+ flagColumns = FindFlagColumns(line);
continue;
}
@@ -1609,7 +1776,7 @@ public static void ImportItemDataFromCsv(string fileName)
try
{
- ItemTable[id].ReadData(split);
+ ItemTable[id].ReadData(split, valueColumn, flagColumns);
}
catch
{
@@ -1626,8 +1793,9 @@ public static void ImportLandDataFromCsv(string fileName)
return;
}
- using (var sr = new StreamReader(fileName))
+ using (StreamReader sr = OpenCsv(fileName))
{
+ Dictionary flagColumns = null;
while (sr.ReadLine() is { } line)
{
line = line.Trim();
@@ -1639,6 +1807,7 @@ public static void ImportLandDataFromCsv(string fileName)
if (line.StartsWith("ID;"))
{
+ flagColumns = FindFlagColumns(line);
continue;
}
@@ -1656,7 +1825,7 @@ public static void ImportLandDataFromCsv(string fileName)
try
{
- LandTable[id].ReadData(split);
+ LandTable[id].ReadData(split, flagColumns);
}
catch
{
diff --git a/Ultima/UnicodeFont.cs b/Ultima/UnicodeFont.cs
index 90c327cc..a1e18b7b 100644
--- a/Ultima/UnicodeFont.cs
+++ b/Ultima/UnicodeFont.cs
@@ -187,6 +187,8 @@ public static void Initialize()
string filePath = Files.GetFilePath(_files[i]);
if (filePath == null)
{
+ // Not in this client: drop the previous client's font.
+ Fonts[i] = null;
continue;
}
diff --git a/Ultima/Verdata.cs b/Ultima/Verdata.cs
index 50378360..f9a608af 100644
--- a/Ultima/Verdata.cs
+++ b/Ultima/Verdata.cs
@@ -68,6 +68,34 @@ public static void Initialize()
}
}
+ private static readonly object _readLock = new object();
+
+ ///
+ /// Reads a patch into a stream of its own. Every client file shares the one verdata stream,
+ /// so seek and read are done under a lock and callers never touch the shared position.
+ ///
+ ///
+ /// A short or missing verdata.mul gives a shorter stream; the caller's read then fails the
+ /// same way it did when reading the shared stream directly.
+ ///
+ public static Stream ReadPatch(int lookup, int length)
+ {
+ if (length <= 0)
+ {
+ return new MemoryStream(Array.Empty(), false);
+ }
+
+ var buffer = new byte[length];
+ int read;
+ lock (_readLock)
+ {
+ Seek(lookup);
+ read = Stream.ReadAtLeast(buffer, length, throwOnEndOfStream: false);
+ }
+
+ return new MemoryStream(buffer, 0, read, false);
+ }
+
public static void Seek(int lookup)
{
if ((Stream?.CanRead != true || !Stream.CanSeek) && _path != null)
diff --git a/UoFiddler.Controls/Forms/AnimationEditForm.cs b/UoFiddler.Controls/Forms/AnimationEditForm.cs
index 32f1c150..8b90b5b8 100644
--- a/UoFiddler.Controls/Forms/AnimationEditForm.cs
+++ b/UoFiddler.Controls/Forms/AnimationEditForm.cs
@@ -1075,6 +1075,13 @@ private void OnCenterXValueChanged(object sender, EventArgs e)
return;
}
+ if (!frame.CanCenterAt((int)CenterXNumericUpDown.Value, frame.Center.Y))
+ {
+ ShowCenterOutOfRange((int)CenterXNumericUpDown.Value, frame.Center.Y);
+ CenterXNumericUpDown.Value = frame.Center.X;
+ return;
+ }
+
frame.ChangeCenter((int)CenterXNumericUpDown.Value, frame.Center.Y);
Options.ChangedUltimaClass["Animations"] = true;
AnimationPictureBox.Invalidate();
@@ -1107,6 +1114,13 @@ private void OnCenterYValueChanged(object sender, EventArgs e)
return;
}
+ if (!frame.CanCenterAt(frame.Center.X, (int)CenterYNumericUpDown.Value))
+ {
+ ShowCenterOutOfRange(frame.Center.X, (int)CenterYNumericUpDown.Value);
+ CenterYNumericUpDown.Value = frame.Center.Y;
+ return;
+ }
+
frame.ChangeCenter(frame.Center.X, (int)CenterYNumericUpDown.Value);
Options.ChangedUltimaClass["Animations"] = true;
AnimationPictureBox.Invalidate();
@@ -1329,7 +1343,11 @@ private void OnClickSave(object sender, EventArgs e)
return;
}
- AnimationEdit.Save(_fileType, Options.OutputPath);
+ if (!TryRunAnimationWrite(() => AnimationEdit.Save(_fileType, Options.OutputPath), "Save"))
+ {
+ return;
+ }
+
Options.ChangedUltimaClass["Animations"] = false;
MessageBox.Show($"AnimationFile saved to {Options.OutputPath}", "Saved", MessageBoxButtons.OK,
@@ -1785,7 +1803,10 @@ private void OnClickExportToVD(object sender, EventArgs e)
string path = Options.OutputPath;
string fileName = Path.Combine(path, $"anim{_fileType}_{Utils.FormatExportId(_currentBody)}.vd");
- AnimationEdit.ExportToVD(_fileType, _currentBody, fileName);
+ if (!TryRunAnimationWrite(() => AnimationEdit.ExportToVD(_fileType, _currentBody, fileName), "Export"))
+ {
+ return;
+ }
FileSavedDialog.Show(FindForm(), Options.OutputPath, "Animation saved successfully.");
}
@@ -1821,7 +1842,10 @@ private void OnClickExportActionToVD(object sender, EventArgs e)
}
string fileName = Path.Combine(Options.OutputPath, $"anim{_fileType}_{Utils.FormatExportId(body)}_action{action}.vd");
- AnimationEdit.ExportActionToVD(_fileType, body, action, fileName);
+ if (!TryRunAnimationWrite(() => AnimationEdit.ExportActionToVD(_fileType, body, action, fileName), "Export"))
+ {
+ return;
+ }
FileSavedDialog.Show(FindForm(), Options.OutputPath, "Action saved successfully.");
}
@@ -1987,6 +2011,14 @@ private void SameCenterButton_Click(object sender, EventArgs e)
return;
}
+ int centerX = (int)CenterXNumericUpDown.Value;
+ int centerY = (int)CenterYNumericUpDown.Value;
+ if (edit.Frames.Any(f => !f.CanCenterAt(centerX, centerY)))
+ {
+ ShowCenterOutOfRange(centerX, centerY);
+ return;
+ }
+
FrameEdit[] frame = new FrameEdit[edit.Frames.Count];
for (int index = 0; index < edit.Frames.Count; index++)
{
@@ -3123,7 +3155,10 @@ private void OnClickExportAllToVD(object sender, EventArgs e)
}
string fileName = Path.Combine(dialog.SelectedPath, $"anim{_fileType}_{Utils.FormatExportId(index)}.vd");
- AnimationEdit.ExportToVD(_fileType, index, fileName);
+ if (!TryRunAnimationWrite(() => AnimationEdit.ExportToVD(_fileType, index, fileName), "Export"))
+ {
+ return;
+ }
}
MessageBox.Show($"All Animations saved to {dialog.SelectedPath}",
@@ -3180,6 +3215,7 @@ private void SetButton_Click(object sender, EventArgs e)
{
DirectionTrackBar.Value = 0;
DirectionTrackBar.Enabled = false;
+ var outOfRange = new List();
for (int i = 0; i <= DirectionTrackBar.Maximum; i++)
{
try
@@ -3187,7 +3223,13 @@ private void SetButton_Click(object sender, EventArgs e)
if (_fileType != 0)
{
AnimIdx edit = AnimationEdit.GetAnimation(_fileType, _currentBody, _currentAction, _currentDir);
- if (edit != null && edit.Frames.Count >= FramesTrackBar.Value)
+ if (edit != null && edit.Frames.Count >= FramesTrackBar.Value &&
+ edit.Frames.Any(f => !f.CanCenterAt(_animCx[i], _animCy[i])))
+ {
+ // Left as it was: the saved center can't hold this direction's frames.
+ outOfRange.Add(i);
+ }
+ else if (edit != null && edit.Frames.Count >= FramesTrackBar.Value)
{
foreach (var editFrame in edit.Frames)
{
@@ -3215,6 +3257,36 @@ private void SetButton_Click(object sender, EventArgs e)
}
}
DirectionTrackBar.Enabled = true;
+
+ if (outOfRange.Count > 0)
+ {
+ MessageBox.Show(
+ $"Directions {string.Join(", ", outOfRange)} were left unchanged: their saved center would put pixels " +
+ $"more than {FrameEdit.MaxCenterDistance} pixels from the center, which the animation format can't store.",
+ "Center", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ }
+ }
+
+ private static void ShowCenterOutOfRange(int x, int y)
+ {
+ MessageBox.Show(
+ $"Center {x},{y} would put pixels more than {FrameEdit.MaxCenterDistance} pixels from the center, " +
+ "which the animation format can't store.",
+ "Center", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ }
+
+ private static bool TryRunAnimationWrite(Action write, string title)
+ {
+ try
+ {
+ write();
+ return true;
+ }
+ catch (InvalidOperationException ex)
+ {
+ MessageBox.Show(ex.Message, title, MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return false;
+ }
}
// Add Directions with Canvas ( CV5 style GIF )
diff --git a/UoFiddler.Controls/Forms/TileDataHelpForm.cs b/UoFiddler.Controls/Forms/TileDataHelpForm.cs
index dc4bbf60..b41f3eda 100644
--- a/UoFiddler.Controls/Forms/TileDataHelpForm.cs
+++ b/UoFiddler.Controls/Forms/TileDataHelpForm.cs
@@ -143,6 +143,7 @@ private void PopulateFields()
Add("NoShadow", "Disallow shadow on this tile, light source? lava?", "flags");
Add("PixelBleed", "Let pixels bleed in to other tiles? Is this Disabling Texture Clamp?", "flags");
Add("PlayAnimOnce", "Play tile animation once.", "flags");
+ Add("MissileFrames", "Read when the client builds a missile gump, to pick which animdata field sets the frame speed.", "flags");
Add("MultiMovable", "Movable multi? Cool ships and vehicles etc? Something related to the masts.", "flags");
Add("Unused10", "Unused or unknown yet.", "flags");
Add("Unused11", "Unused or unknown yet.", "flags");
diff --git a/UoFiddler.Controls/UserControls/TileDataControl.cs b/UoFiddler.Controls/UserControls/TileDataControl.cs
index dfbba2c5..dfb5b521 100644
--- a/UoFiddler.Controls/UserControls/TileDataControl.cs
+++ b/UoFiddler.Controls/UserControls/TileDataControl.cs
@@ -1323,15 +1323,7 @@ private void OnClickSaveChanges(object sender, EventArgs e)
item.Unk3 = byteRes;
}
- item.Flags = TileFlag.None;
- Array enumValues = Enum.GetValues(typeof(TileFlag));
- for (int i = 0; i < checkedListBox1.Items.Count; ++i)
- {
- if (checkedListBox1.GetItemChecked(i))
- {
- item.Flags |= (TileFlag)enumValues.GetValue(i + 1);
- }
- }
+ item.Flags = ApplyCheckedFlags(item.Flags, checkedListBox1);
TileData.ItemTable[index] = item;
MarkItemModified(index);
@@ -1372,15 +1364,7 @@ private void OnClickSaveChanges(object sender, EventArgs e)
land.TextureId = shortRes;
}
- land.Flags = TileFlag.None;
- Array enumValues = Enum.GetValues(typeof(TileFlag));
- for (int i = 0; i < checkedListBox2.Items.Count; ++i)
- {
- if (checkedListBox2.GetItemChecked(i))
- {
- land.Flags |= (TileFlag)enumValues.GetValue(i + 1);
- }
- }
+ land.Flags = ApplyCheckedFlags(land.Flags, checkedListBox2);
TileData.LandTable[index] = land;
Options.ChangedUltimaClass["TileData"] = true;
@@ -1443,6 +1427,16 @@ private LandDataEdit BuildLandEditFromEditor()
return edit;
}
+ ///
+ /// The flags a single-tile save writes: what the list shows, as checked, and every bit the list
+ /// doesn't show (high bits on pre-HS clients, unnamed bits) as it was.
+ ///
+ private static TileFlag ApplyCheckedFlags(TileFlag current, CheckedListBox checkedListBox)
+ {
+ ReadFlagMasks(checkedListBox, out TileFlag setFlags, out TileFlag clearFlags);
+ return (current & ~(setFlags | clearFlags)) | setFlags;
+ }
+
private static void ReadFlagMasks(CheckedListBox checkedListBox, out TileFlag setFlags, out TileFlag clearFlags)
{
setFlags = TileFlag.None;
diff --git a/UoFiddler.Plugin.Compare/UserControls/CompareTileDataControl.cs b/UoFiddler.Plugin.Compare/UserControls/CompareTileDataControl.cs
index f2d92ca4..ffe815b9 100644
--- a/UoFiddler.Plugin.Compare/UserControls/CompareTileDataControl.cs
+++ b/UoFiddler.Plugin.Compare/UserControls/CompareTileDataControl.cs
@@ -75,7 +75,7 @@ private static readonly (string Name, TileFlag Flag)[] MeaningfulFlags = WithOth
("PlayAnimOnce", TileFlag.PlayAnimOnce),
("MultiMovable", TileFlag.MultiMovable),
("Unused8", TileFlag.Unused8),
- ("Bit 39", (TileFlag)0x8000000000),
+ ("MissileFrames", TileFlag.MissileFrames),
});
private static (string Name, TileFlag Flag)[] WithOtherBits((string Name, TileFlag Flag)[] named)
diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs
index e01772f4..e8a9dd6a 100644
--- a/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs
+++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs
@@ -82,11 +82,25 @@ public string Test()
return message;
}
+ ///
+ /// Column of the item Value field in the CSV last read by , or -1
+ /// when its header has none.
+ ///
+ protected int ItemValueColumn { get; private set; } = -1;
+
+ ///
+ /// Flag columns named in the header of the CSV last read by , or
+ /// null when it has no header, so flags are read by position.
+ ///
+ protected Dictionary FlagColumns { get; private set; }
+
protected bool GetTileDataInfo(string fileName, ref string message, ref string[] tiledata)
{
+ ItemValueColumn = -1;
+ FlagColumns = null;
try
{
- using (StreamReader sr = new StreamReader(fileName))
+ using (StreamReader sr = Ultima.TileData.OpenCsv(fileName))
{
while (sr.ReadLine() is { } line)
{
@@ -99,6 +113,8 @@ protected bool GetTileDataInfo(string fileName, ref string message, ref string[]
if (line.StartsWith("ID;"))
{
+ ItemValueColumn = Ultima.TileData.FindItemValueColumn(line);
+ FlagColumns = Ultima.TileData.FindFlagColumns(line);
continue;
}
diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataItem.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataItem.cs
index a85e3a7f..4db70f32 100644
--- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataItem.cs
+++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataItem.cs
@@ -45,7 +45,7 @@ protected override void TestFile(ref string message)
public override void Import(bool direct, ref Dictionary changedClasses)
{
- Ultima.TileData.ItemTable[Index].ReadData(_tiledata);
+ Ultima.TileData.ItemTable[Index].ReadData(_tiledata, ItemValueColumn, FlagColumns);
if (!direct)
{
Options.ChangedUltimaClass["TileData"] = true;
diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataLand.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataLand.cs
index 24434f98..efdbc9af 100644
--- a/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataLand.cs
+++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataLand.cs
@@ -43,7 +43,7 @@ protected override void TestFile(ref string message)
public override void Import(bool direct, ref Dictionary changedClasses)
{
- Ultima.TileData.LandTable[Index].ReadData(_tiledata);
+ Ultima.TileData.LandTable[Index].ReadData(_tiledata, FlagColumns);
if (!direct)
{
Options.ChangedUltimaClass["TileData"] = true;