From 128e75f53be6df6423eea20a87c503af2b4b3384 Mon Sep 17 00:00:00 2001
From: Moshu <1709219+DatMoshu@users.noreply.github.com>
Date: Thu, 12 Nov 2026 02:02:01 +0100
Subject: [PATCH 1/5] Pack animation frames into a sprite sheet with a Json
descriptor
Ported from #143. Png sheet plus per-frame rect and center, with a guide image; directions 0-4 only.
---
UoFiddler.Controls/Classes/PackedSheet.cs | 78 ++++++
.../Classes/SpriteSheetPacker.cs | 255 ++++++++++++++++++
.../Forms/PackOptionsForm.Designer.cs | 219 +++++++++++++++
UoFiddler.Controls/Forms/PackOptionsForm.cs | 85 ++++++
UoFiddler.Controls/Forms/PackOptionsForm.resx | 61 +++++
.../AnimationListControl.Designer.cs | 19 +-
.../UserControls/AnimationListControl.cs | 245 +++++++++++++++--
7 files changed, 939 insertions(+), 23 deletions(-)
create mode 100644 UoFiddler.Controls/Classes/PackedSheet.cs
create mode 100644 UoFiddler.Controls/Classes/SpriteSheetPacker.cs
create mode 100644 UoFiddler.Controls/Forms/PackOptionsForm.Designer.cs
create mode 100644 UoFiddler.Controls/Forms/PackOptionsForm.cs
create mode 100644 UoFiddler.Controls/Forms/PackOptionsForm.resx
diff --git a/UoFiddler.Controls/Classes/PackedSheet.cs b/UoFiddler.Controls/Classes/PackedSheet.cs
new file mode 100644
index 00000000..9ad89ebf
--- /dev/null
+++ b/UoFiddler.Controls/Classes/PackedSheet.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;
+using System.Text.Json.Serialization;
+
+namespace UoFiddler.Controls.Classes
+{
+ ///
+ /// JSON descriptor of a packed animation sprite sheet: where each frame of each direction sits on
+ /// the sheet image and the frame's center.
+ ///
+ public class PackedAnimation
+ {
+ [JsonPropertyName("meta")] public PackedMeta Meta { get; set; }
+ [JsonPropertyName("frames")] public List Frames { get; set; }
+ }
+
+ ///
+ /// JSON descriptor of a packed item (static art) sprite sheet.
+ ///
+ public class PackedItems
+ {
+ [JsonPropertyName("meta")] public PackedMeta Meta { get; set; }
+ [JsonPropertyName("items")] public List Items { get; set; }
+ }
+
+ public class PackedMeta
+ {
+ /// Sheet image file name, next to the JSON file.
+ [JsonPropertyName("image")] public string Image { get; set; }
+ [JsonPropertyName("size")] public PackedSize Size { get; set; }
+ [JsonPropertyName("format")] public string Format { get; set; }
+ }
+
+ public class PackedFrameEntry
+ {
+ [JsonPropertyName("direction")] public int Direction { get; set; }
+ [JsonPropertyName("index")] public int Index { get; set; }
+ [JsonPropertyName("frame")] public PackedRect Frame { get; set; }
+ [JsonPropertyName("center")] public PackedPoint Center { get; set; }
+ }
+
+ public class PackedItemEntry
+ {
+ /// Item (static art) id.
+ [JsonPropertyName("index")] public int Index { get; set; }
+ [JsonPropertyName("frame")] public PackedRect Frame { get; set; }
+ }
+
+ public class PackedRect
+ {
+ [JsonPropertyName("x")] public int X { get; set; }
+ [JsonPropertyName("y")] public int Y { get; set; }
+ [JsonPropertyName("w")] public int W { get; set; }
+ [JsonPropertyName("h")] public int H { get; set; }
+ }
+
+ public class PackedPoint
+ {
+ [JsonPropertyName("x")] public int X { get; set; }
+ [JsonPropertyName("y")] public int Y { get; set; }
+ }
+
+ public class PackedSize
+ {
+ [JsonPropertyName("w")] public int W { get; set; }
+ [JsonPropertyName("h")] public int H { get; set; }
+ }
+}
diff --git a/UoFiddler.Controls/Classes/SpriteSheetPacker.cs b/UoFiddler.Controls/Classes/SpriteSheetPacker.cs
new file mode 100644
index 00000000..9d7aa297
--- /dev/null
+++ b/UoFiddler.Controls/Classes/SpriteSheetPacker.cs
@@ -0,0 +1,255 @@
+/***************************************************************************
+ *
+ * $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.Drawing;
+using System.Drawing.Imaging;
+using System.IO;
+using System.Text.Json;
+
+namespace UoFiddler.Controls.Classes
+{
+ ///
+ /// Places images left to right in rows on a sprite sheet, starting a new row when the next image
+ /// would pass the maximum width. Every image is surrounded by spacing pixels.
+ ///
+ public sealed class SpriteSheetLayout
+ {
+ private readonly int _maxWidth;
+ private readonly int _spacing;
+ private int _x;
+ private int _y;
+ private int _rowHeight;
+
+ public SpriteSheetLayout(int maxWidth, int spacing)
+ {
+ _maxWidth = maxWidth;
+ _spacing = Math.Max(0, spacing);
+ _x = _spacing;
+ _y = _spacing;
+ }
+
+ public int Width { get; private set; }
+
+ public int Height { get; private set; }
+
+ ///
+ /// Starts a new row unless the current one is still empty.
+ ///
+ public void NewRow()
+ {
+ if (_x <= _spacing)
+ {
+ return;
+ }
+
+ _y += _rowHeight + _spacing;
+ _x = _spacing;
+ _rowHeight = 0;
+ }
+
+ /// Start a new row when the image would pass the maximum width.
+ public PackedRect Place(int width, int height, bool wrap = true)
+ {
+ // An image wider than the sheet still goes into an empty row rather than leaving a blank one
+ if (wrap && _x > _spacing && _x + width > _maxWidth)
+ {
+ NewRow();
+ }
+
+ var rect = new PackedRect { X = _x, Y = _y, W = width, H = height };
+
+ _rowHeight = Math.Max(_rowHeight, height);
+ _x += width + _spacing;
+
+ Width = Math.Max(Width, _x);
+ Height = Math.Max(Height, _y + _rowHeight + _spacing);
+
+ return rect;
+ }
+ }
+
+ ///
+ /// Shared sprite sheet work for packed animation and item exports: composing and reading the
+ /// sheet image, the guide image and the JSON descriptor.
+ ///
+ public static class SpriteSheetPacker
+ {
+ public const string ImageFormatName = "RGBA8888";
+
+ private static readonly JsonSerializerOptions _writeOptions = new JsonSerializerOptions { WriteIndented = true };
+
+ // Case-insensitive so item sheets written by the original PR (PascalCase names) still load
+ private static readonly JsonSerializerOptions _readOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
+
+ ///
+ /// Draws onto a transparent sheet at .
+ ///
+ public static Bitmap Compose(int width, int height, IReadOnlyList images, IReadOnlyList rects)
+ {
+ var sheet = new Bitmap(Math.Max(1, width), Math.Max(1, height), PixelFormat.Format32bppArgb);
+ using (var g = Graphics.FromImage(sheet))
+ {
+ g.Clear(Color.Transparent);
+ for (int i = 0; i < images.Count; ++i)
+ {
+ PackedRect rect = rects[i];
+ g.DrawImage(images[i], new Rectangle(rect.X, rect.Y, rect.W, rect.H));
+ }
+ }
+
+ return sheet;
+ }
+
+ public static PackedMeta CreateMeta(string imageFile, Bitmap sheet)
+ {
+ return new PackedMeta
+ {
+ Image = Path.GetFileName(imageFile),
+ Size = new PackedSize { W = sheet.Width, H = sheet.Height },
+ Format = ImageFormatName
+ };
+ }
+
+ public static void WriteJson(string fileName, T value)
+ {
+ File.WriteAllText(fileName, JsonSerializer.Serialize(value, _writeOptions));
+ }
+
+ public static T ReadJson(string fileName) where T : class
+ {
+ T value;
+ try
+ {
+ value = JsonSerializer.Deserialize(File.ReadAllText(fileName), _readOptions);
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidDataException($"{Path.GetFileName(fileName)} is not a valid packed sheet: {ex.Message}", ex);
+ }
+
+ return value ?? throw new InvalidDataException($"{Path.GetFileName(fileName)} is empty.");
+ }
+
+ ///
+ /// Loads the sheet image named in . Only the file name is used, so the image
+ /// is always read from the JSON file's folder.
+ ///
+ public static Bitmap LoadSheet(string jsonFile, PackedMeta meta)
+ {
+ if (string.IsNullOrWhiteSpace(meta?.Image))
+ {
+ throw new InvalidDataException($"{Path.GetFileName(jsonFile)} does not name a sheet image.");
+ }
+
+ string imageFile = Path.Combine(Path.GetDirectoryName(jsonFile) ?? string.Empty, Path.GetFileName(meta.Image));
+ if (!File.Exists(imageFile))
+ {
+ throw new FileNotFoundException($"Sheet image not found: {imageFile}", imageFile);
+ }
+
+ using (var loaded = new Bitmap(imageFile))
+ {
+ // Copy so the image file is not kept locked
+ return loaded.Clone(new Rectangle(0, 0, loaded.Width, loaded.Height), PixelFormat.Format32bppArgb);
+ }
+ }
+
+ public static bool IsInside(PackedRect rect, Bitmap sheet)
+ {
+ return rect != null && rect.W > 0 && rect.H > 0 && rect.X >= 0 && rect.Y >= 0 &&
+ rect.X + rect.W <= sheet.Width && rect.Y + rect.H <= sheet.Height;
+ }
+
+ ///
+ /// Copies out of as a 32bpp ARGB bitmap.
+ ///
+ public static Bitmap Crop(Bitmap sheet, PackedRect rect)
+ {
+ return sheet.Clone(new Rectangle(rect.X, rect.Y, rect.W, rect.H), PixelFormat.Format32bppArgb);
+ }
+
+ ///
+ /// Converts of to an ARGB1555 animation frame.
+ /// Pixels under half alpha become transparent; black is kept visible as 0,0,8 because 0,0,0 is
+ /// transparent in the client (same rule as the Animation Editor import).
+ ///
+ public static unsafe Bitmap CropAnimationFrame(Bitmap sheet, PackedRect rect)
+ {
+ var frame = new Bitmap(rect.W, rect.H, PixelFormat.Format16bppArgb1555);
+
+ BitmapData src = sheet.LockBits(new Rectangle(rect.X, rect.Y, rect.W, rect.H), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
+ BitmapData dst = frame.LockBits(new Rectangle(0, 0, rect.W, rect.H), ImageLockMode.WriteOnly, PixelFormat.Format16bppArgb1555);
+ try
+ {
+ for (int y = 0; y < rect.H; ++y)
+ {
+ uint* srcLine = (uint*)((byte*)src.Scan0 + (y * src.Stride));
+ ushort* dstLine = (ushort*)((byte*)dst.Scan0 + (y * dst.Stride));
+
+ for (int x = 0; x < rect.W; ++x)
+ {
+ uint argb = srcLine[x];
+ if ((argb >> 24) < 0x80)
+ {
+ dstLine[x] = 0;
+ continue;
+ }
+
+ int color = 0x8000 | (int)(((argb >> 9) & 0x7C00) | ((argb >> 6) & 0x03E0) | ((argb >> 3) & 0x001F));
+ dstLine[x] = (ushort)(color == 0x8000 ? 0x8001 : color);
+ }
+ }
+ }
+ finally
+ {
+ sheet.UnlockBits(src);
+ frame.UnlockBits(dst);
+ }
+
+ return frame;
+ }
+
+ ///
+ /// Saves a copy of with every rect outlined in red, a green cross on each
+ /// anchor and each label in the rect's top-left corner.
+ ///
+ public static void SaveGuide(string fileName, Bitmap sheet, IReadOnlyList rects, IReadOnlyList anchors = null,
+ IReadOnlyList labels = null, Font font = null)
+ {
+ using (var guide = new Bitmap(sheet))
+ using (var g = Graphics.FromImage(guide))
+ using (var rectPen = new Pen(Color.Red, 1))
+ using (var anchorPen = new Pen(Color.Lime, 1))
+ {
+ for (int i = 0; i < rects.Count; ++i)
+ {
+ PackedRect rect = rects[i];
+ g.DrawRectangle(rectPen, rect.X, rect.Y, rect.W - 1, rect.H - 1);
+
+ if (anchors?[i] is { } anchor)
+ {
+ g.DrawLine(anchorPen, anchor.X - 3, anchor.Y, anchor.X + 3, anchor.Y);
+ g.DrawLine(anchorPen, anchor.X, anchor.Y - 3, anchor.X, anchor.Y + 3);
+ }
+
+ if (labels != null && font != null)
+ {
+ g.DrawString(labels[i], font, Brushes.Black, rect.X + 1, rect.Y + 1);
+ }
+ }
+
+ guide.Save(fileName, ImageFormat.Png);
+ }
+ }
+ }
+}
diff --git a/UoFiddler.Controls/Forms/PackOptionsForm.Designer.cs b/UoFiddler.Controls/Forms/PackOptionsForm.Designer.cs
new file mode 100644
index 00000000..1eb6b70a
--- /dev/null
+++ b/UoFiddler.Controls/Forms/PackOptionsForm.Designer.cs
@@ -0,0 +1,219 @@
+/***************************************************************************
+ *
+ * $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 PackOptionsForm
+ {
+ private System.ComponentModel.IContainer components = null;
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && (components != null))
+ {
+ components.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ private void InitializeComponent()
+ {
+ DirectionsLabel = new System.Windows.Forms.Label();
+ DirectionsListBox = new System.Windows.Forms.CheckedListBox();
+ MaxWidthLabel = new System.Windows.Forms.Label();
+ MaxWidthUpDown = new System.Windows.Forms.NumericUpDown();
+ Preset1024Button = new System.Windows.Forms.Button();
+ Preset2048Button = new System.Windows.Forms.Button();
+ Preset4096Button = new System.Windows.Forms.Button();
+ SpacingLabel = new System.Windows.Forms.Label();
+ SpacingUpDown = new System.Windows.Forms.NumericUpDown();
+ OneRowPerDirectionCheckBox = new System.Windows.Forms.CheckBox();
+ ExportAllActionsCheckBox = new System.Windows.Forms.CheckBox();
+ OkButton = new System.Windows.Forms.Button();
+ CancelDialogButton = new System.Windows.Forms.Button();
+ ((System.ComponentModel.ISupportInitialize)MaxWidthUpDown).BeginInit();
+ ((System.ComponentModel.ISupportInitialize)SpacingUpDown).BeginInit();
+ SuspendLayout();
+ //
+ // DirectionsLabel
+ //
+ DirectionsLabel.AutoSize = true;
+ DirectionsLabel.Location = new System.Drawing.Point(12, 12);
+ DirectionsLabel.Name = "DirectionsLabel";
+ DirectionsLabel.Size = new System.Drawing.Size(63, 15);
+ DirectionsLabel.Text = "Directions:";
+ //
+ // DirectionsListBox
+ //
+ DirectionsListBox.CheckOnClick = true;
+ DirectionsListBox.IntegralHeight = false;
+ DirectionsListBox.Location = new System.Drawing.Point(12, 30);
+ DirectionsListBox.Name = "DirectionsListBox";
+ DirectionsListBox.Size = new System.Drawing.Size(140, 100);
+ DirectionsListBox.TabIndex = 0;
+ //
+ // MaxWidthLabel
+ //
+ MaxWidthLabel.AutoSize = true;
+ MaxWidthLabel.Location = new System.Drawing.Point(170, 12);
+ MaxWidthLabel.Name = "MaxWidthLabel";
+ MaxWidthLabel.Size = new System.Drawing.Size(98, 15);
+ MaxWidthLabel.Text = "Max sheet width:";
+ //
+ // MaxWidthUpDown
+ //
+ MaxWidthUpDown.Increment = new decimal(new int[] { 64, 0, 0, 0 });
+ MaxWidthUpDown.Location = new System.Drawing.Point(170, 30);
+ MaxWidthUpDown.Maximum = new decimal(new int[] { 8192, 0, 0, 0 });
+ MaxWidthUpDown.Minimum = new decimal(new int[] { 256, 0, 0, 0 });
+ MaxWidthUpDown.Name = "MaxWidthUpDown";
+ MaxWidthUpDown.Size = new System.Drawing.Size(162, 23);
+ MaxWidthUpDown.TabIndex = 1;
+ MaxWidthUpDown.ThousandsSeparator = true;
+ MaxWidthUpDown.Value = new decimal(new int[] { 2048, 0, 0, 0 });
+ //
+ // Preset1024Button
+ //
+ Preset1024Button.Location = new System.Drawing.Point(170, 59);
+ Preset1024Button.Name = "Preset1024Button";
+ Preset1024Button.Size = new System.Drawing.Size(50, 25);
+ Preset1024Button.TabIndex = 2;
+ Preset1024Button.Text = "1024";
+ Preset1024Button.UseVisualStyleBackColor = true;
+ Preset1024Button.Click += OnClickPreset;
+ //
+ // Preset2048Button
+ //
+ Preset2048Button.Location = new System.Drawing.Point(226, 59);
+ Preset2048Button.Name = "Preset2048Button";
+ Preset2048Button.Size = new System.Drawing.Size(50, 25);
+ Preset2048Button.TabIndex = 3;
+ Preset2048Button.Text = "2048";
+ Preset2048Button.UseVisualStyleBackColor = true;
+ Preset2048Button.Click += OnClickPreset;
+ //
+ // Preset4096Button
+ //
+ Preset4096Button.Location = new System.Drawing.Point(282, 59);
+ Preset4096Button.Name = "Preset4096Button";
+ Preset4096Button.Size = new System.Drawing.Size(50, 25);
+ Preset4096Button.TabIndex = 4;
+ Preset4096Button.Text = "4096";
+ Preset4096Button.UseVisualStyleBackColor = true;
+ Preset4096Button.Click += OnClickPreset;
+ //
+ // SpacingLabel
+ //
+ SpacingLabel.AutoSize = true;
+ SpacingLabel.Location = new System.Drawing.Point(170, 92);
+ SpacingLabel.Name = "SpacingLabel";
+ SpacingLabel.Size = new System.Drawing.Size(52, 15);
+ SpacingLabel.Text = "Spacing:";
+ //
+ // SpacingUpDown
+ //
+ SpacingUpDown.Location = new System.Drawing.Point(170, 110);
+ SpacingUpDown.Maximum = new decimal(new int[] { 20, 0, 0, 0 });
+ SpacingUpDown.Name = "SpacingUpDown";
+ SpacingUpDown.Size = new System.Drawing.Size(80, 23);
+ SpacingUpDown.TabIndex = 5;
+ //
+ // OneRowPerDirectionCheckBox
+ //
+ OneRowPerDirectionCheckBox.AutoSize = true;
+ OneRowPerDirectionCheckBox.Location = new System.Drawing.Point(12, 142);
+ OneRowPerDirectionCheckBox.Name = "OneRowPerDirectionCheckBox";
+ OneRowPerDirectionCheckBox.Size = new System.Drawing.Size(146, 19);
+ OneRowPerDirectionCheckBox.TabIndex = 6;
+ OneRowPerDirectionCheckBox.Text = "One row per direction";
+ OneRowPerDirectionCheckBox.UseVisualStyleBackColor = true;
+ OneRowPerDirectionCheckBox.CheckedChanged += OnCheckedChangedOneRowPerDirection;
+ //
+ // ExportAllActionsCheckBox
+ //
+ ExportAllActionsCheckBox.AutoSize = true;
+ ExportAllActionsCheckBox.Location = new System.Drawing.Point(12, 167);
+ ExportAllActionsCheckBox.Name = "ExportAllActionsCheckBox";
+ ExportAllActionsCheckBox.Size = new System.Drawing.Size(209, 19);
+ ExportAllActionsCheckBox.TabIndex = 7;
+ ExportAllActionsCheckBox.Text = "Export all actions of the body";
+ ExportAllActionsCheckBox.UseVisualStyleBackColor = true;
+ //
+ // OkButton
+ //
+ OkButton.Location = new System.Drawing.Point(176, 200);
+ OkButton.Name = "OkButton";
+ OkButton.Size = new System.Drawing.Size(75, 25);
+ OkButton.TabIndex = 8;
+ OkButton.Text = "OK";
+ OkButton.UseVisualStyleBackColor = true;
+ OkButton.Click += OnClickOk;
+ //
+ // CancelDialogButton
+ //
+ CancelDialogButton.DialogResult = System.Windows.Forms.DialogResult.Cancel;
+ CancelDialogButton.Location = new System.Drawing.Point(257, 200);
+ CancelDialogButton.Name = "CancelDialogButton";
+ CancelDialogButton.Size = new System.Drawing.Size(75, 25);
+ CancelDialogButton.TabIndex = 9;
+ CancelDialogButton.Text = "Cancel";
+ CancelDialogButton.UseVisualStyleBackColor = true;
+ //
+ // PackOptionsForm
+ //
+ AcceptButton = OkButton;
+ CancelButton = CancelDialogButton;
+ ClientSize = new System.Drawing.Size(344, 237);
+ Controls.Add(DirectionsLabel);
+ Controls.Add(DirectionsListBox);
+ Controls.Add(MaxWidthLabel);
+ Controls.Add(MaxWidthUpDown);
+ Controls.Add(Preset1024Button);
+ Controls.Add(Preset2048Button);
+ Controls.Add(Preset4096Button);
+ Controls.Add(SpacingLabel);
+ Controls.Add(SpacingUpDown);
+ Controls.Add(OneRowPerDirectionCheckBox);
+ Controls.Add(ExportAllActionsCheckBox);
+ Controls.Add(OkButton);
+ Controls.Add(CancelDialogButton);
+ FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
+ MaximizeBox = false;
+ MinimizeBox = false;
+ Name = "PackOptionsForm";
+ ShowInTaskbar = false;
+ StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
+ Text = "Pack Frames";
+ ((System.ComponentModel.ISupportInitialize)MaxWidthUpDown).EndInit();
+ ((System.ComponentModel.ISupportInitialize)SpacingUpDown).EndInit();
+ ResumeLayout(false);
+ PerformLayout();
+ }
+
+ #endregion
+
+ private System.Windows.Forms.Label DirectionsLabel;
+ private System.Windows.Forms.CheckedListBox DirectionsListBox;
+ private System.Windows.Forms.Label MaxWidthLabel;
+ private System.Windows.Forms.NumericUpDown MaxWidthUpDown;
+ private System.Windows.Forms.Button Preset1024Button;
+ private System.Windows.Forms.Button Preset2048Button;
+ private System.Windows.Forms.Button Preset4096Button;
+ private System.Windows.Forms.Label SpacingLabel;
+ private System.Windows.Forms.NumericUpDown SpacingUpDown;
+ private System.Windows.Forms.CheckBox OneRowPerDirectionCheckBox;
+ private System.Windows.Forms.CheckBox ExportAllActionsCheckBox;
+ private System.Windows.Forms.Button OkButton;
+ private System.Windows.Forms.Button CancelDialogButton;
+ }
+}
diff --git a/UoFiddler.Controls/Forms/PackOptionsForm.cs b/UoFiddler.Controls/Forms/PackOptionsForm.cs
new file mode 100644
index 00000000..c5cbdde1
--- /dev/null
+++ b/UoFiddler.Controls/Forms/PackOptionsForm.cs
@@ -0,0 +1,85 @@
+/***************************************************************************
+ *
+ * $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 UoFiddler.Controls.Classes;
+
+namespace UoFiddler.Controls.Forms
+{
+ ///
+ /// Options for packing animation frames into a sprite sheet.
+ ///
+ public partial class PackOptionsForm : Form
+ {
+ ///
+ /// Directions stored in the anim files. 5-7 are drawn by mirroring 3-1 and are not offered,
+ /// since frames packed from them could not be imported back.
+ ///
+ public static readonly string[] DirectionNames = { "South", "South West", "West", "North West", "North" };
+
+ public PackOptionsForm()
+ {
+ InitializeComponent();
+
+ Icon = Options.GetFiddlerIcon();
+
+ for (int i = 0; i < DirectionNames.Length; ++i)
+ {
+ DirectionsListBox.Items.Add($"{i} {DirectionNames[i]}", true);
+ }
+ }
+
+ public List SelectedDirections { get; } = new List();
+
+ public int MaxWidth => (int)MaxWidthUpDown.Value;
+
+ public int Spacing => (int)SpacingUpDown.Value;
+
+ public bool OneRowPerDirection => OneRowPerDirectionCheckBox.Checked;
+
+ public bool ExportAllActions => ExportAllActionsCheckBox.Checked;
+
+ private void OnClickPreset(object sender, EventArgs e)
+ {
+ MaxWidthUpDown.Value = decimal.Parse(((Button)sender).Text);
+ }
+
+ private void OnCheckedChangedOneRowPerDirection(object sender, EventArgs e)
+ {
+ // Rows follow the directions, so the width limit does not apply
+ bool limitWidth = !OneRowPerDirectionCheckBox.Checked;
+ MaxWidthUpDown.Enabled = limitWidth;
+ Preset1024Button.Enabled = limitWidth;
+ Preset2048Button.Enabled = limitWidth;
+ Preset4096Button.Enabled = limitWidth;
+ }
+
+ private void OnClickOk(object sender, EventArgs e)
+ {
+ SelectedDirections.Clear();
+ foreach (int index in DirectionsListBox.CheckedIndices)
+ {
+ SelectedDirections.Add(index);
+ }
+
+ if (SelectedDirections.Count == 0)
+ {
+ MessageBox.Show("Select at least one direction.", "Pack Frames", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return;
+ }
+
+ DialogResult = DialogResult.OK;
+ Close();
+ }
+ }
+}
diff --git a/UoFiddler.Controls/Forms/PackOptionsForm.resx b/UoFiddler.Controls/Forms/PackOptionsForm.resx
new file mode 100644
index 00000000..459ff736
--- /dev/null
+++ b/UoFiddler.Controls/Forms/PackOptionsForm.resx
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
diff --git a/UoFiddler.Controls/UserControls/AnimationListControl.Designer.cs b/UoFiddler.Controls/UserControls/AnimationListControl.Designer.cs
index f3cd59f1..e9517731 100644
--- a/UoFiddler.Controls/UserControls/AnimationListControl.Designer.cs
+++ b/UoFiddler.Controls/UserControls/AnimationListControl.Designer.cs
@@ -67,6 +67,8 @@ private void InitializeComponent()
asAnimatedGifnoLoopingToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
alignFramesSeparator = new System.Windows.Forms.ToolStripSeparator();
alignFramesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ packFramesSeparator = new System.Windows.Forms.ToolStripSeparator();
+ packFramesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
listView1 = new ListViewWithScrollbar();
contextMenuStrip3 = new System.Windows.Forms.ContextMenuStrip(components);
exportFrameToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
@@ -314,7 +316,7 @@ private void InitializeComponent()
//
// extractAnimationToolStripMenuItem
//
- extractAnimationToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { asBmpToolStripMenuItem1, asTiffToolStripMenuItem1, asJpgToolStripMenuItem1, asPngToolStripMenuItem1, asAnimatedGifToolStripMenuItem, asAnimatedGifnoLoopingToolStripMenuItem, alignFramesSeparator, alignFramesToolStripMenuItem });
+ extractAnimationToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { asBmpToolStripMenuItem1, asTiffToolStripMenuItem1, asJpgToolStripMenuItem1, asPngToolStripMenuItem1, asAnimatedGifToolStripMenuItem, asAnimatedGifnoLoopingToolStripMenuItem, alignFramesSeparator, alignFramesToolStripMenuItem, packFramesSeparator, packFramesToolStripMenuItem });
extractAnimationToolStripMenuItem.Name = "extractAnimationToolStripMenuItem";
extractAnimationToolStripMenuItem.Size = new System.Drawing.Size(173, 22);
extractAnimationToolStripMenuItem.Text = "Export Animation..";
@@ -373,6 +375,19 @@ private void InitializeComponent()
alignFramesToolStripMenuItem.Size = new System.Drawing.Size(227, 22);
alignFramesToolStripMenuItem.Text = "Align Frames on One Canvas";
alignFramesToolStripMenuItem.ToolTipText = "Bmp/Tiff/Jpg/Png: draw every frame on one canvas, anchored at its center like the Gif export, so frames don't jump. Png and Tiff get a transparent background.";
+ //
+ // packFramesSeparator
+ //
+ packFramesSeparator.Name = "packFramesSeparator";
+ packFramesSeparator.Size = new System.Drawing.Size(224, 6);
+ //
+ // packFramesToolStripMenuItem
+ //
+ packFramesToolStripMenuItem.Name = "packFramesToolStripMenuItem";
+ packFramesToolStripMenuItem.Size = new System.Drawing.Size(227, 22);
+ packFramesToolStripMenuItem.Text = "Pack Frames to Sheet (Png + Json)..";
+ packFramesToolStripMenuItem.ToolTipText = "Pack the frames of the chosen directions into one sprite sheet with a Json file holding each frame's position and center.";
+ packFramesToolStripMenuItem.Click += OnClickPackFrames;
//
// listView1
//
@@ -745,6 +760,8 @@ private void InitializeComponent()
private System.Windows.Forms.ToolStripMenuItem asAnimatedGifToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem asAnimatedGifnoLoopingToolStripMenuItem;
private System.Windows.Forms.ToolStripSeparator alignFramesSeparator;
+ private System.Windows.Forms.ToolStripSeparator packFramesSeparator;
+ private System.Windows.Forms.ToolStripMenuItem packFramesToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem alignFramesToolStripMenuItem;
private System.Windows.Forms.TabControl tabControl1;
private System.Windows.Forms.TabPage tabPage1;
diff --git a/UoFiddler.Controls/UserControls/AnimationListControl.cs b/UoFiddler.Controls/UserControls/AnimationListControl.cs
index fe6ba8f1..f057be73 100644
--- a/UoFiddler.Controls/UserControls/AnimationListControl.cs
+++ b/UoFiddler.Controls/UserControls/AnimationListControl.cs
@@ -16,6 +16,7 @@
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
+using System.Text;
using System.Windows.Forms;
using System.Xml;
using Ultima;
@@ -584,21 +585,30 @@ private static int CompareNodeByGraphic(TreeNode x, TreeNode y)
return ((int[])x.Tag)[0].CompareTo(((int[])y.Tag)[0]);
}
- private void AddUopActionNodes(TreeNode parent, int body, int actionType)
+ ///
+ /// Lists the actions defined for : every defined UOP action for UOP bodies,
+ /// the named-action table for MUL bodies.
+ ///
+ private List GetDefinedActions(int body, int actionType)
{
// UOP animations are not bounded by the legacy per-type group counts (Low=13, High=22,
// People=34): a UOP body can define action bins well past them (the client scans up to
- // MaxAnimActions - "gargoyle is like 78"). List every defined UOP action, naming those within
- // the type's table and falling back to a generic "ActionN" for the higher UOP-specific bins.
- var definedActions = Animations.GetUopDefinedActions(body);
- foreach (int i in definedActions)
+ // MaxAnimActions - "gargoyle is like 78").
+ if (Animations.IsUopBody(body))
{
- string actionName = i < GetActionNames[actionType].Length
- ? GetActionNames[actionType][i]
- : $"Action{i}";
+ return Animations.GetUopDefinedActions(body);
+ }
- parent.Nodes.Add(new TreeNode($"{i} {actionName}") { Tag = i });
+ var actions = new List();
+ for (int i = 0; i < GetActionNames[actionType].GetLength(0); ++i)
+ {
+ if (Animations.IsActionDefined(body, i, 0))
+ {
+ actions.Add(i);
+ }
}
+
+ return actions;
}
///
@@ -668,21 +678,14 @@ private void PopulateActionNodes(TreeNode bodyNode)
TreeViewMobs.BeginUpdate();
try
{
- if (Animations.IsUopBody(graphic))
+ // Actions past the type's name table (UOP-specific bins) get a generic "ActionN"
+ foreach (int i in GetDefinedActions(graphic, actionType))
{
- AddUopActionNodes(bodyNode, graphic, actionType);
- }
- else
- {
- for (int i = 0; i < GetActionNames[actionType].GetLength(0); ++i)
- {
- if (!Animations.IsActionDefined(graphic, i, 0))
- {
- continue;
- }
+ string actionName = i < GetActionNames[actionType].Length
+ ? GetActionNames[actionType][i]
+ : $"Action{i}";
- bodyNode.Nodes.Add(new TreeNode($"{i} {GetActionNames[actionType][i]}") { Tag = i });
- }
+ bodyNode.Nodes.Add(new TreeNode($"{i} {actionName}") { Tag = i });
}
}
finally
@@ -1396,6 +1399,204 @@ private void ShowFrameBoundsCheckBox_Click(object sender, EventArgs e)
MainPictureBox.ShowFrameBounds = !MainPictureBox.ShowFrameBounds;
ShowFrameBoundsCheckBox.Checked = MainPictureBox.ShowFrameBounds;
}
+
+ ///
+ /// The action-name table of the body node for , found from the tree
+ /// selection or, when the body was picked from the thumbnail list, by searching the tree.
+ ///
+ private int GetSelectedBodyActionType()
+ {
+ TreeNode node = TreeViewMobs.SelectedNode;
+ if (node?.Tag is int && node.Parent?.Tag is int[])
+ {
+ node = node.Parent;
+ }
+
+ if (node?.Tag is int[] selectedTag && selectedTag[0] == _currentSelect)
+ {
+ return GetActionNameType(selectedTag[1]);
+ }
+
+ string rootName = _displayType == 1 ? "Equipment" : "Mobs";
+ foreach (TreeNode root in TreeViewMobs.Nodes)
+ {
+ if (root.Name != rootName)
+ {
+ continue;
+ }
+
+ foreach (TreeNode bodyNode in root.Nodes)
+ {
+ if (bodyNode.Tag is int[] tag && tag[0] == _currentSelect)
+ {
+ return GetActionNameType(tag[1]);
+ }
+ }
+ }
+
+ // Largest name table, so no defined MUL action is missed
+ return (int)MobType.Human;
+ }
+
+ private void OnClickPackFrames(object sender, EventArgs e)
+ {
+ if (_currentSelect == 0)
+ {
+ MessageBox.Show("No animation selected.", "Pack Frames", MessageBoxButtons.OK, MessageBoxIcon.Information);
+ return;
+ }
+
+ using var optionsForm = new PackOptionsForm();
+ if (optionsForm.ShowDialog(this) != DialogResult.OK)
+ {
+ return;
+ }
+
+ using var folderDialog = new FolderBrowserDialog
+ {
+ Description = "Select the folder to save the packed sheets in",
+ ShowNewFolderButton = true,
+ SelectedPath = Options.OutputPath
+ };
+ if (folderDialog.ShowDialog(this) != DialogResult.OK)
+ {
+ return;
+ }
+
+ string outDir = folderDialog.SelectedPath;
+ int body = _currentSelect;
+
+ var saved = new List();
+ try
+ {
+ using (new WaitCursorScope(this))
+ {
+ List actions = optionsForm.ExportAllActions
+ ? GetDefinedActions(body, GetSelectedBodyActionType())
+ : new List { _currentSelectAction };
+
+ foreach (int action in actions)
+ {
+ string baseName = PackAnimation(outDir, body, action, optionsForm);
+ if (baseName != null)
+ {
+ saved.Add(baseName);
+ }
+ }
+ }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Runtime.InteropServices.ExternalException)
+ {
+ MessageBox.Show($"Failed to pack frames: {ex.Message}", "Pack Frames", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ return;
+ }
+
+ if (saved.Count == 0)
+ {
+ MessageBox.Show("No frames found to pack.", "Pack Frames", MessageBoxButtons.OK, MessageBoxIcon.Information);
+ return;
+ }
+
+ string message = saved.Count == 1
+ ? $"Packed sheet {Path.GetFileName(saved[0])} (.png, .json and _guide.png) saved successfully."
+ : $"{saved.Count} packed sheets anim_{body}_ (.png, .json and _guide.png) saved successfully.";
+ FileSavedDialog.Show(FindForm(), outDir, message);
+ }
+
+ ///
+ /// Packs the frames of the chosen directions of one action into anim_{body}_{action}.png
+ /// with a .json descriptor, a _guide.png showing frame bounds and anchors and, with one
+ /// row per direction, a _rows.txt naming the rows.
+ ///
+ /// The output path without extension, or null if the action has no frames.
+ private static string PackAnimation(string outDir, int body, int action, PackOptionsForm options)
+ {
+ var layout = new SpriteSheetLayout(options.MaxWidth, options.Spacing);
+ var entries = new List();
+ var images = new List();
+ var rows = new StringBuilder();
+ int rowCount = 0;
+
+ try
+ {
+ foreach (int direction in options.SelectedDirections)
+ {
+ // No hue: pack the stored colours, not a body.def hue, so the sheet imports back unchanged
+ int hue = 0;
+ AnimationFrame[] frames = Animations.GetAnimation(body, action, direction, ref hue, true, false);
+ if (frames == null || !frames.Any(frame => frame?.Bitmap != null))
+ {
+ continue;
+ }
+
+ if (options.OneRowPerDirection)
+ {
+ layout.NewRow();
+ rows.AppendLine($"Row {rowCount++}: {direction} {PackOptionsForm.DirectionNames[direction]}");
+ }
+
+ for (int i = 0; i < frames.Length; ++i)
+ {
+ AnimationFrame frame = frames[i];
+ if (frame?.Bitmap == null)
+ {
+ continue;
+ }
+
+ PackedRect rect = layout.Place(frame.Bitmap.Width, frame.Bitmap.Height, wrap: !options.OneRowPerDirection);
+ entries.Add(new PackedFrameEntry
+ {
+ Direction = direction,
+ Index = i,
+ Frame = rect,
+ Center = new PackedPoint { X = frame.Center.X, Y = frame.Center.Y }
+ });
+
+ // The frames are cache-owned
+ images.Add(new Bitmap(frame.Bitmap));
+ }
+ }
+
+ if (entries.Count == 0)
+ {
+ return null;
+ }
+
+ string baseName = Path.Combine(outDir, $"anim_{body}_{action}");
+ string imageFile = baseName + ".png";
+ List rects = entries.Select(entry => entry.Frame).ToList();
+
+ using (Bitmap sheet = SpriteSheetPacker.Compose(layout.Width, layout.Height, images, rects))
+ {
+ sheet.Save(imageFile, ImageFormat.Png);
+ SpriteSheetPacker.WriteJson(baseName + ".json", new PackedAnimation
+ {
+ Meta = SpriteSheetPacker.CreateMeta(imageFile, sheet),
+ Frames = entries
+ });
+
+ // The anchor is where the frame's center is drawn, as in GetAlignedLocation
+ List anchors = entries
+ .Select(entry => (Point?)new Point(entry.Frame.X + entry.Center.X, entry.Frame.Y + entry.Frame.H + entry.Center.Y))
+ .ToList();
+ SpriteSheetPacker.SaveGuide(baseName + "_guide.png", sheet, rects, anchors);
+ }
+
+ if (options.OneRowPerDirection)
+ {
+ File.WriteAllText(baseName + "_rows.txt", rows.ToString());
+ }
+
+ return baseName;
+ }
+ finally
+ {
+ foreach (Bitmap image in images)
+ {
+ image.Dispose();
+ }
+ }
+ }
}
public class AlphaSorter : IComparer
From 39553d8cb310503b957eda196bc5966aa6f17959 Mon Sep 17 00:00:00 2001
From: Moshu <1709219+DatMoshu@users.noreply.github.com>
Date: Thu, 12 Nov 2026 02:05:20 +0100
Subject: [PATCH 2/5] Import animation frames from packed sprite sheets
Ported from #143. Single sheet or a folder of anim__.json; replace or append, MUL bodies only.
---
.../AnimationListControl.Designer.cs | 33 +-
.../UserControls/AnimationListControl.cs | 346 ++++++++++++++++++
2 files changed, 377 insertions(+), 2 deletions(-)
diff --git a/UoFiddler.Controls/UserControls/AnimationListControl.Designer.cs b/UoFiddler.Controls/UserControls/AnimationListControl.Designer.cs
index e9517731..04a18ae1 100644
--- a/UoFiddler.Controls/UserControls/AnimationListControl.Designer.cs
+++ b/UoFiddler.Controls/UserControls/AnimationListControl.Designer.cs
@@ -69,6 +69,9 @@ private void InitializeComponent()
alignFramesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
packFramesSeparator = new System.Windows.Forms.ToolStripSeparator();
packFramesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ importAnimationToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ unpackFramesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
+ bulkUnpackFramesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
listView1 = new ListViewWithScrollbar();
contextMenuStrip3 = new System.Windows.Forms.ContextMenuStrip(components);
exportFrameToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
@@ -275,9 +278,9 @@ private void InitializeComponent()
//
// contextMenuStrip1
//
- contextMenuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { extractImageToolStripMenuItem, extractAnimationToolStripMenuItem });
+ contextMenuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { extractImageToolStripMenuItem, extractAnimationToolStripMenuItem, importAnimationToolStripMenuItem });
contextMenuStrip1.Name = "contextMenuStrip1";
- contextMenuStrip1.Size = new System.Drawing.Size(174, 48);
+ contextMenuStrip1.Size = new System.Drawing.Size(174, 70);
//
// extractImageToolStripMenuItem
//
@@ -388,6 +391,29 @@ private void InitializeComponent()
packFramesToolStripMenuItem.Text = "Pack Frames to Sheet (Png + Json)..";
packFramesToolStripMenuItem.ToolTipText = "Pack the frames of the chosen directions into one sprite sheet with a Json file holding each frame's position and center.";
packFramesToolStripMenuItem.Click += OnClickPackFrames;
+ //
+ // importAnimationToolStripMenuItem
+ //
+ importAnimationToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { unpackFramesToolStripMenuItem, bulkUnpackFramesToolStripMenuItem });
+ importAnimationToolStripMenuItem.Name = "importAnimationToolStripMenuItem";
+ importAnimationToolStripMenuItem.Size = new System.Drawing.Size(173, 22);
+ importAnimationToolStripMenuItem.Text = "Import Animation..";
+ //
+ // unpackFramesToolStripMenuItem
+ //
+ unpackFramesToolStripMenuItem.Name = "unpackFramesToolStripMenuItem";
+ unpackFramesToolStripMenuItem.Size = new System.Drawing.Size(227, 22);
+ unpackFramesToolStripMenuItem.Text = "Unpack Frames from Sheet..";
+ unpackFramesToolStripMenuItem.ToolTipText = "Import a packed sheet (Json + Png) into the selected action.";
+ unpackFramesToolStripMenuItem.Click += OnClickUnpackFrames;
+ //
+ // bulkUnpackFramesToolStripMenuItem
+ //
+ bulkUnpackFramesToolStripMenuItem.Name = "bulkUnpackFramesToolStripMenuItem";
+ bulkUnpackFramesToolStripMenuItem.Size = new System.Drawing.Size(227, 22);
+ bulkUnpackFramesToolStripMenuItem.Text = "Bulk Unpack Frames from Folder..";
+ bulkUnpackFramesToolStripMenuItem.ToolTipText = "Import every packed sheet anim__.json of the selected body from a folder.";
+ bulkUnpackFramesToolStripMenuItem.Click += OnClickBulkUnpackFrames;
//
// listView1
//
@@ -762,6 +788,9 @@ private void InitializeComponent()
private System.Windows.Forms.ToolStripSeparator alignFramesSeparator;
private System.Windows.Forms.ToolStripSeparator packFramesSeparator;
private System.Windows.Forms.ToolStripMenuItem packFramesToolStripMenuItem;
+ private System.Windows.Forms.ToolStripMenuItem importAnimationToolStripMenuItem;
+ private System.Windows.Forms.ToolStripMenuItem unpackFramesToolStripMenuItem;
+ private System.Windows.Forms.ToolStripMenuItem bulkUnpackFramesToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem alignFramesToolStripMenuItem;
private System.Windows.Forms.TabControl tabControl1;
private System.Windows.Forms.TabPage tabPage1;
diff --git a/UoFiddler.Controls/UserControls/AnimationListControl.cs b/UoFiddler.Controls/UserControls/AnimationListControl.cs
index f057be73..76310106 100644
--- a/UoFiddler.Controls/UserControls/AnimationListControl.cs
+++ b/UoFiddler.Controls/UserControls/AnimationListControl.cs
@@ -17,6 +17,7 @@
using System.IO;
using System.Linq;
using System.Text;
+using System.Text.RegularExpressions;
using System.Windows.Forms;
using System.Xml;
using Ultima;
@@ -1597,6 +1598,351 @@ private static string PackAnimation(string outDir, int body, int action, PackOpt
}
}
}
+
+ private const string SaveAnimationsHint =
+ "Save with Settings > Animation Edit > Misc > Save; this tab shows the new frames after the files are reloaded.";
+
+ private static readonly Regex _packedAnimationFileName = new Regex(@"^anim_(\d+)_(\d+)$", RegexOptions.CultureInvariant);
+
+ ///
+ /// Body and action from a packed sheet file name anim_{body}_{action}.json.
+ ///
+ private static bool TryParsePackedAnimationName(string fileName, out int body, out int action)
+ {
+ body = action = 0;
+ Match match = _packedAnimationFileName.Match(Path.GetFileNameWithoutExtension(fileName));
+ return match.Success &&
+ int.TryParse(match.Groups[1].Value, out body) &&
+ int.TryParse(match.Groups[2].Value, out action);
+ }
+
+ private bool CanUnpackInto(int body, string title)
+ {
+ if (body == 0)
+ {
+ MessageBox.Show("No animation selected.", title, MessageBoxButtons.OK, MessageBoxIcon.Information);
+ return false;
+ }
+
+ // AnimationEdit only writes the anim.mul files
+ if (Animations.IsUopBody(body))
+ {
+ MessageBox.Show($"Body {body} is stored in a UOP file. Frames can only be imported into bodies from the anim.mul files.",
+ title, MessageBoxButtons.OK, MessageBoxIcon.Information);
+ return false;
+ }
+
+ return true;
+ }
+
+ /// True to replace the existing frames, false to append, null to cancel.
+ private static bool? AskReplaceFrames(string title, string what)
+ {
+ DialogResult result = MessageBox.Show(
+ $"{what}\n\nYes = replace the existing frames\nNo = append to the existing frames\nCancel = abort",
+ title, MessageBoxButtons.YesNoCancel, MessageBoxIcon.Question, MessageBoxDefaultButton.Button3);
+
+ return result switch
+ {
+ DialogResult.Yes => true,
+ DialogResult.No => false,
+ _ => null
+ };
+ }
+
+ private static bool IsUnpackException(Exception ex)
+ {
+ // Bitmap reports an unreadable image as ArgumentException or OutOfMemoryException
+ return ex is IOException or InvalidDataException or UnauthorizedAccessException or ArgumentException or
+ OutOfMemoryException or System.Runtime.InteropServices.ExternalException;
+ }
+
+ private void OnClickUnpackFrames(object sender, EventArgs e)
+ {
+ const string title = "Unpack Frames";
+
+ int body = _currentSelect;
+ int action = _currentSelectAction;
+ if (!CanUnpackInto(body, title))
+ {
+ return;
+ }
+
+ using var fileDialog = new OpenFileDialog
+ {
+ Filter = "Packed sheet (*.json)|*.json|All files (*.*)|*.*",
+ Title = "Select the Json file of a packed sheet"
+ };
+ if (fileDialog.ShowDialog(this) != DialogResult.OK)
+ {
+ return;
+ }
+
+ string what = $"Import {Path.GetFileName(fileDialog.FileName)} into body {body}, action {action}?";
+ if (TryParsePackedAnimationName(fileDialog.FileName, out int packedBody, out int packedAction) &&
+ (packedBody != body || packedAction != action))
+ {
+ what += $"\nThe sheet was packed from body {packedBody}, action {packedAction}.";
+ }
+
+ bool? replace = AskReplaceFrames(title, what);
+ if (replace == null)
+ {
+ return;
+ }
+
+ UnpackResult result;
+ try
+ {
+ using (new WaitCursorScope(this))
+ {
+ result = UnpackAnimation(fileDialog.FileName, body, action, replace.Value);
+ }
+ }
+ catch (Exception ex) when (IsUnpackException(ex))
+ {
+ MessageBox.Show($"Failed to import frames: {ex.Message}", title, MessageBoxButtons.OK, MessageBoxIcon.Error);
+ return;
+ }
+
+ Options.ChangedUltimaClass["Animations"] = true;
+
+ MessageBox.Show($"Imported {result.Frames} frames in {result.Directions} direction(s) into body {body}, action {action}.{result.Notes}\n\n{SaveAnimationsHint}",
+ title, MessageBoxButtons.OK, MessageBoxIcon.Information);
+ }
+
+ private void OnClickBulkUnpackFrames(object sender, EventArgs e)
+ {
+ const string title = "Bulk Unpack Frames";
+
+ int body = _currentSelect;
+ if (!CanUnpackInto(body, title))
+ {
+ return;
+ }
+
+ using var folderDialog = new FolderBrowserDialog
+ {
+ Description = $"Select the folder holding the packed sheets anim_{body}_.json",
+ ShowNewFolderButton = false,
+ SelectedPath = Options.OutputPath
+ };
+ if (folderDialog.ShowDialog(this) != DialogResult.OK)
+ {
+ return;
+ }
+
+ // Only sheets of the selected body are imported; their actions come from the file names
+ var sheets = new SortedDictionary();
+ int otherFiles = 0;
+ foreach (string file in Directory.GetFiles(folderDialog.SelectedPath, "*.json"))
+ {
+ if (TryParsePackedAnimationName(file, out int packedBody, out int packedAction) && packedBody == body)
+ {
+ sheets[packedAction] = file;
+ }
+ else
+ {
+ otherFiles++;
+ }
+ }
+
+ if (sheets.Count == 0)
+ {
+ MessageBox.Show($"No packed sheets anim_{body}_.json found in {folderDialog.SelectedPath}.",
+ title, MessageBoxButtons.OK, MessageBoxIcon.Information);
+ return;
+ }
+
+ bool? replace = AskReplaceFrames(title, $"Import {sheets.Count} packed sheet(s) into body {body}, actions {string.Join(", ", sheets.Keys)}?");
+ if (replace == null)
+ {
+ return;
+ }
+
+ int imported = 0;
+ int frames = 0;
+ var errors = new List();
+ using (new WaitCursorScope(this))
+ {
+ foreach (var (action, file) in sheets)
+ {
+ try
+ {
+ UnpackResult result = UnpackAnimation(file, body, action, replace.Value);
+ imported++;
+ frames += result.Frames;
+ if (result.Notes.Length > 0)
+ {
+ errors.Add($"{Path.GetFileName(file)}:{result.Notes.Replace('\n', ' ')}");
+ }
+ }
+ catch (Exception ex) when (IsUnpackException(ex))
+ {
+ errors.Add($"{Path.GetFileName(file)}: {ex.Message}");
+ }
+ }
+ }
+
+ if (imported > 0)
+ {
+ Options.ChangedUltimaClass["Animations"] = true;
+ }
+
+ string message = $"Imported {imported} of {sheets.Count} sheet(s), {frames} frames, into body {body}.";
+ if (otherFiles > 0)
+ {
+ message += $"\nIgnored {otherFiles} other Json file(s).";
+ }
+
+ if (errors.Count > 0)
+ {
+ const int maxShown = 10;
+ message += "\n\n" + string.Join("\n", errors.Take(maxShown));
+ if (errors.Count > maxShown)
+ {
+ message += $"\n... and {errors.Count - maxShown} more.";
+ }
+ }
+
+ if (imported > 0)
+ {
+ message += $"\n\n{SaveAnimationsHint}";
+ }
+
+ MessageBox.Show(message, title, MessageBoxButtons.OK, errors.Count > 0 ? MessageBoxIcon.Warning : MessageBoxIcon.Information);
+ }
+
+ private sealed class UnpackResult
+ {
+ public int Frames { get; set; }
+ public int Directions { get; set; }
+
+ /// What was skipped, each line starting with a line break; empty if nothing was.
+ public string Notes { get; set; } = string.Empty;
+ }
+
+ ///
+ /// Imports a packed sheet into of (a MUL body).
+ /// The sheet is read and checked before anything changes, so a failure leaves the animation as it was.
+ ///
+ /// Replace each imported direction's frames instead of appending to them.
+ private static UnpackResult UnpackAnimation(string jsonFile, int body, int action, bool replace)
+ {
+ string name = Path.GetFileName(jsonFile);
+ PackedAnimation packed = SpriteSheetPacker.ReadJson(jsonFile);
+ if (packed.Frames == null || packed.Frames.Count == 0)
+ {
+ throw new InvalidDataException($"{name} lists no frames.");
+ }
+
+ int fileBody = body;
+ Animations.Translate(ref fileBody);
+ int fileType = BodyConverter.Convert(ref fileBody);
+ if (action < 0 || action >= Animations.GetActionCapacity(fileBody, fileType))
+ {
+ throw new InvalidDataException($"Body {body} has no action {action}.");
+ }
+
+ var result = new UnpackResult();
+ var skippedDirections = new SortedSet();
+ int skippedFrames = 0;
+ var directions = new SortedDictionary>();
+
+ try
+ {
+ using (Bitmap sheet = SpriteSheetPacker.LoadSheet(jsonFile, packed.Meta))
+ {
+ foreach (PackedFrameEntry entry in packed.Frames.Where(entry => entry != null).OrderBy(entry => entry.Index))
+ {
+ // 5-7 are drawn mirrored from 3-1 and are not stored
+ if (entry.Direction < 0 || entry.Direction >= PackOptionsForm.DirectionNames.Length)
+ {
+ skippedDirections.Add(entry.Direction);
+ continue;
+ }
+
+ if (!SpriteSheetPacker.IsInside(entry.Frame, sheet))
+ {
+ skippedFrames++;
+ continue;
+ }
+
+ if (!directions.TryGetValue(entry.Direction, out var frames))
+ {
+ directions[entry.Direction] = frames = new List<(Bitmap, PackedPoint)>();
+ }
+
+ frames.Add((SpriteSheetPacker.CropAnimationFrame(sheet, entry.Frame), entry.Center));
+ }
+ }
+
+ if (directions.Count == 0)
+ {
+ throw new InvalidDataException($"No frame of {name} could be imported.");
+ }
+
+ var targets = new Dictionary();
+ foreach (int direction in directions.Keys)
+ {
+ targets[direction] = GetAnimationEditTarget(fileType, fileBody, action, direction, body);
+ }
+
+ foreach (var (direction, frames) in directions)
+ {
+ AnimIdx edit = targets[direction];
+ if (replace)
+ {
+ edit.ClearFrames();
+ }
+
+ // One palette for the whole direction, fitted next to any frames that stay
+ edit.FitPalette(frames.Select(frame => frame.Bitmap));
+ foreach (var (bitmap, center) in frames)
+ {
+ edit.AddFrame(bitmap, center?.X ?? 0, center?.Y ?? 0);
+ }
+
+ result.Frames += frames.Count;
+ result.Directions++;
+ }
+ }
+ finally
+ {
+ foreach (var frames in directions.Values)
+ {
+ foreach (var (bitmap, _) in frames)
+ {
+ bitmap.Dispose();
+ }
+ }
+ }
+
+ if (skippedDirections.Count > 0)
+ {
+ result.Notes += $"\nSkipped direction(s) {string.Join(", ", skippedDirections)}: only 0-4 are stored.";
+ }
+
+ if (skippedFrames > 0)
+ {
+ result.Notes += $"\nSkipped {skippedFrames} frame(s) outside the sheet image.";
+ }
+
+ return result;
+ }
+
+ private static AnimIdx GetAnimationEditTarget(int fileType, int fileBody, int action, int direction, int body)
+ {
+ try
+ {
+ return AnimationEdit.GetAnimation(fileType, fileBody, action, direction);
+ }
+ catch (Exception ex) when (ex is NullReferenceException or IndexOutOfRangeException)
+ {
+ // The anim file of this body is missing or too short for it
+ throw new InvalidDataException($"Body {body} (anim{(fileType == 1 ? string.Empty : fileType.ToString())}.mul body {fileBody}) has no entry for action {action}, direction {direction}.", ex);
+ }
+ }
}
public class AlphaSorter : IComparer
From 718aa1068f445acf1e5dd07303f75fb83a1a57d8 Mon Sep 17 00:00:00 2001
From: Moshu <1709219+DatMoshu@users.noreply.github.com>
Date: Thu, 12 Nov 2026 02:25:51 +0100
Subject: [PATCH 3/5] Export and import item ranges as packed sprite sheets
Ported from #143. Misc > Export Range to Sheet / Import from Sheet; sizes checked before anything is replaced.
---
.../Forms/ItemRangeInputForm.Designer.cs | 99 ++++++
.../Forms/ItemRangeInputForm.cs | 80 +++++
.../Forms/ItemRangeInputForm.resx | 61 ++++
.../UserControls/ItemsControl.Designer.cs | 29 +-
.../UserControls/ItemsControl.cs | 287 ++++++++++++++++++
5 files changed, 555 insertions(+), 1 deletion(-)
create mode 100644 UoFiddler.Controls/Forms/ItemRangeInputForm.Designer.cs
create mode 100644 UoFiddler.Controls/Forms/ItemRangeInputForm.cs
create mode 100644 UoFiddler.Controls/Forms/ItemRangeInputForm.resx
diff --git a/UoFiddler.Controls/Forms/ItemRangeInputForm.Designer.cs b/UoFiddler.Controls/Forms/ItemRangeInputForm.Designer.cs
new file mode 100644
index 00000000..864fb7f6
--- /dev/null
+++ b/UoFiddler.Controls/Forms/ItemRangeInputForm.Designer.cs
@@ -0,0 +1,99 @@
+/***************************************************************************
+ *
+ * $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 ItemRangeInputForm
+ {
+ private System.ComponentModel.IContainer components = null;
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && (components != null))
+ {
+ components.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ private void InitializeComponent()
+ {
+ PromptLabel = new System.Windows.Forms.Label();
+ RangeTextBox = new System.Windows.Forms.TextBox();
+ OkButton = new System.Windows.Forms.Button();
+ CancelDialogButton = new System.Windows.Forms.Button();
+ SuspendLayout();
+ //
+ // PromptLabel
+ //
+ PromptLabel.AutoSize = true;
+ PromptLabel.Location = new System.Drawing.Point(12, 12);
+ PromptLabel.Name = "PromptLabel";
+ PromptLabel.Size = new System.Drawing.Size(231, 15);
+ PromptLabel.Text = "Item range (e.g. 100-200 or 0x64-0xC8):";
+ //
+ // RangeTextBox
+ //
+ RangeTextBox.Location = new System.Drawing.Point(12, 30);
+ RangeTextBox.Name = "RangeTextBox";
+ RangeTextBox.Size = new System.Drawing.Size(320, 23);
+ RangeTextBox.TabIndex = 0;
+ //
+ // OkButton
+ //
+ OkButton.Location = new System.Drawing.Point(176, 66);
+ OkButton.Name = "OkButton";
+ OkButton.Size = new System.Drawing.Size(75, 25);
+ OkButton.TabIndex = 1;
+ OkButton.Text = "OK";
+ OkButton.UseVisualStyleBackColor = true;
+ OkButton.Click += OnClickOk;
+ //
+ // CancelDialogButton
+ //
+ CancelDialogButton.DialogResult = System.Windows.Forms.DialogResult.Cancel;
+ CancelDialogButton.Location = new System.Drawing.Point(257, 66);
+ CancelDialogButton.Name = "CancelDialogButton";
+ CancelDialogButton.Size = new System.Drawing.Size(75, 25);
+ CancelDialogButton.TabIndex = 2;
+ CancelDialogButton.Text = "Cancel";
+ CancelDialogButton.UseVisualStyleBackColor = true;
+ //
+ // ItemRangeInputForm
+ //
+ AcceptButton = OkButton;
+ CancelButton = CancelDialogButton;
+ ClientSize = new System.Drawing.Size(344, 103);
+ Controls.Add(PromptLabel);
+ Controls.Add(RangeTextBox);
+ Controls.Add(OkButton);
+ Controls.Add(CancelDialogButton);
+ FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
+ MaximizeBox = false;
+ MinimizeBox = false;
+ Name = "ItemRangeInputForm";
+ ShowInTaskbar = false;
+ StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
+ Text = "Export Items to Sheet";
+ ResumeLayout(false);
+ PerformLayout();
+ }
+
+ #endregion
+
+ private System.Windows.Forms.Label PromptLabel;
+ private System.Windows.Forms.TextBox RangeTextBox;
+ private System.Windows.Forms.Button OkButton;
+ private System.Windows.Forms.Button CancelDialogButton;
+ }
+}
diff --git a/UoFiddler.Controls/Forms/ItemRangeInputForm.cs b/UoFiddler.Controls/Forms/ItemRangeInputForm.cs
new file mode 100644
index 00000000..bf32c84c
--- /dev/null
+++ b/UoFiddler.Controls/Forms/ItemRangeInputForm.cs
@@ -0,0 +1,80 @@
+/***************************************************************************
+ *
+ * $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.Windows.Forms;
+using UoFiddler.Controls.Classes;
+
+namespace UoFiddler.Controls.Forms
+{
+ ///
+ /// Asks for an inclusive item id range, in decimal or 0x hex.
+ ///
+ public partial class ItemRangeInputForm : Form
+ {
+ /// Most items one sheet may hold.
+ public const int MaxRangeLength = 1024;
+
+ private readonly int _maxId;
+
+ public ItemRangeInputForm(int maxId, int initialStart, int initialEnd)
+ {
+ InitializeComponent();
+
+ Icon = Options.GetFiddlerIcon();
+ _maxId = maxId;
+
+ if (initialStart >= 0 && initialEnd >= initialStart)
+ {
+ RangeTextBox.Text = $"0x{initialStart:X4}-0x{initialEnd:X4}";
+ RangeTextBox.SelectAll();
+ }
+ }
+
+ public int StartId { get; private set; }
+
+ public int EndId { get; private set; }
+
+ private void OnClickOk(object sender, EventArgs e)
+ {
+ string[] parts = RangeTextBox.Text.Split('-', StringSplitOptions.TrimEntries);
+ if (parts.Length != 2 ||
+ !Utils.ConvertStringToInt(parts[0], out int start, 0, _maxId) ||
+ !Utils.ConvertStringToInt(parts[1], out int end, 0, _maxId))
+ {
+ ShowError($"Enter the range as start-end, each between 0 and 0x{_maxId:X} (e.g. 100-200 or 0x64-0xC8).");
+ return;
+ }
+
+ if (start > end)
+ {
+ ShowError("The start of the range is past its end.");
+ return;
+ }
+
+ if (end - start + 1 > MaxRangeLength)
+ {
+ ShowError($"A sheet can hold at most {MaxRangeLength} items.");
+ return;
+ }
+
+ StartId = start;
+ EndId = end;
+ DialogResult = DialogResult.OK;
+ Close();
+ }
+
+ private static void ShowError(string message)
+ {
+ MessageBox.Show(message, "Export Items to Sheet", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ }
+ }
+}
diff --git a/UoFiddler.Controls/Forms/ItemRangeInputForm.resx b/UoFiddler.Controls/Forms/ItemRangeInputForm.resx
new file mode 100644
index 00000000..459ff736
--- /dev/null
+++ b/UoFiddler.Controls/Forms/ItemRangeInputForm.resx
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
diff --git a/UoFiddler.Controls/UserControls/ItemsControl.Designer.cs b/UoFiddler.Controls/UserControls/ItemsControl.Designer.cs
index de21dc8a..6a15e63e 100644
--- a/UoFiddler.Controls/UserControls/ItemsControl.Designer.cs
+++ b/UoFiddler.Controls/UserControls/ItemsControl.Designer.cs
@@ -98,6 +98,9 @@ private void InitializeComponent()
toolStripSeparator4 = new ToolStripSeparator();
MiscToolStripDropDownButton = new ToolStripDropDownButton();
ExportAllToolStripMenuItem = new ToolStripMenuItem();
+ packedSheetSeparator = new ToolStripSeparator();
+ exportItemsToSheetToolStripMenuItem = new ToolStripMenuItem();
+ importItemsFromSheetToolStripMenuItem = new ToolStripMenuItem();
asBmpToolStripMenuItem = new ToolStripMenuItem();
asTiffToolStripMenuItem = new ToolStripMenuItem();
asJpgToolStripMenuItem = new ToolStripMenuItem();
@@ -555,7 +558,7 @@ private void InitializeComponent()
// MiscToolStripDropDownButton
//
MiscToolStripDropDownButton.DisplayStyle = ToolStripItemDisplayStyle.Text;
- MiscToolStripDropDownButton.DropDownItems.AddRange(new ToolStripItem[] { ExportAllToolStripMenuItem });
+ MiscToolStripDropDownButton.DropDownItems.AddRange(new ToolStripItem[] { ExportAllToolStripMenuItem, packedSheetSeparator, exportItemsToSheetToolStripMenuItem, importItemsFromSheetToolStripMenuItem });
MiscToolStripDropDownButton.ImageTransparentColor = System.Drawing.Color.Magenta;
MiscToolStripDropDownButton.Name = "MiscToolStripDropDownButton";
MiscToolStripDropDownButton.Size = new System.Drawing.Size(45, 25);
@@ -567,6 +570,27 @@ private void InitializeComponent()
ExportAllToolStripMenuItem.Name = "ExportAllToolStripMenuItem";
ExportAllToolStripMenuItem.Size = new System.Drawing.Size(129, 22);
ExportAllToolStripMenuItem.Text = "Export all..";
+ //
+ // packedSheetSeparator
+ //
+ packedSheetSeparator.Name = "packedSheetSeparator";
+ packedSheetSeparator.Size = new System.Drawing.Size(206, 6);
+ //
+ // exportItemsToSheetToolStripMenuItem
+ //
+ exportItemsToSheetToolStripMenuItem.Name = "exportItemsToSheetToolStripMenuItem";
+ exportItemsToSheetToolStripMenuItem.Size = new System.Drawing.Size(209, 22);
+ exportItemsToSheetToolStripMenuItem.Text = "Export Range to Sheet..";
+ exportItemsToSheetToolStripMenuItem.ToolTipText = "Pack a range of items into one sprite sheet (Png) with a Json file holding each item's position.";
+ exportItemsToSheetToolStripMenuItem.Click += OnClickExportItemsToSheet;
+ //
+ // importItemsFromSheetToolStripMenuItem
+ //
+ importItemsFromSheetToolStripMenuItem.Name = "importItemsFromSheetToolStripMenuItem";
+ importItemsFromSheetToolStripMenuItem.Size = new System.Drawing.Size(209, 22);
+ importItemsFromSheetToolStripMenuItem.Text = "Import from Sheet..";
+ importItemsFromSheetToolStripMenuItem.ToolTipText = "Replace the items listed in a packed sheet's Json file with their images from the sheet.";
+ importItemsFromSheetToolStripMenuItem.Click += OnClickImportItemsFromSheet;
//
// asBmpToolStripMenuItem
//
@@ -658,6 +682,9 @@ private void InitializeComponent()
private ContextMenuStrip TileViewContextMenuStrip;
private PictureBox DetailPictureBox;
private ToolStripMenuItem ExportAllToolStripMenuItem;
+ private ToolStripSeparator packedSheetSeparator;
+ private ToolStripMenuItem exportItemsToSheetToolStripMenuItem;
+ private ToolStripMenuItem importItemsFromSheetToolStripMenuItem;
private ToolStripMenuItem extractToolStripMenuItem;
private ToolStripMenuItem findNextFreeSlotToolStripMenuItem;
private ToolStripStatusLabel GraphicLabel;
diff --git a/UoFiddler.Controls/UserControls/ItemsControl.cs b/UoFiddler.Controls/UserControls/ItemsControl.cs
index 13eec260..607609e8 100644
--- a/UoFiddler.Controls/UserControls/ItemsControl.cs
+++ b/UoFiddler.Controls/UserControls/ItemsControl.cs
@@ -1078,6 +1078,293 @@ private void ExportAllItemImages(ImageFormat imageFormat)
}
}
+ private void OnClickExportItemsToSheet(object sender, EventArgs e)
+ {
+ const string title = "Export Items to Sheet";
+
+ List selected = GetSelectedGraphicIds();
+ int initialStart = selected.Count > 0 ? selected[0] : -1;
+ int initialEnd = selected.Count > 0 ? Math.Min(selected[^1], initialStart + ItemRangeInputForm.MaxRangeLength - 1) : -1;
+
+ using var rangeForm = new ItemRangeInputForm(Art.GetMaxItemId(), initialStart, initialEnd);
+ if (rangeForm.ShowDialog(this) != DialogResult.OK)
+ {
+ return;
+ }
+
+ using var folderDialog = new FolderBrowserDialog
+ {
+ Description = "Select the folder to save the packed sheet in",
+ ShowNewFolderButton = true,
+ SelectedPath = Options.OutputPath
+ };
+ if (folderDialog.ShowDialog(this) != DialogResult.OK)
+ {
+ return;
+ }
+
+ string baseName;
+ int count;
+ try
+ {
+ using (new WaitCursorScope(this))
+ {
+ baseName = PackItems(rangeForm.StartId, rangeForm.EndId, folderDialog.SelectedPath, out count);
+ }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Runtime.InteropServices.ExternalException)
+ {
+ MessageBox.Show($"Failed to export items: {ex.Message}", title, MessageBoxButtons.OK, MessageBoxIcon.Error);
+ return;
+ }
+
+ if (baseName == null)
+ {
+ MessageBox.Show("No valid items found in the range.", title, MessageBoxButtons.OK, MessageBoxIcon.Information);
+ return;
+ }
+
+ FileSavedDialog.Show(FindForm(), folderDialog.SelectedPath,
+ $"Packed {count} items into {Path.GetFileName(baseName)} (.png, .json and _guide.png).");
+ }
+
+ ///
+ /// Packs the valid items of - into
+ /// items_{start}-{end}.png with a .json descriptor and a _guide.png.
+ ///
+ /// The output path without extension, or null if the range has no valid item.
+ private string PackItems(int startId, int endId, string outDir, out int count)
+ {
+ const int maxWidth = 1024;
+ const int spacing = 2;
+
+ var layout = new SpriteSheetLayout(maxWidth, spacing);
+ var entries = new List();
+ var images = new List();
+ count = 0;
+
+ try
+ {
+ for (int id = startId; id <= endId; ++id)
+ {
+ if (!Art.IsValidStatic(id))
+ {
+ continue;
+ }
+
+ Bitmap bitmap = Art.GetStatic(id);
+ if (bitmap == null)
+ {
+ continue;
+ }
+
+ entries.Add(new PackedItemEntry { Index = id, Frame = layout.Place(bitmap.Width, bitmap.Height) });
+
+ // Art bitmaps are cache-owned
+ images.Add(new Bitmap(bitmap));
+ }
+
+ if (entries.Count == 0)
+ {
+ return null;
+ }
+
+ string baseName = Path.Combine(outDir, $"items_{Utils.FormatExportId(startId)}-{Utils.FormatExportId(endId)}");
+ string imageFile = baseName + ".png";
+ List rects = entries.Select(entry => entry.Frame).ToList();
+
+ using (Bitmap sheet = SpriteSheetPacker.Compose(layout.Width, layout.Height, images, rects))
+ {
+ sheet.Save(imageFile, ImageFormat.Png);
+ SpriteSheetPacker.WriteJson(baseName + ".json", new PackedItems
+ {
+ Meta = SpriteSheetPacker.CreateMeta(imageFile, sheet),
+ Items = entries
+ });
+
+ List labels = entries.Select(entry => $"0x{entry.Index:X4}").ToList();
+ SpriteSheetPacker.SaveGuide(baseName + "_guide.png", sheet, rects, labels: labels, font: Font);
+ }
+
+ count = entries.Count;
+ return baseName;
+ }
+ finally
+ {
+ foreach (Bitmap image in images)
+ {
+ image.Dispose();
+ }
+ }
+ }
+
+ private void OnClickImportItemsFromSheet(object sender, EventArgs e)
+ {
+ const string title = "Import Items from Sheet";
+
+ using var fileDialog = new OpenFileDialog
+ {
+ Filter = "Packed sheet (*.json)|*.json|All files (*.*)|*.*",
+ Title = "Select the Json file of a packed item sheet"
+ };
+ if (fileDialog.ShowDialog(this) != DialogResult.OK)
+ {
+ return;
+ }
+
+ // Read and check the whole sheet before anything is replaced
+ var items = new List<(int Id, Bitmap Bitmap)>();
+ var skipped = new List();
+ int handedOver = 0;
+ try
+ {
+ try
+ {
+ using (new WaitCursorScope(this))
+ {
+ ReadPackedItems(fileDialog.FileName, items, skipped);
+ }
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or
+ ArgumentException or OutOfMemoryException or System.Runtime.InteropServices.ExternalException)
+ {
+ MessageBox.Show($"Failed to read the sheet: {ex.Message}", title, MessageBoxButtons.OK, MessageBoxIcon.Error);
+ return;
+ }
+
+ string skippedText = FormatSkipped(skipped);
+ if (items.Count == 0)
+ {
+ MessageBox.Show($"No item of {Path.GetFileName(fileDialog.FileName)} could be imported.{skippedText}",
+ title, MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return;
+ }
+
+ int existing = items.Count(item => Art.IsValidStatic(item.Id));
+ if (MessageBox.Show(
+ $"Import {items.Count} items (0x{items[0].Id:X4}-0x{items[^1].Id:X4}), replacing {existing} existing item(s)?{skippedText}",
+ title, MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2) != DialogResult.Yes)
+ {
+ return;
+ }
+
+ foreach (var (id, bitmap) in items)
+ {
+ Art.ReplaceStatic(id, bitmap);
+ handedOver++;
+ ControlEvents.FireItemChangeEvent(this, id);
+
+ // Ids that were free slots join the list when only valid items are shown
+ if (!_showFreeSlots)
+ {
+ int position = _itemList.BinarySearch(id);
+ if (position < 0)
+ {
+ _itemList.Insert(~position, id);
+ }
+ }
+ }
+ }
+ finally
+ {
+ // Art owns the bitmaps it was given
+ foreach (var (_, bitmap) in items.Skip(handedOver))
+ {
+ bitmap.Dispose();
+ }
+ }
+
+ Options.ChangedUltimaClass["Art"] = true;
+
+ ItemsTileView.VirtualListSize = _itemList.Count;
+ ItemsTileView.Invalidate();
+ if (_selectedGraphicId >= 0)
+ {
+ UpdateToolStripLabels(_selectedGraphicId);
+ UpdateDetail(_selectedGraphicId);
+ }
+ }
+
+ ///
+ /// Reads every importable item of a packed item sheet into , sorted by id,
+ /// and describes the ones it leaves out in .
+ ///
+ private static void ReadPackedItems(string jsonFile, List<(int Id, Bitmap Bitmap)> items, List skipped)
+ {
+ PackedItems packed = SpriteSheetPacker.ReadJson(jsonFile);
+ if (packed.Items == null || packed.Items.Count == 0)
+ {
+ throw new InvalidDataException($"{Path.GetFileName(jsonFile)} lists no items.");
+ }
+
+ int maxId = Art.GetMaxItemId();
+ var seen = new HashSet();
+ try
+ {
+ using (Bitmap sheet = SpriteSheetPacker.LoadSheet(jsonFile, packed.Meta))
+ {
+ foreach (PackedItemEntry entry in packed.Items.Where(entry => entry != null).OrderBy(entry => entry.Index))
+ {
+ string id = $"0x{entry.Index:X4}";
+ if (entry.Index < 0 || entry.Index > maxId)
+ {
+ skipped.Add($"{id}: past the last item id 0x{maxId:X4}");
+ continue;
+ }
+
+ if (!seen.Add(entry.Index))
+ {
+ skipped.Add($"{id}: listed more than once");
+ continue;
+ }
+
+ if (!SpriteSheetPacker.IsInside(entry.Frame, sheet))
+ {
+ skipped.Add($"{id}: outside the sheet image");
+ continue;
+ }
+
+ Bitmap bitmap = SpriteSheetPacker.Crop(sheet, entry.Frame);
+ if (!Art.ValidateStaticSize(bitmap, out int estimatedSize))
+ {
+ bitmap.Dispose();
+ skipped.Add($"{id}: too large for the MUL format ({estimatedSize:N0} of 65,535 ushorts)");
+ continue;
+ }
+
+ items.Add((entry.Index, bitmap));
+ }
+ }
+ }
+ catch
+ {
+ foreach (var (_, bitmap) in items)
+ {
+ bitmap.Dispose();
+ }
+
+ items.Clear();
+ throw;
+ }
+ }
+
+ private static string FormatSkipped(List skipped)
+ {
+ if (skipped.Count == 0)
+ {
+ return string.Empty;
+ }
+
+ const int maxShown = 10;
+ string text = $"\n\nSkipped {skipped.Count}:\n" + string.Join("\n", skipped.Take(maxShown));
+ if (skipped.Count > maxShown)
+ {
+ text += $"\n... and {skipped.Count - maxShown} more.";
+ }
+
+ return text;
+ }
+
private void OnClickPreLoad(object sender, EventArgs e)
{
if (PreLoader.IsBusy)
From f39edc4706c88efffe9182a7e4fa69f06167543c Mon Sep 17 00:00:00 2001
From: AsY!um- <377468+AsYlum-@users.noreply.github.com>
Date: Thu, 12 Nov 2026 02:31:40 +0100
Subject: [PATCH 4/5] Remember the last sprite sheet folder for the session
All sheet dialogs share it; the first one opens in the output path.
---
UoFiddler.Controls/Classes/SpriteSheetPacker.cs | 12 ++++++++++++
.../UserControls/AnimationListControl.cs | 11 +++++++++--
UoFiddler.Controls/UserControls/ItemsControl.cs | 7 ++++++-
3 files changed, 27 insertions(+), 3 deletions(-)
diff --git a/UoFiddler.Controls/Classes/SpriteSheetPacker.cs b/UoFiddler.Controls/Classes/SpriteSheetPacker.cs
index 9d7aa297..6aff3bc9 100644
--- a/UoFiddler.Controls/Classes/SpriteSheetPacker.cs
+++ b/UoFiddler.Controls/Classes/SpriteSheetPacker.cs
@@ -86,6 +86,18 @@ public static class SpriteSheetPacker
{
public const string ImageFormatName = "RGBA8888";
+ private static string _lastFolder;
+
+ ///
+ /// Folder of the last sheet saved or opened in this session, shared by all sheet dialogs so an
+ /// import starts where the export went. Starts in the profile's output path.
+ ///
+ public static string LastFolder
+ {
+ get => string.IsNullOrEmpty(_lastFolder) ? Options.OutputPath : _lastFolder;
+ set => _lastFolder = value;
+ }
+
private static readonly JsonSerializerOptions _writeOptions = new JsonSerializerOptions { WriteIndented = true };
// Case-insensitive so item sheets written by the original PR (PascalCase names) still load
diff --git a/UoFiddler.Controls/UserControls/AnimationListControl.cs b/UoFiddler.Controls/UserControls/AnimationListControl.cs
index 76310106..3df551d4 100644
--- a/UoFiddler.Controls/UserControls/AnimationListControl.cs
+++ b/UoFiddler.Controls/UserControls/AnimationListControl.cs
@@ -1457,13 +1457,15 @@ private void OnClickPackFrames(object sender, EventArgs e)
{
Description = "Select the folder to save the packed sheets in",
ShowNewFolderButton = true,
- SelectedPath = Options.OutputPath
+ SelectedPath = SpriteSheetPacker.LastFolder
};
if (folderDialog.ShowDialog(this) != DialogResult.OK)
{
return;
}
+ SpriteSheetPacker.LastFolder = folderDialog.SelectedPath;
+
string outDir = folderDialog.SelectedPath;
int body = _currentSelect;
@@ -1670,6 +1672,7 @@ private void OnClickUnpackFrames(object sender, EventArgs e)
using var fileDialog = new OpenFileDialog
{
+ InitialDirectory = SpriteSheetPacker.LastFolder,
Filter = "Packed sheet (*.json)|*.json|All files (*.*)|*.*",
Title = "Select the Json file of a packed sheet"
};
@@ -1678,6 +1681,8 @@ private void OnClickUnpackFrames(object sender, EventArgs e)
return;
}
+ SpriteSheetPacker.LastFolder = Path.GetDirectoryName(fileDialog.FileName);
+
string what = $"Import {Path.GetFileName(fileDialog.FileName)} into body {body}, action {action}?";
if (TryParsePackedAnimationName(fileDialog.FileName, out int packedBody, out int packedAction) &&
(packedBody != body || packedAction != action))
@@ -1725,13 +1730,15 @@ private void OnClickBulkUnpackFrames(object sender, EventArgs e)
{
Description = $"Select the folder holding the packed sheets anim_{body}_.json",
ShowNewFolderButton = false,
- SelectedPath = Options.OutputPath
+ SelectedPath = SpriteSheetPacker.LastFolder
};
if (folderDialog.ShowDialog(this) != DialogResult.OK)
{
return;
}
+ SpriteSheetPacker.LastFolder = folderDialog.SelectedPath;
+
// Only sheets of the selected body are imported; their actions come from the file names
var sheets = new SortedDictionary();
int otherFiles = 0;
diff --git a/UoFiddler.Controls/UserControls/ItemsControl.cs b/UoFiddler.Controls/UserControls/ItemsControl.cs
index 607609e8..c05922c7 100644
--- a/UoFiddler.Controls/UserControls/ItemsControl.cs
+++ b/UoFiddler.Controls/UserControls/ItemsControl.cs
@@ -1096,13 +1096,15 @@ private void OnClickExportItemsToSheet(object sender, EventArgs e)
{
Description = "Select the folder to save the packed sheet in",
ShowNewFolderButton = true,
- SelectedPath = Options.OutputPath
+ SelectedPath = SpriteSheetPacker.LastFolder
};
if (folderDialog.ShowDialog(this) != DialogResult.OK)
{
return;
}
+ SpriteSheetPacker.LastFolder = folderDialog.SelectedPath;
+
string baseName;
int count;
try
@@ -1204,6 +1206,7 @@ private void OnClickImportItemsFromSheet(object sender, EventArgs e)
using var fileDialog = new OpenFileDialog
{
+ InitialDirectory = SpriteSheetPacker.LastFolder,
Filter = "Packed sheet (*.json)|*.json|All files (*.*)|*.*",
Title = "Select the Json file of a packed item sheet"
};
@@ -1212,6 +1215,8 @@ private void OnClickImportItemsFromSheet(object sender, EventArgs e)
return;
}
+ SpriteSheetPacker.LastFolder = Path.GetDirectoryName(fileDialog.FileName);
+
// Read and check the whole sheet before anything is replaced
var items = new List<(int Id, Bitmap Bitmap)>();
var skipped = new List();
From 912ee5e8d5255803a9bd7fd5758d4730a7f32545 Mon Sep 17 00:00:00 2001
From: AsY!um- <377468+AsYlum-@users.noreply.github.com>
Date: Thu, 12 Nov 2026 02:41:23 +0100
Subject: [PATCH 5/5] Pack mirrored directions 5-7 and flip them back on import
5-7 import into 3-1 unless the sheet also holds the stored direction.
---
.../Classes/SpriteSheetPacker.cs | 8 ++-
.../Forms/PackOptionsForm.Designer.cs | 26 ++++----
UoFiddler.Controls/Forms/PackOptionsForm.cs | 22 +++++--
.../UserControls/AnimationListControl.cs | 65 +++++++++++++++----
4 files changed, 88 insertions(+), 33 deletions(-)
diff --git a/UoFiddler.Controls/Classes/SpriteSheetPacker.cs b/UoFiddler.Controls/Classes/SpriteSheetPacker.cs
index 6aff3bc9..4e8a2975 100644
--- a/UoFiddler.Controls/Classes/SpriteSheetPacker.cs
+++ b/UoFiddler.Controls/Classes/SpriteSheetPacker.cs
@@ -195,7 +195,8 @@ public static Bitmap Crop(Bitmap sheet, PackedRect rect)
/// Pixels under half alpha become transparent; black is kept visible as 0,0,8 because 0,0,0 is
/// transparent in the client (same rule as the Animation Editor import).
///
- public static unsafe Bitmap CropAnimationFrame(Bitmap sheet, PackedRect rect)
+ /// Flip the frame horizontally.
+ public static unsafe Bitmap CropAnimationFrame(Bitmap sheet, PackedRect rect, bool mirror = false)
{
var frame = new Bitmap(rect.W, rect.H, PixelFormat.Format16bppArgb1555);
@@ -210,15 +211,16 @@ public static unsafe Bitmap CropAnimationFrame(Bitmap sheet, PackedRect rect)
for (int x = 0; x < rect.W; ++x)
{
+ int dstX = mirror ? rect.W - 1 - x : x;
uint argb = srcLine[x];
if ((argb >> 24) < 0x80)
{
- dstLine[x] = 0;
+ dstLine[dstX] = 0;
continue;
}
int color = 0x8000 | (int)(((argb >> 9) & 0x7C00) | ((argb >> 6) & 0x03E0) | ((argb >> 3) & 0x001F));
- dstLine[x] = (ushort)(color == 0x8000 ? 0x8001 : color);
+ dstLine[dstX] = (ushort)(color == 0x8000 ? 0x8001 : color);
}
}
}
diff --git a/UoFiddler.Controls/Forms/PackOptionsForm.Designer.cs b/UoFiddler.Controls/Forms/PackOptionsForm.Designer.cs
index 1eb6b70a..235d490f 100644
--- a/UoFiddler.Controls/Forms/PackOptionsForm.Designer.cs
+++ b/UoFiddler.Controls/Forms/PackOptionsForm.Designer.cs
@@ -59,13 +59,13 @@ private void InitializeComponent()
DirectionsListBox.IntegralHeight = false;
DirectionsListBox.Location = new System.Drawing.Point(12, 30);
DirectionsListBox.Name = "DirectionsListBox";
- DirectionsListBox.Size = new System.Drawing.Size(140, 100);
+ DirectionsListBox.Size = new System.Drawing.Size(190, 140);
DirectionsListBox.TabIndex = 0;
//
// MaxWidthLabel
//
MaxWidthLabel.AutoSize = true;
- MaxWidthLabel.Location = new System.Drawing.Point(170, 12);
+ MaxWidthLabel.Location = new System.Drawing.Point(212, 12);
MaxWidthLabel.Name = "MaxWidthLabel";
MaxWidthLabel.Size = new System.Drawing.Size(98, 15);
MaxWidthLabel.Text = "Max sheet width:";
@@ -73,7 +73,7 @@ private void InitializeComponent()
// MaxWidthUpDown
//
MaxWidthUpDown.Increment = new decimal(new int[] { 64, 0, 0, 0 });
- MaxWidthUpDown.Location = new System.Drawing.Point(170, 30);
+ MaxWidthUpDown.Location = new System.Drawing.Point(212, 30);
MaxWidthUpDown.Maximum = new decimal(new int[] { 8192, 0, 0, 0 });
MaxWidthUpDown.Minimum = new decimal(new int[] { 256, 0, 0, 0 });
MaxWidthUpDown.Name = "MaxWidthUpDown";
@@ -84,7 +84,7 @@ private void InitializeComponent()
//
// Preset1024Button
//
- Preset1024Button.Location = new System.Drawing.Point(170, 59);
+ Preset1024Button.Location = new System.Drawing.Point(212, 59);
Preset1024Button.Name = "Preset1024Button";
Preset1024Button.Size = new System.Drawing.Size(50, 25);
Preset1024Button.TabIndex = 2;
@@ -94,7 +94,7 @@ private void InitializeComponent()
//
// Preset2048Button
//
- Preset2048Button.Location = new System.Drawing.Point(226, 59);
+ Preset2048Button.Location = new System.Drawing.Point(268, 59);
Preset2048Button.Name = "Preset2048Button";
Preset2048Button.Size = new System.Drawing.Size(50, 25);
Preset2048Button.TabIndex = 3;
@@ -104,7 +104,7 @@ private void InitializeComponent()
//
// Preset4096Button
//
- Preset4096Button.Location = new System.Drawing.Point(282, 59);
+ Preset4096Button.Location = new System.Drawing.Point(324, 59);
Preset4096Button.Name = "Preset4096Button";
Preset4096Button.Size = new System.Drawing.Size(50, 25);
Preset4096Button.TabIndex = 4;
@@ -115,14 +115,14 @@ private void InitializeComponent()
// SpacingLabel
//
SpacingLabel.AutoSize = true;
- SpacingLabel.Location = new System.Drawing.Point(170, 92);
+ SpacingLabel.Location = new System.Drawing.Point(212, 92);
SpacingLabel.Name = "SpacingLabel";
SpacingLabel.Size = new System.Drawing.Size(52, 15);
SpacingLabel.Text = "Spacing:";
//
// SpacingUpDown
//
- SpacingUpDown.Location = new System.Drawing.Point(170, 110);
+ SpacingUpDown.Location = new System.Drawing.Point(212, 110);
SpacingUpDown.Maximum = new decimal(new int[] { 20, 0, 0, 0 });
SpacingUpDown.Name = "SpacingUpDown";
SpacingUpDown.Size = new System.Drawing.Size(80, 23);
@@ -131,7 +131,7 @@ private void InitializeComponent()
// OneRowPerDirectionCheckBox
//
OneRowPerDirectionCheckBox.AutoSize = true;
- OneRowPerDirectionCheckBox.Location = new System.Drawing.Point(12, 142);
+ OneRowPerDirectionCheckBox.Location = new System.Drawing.Point(12, 182);
OneRowPerDirectionCheckBox.Name = "OneRowPerDirectionCheckBox";
OneRowPerDirectionCheckBox.Size = new System.Drawing.Size(146, 19);
OneRowPerDirectionCheckBox.TabIndex = 6;
@@ -142,7 +142,7 @@ private void InitializeComponent()
// ExportAllActionsCheckBox
//
ExportAllActionsCheckBox.AutoSize = true;
- ExportAllActionsCheckBox.Location = new System.Drawing.Point(12, 167);
+ ExportAllActionsCheckBox.Location = new System.Drawing.Point(12, 207);
ExportAllActionsCheckBox.Name = "ExportAllActionsCheckBox";
ExportAllActionsCheckBox.Size = new System.Drawing.Size(209, 19);
ExportAllActionsCheckBox.TabIndex = 7;
@@ -151,7 +151,7 @@ private void InitializeComponent()
//
// OkButton
//
- OkButton.Location = new System.Drawing.Point(176, 200);
+ OkButton.Location = new System.Drawing.Point(218, 240);
OkButton.Name = "OkButton";
OkButton.Size = new System.Drawing.Size(75, 25);
OkButton.TabIndex = 8;
@@ -162,7 +162,7 @@ private void InitializeComponent()
// CancelDialogButton
//
CancelDialogButton.DialogResult = System.Windows.Forms.DialogResult.Cancel;
- CancelDialogButton.Location = new System.Drawing.Point(257, 200);
+ CancelDialogButton.Location = new System.Drawing.Point(299, 240);
CancelDialogButton.Name = "CancelDialogButton";
CancelDialogButton.Size = new System.Drawing.Size(75, 25);
CancelDialogButton.TabIndex = 9;
@@ -173,7 +173,7 @@ private void InitializeComponent()
//
AcceptButton = OkButton;
CancelButton = CancelDialogButton;
- ClientSize = new System.Drawing.Size(344, 237);
+ ClientSize = new System.Drawing.Size(386, 277);
Controls.Add(DirectionsLabel);
Controls.Add(DirectionsListBox);
Controls.Add(MaxWidthLabel);
diff --git a/UoFiddler.Controls/Forms/PackOptionsForm.cs b/UoFiddler.Controls/Forms/PackOptionsForm.cs
index c5cbdde1..7409bf4a 100644
--- a/UoFiddler.Controls/Forms/PackOptionsForm.cs
+++ b/UoFiddler.Controls/Forms/PackOptionsForm.cs
@@ -22,10 +22,20 @@ namespace UoFiddler.Controls.Forms
public partial class PackOptionsForm : Form
{
///
- /// Directions stored in the anim files. 5-7 are drawn by mirroring 3-1 and are not offered,
- /// since frames packed from them could not be imported back.
+ /// Directions 0-4 are stored in the anim files; 5-7 are drawn by mirroring 3-1.
///
- public static readonly string[] DirectionNames = { "South", "South West", "West", "North West", "North" };
+ public static readonly string[] DirectionNames =
+ {
+ "South", "South West", "West", "North West", "North", "North East", "East", "South East"
+ };
+
+ /// Number of directions stored in the anim files.
+ public const int StoredDirections = 5;
+
+ ///
+ /// The stored direction (5-7) is mirrored from.
+ ///
+ public static int GetMirroredSource(int direction) => 8 - direction;
public PackOptionsForm()
{
@@ -35,7 +45,11 @@ public PackOptionsForm()
for (int i = 0; i < DirectionNames.Length; ++i)
{
- DirectionsListBox.Items.Add($"{i} {DirectionNames[i]}", true);
+ // Mirrored directions are off by default: they import back into their stored direction
+ string text = i < StoredDirections
+ ? $"{i} {DirectionNames[i]}"
+ : $"{i} {DirectionNames[i]} (mirror of {GetMirroredSource(i)})";
+ DirectionsListBox.Items.Add(text, i < StoredDirections);
}
}
diff --git a/UoFiddler.Controls/UserControls/AnimationListControl.cs b/UoFiddler.Controls/UserControls/AnimationListControl.cs
index 3df551d4..dfe66c9f 100644
--- a/UoFiddler.Controls/UserControls/AnimationListControl.cs
+++ b/UoFiddler.Controls/UserControls/AnimationListControl.cs
@@ -1712,7 +1712,7 @@ private void OnClickUnpackFrames(object sender, EventArgs e)
Options.ChangedUltimaClass["Animations"] = true;
- MessageBox.Show($"Imported {result.Frames} frames in {result.Directions} direction(s) into body {body}, action {action}.{result.Notes}\n\n{SaveAnimationsHint}",
+ MessageBox.Show($"Imported {result.Frames} frames in {result.Directions} direction(s) into body {body}, action {action}.{result.Info}{result.Notes}\n\n{SaveAnimationsHint}",
title, MessageBoxButtons.OK, MessageBoxIcon.Information);
}
@@ -1827,6 +1827,9 @@ private sealed class UnpackResult
/// What was skipped, each line starting with a line break; empty if nothing was.
public string Notes { get; set; } = string.Empty;
+
+ /// Other remarks, such as mirrored directions flipped back, formatted like .
+ public string Info { get; set; } = string.Empty;
}
///
@@ -1852,9 +1855,17 @@ private static UnpackResult UnpackAnimation(string jsonFile, int body, int actio
}
var result = new UnpackResult();
- var skippedDirections = new SortedSet();
+ var invalidDirections = new SortedSet();
+ var unflippedDirections = new SortedSet();
+ var shadowedDirections = new SortedSet();
int skippedFrames = 0;
- var directions = new SortedDictionary>();
+ var directions = new SortedDictionary>();
+
+ // A mirrored direction (5-7) is only imported when the sheet does not also hold the stored one
+ var storedOnSheet = packed.Frames
+ .Where(entry => entry != null && entry.Direction >= 0 && entry.Direction < PackOptionsForm.StoredDirections)
+ .Select(entry => entry.Direction)
+ .ToHashSet();
try
{
@@ -1862,10 +1873,17 @@ private static UnpackResult UnpackAnimation(string jsonFile, int body, int actio
{
foreach (PackedFrameEntry entry in packed.Frames.Where(entry => entry != null).OrderBy(entry => entry.Index))
{
- // 5-7 are drawn mirrored from 3-1 and are not stored
if (entry.Direction < 0 || entry.Direction >= PackOptionsForm.DirectionNames.Length)
{
- skippedDirections.Add(entry.Direction);
+ invalidDirections.Add(entry.Direction);
+ continue;
+ }
+
+ bool mirrored = entry.Direction >= PackOptionsForm.StoredDirections;
+ int target = mirrored ? PackOptionsForm.GetMirroredSource(entry.Direction) : entry.Direction;
+ if (mirrored && storedOnSheet.Contains(target))
+ {
+ shadowedDirections.Add(entry.Direction);
continue;
}
@@ -1875,12 +1893,21 @@ private static UnpackResult UnpackAnimation(string jsonFile, int body, int actio
continue;
}
- if (!directions.TryGetValue(entry.Direction, out var frames))
+ if (!directions.TryGetValue(target, out var frames))
{
- directions[entry.Direction] = frames = new List<(Bitmap, PackedPoint)>();
+ directions[target] = frames = new List<(Bitmap, int, int)>();
}
- frames.Add((SpriteSheetPacker.CropAnimationFrame(sheet, entry.Frame), entry.Center));
+ // The client draws 5-7 by flipping 3-1 and mirroring the center: undo both
+ int centerX = entry.Center?.X ?? 0;
+ int centerY = entry.Center?.Y ?? 0;
+ if (mirrored)
+ {
+ centerX = entry.Frame.W - centerX;
+ unflippedDirections.Add(entry.Direction);
+ }
+
+ frames.Add((SpriteSheetPacker.CropAnimationFrame(sheet, entry.Frame, mirrored), centerX, centerY));
}
}
@@ -1905,9 +1932,9 @@ private static UnpackResult UnpackAnimation(string jsonFile, int body, int actio
// One palette for the whole direction, fitted next to any frames that stay
edit.FitPalette(frames.Select(frame => frame.Bitmap));
- foreach (var (bitmap, center) in frames)
+ foreach (var (bitmap, centerX, centerY) in frames)
{
- edit.AddFrame(bitmap, center?.X ?? 0, center?.Y ?? 0);
+ edit.AddFrame(bitmap, centerX, centerY);
}
result.Frames += frames.Count;
@@ -1918,16 +1945,28 @@ private static UnpackResult UnpackAnimation(string jsonFile, int body, int actio
{
foreach (var frames in directions.Values)
{
- foreach (var (bitmap, _) in frames)
+ foreach (var (bitmap, _, _) in frames)
{
bitmap.Dispose();
}
}
}
- if (skippedDirections.Count > 0)
+ if (unflippedDirections.Count > 0)
+ {
+ result.Info += $"\nDirection(s) {string.Join(", ", unflippedDirections)} flipped back into " +
+ $"{string.Join(", ", unflippedDirections.Select(PackOptionsForm.GetMirroredSource))}.";
+ }
+
+ if (shadowedDirections.Count > 0)
+ {
+ result.Notes += $"\nSkipped mirrored direction(s) {string.Join(", ", shadowedDirections)}: the sheet also holds " +
+ $"{string.Join(", ", shadowedDirections.Select(PackOptionsForm.GetMirroredSource))}.";
+ }
+
+ if (invalidDirections.Count > 0)
{
- result.Notes += $"\nSkipped direction(s) {string.Join(", ", skippedDirections)}: only 0-4 are stored.";
+ result.Notes += $"\nSkipped invalid direction(s) {string.Join(", ", invalidDirections)}.";
}
if (skippedFrames > 0)