From b5d22e305c5abfeacfc479caffc0eb3301969460 Mon Sep 17 00:00:00 2001 From: Shuro <944030+Shuro@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:52:06 +0200 Subject: [PATCH 1/2] Add text glow rendering and an outline all text option - Render the game-specified glow colour as a text outline in chat - Resolve game glow payloads via the dark-theme UIColor variant to match vanilla chat (the sheet columns are UI theme variants) - Scale game glow alpha by 0x66/0xFF so the composite opacity across the 8 outline stamps matches the vanilla glow (~85% peak) - Add an option to outline all text with a configurable colour - Default outline colour is black at 0x66 alpha for the same reason - Reset button for the outline colour, styled like the chat colours tab --- ChatTwo/Configuration.cs | 10 +++++ ChatTwo/Resources/Language.Designer.cs | 45 +++++++++++++++++++++++ ChatTwo/Resources/Language.resx | 15 ++++++++ ChatTwo/Ui/Handler/ChunkHandler.cs | 5 +++ ChatTwo/Ui/SettingsTabs/Fonts.cs | 19 ++++++++++ ChatTwo/Util/ChunkUtil.cs | 2 +- ChatTwo/Util/ColourUtil.cs | 8 ++++ ChatTwo/Util/ImGuiUtil.cs | 51 ++++++++++++++++++++++++++ 8 files changed, 154 insertions(+), 1 deletion(-) diff --git a/ChatTwo/Configuration.cs b/ChatTwo/Configuration.cs index d8e0b92..e63a23a 100755 --- a/ChatTwo/Configuration.cs +++ b/ChatTwo/Configuration.cs @@ -118,6 +118,13 @@ public class Configuration : IPluginConfiguration FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular), SizePt = 12.75f, }; + // Black at 0x66 alpha; composites to roughly the vanilla glow opacity + // across the 8 outline stamps drawn by ImGuiUtil.DrawTextGlow. + public const uint DefaultOutlineColor = 0x00000066; + + public bool RenderGlow = true; + public bool OutlineAllText; + public uint OutlineColor = DefaultOutlineColor; public float TooltipOffset; public float WindowAlpha = 100f; @@ -198,6 +205,9 @@ public void UpdateFrom(Configuration other, bool backToOriginal) JapaneseFontV2 = other.JapaneseFontV2; ItalicFontV2 = other.ItalicFontV2; SymbolsFontSizeV2 = other.SymbolsFontSizeV2; + RenderGlow = other.RenderGlow; + OutlineAllText = other.OutlineAllText; + OutlineColor = other.OutlineColor; TooltipOffset = other.TooltipOffset; WindowAlpha = other.WindowAlpha; ChatColours = other.ChatColours.ToDictionary(entry => entry.Key, entry => entry.Value); diff --git a/ChatTwo/Resources/Language.Designer.cs b/ChatTwo/Resources/Language.Designer.cs index 06aab33..3aa16b5 100755 --- a/ChatTwo/Resources/Language.Designer.cs +++ b/ChatTwo/Resources/Language.Designer.cs @@ -3039,6 +3039,33 @@ internal static string Options_NativeItemTooltips_Name { } } + /// + /// Looks up a localized string similar to Outline colour. + /// + internal static string Options_OutlineAllText_Color { + get { + return ResourceManager.GetString("Options_OutlineAllText_Color", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Draw an outline around all chat text to improve readability. Text with a game-specified glow colour keeps that colour.. + /// + internal static string Options_OutlineAllText_Description { + get { + return ResourceManager.GetString("Options_OutlineAllText_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Outline all text. + /// + internal static string Options_OutlineAllText_Name { + get { + return ResourceManager.GetString("Options_OutlineAllText_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Override Style. /// @@ -3255,6 +3282,24 @@ internal static string Options_PrintChangelog_Name { } } + /// + /// Looks up a localized string similar to Draw the glow colour the game specifies around text as a thin outline, like the vanilla chat log.. + /// + internal static string Options_RenderGlow_Description { + get { + return ResourceManager.GetString("Options_RenderGlow_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Render text glow. + /// + internal static string Options_RenderGlow_Name { + get { + return ResourceManager.GetString("Options_RenderGlow_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Replaces words with their emote version, currently supports BetterTTV.. /// diff --git a/ChatTwo/Resources/Language.resx b/ChatTwo/Resources/Language.resx index db02521..2632e97 100644 --- a/ChatTwo/Resources/Language.resx +++ b/ChatTwo/Resources/Language.resx @@ -553,6 +553,21 @@ Use italic font + + Render text glow + + + Draw the glow colour the game specifies around text as a thin outline, like the vanilla chat log. + + + Outline all text + + + Draw an outline around all chat text to improve readability. Text with a game-specified glow colour keeps that colour. + + + Outline colour + Search Auto Translate... diff --git a/ChatTwo/Ui/Handler/ChunkHandler.cs b/ChatTwo/Ui/Handler/ChunkHandler.cs index e0ff280..f2ab399 100644 --- a/ChatTwo/Ui/Handler/ChunkHandler.cs +++ b/ChatTwo/Ui/Handler/ChunkHandler.cs @@ -1,4 +1,5 @@ using System.Numerics; +using System.Text; using ChatTwo.Code; using ChatTwo.Util; using Dalamud.Bindings.ImGui; @@ -140,6 +141,10 @@ public void DrawChunk(Chunk chunk, bool wrap = true, PayloadHandler? handler = n } else { + var glow = ImGuiUtil.GetGlowColor(chunk); + if (glow != null && content.Length > 0) + ImGuiUtil.DrawTextGlow(ImGui.GetCursorScreenPos(), glow.Value, Encoding.UTF8.GetBytes(content)); + ImGui.TextUnformatted(content); ImGuiUtil.PostPayload(chunk, handler); } diff --git a/ChatTwo/Ui/SettingsTabs/Fonts.cs b/ChatTwo/Ui/SettingsTabs/Fonts.cs index 77c9ef3..21aaa22 100755 --- a/ChatTwo/Ui/SettingsTabs/Fonts.cs +++ b/ChatTwo/Ui/SettingsTabs/Fonts.cs @@ -1,6 +1,7 @@ using ChatTwo.Resources; using ChatTwo.Util; using Dalamud; +using Dalamud.Interface; using Dalamud.Interface.FontIdentifier; using Dalamud.Bindings.ImGui; using Dalamud.Interface.Utility.Raii; @@ -93,5 +94,23 @@ public void Draw(bool _) ImGuiUtil.HelpText(Language.Options_SymbolsFontSize_Description); ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + ImGuiUtil.OptionCheckbox(ref Mutable.RenderGlow, Language.Options_RenderGlow_Name, Language.Options_RenderGlow_Description); + ImGui.Spacing(); + + ImGuiUtil.OptionCheckbox(ref Mutable.OutlineAllText, Language.Options_OutlineAllText_Name, Language.Options_OutlineAllText_Description); + if (Mutable.OutlineAllText) + { + using var indent = ImRaii.PushIndent(); + if (ImGuiUtil.IconButton(FontAwesomeIcon.UndoAlt, "outline", Language.Options_ChatColours_Reset)) + Mutable.OutlineColor = Configuration.DefaultOutlineColor; + ImGui.SameLine(); + var outlineColour = ColourUtil.RgbaToVector4(Mutable.OutlineColor)!.Value; + if (ImGui.ColorEdit4(Language.Options_OutlineAllText_Color, ref outlineColour, ImGuiColorEditFlags.NoInputs)) + Mutable.OutlineColor = ColourUtil.Vector4ToRgba(outlineColour); + } + ImGui.Spacing(); } } diff --git a/ChatTwo/Util/ChunkUtil.cs b/ChatTwo/Util/ChunkUtil.cs index 7914fdb..a760595 100755 --- a/ChatTwo/Util/ChunkUtil.cs +++ b/ChatTwo/Util/ChunkUtil.cs @@ -287,7 +287,7 @@ void Append(string text) case PayloadType.UIGlow: var glowPayload = (UIGlowPayload) payload; if (glowPayload.IsEnabled) - glow.Push(glowPayload.UIColor.Value.Light); + glow.Push(glowPayload.UIColor.Value.Dark); else if (glow.Count > 0) glow.Pop(); break; diff --git a/ChatTwo/Util/ColourUtil.cs b/ChatTwo/Util/ColourUtil.cs index 0dec863..f42744d 100755 --- a/ChatTwo/Util/ColourUtil.cs +++ b/ChatTwo/Util/ColourUtil.cs @@ -39,6 +39,14 @@ public static Vector3 RgbaToVector3(uint rgba) public static uint Vector3ToRgba(Vector3 col) => ComponentsToRgba((byte)Math.Round(col.X * 255), (byte)Math.Round(col.Y * 255), (byte)Math.Round(col.Z * 255)); + /// + /// Converts a Vector4 to an RGBA color value. + /// + /// The color + /// Color as byte representation RR GG BB AA + public static uint Vector4ToRgba(Vector4 col) + => ComponentsToRgba((byte)Math.Round(col.X * 255), (byte)Math.Round(col.Y * 255), (byte)Math.Round(col.Z * 255), (byte)Math.Round(col.W * 255)); + /// /// Converts a Vector4 to an ABGR color value. /// diff --git a/ChatTwo/Util/ImGuiUtil.cs b/ChatTwo/Util/ImGuiUtil.cs index 08dece5..ec6e129 100755 --- a/ChatTwo/Util/ImGuiUtil.cs +++ b/ChatTwo/Util/ImGuiUtil.cs @@ -59,12 +59,63 @@ public static void PostPayload(Chunk chunk, PayloadHandler? handler) handler.Click(chunk, payload, button); } + /// + /// Resolves the effective glow (outline) colour for a chunk, combining + /// the game-specified glow payload with the outline all text option. + /// + /// The chunk about to be drawn + /// Colour as byte representation RR GG BB AA, or null if no outline should be drawn + public static uint? GetGlowColor(Chunk chunk) + { + if (Plugin.Config.RenderGlow && chunk is TextChunk { Glow: not null and not 0 } text) + { + // Game glow colours are fully opaque; scale the alpha so the composite + // opacity across the 8 outline stamps matches the vanilla glow (~85%). + var glow = text.Glow.Value; + var alpha = (byte) ((glow & 0xFF) * 0x66 / 0xFF); + return (glow & 0xFFFFFF00u) | alpha; + } + + return Plugin.Config.OutlineAllText ? Plugin.Config.OutlineColor : null; + } + + /// + /// Draws a text outline by rendering the text into the window draw list, + /// offset by a pixel in each direction. Call this before drawing the text + /// itself so the outline stays underneath it. + /// + /// Screen position the text will be drawn at + /// Outline colour as byte representation RR GG BB AA + /// UTF-8 text about to be drawn + public static void DrawTextGlow(Vector2 pos, uint rgba, ReadOnlySpan text) + { + // Style alpha (e.g. faded windows) is applied to regular text items + // but not to raw draw list commands, so bake it into the colour. + var colour = ColourUtil.RgbaToVector4(rgba)!.Value; + colour.W *= ImGui.GetStyle().Alpha; + if (colour.W <= 0) + return; + + var abgr = ColourUtil.Vector4ToAbgr(colour); + var drawList = ImGui.GetWindowDrawList(); + var offset = ImGuiHelpers.GlobalScale; + for (var x = -1; x <= 1; x++) + for (var y = -1; y <= 1; y++) + if (x != 0 || y != 0) + drawList.AddText(pos + new Vector2(x, y) * offset, abgr, text); + } + public static unsafe void WrapText(string csText, Chunk chunk, PayloadHandler? handler, Vector4 defaultText, float lineWidth) { + var glow = GetGlowColor(chunk); + void Text(byte* text, byte* textEnd) { var oldPos = ImGui.GetCursorScreenPos(); + if (glow != null) + DrawTextGlow(oldPos, glow.Value, new ReadOnlySpan(text, (int) (textEnd - text))); + ImGuiNative.TextUnformatted(text, textEnd); PostPayload(chunk, handler); From 3de81c31a10f926ebcaf6b8079ed4ee3f830e69f Mon Sep 17 00:00:00 2001 From: Shuro <944030+Shuro@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:36:55 +0200 Subject: [PATCH 2/2] Fix glow rendering for raw EdgeColor values - Use a fixed outline stamp alpha instead of deriving it from the pushed color; the game renders edge colors fully opaque regardless of their alpha byte - Parse raw EdgeColor macros through ColorPayload (packed ints, gnum, stackcolor and small-int literals) instead of popping the glow stack on any raw payload - Push resolved zero values as real no-glow entries to keep the color stack balanced, matching the vanilla renderer - Guard UIColor sheet lookups: a color payload referencing a missing row previously threw and dropped the whole message - Return null from ColorPayload.From on truncated packed integers instead of throwing - Decode EdgeColor payloads in the SeString debugger --- ChatTwo/Ui/SeStringDebugger.cs | 5 ++-- ChatTwo/Util/ChunkUtil.cs | 35 ++++++++++++++++++------ ChatTwo/Util/ColourUtil.cs | 7 +++-- ChatTwo/Util/ExtraPayload.cs | 50 +++++++++++++++++++++++----------- ChatTwo/Util/ImGuiUtil.cs | 11 ++++---- 5 files changed, 74 insertions(+), 34 deletions(-) diff --git a/ChatTwo/Ui/SeStringDebugger.cs b/ChatTwo/Ui/SeStringDebugger.cs index d0f83f1..1ea7cda 100644 --- a/ChatTwo/Ui/SeStringDebugger.cs +++ b/ChatTwo/Ui/SeStringDebugger.cs @@ -8,6 +8,7 @@ using Dalamud.Interface.Windowing; using Dalamud.Bindings.ImGui; using Dalamud.Utility; +using Lumina.Text.Payloads; using Lumina.Text.ReadOnly; using DalamudPartyFinderPayload = Dalamud.Game.Text.SeStringHandling.Payloads.PartyFinderPayload; @@ -183,10 +184,10 @@ private void ProcessPayloads(List payloads) } case RawPayload raw: { - var colorPayload = ColorPayload.From(raw.Data); + var colorPayload = ColorPayload.From(raw.Data) ?? ColorPayload.From(raw.Data, MacroCode.EdgeColor); if (colorPayload != null) { - RenderMetadataDictionary("Link ColorPayload", new Dictionary + RenderMetadataDictionary($"Link ColorPayload ({colorPayload.MacroCode})", new Dictionary { { "Unshifted", colorPayload.UnshiftedColor.ToString("X8") }, { "Color", colorPayload.Color.ToString("X8") }, diff --git a/ChatTwo/Util/ChunkUtil.cs b/ChatTwo/Util/ChunkUtil.cs index a760595..58d60cf 100755 --- a/ChatTwo/Util/ChunkUtil.cs +++ b/ChatTwo/Util/ChunkUtil.cs @@ -280,16 +280,32 @@ void Append(string text) case PayloadType.UIForeground: var foregroundPayload = (UIForegroundPayload) payload; if (foregroundPayload.IsEnabled) - foreground.Push(foregroundPayload.UIColor.Value.Dark); + { + // Rows missing from the UIColor sheet throw on RowRef.Value, which would + // drop the whole message; keep the previous color instead as we don't + // want invisible text. + if (foregroundPayload.UIColor.ValueNullable is { } foregroundRow) + foreground.Push(foregroundRow.Dark); + else if (foreground.Count > 0) + foreground.Push(foreground.Peek()); + } else if (foreground.Count > 0) + { foreground.Pop(); + } break; case PayloadType.UIGlow: var glowPayload = (UIGlowPayload) payload; if (glowPayload.IsEnabled) - glow.Push(glowPayload.UIColor.Value.Dark); + { + // Rows missing from the UIColor sheet throw on RowRef.Value, which would + // drop the whole message; push a no-glow entry to keep the stack balanced. + glow.Push(glowPayload.UIColor.ValueNullable?.Dark ?? 0); + } else if (glow.Count > 0) + { glow.Pop(); + } break; case PayloadType.AutoTranslateText: chunks.Add(new IconChunk(source, payload, BitmapFontIcon.AutoTranslateBegin)); @@ -328,16 +344,19 @@ void Append(string text) foreground.Pop(); } } - else if (rawPayload.Data.Length > 1 && rawPayload.Data[1] == 0x14) + else if (ColorPayload.From(rawPayload.Data, MacroCode.EdgeColor) is { } edgeColorPayload) { - if (glow.Count > 0) + if (edgeColorPayload.Enabled) { - glow.Pop(); + // The game pushes the resolved value even when it is 0, which draws + // no outline (verified against the vanilla renderer). A 0 entry keeps + // the stack balanced for the matching stackcolor pop; unlike the + // foreground there is no invisible-text concern. + glow.Push(edgeColorPayload.Color); } - else if (rawPayload.Data.Length > 6 && rawPayload.Data[2] == 0x05 && rawPayload.Data[3] == 0xF6) + else if (glow.Count > 0) { - var (r, g, b) = (rawPayload.Data[4], rawPayload.Data[5], rawPayload.Data[6]); - glow.Push(ColourUtil.ComponentsToRgba(r, g, b)); + glow.Pop(); } } else if (rawPayload.Data.Length > 7 && rawPayload.Data[1] == 0x27 && rawPayload.Data[3] == 0x0A) diff --git a/ChatTwo/Util/ColourUtil.cs b/ChatTwo/Util/ColourUtil.cs index f42744d..0f9349b 100755 --- a/ChatTwo/Util/ColourUtil.cs +++ b/ChatTwo/Util/ColourUtil.cs @@ -68,9 +68,10 @@ public static unsafe uint ArgbToRgba(uint col) if (col == 0) return 0; - // Check if Alpha is set, if not set to 255 - if (col <= 0x00FFFFFFu) - col |= 0xFF000000u; + // The game ignores the alpha of pushed colors entirely and renders them + // fully opaque (verified against the vanilla renderer), so force alpha + // regardless of what the payload carries. + col |= 0xFF000000u; var buf = (byte*)&col; (buf[1], buf[2], buf[3], buf[0]) = (buf[0], buf[1], buf[2], buf[3]); diff --git a/ChatTwo/Util/ExtraPayload.cs b/ChatTwo/Util/ExtraPayload.cs index 4d2ab5e..f17fecc 100644 --- a/ChatTwo/Util/ExtraPayload.cs +++ b/ChatTwo/Util/ExtraPayload.cs @@ -1,4 +1,6 @@ -namespace ChatTwo.Util; +using Lumina.Text.Payloads; + +namespace ChatTwo.Util; public class ColorPayload { @@ -7,17 +9,18 @@ public class ColorPayload public bool Enabled; public uint Color; public uint UnshiftedColor; + public MacroCode MacroCode; - public static ColorPayload? From(byte[] data) + public static ColorPayload? From(byte[] data, MacroCode macroCode = MacroCode.Color) { using var stream = new MemoryStream(data); - if (stream.ReadByte() != StartByte || stream.ReadByte() != 0x13) + if (stream.ReadByte() != StartByte || stream.ReadByte() != (byte) macroCode) return null; stream.ReadByte(); // skip the length byte; var typeByte = stream.ReadByte(); - var payload = new ColorPayload(); + var payload = new ColorPayload { MacroCode = macroCode }; switch (typeByte) { case 0xEC: @@ -33,32 +36,47 @@ public class ColorPayload return payload; case >= 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) + // Component bytes are never zero in this encoding, so zero doubles + // as the truncation sentinel (EOF or premature null); payload data + // comes from the game or other plugins, so malformed input must + // return null rather than throw. + var truncated = false; + uint ReadComponent(int shift) { - return v switch + var v = stream.ReadByte(); + if (v <= 0) { - // ReSharper disable once LocalizableElement - -1 => throw new ArgumentException("Encountered premature end of input (unexpected EOF).", nameof(v)), - // ReSharper disable once LocalizableElement - 0 => throw new ArgumentException("Encountered premature end of input (unexpected null character).", nameof(v)), - _ => (uint)v << shift, - }; + truncated = true; + return 0; + } + + return (uint) v << shift; } typeByte += 1; var argbValue = 0u; if ((typeByte & 8) != 0) - argbValue |= ShiftAndThrowIfZero(stream.ReadByte(), 24); + argbValue |= ReadComponent(24); else argbValue |= 0xFF000000u; - if( (typeByte & 4) != 0 ) argbValue |= ShiftAndThrowIfZero( stream.ReadByte(), 16 ); - if( (typeByte & 2) != 0 ) argbValue |= ShiftAndThrowIfZero( stream.ReadByte(), 8 ); - if( (typeByte & 1) != 0 ) argbValue |= ShiftAndThrowIfZero( stream.ReadByte(), 0 ); + if ((typeByte & 4) != 0) argbValue |= ReadComponent(16); + if ((typeByte & 2) != 0) argbValue |= ReadComponent(8); + if ((typeByte & 1) != 0) argbValue |= ReadComponent(0); + + if (truncated) + return null; payload.Enabled = true; payload.Color = ColourUtil.ArgbToRgba(argbValue); + return payload; + case >= 0x01 and <= 0xCF: + // Inline small integer: a single byte encoding value + 1. The game pushes the + // resolved value as-is; 0 draws no edge (verified against the vanilla renderer). + payload.Enabled = true; + payload.Color = ColourUtil.ArgbToRgba((uint) (typeByte - 1)); + return payload; default: return null; diff --git a/ChatTwo/Util/ImGuiUtil.cs b/ChatTwo/Util/ImGuiUtil.cs index ec6e129..89b6c99 100755 --- a/ChatTwo/Util/ImGuiUtil.cs +++ b/ChatTwo/Util/ImGuiUtil.cs @@ -69,11 +69,12 @@ public static void PostPayload(Chunk chunk, PayloadHandler? handler) { if (Plugin.Config.RenderGlow && chunk is TextChunk { Glow: not null and not 0 } text) { - // Game glow colours are fully opaque; scale the alpha so the composite - // opacity across the 8 outline stamps matches the vanilla glow (~85%). - var glow = text.Glow.Value; - var alpha = (byte) ((glow & 0xFF) * 0x66 / 0xFF); - return (glow & 0xFFFFFF00u) | alpha; + // The game ignores the alpha of pushed edge colours entirely (both + // EdgeColorType sheet lookups and raw EdgeColor values render fully + // opaque), so don't derive anything from the source alpha. Use the + // fixed per-stamp alpha whose composite over the 8 outline stamps + // matches the vanilla glow (~85%). + return (text.Glow.Value & 0xFFFFFF00u) | 0x66; } return Plugin.Config.OutlineAllText ? Plugin.Config.OutlineColor : null;