diff --git a/ChatTwo/PayloadHandler.cs b/ChatTwo/PayloadHandler.cs index a971615..3980c44 100755 --- a/ChatTwo/PayloadHandler.cs +++ b/ChatTwo/PayloadHandler.cs @@ -102,9 +102,7 @@ public sealed class PayloadHandler { } var contentId = chunk.Message?.ContentId ?? 0; - var sender = chunk.Message?.Sender - .Select(chunk => chunk.Link) - .FirstOrDefault(chunk => chunk is PlayerPayload) as PlayerPayload; + var sender = chunk.Message?.Sender.Select(c => c.Link).FirstOrDefault(p => p is PlayerPayload) as PlayerPayload; if (ImGui.BeginMenu(Language.Context_Integrations)) { var cursor = ImGui.GetCursorPos(); diff --git a/ChatTwo/Plugin.cs b/ChatTwo/Plugin.cs index 45c1092..3a06b03 100755 --- a/ChatTwo/Plugin.cs +++ b/ChatTwo/Plugin.cs @@ -42,6 +42,7 @@ public sealed class Plugin : IDalamudPlugin { public SettingsWindow SettingsWindow { get; } public ChatLogWindow ChatLogWindow { get; } public CommandHelpWindow CommandHelpWindow { get; } + public SeStringDebugger SeStringDebugger { get; } internal Configuration Config { get; } internal Commands Commands { get; } @@ -81,10 +82,12 @@ public sealed class Plugin : IDalamudPlugin { ChatLogWindow = new ChatLogWindow(this); SettingsWindow = new SettingsWindow(this); CommandHelpWindow = new CommandHelpWindow(ChatLogWindow); + SeStringDebugger = new SeStringDebugger(this); WindowSystem.AddWindow(ChatLogWindow); WindowSystem.AddWindow(SettingsWindow); WindowSystem.AddWindow(CommandHelpWindow); + WindowSystem.AddWindow(SeStringDebugger); FontManager.BuildFonts(); Interface.UiBuilder.DisableCutsceneUiHide = true; @@ -114,6 +117,7 @@ public sealed class Plugin : IDalamudPlugin { WindowSystem.RemoveAllWindows(); ChatLogWindow.Dispose(); SettingsWindow.Dispose(); + SeStringDebugger.Dispose(); ExtraChat.Dispose(); Ipc.Dispose(); diff --git a/ChatTwo/Store.cs b/ChatTwo/Store.cs index dda6102..a493d2f 100755 --- a/ChatTwo/Store.cs +++ b/ChatTwo/Store.cs @@ -257,6 +257,7 @@ internal class Store : IDisposable { } } + public (SeString? Sender, SeString? Message) LastMessage = (null, null); private void ChatMessage(XivChatType type, uint senderId, SeString sender, SeString message) { var chatCode = new ChatCode((ushort) type); @@ -265,6 +266,7 @@ internal class Store : IDisposable { formatting = FormatFor(chatCode.Type); } + LastMessage = (sender, message); var senderChunks = new List(); if (formatting is { IsPresent: true }) { senderChunks.Add(new TextChunk(ChunkSource.None, null, formatting.Before) { diff --git a/ChatTwo/Ui/ChatLogWindow.cs b/ChatTwo/Ui/ChatLogWindow.cs index c5f73f6..193985b 100644 --- a/ChatTwo/Ui/ChatLogWindow.cs +++ b/ChatTwo/Ui/ChatLogWindow.cs @@ -75,12 +75,8 @@ public sealed class ChatLogWindow : Window, IUiComponent { Plugin = plugin; Salt = new Random().Next().ToString(); + Size = new Vector2(500, 250); SizeCondition = ImGuiCond.FirstUseEver; - SizeConstraints = new WindowSizeConstraints - { - MinimumSize = new Vector2(500, 250), - MaximumSize = new Vector2(float.MaxValue, float.MaxValue) - }; PayloadHandler = new PayloadHandler(this); HandlerLender = new Lender(() => new PayloadHandler(this)); @@ -1257,10 +1253,8 @@ public sealed class ChatLogWindow : Window, IUiComponent { || cmd.Alias.RawString == command || cmd.ShortCommand.RawString == command || cmd.ShortAlias.RawString == command); - if (cmd != null) { + if (cmd != null) Plugin.CommandHelpWindow.UpdateContent(cmd); - Plugin.CommandHelpWindow.IsOpen = true; - } } if (data->EventFlag != ImGuiInputTextFlags.CallbackHistory) { @@ -1332,15 +1326,15 @@ public sealed class ChatLogWindow : Window, IUiComponent { private void DrawChunk(Chunk chunk, bool wrap = true, PayloadHandler? handler = null, float lineWidth = 0f) { if (chunk is IconChunk icon && _fontIcon != null) { - var bounds = IconUtil.GetBounds((byte) icon.Icon); - if (bounds != null) { + var bounds = IconUtil.GfdFileView.TryGetEntry((uint) icon.Icon, out var entry); + if (bounds) { var texSize = new Vector2(_fontIcon.Width, _fontIcon.Height); - var sizeRatio = Plugin.Config.FontSize / bounds.Value.W; - var size = new Vector2(bounds.Value.Z, bounds.Value.W) * sizeRatio * ImGuiHelpers.GlobalScale; + var sizeRatio = Plugin.Config.FontSize / entry.Height; + var size = new Vector2(entry.Width, entry.Height) * sizeRatio * ImGuiHelpers.GlobalScale; - var uv0 = new Vector2(bounds.Value.X, bounds.Value.Y - 2) / texSize; - var uv1 = new Vector2(bounds.Value.X + bounds.Value.Z, bounds.Value.Y - 2 + bounds.Value.W) / texSize; + var uv0 = new Vector2(entry.Left, entry.Top) / texSize; + var uv1 = new Vector2(entry.Left + entry.Width, entry.Top + entry.Height) / texSize; ImGui.Image(_fontIcon.ImGuiHandle, size, uv0, uv1); ImGuiUtil.PostPayload(chunk, handler); } diff --git a/ChatTwo/Ui/CommandHelpWindow.cs b/ChatTwo/Ui/CommandHelpWindow.cs index ab9ac55..23590a3 100644 --- a/ChatTwo/Ui/CommandHelpWindow.cs +++ b/ChatTwo/Ui/CommandHelpWindow.cs @@ -20,6 +20,7 @@ public class CommandHelpWindow : Window { ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.AlwaysAutoResize; } + // Sets IsOpen to true if it should be drawn public void UpdateContent(TextCommand command) { Command = command; @@ -36,6 +37,7 @@ public class CommandHelpWindow : Window { break; case CommandHelpSide.None: default: + IsOpen = false; return; } @@ -45,6 +47,8 @@ public class CommandHelpWindow : Window { MinimumSize = new Vector2(width, 0), MaximumSize = LogWindow.LastWindowSize with { X = width } }; + + IsOpen = true; } public override void Draw() diff --git a/ChatTwo/Ui/SeStringDebugger.cs b/ChatTwo/Ui/SeStringDebugger.cs new file mode 100644 index 0000000..4bddcc3 --- /dev/null +++ b/ChatTwo/Ui/SeStringDebugger.cs @@ -0,0 +1,319 @@ +using System.Numerics; +using System.Text; +using ChatTwo.Util; +using Dalamud.Game.Text.SeStringHandling.Payloads; +using Dalamud.Interface.Windowing; +using ImGuiNET; +using DalamudPartyFinderPayload = Dalamud.Game.Text.SeStringHandling.Payloads.PartyFinderPayload; + +namespace ChatTwo.Ui; + +public class SeStringDebugger : Window +{ + private readonly Plugin Plugin; + + public SeStringDebugger(Plugin plugin) : base($"SeString Debugger###chat2-sestringdebugger") + { + Plugin = plugin; + + SizeConstraints = new WindowSizeConstraints + { + MinimumSize = new Vector2(475, 600), + MaximumSize = new Vector2(float.MaxValue, float.MaxValue) + }; + + Plugin.Commands.Register("/chat2Debugger").Execute += Toggle; + } + + public void Dispose() + { + Plugin.Commands.Register("/chat2Debugger").Execute -= Toggle; + } + + private void Toggle(string _, string __) => Toggle(); + + public override void Draw() + { + ImGui.TextUnformatted("SeString Content"); + ImGui.Spacing(); + + if (Plugin.Store.LastMessage.Sender == null) + { + ImGui.TextUnformatted("Nothing to show"); + return; + } + + // TODO: Make SeString freely selectable through chat + foreach (var payload in Plugin.Store.LastMessage.Sender.Payloads) + { + switch (payload) + { + case UIForegroundPayload color: + { + RenderMetadataDictionary("Link ForegroundColor", new Dictionary + { + { "Enabled?", color.IsEnabled.ToString() }, + { "ColorKey", color.IsEnabled ? color.ColorKey.ToString() : "Color Ended" }, + }); + break; + } + case MapLinkPayload map: + { + RenderMetadataDictionary("Link MapLinkPayload", new Dictionary + { + { "Map.RowId", map.Map?.RowId.ToString() }, + { "Map.PlaceName", map.Map?.PlaceName.Value?.Name.ToString() }, + { "Map.PlaceNameRegion", map.Map?.PlaceNameRegion.Value?.Name.ToString() }, + { "Map.PlaceNameSub", map.Map?.PlaceNameSub.Value?.Name.ToString() }, + { "TerritoryType.RowId", map.TerritoryType?.RowId.ToString() }, + { "RawX", map.RawX.ToString() }, + { "RawY", map.RawY.ToString() }, + { "XCoord", map.XCoord.ToString() }, + { "YCoord", map.YCoord.ToString() }, + { "CoordinateString", map.CoordinateString }, + { "DataString", map.DataString }, + }); + break; + } + case QuestPayload quest: + { + RenderMetadataDictionary("Link QuestPayload", new Dictionary + { + { "Quest.RowId", quest.Quest?.RowId.ToString() }, + { "Quest.Name", quest.Quest?.Name.ToString() }, + }); + break; + } + case DalamudLinkPayload link: + { + RenderMetadataDictionary("Link DalamudLinkPayload", new Dictionary + { + { "CommandId", link.CommandId.ToString() }, + { "Plugin", link.Plugin }, + }); + break; + } + case DalamudPartyFinderPayload pf: + { + RenderMetadataDictionary("Link PartyFinderPayload", new Dictionary + { + { "ListingId", pf.ListingId.ToString() }, + { "LinkType", EnumName(pf.LinkType) }, + }); + break; + } + case PlayerPayload player: + { + RenderMetadataDictionary("Link PlayerPayload", new Dictionary + { + { "Real", player.DisplayedName }, + { "PlayerName", player.PlayerName }, + { "World.Name", player.World.Name }, + }); + break; + } + case ItemPayload item: + { + RenderMetadataDictionary("Link ItemPayload", new Dictionary + { + { "ItemId", item.ItemId.ToString() }, + { "RawItemId", item.RawItemId.ToString() }, + { "Kind", EnumName(item.Kind) }, + { "IsHQ", item.IsHQ.ToString() }, + { "Item.Name", item.Item?.Name.ToString() }, + }); + break; + } + case AutoTranslatePayload at: + { + RenderMetadataDictionary("Link AutoTranslatePayload", new Dictionary + { + { "Text", at.Text }, + }); + break; + } + case IconPayload icon: + { + var found = IconUtil.GfdFileView.TryGetEntry((uint) icon.Icon, out var entry); + RenderMetadataDictionary("Link IconPayload", new Dictionary + { + { "Found", found.ToString() }, + { "Icon ID", ((uint) icon.Icon).ToString() }, + }); + break; + } + case RawPayload raw: + { + var colorPayload = ColorPayload.From(raw.Data); + if (colorPayload != null) + { + var push = colorPayload.Enabled && colorPayload.Color != 0; + // if (push) ImGui.PushStyleColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(colorPayload.U)); + RenderMetadataDictionary("Link ColorPayload", new Dictionary + { + { "Unshifted", colorPayload.UnshiftedColor.ToString("X8") }, + { "Color", colorPayload.Color.ToString("X8") }, + { "Enabled?", colorPayload.Enabled.ToString() }, + }); + // if (push) ImGui.PopStyleColor(); + } + else + { + RenderMetadataDictionary("Link RawPayload", new Dictionary + { + { "Data", string.Join(" ", raw.Data.Select(b => b.ToString("X2"))) }, + { "Type", EnumName(raw.Type) }, + }); + } + break; + } + case StatusPayload status: + { + RenderMetadataDictionary("Link StatusPayload", new Dictionary + { + { "Status.RowId", status.Status.RowId.ToString() }, + { "Status.Name", status.Status.Name }, + { "Status.Icon", status.Status.Icon.ToString() } + }); + break; + } + + case Util.PartyFinderPayload pf: + { + RenderMetadataDictionary("Link PartyFinderPayload", new Dictionary + { + { "Id", pf.Id.ToString() } + }); + break; + } + case AchievementPayload achievement: + { + RenderMetadataDictionary("Link AchievementPayload", new Dictionary + { + { "Id", achievement.Id.ToString() } + }); + break; + } + default: + var payloadData = payload.Encode(); + + var initialByte = payloadData.First(); + if (initialByte != 0x02) + { + RenderMetadataDictionary("Text Payload", new Dictionary + { + { "Content", Encoding.UTF8.GetString(payloadData) }, + }); + } + else + { + var unknown = new RawPayload(payloadData); + RenderMetadataDictionary("Link Unknown", new Dictionary + { + { "Unknown", string.Join(" ", unknown.Data.Select(b => b.ToString("X2"))) }, + }); + } + break; + } + } + } + + private static string? EnumName(T? value) where T : Enum + { + if (value == null) + { + return null; + } + var rawValue = Convert.ChangeType(value, value.GetTypeCode()); + return (Enum.GetName(value.GetType(), value) ?? "Unknown") + $" ({rawValue})"; + } + + private static void RenderMetadataDictionary(string name, Dictionary metadata) + { + var style = ImGui.GetStyle(); + + ImGui.Text($"{name}:"); + ImGui.Indent(style.IndentSpacing); + if (!ImGui.BeginTable($"##chat3-{name}", 2, 0)) + { + ImGui.EndTable(); + ImGui.Unindent(style.IndentSpacing); + return; + } + ImGui.TableSetupColumn($"##chat3-{name}-key", 0, 0.4f); + ImGui.TableSetupColumn($"##chat3-{name}-value"); + for (var i = 0; i < metadata.Count; i++) + { + var (key, value) = metadata.ElementAt(i); + ImGui.PushID(i); + ImGui.TableNextColumn(); + ImGui.Text(key); + ImGui.TableNextColumn(); + ImGuiTextVisibleWhitespace(value); + ImGui.PopID(); + } + ImGui.EndTable(); + ImGui.Unindent(style.IndentSpacing); + ImGui.NewLine(); + } + + // ImGuiTextVisibleWhitespace replaces leading and trailing whitespace with + // visible characters. The extra characters are rendered with a muted font. + private static void ImGuiTextVisibleWhitespace(string? original, bool wrap = true) + { + if (string.IsNullOrEmpty(original)) + { + var str = original == null ? "(null)" : "(empty)"; + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1, 1, 1, 0.5f)); + ImGui.TextUnformatted(str); + ImGui.PopStyleColor(); + return; + } + + var text = original; + var start = 0; + var end = text.Length; + + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(0, 0)); + + void WriteText(string text) + { + if (wrap) + { + ImGui.TextWrapped(text); + } + else + { + ImGui.TextUnformatted(text); + } + } + + while (start < end && char.IsWhiteSpace(text[start])) + { + start++; + } + if (start > 0) + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1, 1, 1, 0.5f)); + WriteText(new string('_', start)); + ImGui.PopStyleColor(); + ImGui.SameLine(); + } + + while (end > start && char.IsWhiteSpace(text[end - 1])) + { + end--; + } + + WriteText(text[start..end]); + if (end < text.Length) + { + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1, 1, 1, 0.5f)); + WriteText(new string('_', text.Length - end)); + ImGui.PopStyleColor(); + } + + ImGui.PopStyleVar(); + } +} \ No newline at end of file diff --git a/ChatTwo/Util/ChunkUtil.cs b/ChatTwo/Util/ChunkUtil.cs index 45368da..ab045ea 100755 --- a/ChatTwo/Util/ChunkUtil.cs +++ b/ChatTwo/Util/ChunkUtil.cs @@ -68,15 +68,19 @@ internal static class ChunkUtil { break; case PayloadType.Unknown: var rawPayload = (RawPayload) payload; - if (rawPayload.Data.Length > 1 && rawPayload.Data[1] == 0x13) + var colorPayload = ColorPayload.From(rawPayload.Data); + if (colorPayload != null) { - if (foreground.Count > 0) { - foreground.Pop(); - } - else if (rawPayload.Data.Length > 6 && rawPayload.Data[2] == 0x05 && rawPayload.Data[3] == 0xF6) + if (colorPayload.Enabled) { - var (r, g, b) = (rawPayload.Data[4], rawPayload.Data[5], rawPayload.Data[6]); - foreground.Push(ColourUtil.ComponentsToRgba(r, g, b)); + if (colorPayload.Color > 0) + foreground.Push(colorPayload.Color); + else if (foreground.Count > 0) // Push the previous color as we don't want invisible text + foreground.Push(foreground.Peek()); + } + else if (foreground.Count > 0) + { + foreground.Pop(); } } else if (rawPayload.Data.Length > 1 && rawPayload.Data[1] == 0x14) diff --git a/ChatTwo/Util/ColourUtil.cs b/ChatTwo/Util/ColourUtil.cs index 0887e4c..145afe6 100755 --- a/ChatTwo/Util/ColourUtil.cs +++ b/ChatTwo/Util/ColourUtil.cs @@ -12,8 +12,8 @@ internal static class ColourUtil { } internal static uint RgbaToAbgr(uint rgba) { - var (r, g, b, a) = RgbaToComponents(rgba); - return (uint) ((a << 24) | (b << 16) | (g << 8) | r); + var tmp = ((rgba << 8) & 0xFF00FF00) | ((rgba >> 8) & 0xFF00FF); + return (tmp << 16) | (tmp >> 16); } internal static Vector3 RgbaToVector3(uint rgba) { @@ -38,6 +38,13 @@ internal static class ColourUtil { )); } + public static unsafe uint ArgbToRgba(uint x) + { + var buf = (byte*)&x; + (buf[1], buf[2], buf[3], buf[0]) = (buf[0], buf[1], buf[2], buf[3]); + return x; + } + internal static uint ComponentsToRgba(byte red, byte green, byte blue, byte alpha = 0xFF) => alpha | (uint) (red << 24) | (uint) (green << 16) diff --git a/ChatTwo/Util/ExtraPayload.cs b/ChatTwo/Util/ExtraPayload.cs new file mode 100644 index 0000000..9e736c9 --- /dev/null +++ b/ChatTwo/Util/ExtraPayload.cs @@ -0,0 +1,109 @@ +using Dalamud.Utility; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using FFXIVClientStructs.FFXIV.Component.Text; + +namespace ChatTwo.Util; + +public class ColorPayload +{ + private const byte START_BYTE = 2; + + public bool Enabled; + public uint Color; + public uint UnshiftedColor; + + public static ColorPayload? From(byte[] data) + { + using var stream = new MemoryStream(data); + if (stream.ReadByte() != START_BYTE || stream.ReadByte() != 0x13) + return null; + + stream.ReadByte(); // skip the length byte; + + var typeByte = stream.ReadByte(); + var payload = new ColorPayload(); + if (typeByte == 0xEC) + { + payload.Enabled = false; + return payload; + } + + if (typeByte == 0xE9) + { + var param = stream.ReadByte(); + var ok = TryGetGNumDefault((uint) (param - 2), out var value); + if (!ok) + { + Plugin.Log.Error($"Unable to GetGNum for param {param - 2}"); + return null; + } + + payload.Enabled = true; + payload.UnshiftedColor = value; + payload.Color = ColourUtil.ArgbToRgba(value); + + return payload; + } + + if (typeByte is >= 0xF0 and <= 0xFE) + { + // From: https://github.com/NotAdam/Lumina/blob/master/src/Lumina/Text/Expressions/IntegerExpression.cs#L119-L128 + uint ShiftAndThrowIfZero(int v, int shift) + { + return v switch + { + -1 => throw new ArgumentException("Encountered premature end of input (unexpected EOF).", nameof(v)), + 0 => throw new ArgumentException("Encountered premature end of input (unexpected null character).", nameof(v)), + _ => (uint)v << shift + }; + } + + typeByte += 1; + var value = 0u; + if ((typeByte & 8) != 0) + value |= ShiftAndThrowIfZero(stream.ReadByte(), 24); + else + value |= 0xff000000u; + + if( (typeByte & 4) != 0 ) value |= ShiftAndThrowIfZero( stream.ReadByte(), 16 ); + if( (typeByte & 2) != 0 ) value |= ShiftAndThrowIfZero( stream.ReadByte(), 8 ); + if( (typeByte & 1) != 0 ) value |= ShiftAndThrowIfZero( stream.ReadByte(), 0 ); + + payload.Enabled = true; + payload.Color = ColourUtil.ArgbToRgba(value); + + return payload; + } + + return null; + } + + private static unsafe bool TryGetGNumDefault(uint parameterIndex, out uint value) + { + value = 0u; + + var rtm = RaptureTextModule.Instance(); + if (rtm is null) + return false; + + if (!ThreadSafety.IsMainThread) + { + Plugin.Log.Error("Global parameters may only be used from the main thread."); + return false; + } + + ref var gp = ref rtm->TextModule.MacroDecoder.GlobalParameters; + if (parameterIndex >= gp.MySize) + return false; + + var p = rtm->TextModule.MacroDecoder.GlobalParameters.Get(parameterIndex); + switch (p.Type) + { + case TextParameterType.Integer: + value = (uint)p.IntValue; + return true; + default: + return false; + } + } +} \ No newline at end of file diff --git a/ChatTwo/Util/IconUtil.cs b/ChatTwo/Util/IconUtil.cs index d4e68de..11cd426 100755 --- a/ChatTwo/Util/IconUtil.cs +++ b/ChatTwo/Util/IconUtil.cs @@ -1,95 +1,148 @@ -using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; namespace ChatTwo.Util; -internal static class IconUtil { - internal static Vector4? GetBounds(byte id) => id switch { - 1 => new Vector4(0, 342, 40, 40), - 2 => new Vector4(40, 342, 40, 40), - 3 => new Vector4(80, 342, 40, 40), - 4 => new Vector4(120, 342, 40, 40), - 5 => new Vector4(160, 342, 40, 40), - 6 => new Vector4(0, 382, 40, 40), - 7 => new Vector4(40, 382, 40, 40), - 8 => new Vector4(80, 382, 40, 40), - 9 => new Vector4(120, 382, 40, 40), - 10 => new Vector4(160, 382, 40, 40), - 11 => new Vector4(0, 422, 40, 40), - 12 => new Vector4(40, 422, 40, 40), - 13 => new Vector4(80, 422, 40, 40), - 14 => new Vector4(120, 422, 40, 40), - 15 => new Vector4(160, 422, 40, 40), - 16 => new Vector4(120, 542, 40, 40), - 17 => new Vector4(160, 542, 40, 40), - 18 => new Vector4(0, 462, 108, 40), - 19 => new Vector4(108, 462, 108, 40), - 20 => new Vector4(120, 502, 40, 40), - 21 => new Vector4(0, 502, 56, 40), - 22 => new Vector4(56, 502, 64, 40), - 23 => new Vector4(160, 502, 40, 40), - 24 => new Vector4(0, 542, 56, 40), - 25 => new Vector4(56, 542, 64, 40), - 51 => new Vector4(248, 342, 40, 40), - 52 => new Vector4(288, 342, 40, 40), - 53 => new Vector4(328, 342, 40, 40), - 54 => new Vector4(200, 342, 24, 40), - 55 => new Vector4(224, 342, 24, 40), - 56 => new Vector4(200, 382, 40, 40), - 57 => new Vector4(240, 382, 40, 40), - 58 => new Vector4(280, 382, 40, 40), - 59 => new Vector4(200, 422, 40, 40), - 60 => new Vector4(240, 422, 40, 40), - 61 => new Vector4(280, 422, 40, 40), - 62 => new Vector4(320, 382, 40, 40), - 63 => new Vector4(320, 422, 40, 40), - 64 => new Vector4(368, 342, 40, 40), - 65 => new Vector4(408, 342, 40, 40), - 66 => new Vector4(448, 342, 40, 40), - 67 => new Vector4(360, 382, 40, 40), - 68 => new Vector4(400, 382, 40, 40), - 70 => new Vector4(360, 422, 40, 40), - 71 => new Vector4(400, 422, 40, 40), - 72 => new Vector4(440, 422, 40, 40), - 73 => new Vector4(440, 382, 40, 40), - 74 => new Vector4(216, 462, 40, 40), - 75 => new Vector4(256, 462, 40, 40), - 76 => new Vector4(296, 462, 40, 40), - 77 => new Vector4(336, 462, 40, 40), - 78 => new Vector4(376, 462, 40, 40), - 79 => new Vector4(416, 462, 40, 40), - 80 => new Vector4(456, 462, 40, 40), - 81 => new Vector4(200, 502, 40, 40), - 82 => new Vector4(240, 502, 40, 40), - 83 => new Vector4(280, 502, 40, 40), - 84 => new Vector4(320, 502, 40, 40), - 85 => new Vector4(360, 502, 40, 40), - 86 => new Vector4(400, 502, 40, 40), - 87 => new Vector4(440, 502, 40, 40), - 88 => new Vector4(200, 542, 40, 40), - 89 => new Vector4(240, 542, 40, 40), - 90 => new Vector4(280, 542, 40, 40), - 91 => new Vector4(320, 542, 40, 40), - 92 => new Vector4(360, 542, 40, 40), - 93 => new Vector4(400, 542, 40, 40), - 94 => new Vector4(440, 542, 40, 40), - 95 => new Vector4(0, 582, 40, 40), - 96 => new Vector4(40, 582, 40, 40), - 97 => new Vector4(80, 582, 40, 40), - 98 => new Vector4(120, 582, 40, 40), - 99 => new Vector4(160, 582, 40, 40), - 100 => new Vector4(200, 582, 40, 40), - 101 => new Vector4(240, 582, 40, 40), - 102 => new Vector4(280, 582, 40, 40), - 103 => new Vector4(320, 582, 40, 40), - 104 => new Vector4(360, 582, 40, 40), - 105 => new Vector4(400, 582, 40, 40), - 106 => new Vector4(440, 582, 40, 40), - 107 => new Vector4(0, 622, 40, 40), - 108 => new Vector4(40, 622, 40, 40), - 109 => new Vector4(80, 622, 40, 40), - 110 => new Vector4(120, 622, 40, 40), - 111 => new Vector4(160, 622, 40, 40), - 112 => new Vector4(200, 622, 40, 40), - _ => null, - }; +// From Kizer: https://github.com/Soreepeong/Dalamud/blob/feature/log-wordwrap/Dalamud/Interface/Spannables/Internal/GfdFileView.cs +public readonly unsafe ref struct GfdFileView +{ + private readonly ReadOnlySpan Span; + private readonly bool DirectLookup; + + /// Initializes a new instance of the struct. + /// The data. + public GfdFileView(ReadOnlySpan span) + { + Span = span; + if (span.Length < sizeof(GfdHeader)) + throw new InvalidDataException($"Not enough space for a {nameof(GfdHeader)}"); + if (span.Length < sizeof(GfdHeader) + (Header.Count * sizeof(GfdEntry))) + throw new InvalidDataException($"Not enough space for all the {nameof(GfdEntry)}"); + + var entries = Entries; + DirectLookup = true; + for (var i = 0; i < entries.Length && DirectLookup; i++) + DirectLookup &= i + 1 == entries[i].Id; + } + + /// Gets the header. + private ref readonly GfdHeader Header => ref MemoryMarshal.AsRef(Span); + + /// Gets the entries. + private ReadOnlySpan Entries => MemoryMarshal.Cast(Span[sizeof(GfdHeader)..]); + + /// Attempts to get an entry. + /// The icon ID. + /// The entry. + /// Whether to follow redirects. + /// true if found. + public bool TryGetEntry(uint iconId, out GfdEntry entry, bool followRedirect = true) + { + if (iconId == 0) + { + entry = default; + return false; + } + + var entries = Entries; + if (DirectLookup) + { + if (iconId <= entries.Length) + { + entry = entries[(int)(iconId - 1)]; + return !entry.IsEmpty; + } + + entry = default; + return false; + } + + var lo = 0; + var hi = entries.Length; + while (lo <= hi) + { + var i = lo + ((hi - lo) >> 1); + if (entries[i].Id == iconId) + { + if (followRedirect && entries[i].Redirect != 0) + { + iconId = entries[i].Redirect; + lo = 0; + hi = entries.Length; + continue; + } + + entry = entries[i]; + return !entry.IsEmpty; + } + + if (entries[i].Id < iconId) + lo = i + 1; + else + hi = i - 1; + } + + entry = default; + return false; + } + + /// Header of a .gfd file. + [StructLayout(LayoutKind.Sequential)] + public struct GfdHeader + { + /// Signature: "gftd0100". + public fixed byte Signature[8]; + + /// Number of entries. + public int Count; + + /// Unused/unknown. + public fixed byte Padding[4]; + } + + /// An entry of a .gfd file. + [StructLayout(LayoutKind.Sequential, Size = 0x10)] + public struct GfdEntry + { + /// ID of the entry. + public ushort Id; + + /// The left offset of the entry. + public ushort Left; + + /// The top offset of the entry. + public ushort Top; + + /// The width of the entry. + public ushort Width; + + /// The height of the entry. + public ushort Height; + + /// Unknown/unused. + public ushort Unk0A; + + /// The redirected entry, maybe. + public ushort Redirect; + + /// Unknown/unused. + public ushort Unk0E; + + /// Gets a value indicating whether this entry is effectively empty. + public bool IsEmpty => Width == 0 || Height == 0; + } +} + + + +internal static class IconUtil { + private static byte[]? GfdFile; + public static unsafe GfdFileView GfdFileView + { + get + { + GfdFile ??= Plugin.DataManager.GetFile("common/font/gfdata.gfd")!.Data; + return new GfdFileView(new ReadOnlySpan(Unsafe.AsPointer(ref GfdFile[0]), GfdFile.Length)); + } + } }