From adccb19bf83af4c2a814e0000c249c6ebdae5c9c Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:08:12 +0100 Subject: [PATCH 1/7] Drop the previous client's fonts, body conversions and tiledata when a file is missing --- Ultima/ASCIIFont.cs | 2 ++ Ultima/BodyConverter.cs | 3 +++ Ultima/TileData.cs | 24 ++++++++++++++++++++++++ Ultima/UnicodeFont.cs | 2 ++ 4 files changed, 31 insertions(+) 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/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/TileData.cs b/Ultima/TileData.cs index 66d7ba19..db336417 100644 --- a/Ultima/TileData.cs +++ b/Ultima/TileData.cs @@ -1322,6 +1322,7 @@ public static unsafe void Initialize() string filePath = Files.GetFilePath("tiledata.mul"); if (filePath == null) { + InitializeEmpty(); return; } @@ -1393,6 +1394,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 /// 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; } From 9178f4364fa7b99e3c17c6bd1ec3521c3e92a269 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:08:53 +0100 Subject: [PATCH 2/7] Read verdata patches into their own stream under a lock --- Ultima/FileIndex.cs | 10 ++++++---- Ultima/Verdata.cs | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) 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/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) From e9b898a9b4efc5ccdd4b1f76b49fe745f8c771fc Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:10:19 +0100 Subject: [PATCH 3/7] Reject animation frame centers that the run headers can't store Save and .vd export check every frame first and write nothing when one doesn't fit. --- Ultima/AnimationEdit.cs | 98 +++++++++++++++++++ UoFiddler.Controls/Forms/AnimationEditForm.cs | 82 +++++++++++++++- 2 files changed, 175 insertions(+), 5 deletions(-) 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/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 ) From 8b3772810ac3a56f1080e3b1dd5657c9a076287a Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:10:53 +0100 Subject: [PATCH 4/7] Keep empty skill slots when loading skills An empty slot loads as a skill with no name and is saved back as an unused entry. --- Ultima/Skills.cs | 71 ++++++++++++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 32 deletions(-) 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; From 060bcc16bb9be99c5c62b4282ec4f13fa7148b9c Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:16:00 +0100 Subject: [PATCH 5/7] Export and import the tiledata item Value in CSV Value is a last column found by header name, so older CSVs still import and leave it unchanged. --- Ultima/TileData.cs | 48 +++++++++++++++++-- .../Imports/ImportEntry.cs | 8 ++++ .../Imports/ImportEntryTileDataItem.cs | 2 +- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/Ultima/TileData.cs b/Ultima/TileData.cs index db336417..c4f03dea 100644 --- a/Ultima/TileData.cs +++ b/Ultima/TileData.cs @@ -615,8 +615,24 @@ 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. + /// + public void ReadData(string[] split, int valueColumn = -1) { + 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++]); @@ -1517,8 +1533,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) { @@ -1542,11 +1559,34 @@ 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"); } } } + /// + /// 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)); @@ -1605,6 +1645,7 @@ public static void ImportItemDataFromCsv(string fileName) using (var sr = new StreamReader(fileName)) { + int valueColumn = -1; while (sr.ReadLine() is { } line) { line = line.Trim(); @@ -1616,6 +1657,7 @@ public static void ImportItemDataFromCsv(string fileName) if (line.StartsWith("ID;")) { + valueColumn = FindItemValueColumn(line); continue; } @@ -1633,7 +1675,7 @@ public static void ImportItemDataFromCsv(string fileName) try { - ItemTable[id].ReadData(split); + ItemTable[id].ReadData(split, valueColumn); } catch { diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs index e01772f4..6d22e1d2 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs @@ -82,8 +82,15 @@ 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; + protected bool GetTileDataInfo(string fileName, ref string message, ref string[] tiledata) { + ItemValueColumn = -1; try { using (StreamReader sr = new StreamReader(fileName)) @@ -99,6 +106,7 @@ protected bool GetTileDataInfo(string fileName, ref string message, ref string[] if (line.StartsWith("ID;")) { + ItemValueColumn = Ultima.TileData.FindItemValueColumn(line); continue; } diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataItem.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntryTileDataItem.cs index a85e3a7f..b2de61cb 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); if (!direct) { Options.ChangedUltimaClass["TileData"] = true; From 57ce7f08254fb7470782569a00f397b7944aad00 Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:46:34 +0100 Subject: [PATCH 6/7] Keep tiledata names byte for byte on save An unchanged name writes back the bytes it was loaded from; names display as UTF-8, and CSVs are read as Windows-1252 like the export writes them. --- Ultima/Helpers/TileDataHelpers.cs | 34 ++++++++ Ultima/TileData.cs | 80 +++++++++++-------- .../Imports/ImportEntry.cs | 2 +- 3 files changed, 82 insertions(+), 34 deletions(-) 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/TileData.cs b/Ultima/TileData.cs index c4f03dea..5a755c78 100644 --- a/Ultima/TileData.cs +++ b/Ultima/TileData.cs @@ -21,16 +21,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. /// @@ -455,7 +462,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 +480,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 +496,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. /// @@ -1461,19 +1475,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; @@ -1505,24 +1507,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 /// @@ -1565,6 +1570,15 @@ public static void ExportItemDataToCsv(string fileName) } } + /// + /// 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); + } + /// /// Header name of the item CSV column that holds . /// @@ -1643,7 +1657,7 @@ public static void ImportItemDataFromCsv(string fileName) return; } - using (var sr = new StreamReader(fileName)) + using (StreamReader sr = OpenCsv(fileName)) { int valueColumn = -1; while (sr.ReadLine() is { } line) @@ -1692,7 +1706,7 @@ public static void ImportLandDataFromCsv(string fileName) return; } - using (var sr = new StreamReader(fileName)) + using (StreamReader sr = OpenCsv(fileName)) { while (sr.ReadLine() is { } line) { diff --git a/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs b/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs index 6d22e1d2..544111b2 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs @@ -93,7 +93,7 @@ protected bool GetTileDataInfo(string fileName, ref string message, ref string[] ItemValueColumn = -1; try { - using (StreamReader sr = new StreamReader(fileName)) + using (StreamReader sr = Ultima.TileData.OpenCsv(fileName)) { while (sr.ReadLine() is { } line) { From c16e4b9f8c2d777122c4356a174c75d81f17a50a Mon Sep 17 00:00:00 2001 From: AsY!um- <377468+AsYlum-@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:49:23 +0100 Subject: [PATCH 7/7] Name tiledata flag bit 39 MissileFrames and keep it on edits CSV imports match flag columns by header name, and a single-tile save keeps the bits its list doesn't show. --- Ultima/TileData.cs | 97 ++++++++++++++++++- UoFiddler.Controls/Forms/TileDataHelpForm.cs | 1 + .../UserControls/TileDataControl.cs | 30 +++--- .../UserControls/CompareTileDataControl.cs | 2 +- .../Imports/ImportEntry.cs | 8 ++ .../Imports/ImportEntryTileDataItem.cs | 2 +- .../Imports/ImportEntryTileDataLand.cs | 2 +- 7 files changed, 117 insertions(+), 25 deletions(-) diff --git a/Ultima/TileData.cs b/Ultima/TileData.cs index 5a755c78..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; @@ -53,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) @@ -637,7 +652,11 @@ public bool Wearable /// Column of the Value field, found from the CSV header; it comes after the flags. -1 for a CSV /// without one, which leaves unchanged. /// - public void ReadData(string[] split, int valueColumn = -1) + /// + /// 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) { @@ -660,6 +679,12 @@ public void ReadData(string[] split, int valueColumn = -1) 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) @@ -1223,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, @@ -1579,6 +1609,61 @@ 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 . /// @@ -1660,6 +1745,7 @@ public static void ImportItemDataFromCsv(string fileName) using (StreamReader sr = OpenCsv(fileName)) { int valueColumn = -1; + Dictionary flagColumns = null; while (sr.ReadLine() is { } line) { line = line.Trim(); @@ -1672,6 +1758,7 @@ public static void ImportItemDataFromCsv(string fileName) if (line.StartsWith("ID;")) { valueColumn = FindItemValueColumn(line); + flagColumns = FindFlagColumns(line); continue; } @@ -1689,7 +1776,7 @@ public static void ImportItemDataFromCsv(string fileName) try { - ItemTable[id].ReadData(split, valueColumn); + ItemTable[id].ReadData(split, valueColumn, flagColumns); } catch { @@ -1708,6 +1795,7 @@ public static void ImportLandDataFromCsv(string fileName) using (StreamReader sr = OpenCsv(fileName)) { + Dictionary flagColumns = null; while (sr.ReadLine() is { } line) { line = line.Trim(); @@ -1719,6 +1807,7 @@ public static void ImportLandDataFromCsv(string fileName) if (line.StartsWith("ID;")) { + flagColumns = FindFlagColumns(line); continue; } @@ -1736,7 +1825,7 @@ public static void ImportLandDataFromCsv(string fileName) try { - LandTable[id].ReadData(split); + LandTable[id].ReadData(split, flagColumns); } catch { 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 544111b2..e8a9dd6a 100644 --- a/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs +++ b/UoFiddler.Plugin.MassImport/Imports/ImportEntry.cs @@ -88,9 +88,16 @@ public string Test() /// 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 = Ultima.TileData.OpenCsv(fileName)) @@ -107,6 +114,7 @@ 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 b2de61cb..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, ItemValueColumn); + 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;