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;