diff --git a/Dalamud/Interface/UiBuilder.cs b/Dalamud/Interface/UiBuilder.cs
index dad010e59..da4f241ac 100644
--- a/Dalamud/Interface/UiBuilder.cs
+++ b/Dalamud/Interface/UiBuilder.cs
@@ -263,6 +263,14 @@ public sealed class UiBuilder : IDisposable
=> this.InterfaceManagerWithScene?.LoadImageRaw(imageData, width, height, numChannels)
?? throw new InvalidOperationException("Load failed.");
+ ///
+ /// Loads an ULD file that can load textures containing multiple icons in a single texture.
+ ///
+ /// The path of the requested ULD file.
+ /// A wrapper around said ULD file.
+ public UldWrapper LoadUld(string uldPath)
+ => new(this, uldPath);
+
///
/// Asynchronously loads an image from the specified file, when it's possible to do so.
///
diff --git a/Dalamud/Interface/UldWrapper.cs b/Dalamud/Interface/UldWrapper.cs
new file mode 100644
index 000000000..d41256fa2
--- /dev/null
+++ b/Dalamud/Interface/UldWrapper.cs
@@ -0,0 +1,174 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+using Dalamud.Data;
+using Dalamud.Utility;
+using ImGuiScene;
+using Lumina.Data.Files;
+using Lumina.Data.Parsing.Uld;
+
+namespace Dalamud.Interface;
+
+/// Wrapper for multi-icon sprite sheets defined by ULD files.
+public class UldWrapper : IDisposable
+{
+ private readonly DataManager data;
+ private readonly UiBuilder uiBuilder;
+ private readonly Dictionary textures = new();
+
+ /// Initializes a new instance of the class, wrapping an ULD file.
+ /// The UiBuilder used to load textures.
+ /// The requested ULD file.
+ internal UldWrapper(UiBuilder uiBuilder, string uldPath)
+ {
+ this.uiBuilder = uiBuilder;
+ this.data = Service.Get();
+ this.Uld = this.data.GetFile(uldPath);
+ }
+
+ /// Gets the loaded ULD file if it exists.
+ public UldFile? Uld { get; private set; }
+
+ /// Gets a value indicating whether the requested ULD could be loaded.
+ public bool Valid
+ => this.Uld != null;
+
+ /// Load a part of a multi-icon sheet as a texture.
+ /// The path of the requested texture.
+ /// The index of the desired icon.
+ /// A TextureWrap containing the requested part if it exists and null otherwise.
+ public TextureWrap? LoadTexturePart(string texturePath, int part)
+ {
+ if (!this.Valid)
+ {
+ return null;
+ }
+
+ if (!this.textures.TryGetValue(texturePath, out var texture))
+ {
+ var tuple = this.GetTexture(texturePath);
+ if (!tuple.HasValue)
+ {
+ return null;
+ }
+
+ texture = tuple.Value;
+ this.textures[texturePath] = texture;
+ }
+
+ return this.CreateTexture(texture.Id, texture.Width, texture.Height, texture.HD, texture.RgbaData, part);
+ }
+
+ /// Clear all stored data and remove the loaded ULD.
+ public void Dispose()
+ {
+ this.textures.Clear();
+ this.Uld = null;
+ }
+
+ private TextureWrap? CreateTexture(uint id, int width, int height, bool hd, byte[] rgbaData, int partIdx)
+ {
+ var idx = 0;
+ UldRoot.PartData? partData = null;
+
+ // Iterate over all available parts that have the corresponding TextureId,
+ // count up to the required one and return it if it exists.
+ foreach (var part in this.Uld!.Parts.SelectMany(p => p.Parts))
+ {
+ if (part.TextureId != id)
+ {
+ continue;
+ }
+
+ if (idx++ == partIdx)
+ {
+ partData = part;
+ break;
+ }
+ }
+
+ if (!partData.HasValue)
+ {
+ return null;
+ }
+
+ // Double all dimensions for HD textures.
+ var d = hd ? partData.Value with
+ {
+ H = (ushort)(partData.Value.H * 2),
+ W = (ushort)(partData.Value.W * 2),
+ U = (ushort)(partData.Value.U * 2),
+ V = (ushort)(partData.Value.V * 2),
+ } : partData.Value;
+
+ return this.CopyRect(width, height, rgbaData, d);
+ }
+
+ private TextureWrap? CopyRect(int width, int height, byte[] rgbaData, UldRoot.PartData part)
+ {
+ if (part.V + part.W > width || part.U + part.H > height)
+ {
+ return null;
+ }
+
+ var imageData = new byte[part.W * part.H * 4];
+
+ // Iterate over all lines and copy the relevant ones,
+ // assuming a 4-byte-per-pixel standard layout.
+ for (var y = 0; y < part.H; ++y)
+ {
+ var inputSlice = rgbaData.AsSpan().Slice((((part.V + y) * width) + part.U) * 4, part.W * 4);
+ var outputSlice = imageData.AsSpan(y * part.W * 4);
+ inputSlice.CopyTo(outputSlice);
+ }
+
+ return this.uiBuilder.LoadImageRaw(imageData, part.W, part.H, 4);
+ }
+
+ private (uint Id, int Width, int Height, bool HD, byte[] RgbaData)? GetTexture(string texturePath)
+ {
+ if (!this.Valid)
+ {
+ return null;
+ }
+
+ // Always replace the HD version with the regular one as ULDs do not contain the HD suffix.
+ texturePath = texturePath.Replace("_hr1", string.Empty);
+
+ // Search the requested texture asset in the ULD and store its ID if it exists.
+ var id = uint.MaxValue;
+ foreach (var part in this.Uld!.AssetData)
+ {
+ var maxLength = Math.Min(part.Path.Length, texturePath.AsSpan().Length);
+ if (part.Path.AsSpan()[..maxLength].SequenceEqual(texturePath.AsSpan()[..maxLength]))
+ {
+ id = part.Id;
+ break;
+ }
+ }
+
+ if (id == uint.MaxValue)
+ {
+ return null;
+ }
+
+ // Try to load HD textures first.
+ var hrPath = texturePath.Replace(".tex", "_hr1.tex");
+ var hd = true;
+ var file = this.data.GetFile(hrPath);
+ if (file == null)
+ {
+ hd = false;
+ file = this.data.GetFile(texturePath);
+
+ // Neither texture could be loaded.
+ if (file == null)
+ {
+ return null;
+ }
+ }
+
+ return (id, file.Header.Width, file.Header.Height, hd, file.GetRgbaImageData());
+ }
+}