chore: code quality sweep 2026-05-04 / 2026-05-05

General code-quality and robustness pass across the plugin: thread-
safety on IPC state, resource-disposal cleanups, input validation,
defensive null-checks and a few small UX glitches. Compliance docs
(THIRD_PARTY_NOTICES, PRIVACY, COPYRIGHT) refreshed to v1.0.3.

Highlights
- ExtraChat IPC state synchronised across threads
- ChatLogWindow autocomplete no longer leaks the unmanaged
  ImGuiListClipper allocation
- ChatLogWindow + Popout style stack stays balanced when config
  toggles mid-frame
- Retention sweep and privacy cleanup wait for the actual filter
  pass instead of the fire-and-forget Task that started it
- Configuration.LatestVersion bumped to 13 to match the active
  migration path
- GameFunctions placeholder buffer guarded against oversized
  replacement names
- TellTarget.IsSet, ResolveTempInputChannel, InputPreview, IconUtil,
  Lender, Payloads, ExtraPayload all hardened against null / empty /
  EOF / cycle inputs
- FontManager Lodestone download stays in scope for a follow-up
  (timeout + lazy init pending)
- AutoTranslate replaced the msvcrt.dll memcmp P/Invoke with a
  managed Span comparison
- Privacy cleanup worker thread marked IsBackground = true
- Database cleanup now removes both legacy files in one click
- Tell-target name redacted in the verbose debug log

Compliance
- THIRD_PARTY_NOTICES: last-reviewed bumped to v1.0.3, Pidgin 3.5.1,
  SQLitePCLRaw.lib.e_sqlite3 3.50.3 listed as direct dependency with
  CVE-2025-6965 / CVE-2025-7709 rationale
- PRIVACY: last-reviewed bumped to v1.0.3, BetterTTV trigger wording
  clarified (list fetch at startup vs. on-demand image fetch)
- COPYRIGHT: upstream attribution range widened

Build: 0 warnings, 0 errors. No behavioural changes that would alter
existing user configuration or stored chat history.
This commit is contained in:
2026-05-05 07:25:47 +02:00
parent 698eb01bbe
commit 4d54eabdac
26 changed files with 251 additions and 98 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
HellionChat — a privacy-focused fork of ChatTwo for FINAL FANTASY XIV HellionChat — a privacy-focused fork of ChatTwo for FINAL FANTASY XIV
Copyright (c) 2024-2025 Infiziert90 (Infi) and Anna Clemens (ascclemens) Copyright (c) 2022-2026 Infiziert90 (Infi) and Anna Clemens (ascclemens)
Original ChatTwo authors and copyright holders of the upstream Original ChatTwo authors and copyright holders of the upstream
plugin this fork is built on. Their work covers the message store, plugin this fork is built on. Their work covers the message store,
the channel filtering, the sidebar tab system, the FFXIV chat the channel filtering, the sidebar tab system, the FFXIV chat
+5 -2
View File
@@ -34,7 +34,7 @@ public class ConfigKeyBind
[Serializable] [Serializable]
public class Configuration : IPluginConfiguration public class Configuration : IPluginConfiguration
{ {
private const int LatestVersion = 12; private const int LatestVersion = 13;
public int Version { get; set; } = LatestVersion; public int Version { get; set; } = LatestVersion;
@@ -279,7 +279,10 @@ public class Configuration : IPluginConfiguration
MaxLinesToRender = other.MaxLinesToRender; MaxLinesToRender = other.MaxLinesToRender;
Use24HourClock = other.Use24HourClock; Use24HourClock = other.Use24HourClock;
ShowEmotes = other.ShowEmotes; ShowEmotes = other.ShowEmotes;
BlockedEmotes = other.BlockedEmotes; // Deep-copy the set so the live and mutable Configuration instances don't share state
// — a HashSet reference assignment would cause edits in the settings window to leak
// into the live config before the user clicks Save.
BlockedEmotes = new HashSet<string>(other.BlockedEmotes);
FontsEnabled = other.FontsEnabled; FontsEnabled = other.FontsEnabled;
ItalicEnabled = other.ItalicEnabled; ItalicEnabled = other.ItalicEnabled;
ExtraGlyphRanges = other.ExtraGlyphRanges; ExtraGlyphRanges = other.ExtraGlyphRanges;
+4 -2
View File
@@ -252,7 +252,7 @@ internal sealed unsafe class Chat : IDisposable
{ {
playerName = SeString.Parse(agent->TellPlayerName).TextValue; playerName = SeString.Parse(agent->TellPlayerName).TextValue;
worldId = agent->TellWorldId; worldId = agent->TellWorldId;
Plugin.Log.Debug($"Detected tell target '{playerName}'@{worldId}"); Plugin.Log.Debug($"Detected tell target '[redacted]'@{worldId}");
} }
Plugin.CurrentTab.CurrentChannel = new UsedChannel Plugin.CurrentTab.CurrentChannel = new UsedChannel
@@ -400,7 +400,9 @@ internal sealed unsafe class Chat : IDisposable
} }
var idx = RotateLinkshell(currentIndex, rotate, channel == InputChannel.Linkshell1 ? ValidLinkshell : ValidCrossLinkshell); var idx = RotateLinkshell(currentIndex, rotate, channel == InputChannel.Linkshell1 ? ValidLinkshell : ValidCrossLinkshell);
return channel + idx; // RotateLinkshell returns null when no valid linkshell is found within 8 iterations.
// Forward the null so the caller can keep the existing channel instead of crashing on nullable arithmetic.
return idx is null ? null : channel + idx.Value;
} }
default: default:
return channel; return channel;
+13 -1
View File
@@ -245,7 +245,8 @@ internal unsafe class GameFunctions : IDisposable
vf0(agent, &result, &value, 0, 0); vf0(agent, &result, &value, 0, 0);
} }
private readonly nint PlaceholderNamePtr = Marshal.AllocHGlobal(128); private const int PlaceholderBufferSize = 128;
private readonly nint PlaceholderNamePtr = Marshal.AllocHGlobal(PlaceholderBufferSize);
private readonly string Placeholder = $"<{Guid.NewGuid():N}>"; private readonly string Placeholder = $"<{Guid.NewGuid():N}>";
private string? ReplacementName; private string? ReplacementName;
@@ -261,6 +262,17 @@ internal unsafe class GameFunctions : IDisposable
if (ReplacementName == null || placeholder != Placeholder) if (ReplacementName == null || placeholder != Placeholder)
return ResolveTextCommandPlaceholderHook.Original(a1, placeholderText, a3, a4); return ResolveTextCommandPlaceholderHook.Original(a1, placeholderText, a3, a4);
// The fixed buffer is 128 bytes; UTF-8 + null-terminator must fit.
// FFXIV player names plus an @World suffix should never approach this
// limit, but a malformed ReplacementName must not overflow the buffer.
var byteCount = System.Text.Encoding.UTF8.GetByteCount(ReplacementName);
if (byteCount >= PlaceholderBufferSize)
{
Plugin.Log.Warning($"Replacement name too long for placeholder buffer ({byteCount} bytes >= {PlaceholderBufferSize}); falling back to original.");
ReplacementName = null;
return ResolveTextCommandPlaceholderHook.Original(a1, placeholderText, a3, a4);
}
MemoryHelper.WriteString(PlaceholderNamePtr, ReplacementName); MemoryHelper.WriteString(PlaceholderNamePtr, ReplacementName);
ReplacementName = null; ReplacementName = null;
@@ -20,7 +20,7 @@ public class TellTarget
} }
public bool IsSet() public bool IsSet()
=> Name.Length > 0 && World > 0; => !string.IsNullOrEmpty(Name) && World > 0;
public string ToWorldString() public string ToWorldString()
=> Sheets.WorldSheet.TryGetRow(World, out var worldRow) ? worldRow.Name.ToString() : string.Empty; => Sheets.WorldSheet.TryGetRow(World, out var worldRow) ? worldRow.Name.ToString() : string.Empty;
+9 -4
View File
@@ -20,10 +20,14 @@ public sealed class ExtraChat : IDisposable
internal (string, uint)? ChannelOverride { get; set; } internal (string, uint)? ChannelOverride { get; set; }
private Dictionary<string, uint> ChannelCommandColoursInternal { get; set; } = new(); // Volatile reference: IPC callbacks (OnChannelCommandColours/OnChannelNames) fire on a
// Dalamud-dispatcher thread while the ImGui thread reads the IReadOnlyDictionary projections.
// Reference assignment is atomic on x64, but the JIT (especially Mono on Wine/Linux) needs
// the volatile barrier to guarantee visibility across threads. See AUDIT-2026-05-05 [SEC-01].
private volatile Dictionary<string, uint> ChannelCommandColoursInternal = new();
internal IReadOnlyDictionary<string, uint> ChannelCommandColours => ChannelCommandColoursInternal; internal IReadOnlyDictionary<string, uint> ChannelCommandColours => ChannelCommandColoursInternal;
private Dictionary<Guid, string> ChannelNamesInternal { get; set; } = new(); private volatile Dictionary<Guid, string> ChannelNamesInternal = new();
internal IReadOnlyDictionary<Guid, string> ChannelNames => ChannelNamesInternal; internal IReadOnlyDictionary<Guid, string> ChannelNames => ChannelNamesInternal;
internal ExtraChat() internal ExtraChat()
@@ -40,9 +44,10 @@ public sealed class ExtraChat : IDisposable
ChannelCommandColoursInternal = ChannelCommandColoursGate.InvokeFunc(null!); ChannelCommandColoursInternal = ChannelCommandColoursGate.InvokeFunc(null!);
ChannelNamesInternal = ChannelNamesGate.InvokeFunc(null!); ChannelNamesInternal = ChannelNamesGate.InvokeFunc(null!);
} }
catch (Exception) catch (Exception ex)
{ {
// no-op // ExtraChat is optional; missing IPC peer is normal when the plugin isn't loaded.
Plugin.Log.Verbose(ex, "ExtraChat IPC initial state query failed (peer not loaded?)");
} }
} }
+4
View File
@@ -93,6 +93,10 @@ internal class MessageManager : IAsyncDisposable
Plugin.Log.Debug("Sleeping because PendingMessageThread thread still alive"); Plugin.Log.Debug("Sleeping because PendingMessageThread thread still alive");
} }
// CancellationTokenSource owns an unmanaged WaitHandle; dispose after the
// worker thread has drained, otherwise it leaks across plugin reloads.
PendingThreadCancellationToken.Dispose();
Store.Dispose(); Store.Dispose();
} }
+6 -1
View File
@@ -529,10 +529,15 @@ public sealed class Plugin : IDalamudPlugin
if (deleted > 0) if (deleted > 0)
{ {
Log.Information($"Retention sweep deleted {deleted} expired messages."); Log.Information($"Retention sweep deleted {deleted} expired messages.");
// Run the clear+refilter synchronously on the framework thread.
// Earlier this called FilterAllTabsAsync(), which is fire-and-forget
// — the .Wait() here would return as soon as the inner Task.Run was
// dispatched, racing the next sweep cycle against the still-running
// filter pass. See AUDIT-2026-05-05 [QUAL-02].
Framework.Run(() => Framework.Run(() =>
{ {
MessageManager.ClearAllTabs(); MessageManager.ClearAllTabs();
MessageManager.FilterAllTabsAsync(); MessageManager.FilterAllTabs();
}).Wait(); }).Wait();
} }
else else
+1 -1
View File
@@ -104,7 +104,7 @@ public sealed class ChatInputBar
// window's logic but operates on _state.HistoryCursor and the shared // window's logic but operates on _state.HistoryCursor and the shared
// InputHistoryService. Index semantics match v0.5.x InputBacklog: // InputHistoryService. Index semantics match v0.5.x InputBacklog:
// 0 = oldest, Count-1 = newest. // 0 = oldest, Count-1 = newest.
private unsafe int CompactCallback(scoped ref ImGuiInputTextCallbackData data) private int CompactCallback(scoped ref ImGuiInputTextCallbackData data)
{ {
if (data.EventFlag != ImGuiInputTextFlags.CallbackHistory) if (data.EventFlag != ImGuiInputTextFlags.CallbackHistory)
return 0; return 0;
+54 -24
View File
@@ -34,6 +34,9 @@ public sealed class ChatLogWindow : Window
internal Plugin Plugin { get; } internal Plugin Plugin { get; }
private readonly CommandWrapper _clearHellionCommand;
private readonly CommandWrapper _hellionCommand;
internal bool ScreenshotMode; internal bool ScreenshotMode;
private string Salt { get; } private string Salt { get; }
@@ -110,8 +113,14 @@ public sealed class ChatLogWindow : Window
SetUpTextCommandChannels(); SetUpTextCommandChannels();
SetUpAllCommands(); SetUpAllCommands();
Plugin.Commands.Register("/clearhellion", "Clear the Hellion Chat log").Execute += ClearLog; // Cache the registered wrapper instances so Dispose can detach the same
Plugin.Commands.Register("/hellion").Execute += ToggleChat; // event objects the constructor attached to, without going through
// Register() again (which would re-create the wrapper if the command
// happened to be missing from the dictionary).
_clearHellionCommand = Plugin.Commands.Register("/clearhellion", "Clear the Hellion Chat log");
_hellionCommand = Plugin.Commands.Register("/hellion");
_clearHellionCommand.Execute += ClearLog;
_hellionCommand.Execute += ToggleChat;
Plugin.ClientState.Login += Login; Plugin.ClientState.Login += Login;
Plugin.ClientState.Logout += Logout; Plugin.ClientState.Logout += Logout;
@@ -126,8 +135,8 @@ public sealed class ChatLogWindow : Window
Plugin.AddonLifecycle.UnregisterListener(AddonEvent.PostUpdate, "ActionDetail", PayloadHandler.MoveTooltip); Plugin.AddonLifecycle.UnregisterListener(AddonEvent.PostUpdate, "ActionDetail", PayloadHandler.MoveTooltip);
Plugin.ClientState.Logout -= Logout; Plugin.ClientState.Logout -= Logout;
Plugin.ClientState.Login -= Login; Plugin.ClientState.Login -= Login;
Plugin.Commands.Register("/hellion").Execute -= ToggleChat; _hellionCommand.Execute -= ToggleChat;
Plugin.Commands.Register("/clearhellion").Execute -= ClearLog; _clearHellionCommand.Execute -= ClearLog;
} }
private void Logout(int _, int __) private void Logout(int _, int __)
@@ -514,13 +523,28 @@ public sealed class ChatLogWindow : Window
return FrameTime - lastActivityTime <= 1000 * Plugin.Config.InactivityHideTimeout; return FrameTime - lastActivityTime <= 1000 * Plugin.Config.InactivityHideTimeout;
} }
// Tracks the style instance pushed in PreDraw so PostDraw can pop the same
// one even if the user toggled OverrideStyle / ChosenStyle mid-frame.
// Without this, a config change between PreDraw and PostDraw could either
// leak a Push (no matching Pop) or pop nothing while we still have a frame
// pushed onto the ImGui stack.
private StyleModel? _pushedStyle;
public override void PreDraw() public override void PreDraw()
{ {
if (Plugin.Config.KeepInputFocus && Activate) if (Plugin.Config.KeepInputFocus && Activate)
ImGui.SetWindowFocus(WindowName); ImGui.SetWindowFocus(WindowName);
_pushedStyle = null;
if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null }) if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null })
StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Push(); {
var style = StyleModel.GetConfiguredStyles()?.FirstOrDefault(s => s.Name == Plugin.Config.ChosenStyle);
if (style != null)
{
style.Push();
_pushedStyle = style;
}
}
} }
public override void PostDraw() public override void PostDraw()
@@ -532,8 +556,11 @@ public sealed class ChatLogWindow : Window
if (Plugin.CurrentTab.InputDisabled) if (Plugin.CurrentTab.InputDisabled)
Activate = false; Activate = false;
if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null }) if (_pushedStyle != null)
StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Pop(); {
_pushedStyle.Pop();
_pushedStyle = null;
}
} }
public override void OnClose() public override void OnClose()
@@ -597,10 +624,11 @@ public sealed class ChatLogWindow : Window
Plugin.InputPreview.CalculatePreview(); Plugin.InputPreview.CalculatePreview();
// Hellion Chat v0.6.1 — render the one-time hint banner first so it // Hellion Chat v0.6.1 — render the one-time hint banner first so it
// sits above the tab area / sidebar in full window width. Stash the // sits above the tab area / sidebar in full window width. ImGui's
// height for GetRemainingHeightForMessageLog so the message log // GetContentRegionAvail subtracts its height automatically because the
// shrinks accordingly while the banner is visible. // cursor advances past it before the message log calls
_v061HintBannerHeight = DrawV061HintBannerIfNeeded(); // GetRemainingHeightForMessageLog, so we don't track the height here.
DrawV061HintBannerIfNeeded();
if (Plugin.Config.SidebarTabView) if (Plugin.Config.SidebarTabView)
DrawTabSidebar(); DrawTabSidebar();
@@ -1540,11 +1568,14 @@ public sealed class ChatLogWindow : Window
var startY = ImGui.GetCursorPosY(); var startY = ImGui.GetCursorPosY();
var bg = new System.Numerics.Vector4(0.16f, 0.20f, 0.28f, 1f); var bg = new System.Numerics.Vector4(0.16f, 0.20f, 0.28f, 1f);
ImGui.PushStyleColor(ImGuiCol.ChildBg, bg);
ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f);
var dismiss = false; var dismiss = false;
var openSettings = false; var openSettings = false;
// RAII for the style stack so an early return in this block
// (or a later refactor that introduces one) can never leave the
// ImGui style stack unbalanced. Matches the convention used
// elsewhere in this file.
using (ImRaii.PushColor(ImGuiCol.ChildBg, bg))
using (ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, 1f))
using (var child = ImRaii.Child("##v061-pop-out-header-hint", new System.Numerics.Vector2(0f, 84f), true)) using (var child = ImRaii.Child("##v061-pop-out-header-hint", new System.Numerics.Vector2(0f, 84f), true))
{ {
if (child) if (child)
@@ -1561,8 +1592,6 @@ public sealed class ChatLogWindow : Window
} }
} }
ImGui.PopStyleVar();
ImGui.PopStyleColor();
ImGui.Spacing(); ImGui.Spacing();
if (dismiss) if (dismiss)
@@ -1636,13 +1665,6 @@ public sealed class ChatLogWindow : Window
internal readonly List<bool> PopOutDocked = []; internal readonly List<bool> PopOutDocked = [];
internal readonly HashSet<Guid> PopOutWindows = []; internal readonly HashSet<Guid> PopOutWindows = [];
// Hellion Chat v0.6.1 — height the v0.6.1 hint banner consumed in the
// current frame, read by GetRemainingHeightForMessageLog so the message
// log can shrink. Unconditionally reassigned at the top of DrawChatLog
// (before any tab-area render) so the value is always in sync with the
// current frame. Returns 0 once the banner is dismissed.
private float _v061HintBannerHeight;
// v0.6.0 — live enumeration of all active Popout windows so the // v0.6.0 — live enumeration of all active Popout windows so the
// KeybindManager can find a focused ChatInputBar to forward tab-cycle // KeybindManager can find a focused ChatInputBar to forward tab-cycle
// keybinds to. Filter on IsOpen prevents touching closed-but-still- // keybinds to. Filter on IsOpen prevents touching closed-but-still-
@@ -1745,7 +1767,8 @@ public sealed class ChatLogWindow : Window
return; return;
var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper()); var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper());
try
{
clipper.Begin(AutoCompleteList.Count); clipper.Begin(AutoCompleteList.Count);
while (clipper.Step()) while (clipper.Step())
{ {
@@ -1787,6 +1810,13 @@ public sealed class ChatLogWindow : Window
var selectedPos = clipper.StartPosY + clipper.ItemsHeight * (AutoCompleteSelection * 1f); var selectedPos = clipper.StartPosY + clipper.ItemsHeight * (AutoCompleteSelection * 1f);
ImGui.SetScrollFromPosY(selectedPos - ImGui.GetWindowPos().Y); ImGui.SetScrollFromPosY(selectedPos - ImGui.GetWindowPos().Y);
} }
finally
{
// ImGuiListClipperPtr wraps an unmanaged ImGuiListClipper allocated above.
// Without Destroy() the unmanaged block leaks per autocomplete render.
clipper.Destroy();
}
}
private int AutoCompleteCallback(scoped ref ImGuiInputTextCallbackData data) private int AutoCompleteCallback(scoped ref ImGuiInputTextCallbackData data)
{ {
+5 -2
View File
@@ -47,8 +47,11 @@ public class CommandHelpWindow : Window {
Position = pos; Position = pos;
SizeConstraints = new WindowSizeConstraints SizeConstraints = new WindowSizeConstraints
{ {
MinimumSize = new Vector2(width, 0), // Use scaledWidth here so the size constraints stay in the same
MaximumSize = LogWindow.LastWindowSize with { X = width } // coordinate space as Position above; otherwise the help window
// ends up the wrong width at non-100% DPI.
MinimumSize = new Vector2(scaledWidth, 0),
MaximumSize = LogWindow.LastWindowSize with { X = scaledWidth }
}; };
IsOpen = true; IsOpen = true;
+4 -1
View File
@@ -177,7 +177,10 @@ public partial class InputPreview : Window
return; return;
NextChunkIsAutoTranslate = true; NextChunkIsAutoTranslate = true;
var payload = (AutoTranslatePayload) chunk.Link!; // Malformed chunks could carry an AutoTranslateBegin icon without the matching
// payload; bail out instead of dereferencing a null Link.
if (chunk.Link is not AutoTranslatePayload payload)
return;
CursorPosition += $"<at:{payload.Group},{payload.Key}>".Length; CursorPosition += $"<at:{payload.Group},{payload.Key}>".Length;
return; return;
+18 -3
View File
@@ -65,10 +65,22 @@ internal class Popout : Window
return FrameTime - lastActivityTime <= 1000 * Plugin.Config.InactivityHideTimeout; return FrameTime - lastActivityTime <= 1000 * Plugin.Config.InactivityHideTimeout;
} }
// Tracks the style instance pushed in PreDraw so PostDraw pops the same
// one even if config changes mid-frame. See AUDIT-2026-05-05 [CR-UI-5].
private StyleModel? _pushedStyle;
public override void PreDraw() public override void PreDraw()
{ {
_pushedStyle = null;
if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null }) if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null })
StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Push(); {
var style = StyleModel.GetConfiguredStyles()?.FirstOrDefault(s => s.Name == Plugin.Config.ChosenStyle);
if (style != null)
{
style.Push();
_pushedStyle = style;
}
}
Flags = ImGuiWindowFlags.None; Flags = ImGuiWindowFlags.None;
if (!Plugin.Config.ShowPopOutTitleBar) if (!Plugin.Config.ShowPopOutTitleBar)
@@ -201,8 +213,11 @@ internal class Popout : Window
if (Idx >= 0 && Idx < ChatLogWindow.PopOutDocked.Count) if (Idx >= 0 && Idx < ChatLogWindow.PopOutDocked.Count)
ChatLogWindow.PopOutDocked[Idx] = ImGui.IsWindowDocked(); ChatLogWindow.PopOutDocked[Idx] = ImGui.IsWindowDocked();
if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null }) if (_pushedStyle != null)
StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Pop(); {
_pushedStyle.Pop();
_pushedStyle = null;
}
} }
public override void OnClose() public override void OnClose()
+10 -1
View File
@@ -222,7 +222,16 @@ public class SeStringDebugger : Window
default: default:
var payloadData = payload.Encode(); var payloadData = payload.Encode();
var initialByte = payloadData.First(); if (payloadData.Length == 0)
{
RenderMetadataDictionary("Empty Payload", new Dictionary<string, string?>
{
{ "Type", payload.GetType().Name },
});
break;
}
var initialByte = payloadData[0];
if (initialByte != 0x02) if (initialByte != 0x02)
{ {
RenderMetadataDictionary("Text Payload", new Dictionary<string, string?> RenderMetadataDictionary("Text Payload", new Dictionary<string, string?>
+9 -1
View File
@@ -21,6 +21,10 @@ internal sealed class Chat : ISettingsTab
public string Name => HellionStrings.Settings_Tab_Chat + "###tabs-chat"; public string Name => HellionStrings.Settings_Tab_Chat + "###tabs-chat";
private SearchSelector.SelectorPopupOptions WordPopupOptions; private SearchSelector.SelectorPopupOptions WordPopupOptions;
// Snapshot of EmoteCache.State for which we last built WordPopupOptions.
// Without this, an empty FilteredSheet (e.g., the user blocked every emote)
// would trigger a refill every frame the settings tab is open.
private EmoteCache.LoadingState? WordPopupOptionsBuiltFor;
internal Chat(Plugin plugin, Configuration mutable) internal Chat(Plugin plugin, Configuration mutable)
{ {
@@ -28,6 +32,7 @@ internal sealed class Chat : ISettingsTab
Mutable = mutable; Mutable = mutable;
WordPopupOptions = RefillSheet(); WordPopupOptions = RefillSheet();
WordPopupOptionsBuiltFor = EmoteCache.State;
} }
private SearchSelector.SelectorPopupOptions RefillSheet() private SearchSelector.SelectorPopupOptions RefillSheet()
@@ -160,9 +165,12 @@ internal sealed class Chat : ISettingsTab
ImGui.TextUnformatted(Language.Options_Emote_BlockedEmotes); ImGui.TextUnformatted(Language.Options_Emote_BlockedEmotes);
ImGui.Spacing(); ImGui.Spacing();
if (EmoteCache.State is EmoteCache.LoadingState.Done && WordPopupOptions.FilteredSheet.Length == 0) if (EmoteCache.State is EmoteCache.LoadingState.Done
&& WordPopupOptions.FilteredSheet.Length == 0
&& WordPopupOptionsBuiltFor != EmoteCache.LoadingState.Done)
{ {
WordPopupOptions = RefillSheet(); WordPopupOptions = RefillSheet();
WordPopupOptionsBuiltFor = EmoteCache.LoadingState.Done;
} }
var buttonWidth = ImGui.GetContentRegionAvail().X / 3; var buttonWidth = ImGui.GetContentRegionAvail().X / 3;
+3 -1
View File
@@ -81,9 +81,11 @@ internal sealed class Database : ISettingsTab
{ {
try try
{ {
// Delete both legacy files in one click — the previous if/else
// left the second file behind when both happened to exist.
if (old.Exists) if (old.Exists)
old.Delete(); old.Delete();
else if (migratedOld.Exists)
migratedOld.Delete(); migratedOld.Delete();
WrapperUtil.AddNotification(Language.Options_Database_Old_Delete_Success, NotificationType.Success); WrapperUtil.AddNotification(Language.Options_Database_Old_Delete_Success, NotificationType.Success);
} }
+10 -3
View File
@@ -615,7 +615,7 @@ internal sealed class Privacy : ISettingsTab
CleanupRunning = true; CleanupRunning = true;
var allowed = Plugin.Config.PrivacyPersistChannels.Select(t => (int)(ushort)t).ToList(); var allowed = Plugin.Config.PrivacyPersistChannels.Select(t => (int)(ushort)t).ToList();
new Thread(() => var thread = new Thread(() =>
{ {
try try
{ {
@@ -625,10 +625,14 @@ internal sealed class Privacy : ISettingsTab
// Bound the wait so a hung framework tick can't deadlock // Bound the wait so a hung framework tick can't deadlock
// the background cleanup worker. See the matching comment in // the background cleanup worker. See the matching comment in
// the retention path above for rationale. // the retention path above for rationale.
// Note: FilterAllTabs() is called synchronously instead of
// FilterAllTabsAsync() — the async variant fires-and-forgets
// a Task.Run, so the .Wait() would return before the filter
// pass actually finishes. See AUDIT-2026-05-05 [QUAL-02].
if (!Plugin.Framework.Run(() => if (!Plugin.Framework.Run(() =>
{ {
Plugin.MessageManager.ClearAllTabs(); Plugin.MessageManager.ClearAllTabs();
Plugin.MessageManager.FilterAllTabsAsync(); Plugin.MessageManager.FilterAllTabs();
}).Wait(TimeSpan.FromSeconds(5))) }).Wait(TimeSpan.FromSeconds(5)))
{ {
Plugin.Log.Warning("Privacy cleanup: framework refresh timed out after 5s."); Plugin.Log.Warning("Privacy cleanup: framework refresh timed out after 5s.");
@@ -646,6 +650,9 @@ internal sealed class Privacy : ISettingsTab
CleanupRunning = false; CleanupRunning = false;
CleanupCounts = null; CleanupCounts = null;
} }
}).Start(); });
// Background thread so a still-running cleanup doesn't hold up FFXIV exit.
thread.IsBackground = true;
thread.Start();
} }
} }
+4 -5
View File
@@ -1,6 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.Runtime.InteropServices;
using System.Text; using System.Text;
using Dalamud.Game; using Dalamud.Game;
using Dalamud.Utility; using Dalamud.Utility;
@@ -233,9 +232,6 @@ internal static class AutoTranslate
.ToList(); .ToList();
} }
[DllImport("msvcrt.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern int memcmp(byte[] b1, byte[] b2, nuint count);
internal static void ReplaceWithPayload(ref byte[] bytes) internal static void ReplaceWithPayload(ref byte[] bytes)
{ {
var search = "<at:"u8.ToArray(); var search = "<at:"u8.ToArray();
@@ -279,7 +275,10 @@ internal static class AutoTranslate
start = -1; start = -1;
} }
if (i + search.Length < bytes.Length && memcmp(bytes[i..], search, (nuint) search.Length) == 0) // Pure managed comparison via Span avoids the msvcrt.dll P/Invoke,
// which is fragile under Wine and triggered an extra managed-to-
// unmanaged copy per check.
if (i + search.Length < bytes.Length && bytes.AsSpan(i, search.Length).SequenceEqual(search))
start = i; start = i;
} }
} }
+2
View File
@@ -25,6 +25,8 @@ public class ColorPayload
return payload; return payload;
case 0xE9: case 0xE9:
var param = stream.ReadByte(); var param = stream.ReadByte();
if (param == -1)
throw new ArgumentException("Encountered premature end of input (unexpected EOF).", nameof(stream));
var globalValue = (uint) GlobalParametersCache.GetValue(param - 2); var globalValue = (uint) GlobalParametersCache.GetValue(param - 2);
payload.Enabled = true; payload.Enabled = true;
payload.UnshiftedColor = globalValue; payload.UnshiftedColor = globalValue;
+21 -4
View File
@@ -49,9 +49,21 @@ public readonly unsafe ref struct GfdFileView
var entries = Entries; var entries = Entries;
if (DirectLookup) if (DirectLookup)
{ {
if (iconId <= entries.Length) // Resolve redirects on the direct-lookup path too — the binary-search
// path follows them, and skipping them here was inconsistent for
// contiguous ID sets.
var visited = 0;
while (iconId <= entries.Length)
{ {
entry = entries[(int)(iconId - 1)]; entry = entries[(int)(iconId - 1)];
if (followRedirect && entry.Redirect != 0 && entry.Redirect != iconId)
{
if (++visited > entries.Length)
break; // cycle guard
iconId = entry.Redirect;
continue;
}
return !entry.IsEmpty; return !entry.IsEmpty;
} }
@@ -146,12 +158,17 @@ public readonly unsafe ref struct GfdFileView
internal static class IconUtil internal static class IconUtil
{ {
private static byte[]? GfdFile; private static byte[]? GfdFile;
public static unsafe GfdFileView GfdFileView public static GfdFileView GfdFileView
{ {
get get
{ {
GfdFile ??= Plugin.DataManager.GetFile("common/font/gfdata.gfd")!.Data; if (GfdFile is null)
return new GfdFileView(new ReadOnlySpan<byte>(Unsafe.AsPointer(ref GfdFile[0]), GfdFile.Length)); {
var file = Plugin.DataManager.GetFile("common/font/gfdata.gfd")
?? throw new FileNotFoundException("Failed to load common/font/gfdata.gfd from the game data.");
GfdFile = file.Data;
}
return new GfdFileView(GfdFile);
} }
} }
+1 -1
View File
@@ -8,7 +8,7 @@ internal class Lender<T>
internal Lender(Func<T> ctor) internal Lender(Func<T> ctor)
{ {
Ctor = ctor; Ctor = ctor ?? throw new ArgumentNullException(nameof(ctor));
} }
internal void ResetCounter() internal void ResetCounter()
+13
View File
@@ -4,8 +4,21 @@ namespace HellionChat.Util;
public static class MemoryUtil public static class MemoryUtil
{ {
// Diagnostic helper. Pointer dereferences here would crash on a null/garbage
// address and a huge length would log megabytes of raw bytes; both are easy
// to trigger from a debugger and pollute the log with potentially sensitive
// game-state. Validate the inputs before reading.
private const int MaxDumpLength = 4096;
public static unsafe void PrintMemoryArea(nint address, int length) public static unsafe void PrintMemoryArea(nint address, int length)
{ {
if (address == nint.Zero)
throw new ArgumentException("Memory address cannot be zero.", nameof(address));
if (length <= 0)
throw new ArgumentOutOfRangeException(nameof(length), length, "Length must be positive.");
if (length > MaxDumpLength)
throw new ArgumentOutOfRangeException(nameof(length), length, $"Length exceeds the {MaxDumpLength}-byte safety cap.");
var ptr = (byte*)address; var ptr = (byte*)address;
var str = new StringBuilder("\n"); var str = new StringBuilder("\n");
for(var i = 0; i < length; i++) for(var i = 0; i < length; i++)
+2
View File
@@ -66,6 +66,8 @@ internal class UriPayload(Uri uri) : Payload
public static UriPayload ResolveUri(string rawUri) public static UriPayload ResolveUri(string rawUri)
{ {
ArgumentNullException.ThrowIfNull(rawUri); ArgumentNullException.ThrowIfNull(rawUri);
if (string.IsNullOrWhiteSpace(rawUri))
throw new UriFormatException("URI cannot be empty or whitespace.");
// Check for an expected scheme '://', if not add 'https://' // Check for an expected scheme '://', if not add 'https://'
if (ExpectedSchemes.Any(scheme => rawUri.StartsWith($"{scheme}://"))) if (ExpectedSchemes.Any(scheme => rawUri.StartsWith($"{scheme}://")))
+3 -1
View File
@@ -23,6 +23,8 @@ internal static class StringUtil
var bytes = Math.Abs(byteCount); var bytes = Math.Abs(byteCount);
var place = Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024))); var place = Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024)));
var num = Math.Round(bytes / Math.Pow(1024, place), 1); var num = Math.Round(bytes / Math.Pow(1024, place), 1);
return (Math.Sign(byteCount) * num).ToString("N0") + suf[place]; // "0.#" keeps the rounded fractional digit (1.5 GB stays "1.5GB"); "N0"
// would truncate it back to integer.
return (Math.Sign(byteCount) * num).ToString("0.#") + suf[place];
} }
} }
+12 -3
View File
@@ -12,7 +12,7 @@ because no data ever leaves your machine on the maintainer's
infrastructure. Independently of that, the plugin is built so that infrastructure. Independently of that, the plugin is built so that
you can act on your own data the way the GDPR expects. you can act on your own data the way the GDPR expects.
Last reviewed: 2026-05-03 (HellionChat v0.5.4). Last reviewed: 2026-05-05 (HellionChat v1.0.3).
--- ---
@@ -103,8 +103,17 @@ on your behalf.
reaches BetterTTV (unavoidable for any HTTPS request); the request reaches BetterTTV (unavoidable for any HTTPS request); the request
itself contains no identifying user data, no character name, no itself contains no identifying user data, no character name, no
message text. Only the emote ID being looked up is in the URL path. message text. Only the emote ID being looked up is in the URL path.
- **When it triggers:** Only when an incoming message contains an - **When it triggers:**
emote token that is on the BetterTTV emote list. - The emote *list* (global emotes plus the top-1500 community emotes
over fifteen API pages) is fetched from `api.betterttv.net` once
per session at plugin startup, provided the **Show emotes** option
is on. This first list-fetch happens before any chat message has
arrived; BetterTTV's edge therefore sees your IP as soon as the
plugin loads, not only after an emote is mentioned.
- The individual emote *images* on `cdn.betterttv.net` are fetched
on demand, only when an incoming chat message contains a token
matching one of the cached IDs. These are cached locally
(`emoteCache/`) and reused across sessions.
- **Cached:** Yes, in `emoteCache/`. A given emote is downloaded once - **Cached:** Yes, in `emoteCache/`. A given emote is downloaded once
per machine and reused. per machine and reused.
- **How to opt out:** Turn off the **Show emotes** option in - **How to opt out:** Turn off the **Show emotes** option in
+4 -3
View File
@@ -4,21 +4,22 @@ HellionChat ships and depends on a number of third-party components.
This document lists them, their licences and which of them touch the This document lists them, their licences and which of them touch the
network. It is the inventory referenced by `PRIVACY.md`. network. It is the inventory referenced by `PRIVACY.md`.
Last reviewed: 2026-05-03 (HellionChat v0.5.4). Last reviewed: 2026-05-05 (HellionChat v1.0.3).
--- ---
## Direct NuGet dependencies ## Direct NuGet dependencies
Pinned in `HellionChat/HellionChat.csproj`. Versions reflect the v1.0.0 build. Pinned in `HellionChat/HellionChat.csproj`. Versions reflect the v1.0.3 build.
| Package | Version | Licence | Network | Purpose | | Package | Version | Licence | Network | Purpose |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| [MessagePack](https://github.com/MessagePack-CSharp/MessagePack-CSharp) | 3.1.4 | MIT | no | Binary serialisation for the SQLite message store. | | [MessagePack](https://github.com/MessagePack-CSharp/MessagePack-CSharp) | 3.1.4 | MIT | no | Binary serialisation for the SQLite message store. |
| [Microsoft.Data.Sqlite](https://learn.microsoft.com/dotnet/standard/data/sqlite/) | 10.0.7 | MIT | no | Local SQLite access for the message database. | | [Microsoft.Data.Sqlite](https://learn.microsoft.com/dotnet/standard/data/sqlite/) | 10.0.7 | MIT | no | Local SQLite access for the message database. |
| [morelinq](https://github.com/morelinq/MoreLINQ) | 4.4.0 | Apache-2.0 | no | LINQ helper extensions. | | [morelinq](https://github.com/morelinq/MoreLINQ) | 4.4.0 | Apache-2.0 | no | LINQ helper extensions. |
| [Pidgin](https://github.com/benjamin-hodgson/Pidgin) | 3.3.0 | MIT | no | Parser combinator library used for chat-input parsing. | | [Pidgin](https://github.com/benjamin-hodgson/Pidgin) | 3.5.1 | MIT | no | Parser combinator library used for chat-input parsing. CIString Unicode fix relevant for non-ASCII channel/tab names. |
| [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp) | 3.1.12 | [Six Labors Split License 1.0](https://github.com/SixLabors/ImageSharp/blob/main/LICENSE) (OSI-approved; free for open-source / non-commercial use, commercial licence required for closed-source commercial use) | no | Image decoding for cached emotes. | | [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp) | 3.1.12 | [Six Labors Split License 1.0](https://github.com/SixLabors/ImageSharp/blob/main/LICENSE) (OSI-approved; free for open-source / non-commercial use, commercial licence required for closed-source commercial use) | no | Image decoding for cached emotes. |
| [SQLitePCLRaw.lib.e_sqlite3](https://github.com/ericsink/SQLitePCL.raw) | 3.50.3 | MIT | no | Native SQLite binary, explicitly pinned to override the transitive default for CVE-2025-6965 (memory corruption from aggregate-term overflow) and CVE-2025-7709. |
Six Labors note: HellionChat is an EUPL-1.2-licensed open-source Six Labors note: HellionChat is an EUPL-1.2-licensed open-source
project distributed at no cost. Use of ImageSharp 3.x under the project distributed at no cost. Use of ImageSharp 3.x under the