From 4000bbd19911e7aafca12f36f910891eeadbb833 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 10 May 2026 19:54:39 +0200 Subject: [PATCH] chore: reformat after editorconfig update Updated .editorconfig to set indent_style=space and indent_size=4 for C# files. Reformat all .cs files to apply the new indentation settings. No code logic changes, just whitespace reformatting. also updated some comments in files in shorter and Precise way. No logic changes, just comment rewording for clarity and conciseness. --- HellionChat/AutoTellTabsService.cs | 100 ++------ HellionChat/Branding/BrandingLinks.cs | 11 +- HellionChat/Chunk.cs | 10 +- HellionChat/Configuration.cs | 180 +++----------- HellionChat/EmoteCache.cs | 41 +--- HellionChat/FontManager.cs | 32 +-- HellionChat/HellionChat.csproj | 48 +--- HellionChat/HellionChat.yaml | 52 ----- HellionChat/InputHistoryService.cs | 13 +- HellionChat/Integrations/HonorificService.cs | 113 +++------ .../Integrations/HonorificTitleData.cs | 10 +- HellionChat/Integrations/IntegrationLinks.cs | 6 +- HellionChat/MessageManager.cs | 54 +---- HellionChat/Plugin.cs | 219 +++++------------- HellionChat/Resources/ChatColourPresets.cs | 61 +---- .../SelfTests/ThemeSwitchSelfTestStep.cs | 15 +- HellionChat/Themes/Builtin/SynthwaveSunset.cs | 2 +- HellionChat/Themes/ThemeChatColors.cs | 6 +- HellionChat/Themes/ThemeColors.cs | 2 +- HellionChat/Themes/ThemeJsonLoader.cs | 6 +- HellionChat/Themes/ThemeLayout.cs | 2 +- HellionChat/Themes/ThemeRegistry.cs | 17 +- HellionChat/Themes/ThemeTypography.cs | 3 +- 23 files changed, 203 insertions(+), 800 deletions(-) diff --git a/HellionChat/AutoTellTabsService.cs b/HellionChat/AutoTellTabsService.cs index 7ee6456..8bc3436 100644 --- a/HellionChat/AutoTellTabsService.cs +++ b/HellionChat/AutoTellTabsService.cs @@ -10,14 +10,8 @@ using HellionChat.Util; namespace HellionChat; -// Hellion Chat — Auto-Tell-Tabs. -// -// Spawns a session-only tab per /tell partner so a club greeter can track -// multiple parallel conversations without losing context. Subscribes to -// MessageManager.MessageProcessed for live tells and to ClientState.Logout -// for the cleanup pass; everything else hangs off these two entry points. -// -// See spec: Hellion Chat Auto-Tell-Tabs Spec (Obsidian vault). +// Auto-Tell-Tabs: spawns session-only tabs per /tell partner. +// Subscribes to MessageManager.MessageProcessed and ClientState.Logout. internal sealed class AutoTellTabsService : IDisposable { private readonly Plugin _plugin; @@ -87,10 +81,7 @@ internal sealed class AutoTellTabsService : IDisposable var partner = ExtractTellPartner(message); if (partner == null) { - // Real message without a player payload — e.g. GM tells, which - // we deliberately skip. The diagnostics make future regressions - // (FFXIV changing tell payload shape, new edge cases) findable - // without having to crank up debug logging at the source. + // Diagnostics: helps detect regressions (FFXIV payload changes, new edge cases) Plugin.Log.Warning( $"[AutoTellTabs] Could not extract tell partner. type={message.Code.Type}, " + $"senderChunks={message.Sender.Count}, contentChunks={message.Content.Count}, " @@ -105,9 +96,7 @@ internal sealed class AutoTellTabsService : IDisposable var existing = FindTempTab(partner.Value.Name, partner.Value.World); if (existing != null) { - // Tab already exists; Tab.Matches has already routed this - // message via the MessageManager pipeline (see Task 2 sender - // filter). + // Already routed via MessageManager pipeline return; } @@ -124,10 +113,7 @@ internal sealed class AutoTellTabsService : IDisposable { if (message.Code.Type == ChatType.TellIncoming) { - // Incoming tell: the sender is the conversation partner. The - // PlayerPayload normally rides on a chunk's Link slot, but for - // some tell types FFXIV only puts it in the raw SeString — - // fall back to that before giving up. + // Sender is the partner; check chunks first, then raw SeString as fallback var fromSender = ChunkUtil.TryGetPlayerPayload(message.Sender) ?? ChunkUtil.TryGetPlayerPayload(message.SenderSource); @@ -138,10 +124,7 @@ internal sealed class AutoTellTabsService : IDisposable return null; } - // Outgoing tell: the local player is the sender, the partner shows - // up either as a payload in the content (for tells typed via the - // Chat 2 input bar) or as the channel's tracked tell target (set by - // the SetContextTellTarget game hook). Same SeString fallback. + // Outgoing tell: check content first, then channels's TellTarget as fallback var fromContent = ChunkUtil.TryGetPlayerPayload(message.Content) ?? ChunkUtil.TryGetPlayerPayload(message.ContentSource) @@ -175,10 +158,7 @@ internal sealed class AutoTellTabsService : IDisposable private void DropOldestTempTab() { - // Greeted tabs are dropped before un-greeted ones (the user said - // "I'm done with that conversation"), and within each bucket we - // pick the oldest LastActivity. This protects active conversations - // and unfinished greetings while still freeing up a slot. + // Prioritize greeted tabs for drop; within each bucket, drop by oldest LastActivity var victim = Plugin .Config.Tabs.Select((tab, idx) => (Tab: tab, Index: idx)) .Where(t => t.Tab.IsTempTab) @@ -191,12 +171,7 @@ internal sealed class AutoTellTabsService : IDisposable return; } - // v0.6.1 — if the victim is currently popped out, tear down the - // matching Popout window first. Otherwise the window stays in - // PopOutWindows + WindowSystem and renders empty / re-spawns on the - // next AddPopOutsToDraw tick. Latent since pop-outs were introduced; - // becomes visible with AutoTellTabsOpenAsPopout where dropping a - // popped tab is now a routine code path. + // Clean up pop-out window if tab is popped out if (victim.Tab.PopOut) { var popout = _plugin.ChatLogWindow.ActivePopouts.FirstOrDefault(p => @@ -210,8 +185,7 @@ internal sealed class AutoTellTabsService : IDisposable Plugin.Config.Tabs.RemoveAt(victim.Index); - // Re-anchor the active tab so the user does not silently end up on - // a different conversation when their tab gets dropped or shifted. + // Re-anchor active tab to avoid silent switch when tab is dropped if (victim.Index <= _plugin.LastTab) { _plugin.WantedTab = 0; @@ -222,22 +196,12 @@ internal sealed class AutoTellTabsService : IDisposable { var tab = BuildTempTab(partner.Name, partner.World); - // Preload first so the tab opens with chronological history above - // the current message — and so a slow DB query never causes a - // visible "empty tab, then history pops in" effect on screen. - // The current message is already persisted in the store by the - // time MessageProcessed fires (see MessageManager.cs: UpsertMessage - // runs before the event), so we have to exclude it explicitly to - // avoid the separator landing below the live tell. + // Preload history: chronological order with current message already persisted PreloadHistory(tab, partner.Name, partner.World, currentMessage.Id); tab.AddMessage(currentMessage, unread: true); - // Hellion Chat v0.6.1 — opt-in: open new /tell tabs directly as a - // pop-out window. Set BEFORE Tabs.Add so the next render-tick's - // AddPopOutsToDraw() sees PopOut=true and spawns the Popout window - // alongside the tab going into the list. No SaveConfig() because - // auto-tell tabs are IsTempTab (session-only, never persisted). + // Open as pop-out if configured (set before Tabs.Add for next render-tick) if (Plugin.Config.AutoTellTabsOpenAsPopout) { tab.PopOut = true; @@ -272,9 +236,7 @@ internal sealed class AutoTellTabsService : IDisposable { return $"{playerName}@{worldRow.Name}"; } - // World sheet lookup miss is rare (only for FFXIV worlds Dalamud has - // not yet seen). Fall back to the raw RowId so the user still has a - // unique, readable label. + // Fallback if world lookup misses (rare; only for unseen worlds) return $"{playerName}@World{worldRowId}"; } @@ -288,9 +250,7 @@ internal sealed class AutoTellTabsService : IDisposable try { - // Pull one extra row because the live tell that triggered this - // spawn is already in the store and would otherwise eat one of - // the user's preload-budget slots. + // Pull one extra row: current message is already in store and would eat a preload slot var history = _store.GetTellHistoryWithSender( _messageManager.CurrentContentId, senderName, @@ -305,23 +265,17 @@ internal sealed class AutoTellTabsService : IDisposable if (historicMessages.Count == 0) { - // No prior tells with this player — leave the tab to start - // empty so the user does not see a "history loaded" marker - // sitting alone above the very first message. + // No prior tells; leave tab empty to avoid orphaned "history loaded" marker return; } - // The history list is already oldest-first, so a plain AddPrune - // loop produces the chronological order the user expects to see - // when the tab opens. + // History is oldest-first; add in order for chronological display foreach (var message in historicMessages) { tab.Messages.AddPrune(message, MessageManager.MessageDisplayLimit); } - // Visible separator between the loaded history and the live - // tell that triggered this spawn. Goes in last so it sorts - // after the historical messages but before the current one. + // Separator between history and live tell (sorts after history but before current) tab.Messages.AddPrune( MakeSystemMarker(HellionStrings.AutoTellTabs_HistorySeparator), MessageManager.MessageDisplayLimit @@ -329,9 +283,7 @@ internal sealed class AutoTellTabsService : IDisposable } catch (Exception ex) { - // Non-fatal: the tab still spawns, but the user gets a visible - // notice instead of silently missing history. The error logs - // once with full stack trace for diagnosis. + // Non-fatal: tab still spawns with visible error notice instead of silent history loss Plugin.Log.Error(ex, "[AutoTellTabs] History preload failed"); tab.Messages.AddPrune( MakeSystemMarker(HellionStrings.AutoTellTabs_HistoryLoadError), @@ -372,9 +324,7 @@ internal sealed class AutoTellTabsService : IDisposable lock (_tempTabsLock) { - // Frame-race guard (E5): the sidebar might still render a tab - // that has already been removed by LRU drop or logout cleanup. - // Silently skip the toggle so we don't mutate stale state. + // Guard against frame-race: sidebar might render a tab already removed by LRU or logout if (!Plugin.Config.Tabs.Contains(tab)) { return; @@ -388,18 +338,12 @@ internal sealed class AutoTellTabsService : IDisposable { lock (_tempTabsLock) { - // Snapshot whether the active tab is about to be removed, BEFORE - // we mutate the list — index lookups would lie to us afterwards. + // Snapshot active tab index before mutating list var lastIndex = _plugin.LastTab; var lastIndexValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count; var currentWasTempTab = lastIndexValid && Plugin.Config.Tabs[lastIndex].IsTempTab; - // v0.6.1 — symmetric to DropOldestTempTab cleanup: tear down any - // popped-out temp tab windows before removing the tabs themselves, - // otherwise PopOutWindows + WindowSystem keep ghost entries until - // the next plugin reload. Especially relevant once Auto-Pop-Out is - // enabled — every logout would otherwise leak as many ghosts as - // there were active /tell pop-outs. + // Clean up pop-out windows before removing temp tabs var poppedTempTabIds = Plugin .Config.Tabs.Where(t => t.IsTempTab && t.PopOut) .Select(t => t.Identifier) @@ -419,9 +363,7 @@ internal sealed class AutoTellTabsService : IDisposable Plugin.Config.Tabs.RemoveAll(t => t.IsTempTab); - // Force a switch to tab 0 if the active tab was a temp tab OR - // if drops before the active index pushed LastTab out of range. - // Otherwise the user keeps their current persistent tab. + // Force switch to tab 0 if active tab was temp or index is now out of range var stillValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count; if (currentWasTempTab || !stillValid) { diff --git a/HellionChat/Branding/BrandingLinks.cs b/HellionChat/Branding/BrandingLinks.cs index 45adc57..c6eec3c 100644 --- a/HellionChat/Branding/BrandingLinks.cs +++ b/HellionChat/Branding/BrandingLinks.cs @@ -1,11 +1,12 @@ -// HellionChat/Branding/BrandingLinks.cs namespace HellionChat.Branding; -// Centralised so a future invite rotation only touches one file. The same -// link is currently hard-coded in repo.json, README.md, SUPPORT.md, -// CONTRIBUTORS.md and HellionChat.yaml — those will be migrated to consume -// this constant in a separate housekeeping sweep +// Centralised — a future invite/URL rotation only touches this file. internal static class BrandingLinks { public const string HellionForgeDiscordInvite = "https://discord.gg/X9V7Kcv5gR"; + public const string HellionForgeGitea = "https://gitea.hellion-forge.cloud/Hellion-Forge"; + public const string HellionChatRepo = + "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat"; + public const string HellionForgeWebsite = "https://hellion-forge.cloud"; + public const string HellionMediaWebsite = "https://hellion-media.de/de"; } diff --git a/HellionChat/Chunk.cs b/HellionChat/Chunk.cs index 9f943d0..042df73 100755 --- a/HellionChat/Chunk.cs +++ b/HellionChat/Chunk.cs @@ -34,9 +34,7 @@ public abstract class Chunk _ => null, }; - /// - /// Get some basic text for use in generating hashes. - /// + // Returns basic text for hashing (content for TextChunk, icon name for IconChunk) internal string StringValue() { return this switch @@ -108,9 +106,6 @@ public class TextChunk : Chunk Content = content ?? ""; } - /// - /// Creates a new TextChunk with identical styling to this one. - /// public TextChunk NewWithStyle(ChunkSource source, Payload? link, string content) { return new TextChunk(source, link, content) @@ -122,9 +117,6 @@ public class TextChunk : Chunk }; } - /// - /// Creates a new TextChunk with identical styling to this one. - /// public TextChunk NewWithStyle(Chunk chunk, string content) { return new TextChunk(chunk, content) diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index 0465041..5efebfd 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -38,33 +38,26 @@ public class Configuration : IPluginConfiguration public int Version { get; set; } = LatestVersion; - // v1.1.0 — Theme-Engine. Slug-basiert; ThemeRegistry liefert das Objekt. + // Slug-based; ThemeRegistry resolves the object at runtime. public string Theme = "hellion-arctic"; - // v1.1.0 — Globale Window-Opacity, theme-übergreifend. Migration aus - // HellionThemeWindowOpacity beim Bump v13 → v14. + // Global window opacity, applied across all themes. public float WindowOpacity = 0.85f; - // v1.1.0 — Felder für künftige UI-Toggles (v1.2.0 / v1.3.0). Werden - // vorab angelegt, damit später keine Migration nötig ist. + // Reserved for future UI toggles; pre-declared to avoid a migration later. public bool ReduceMotion; - // v1.2.1 — Default geflippt von false → true. Card-Rows-Layout aus - // v1.2.0 wurde als zu dicht empfunden; Single-Line `[HH:mm] Sender: - // Text` ist besser lesbar und platzsparender. Bestand-User mit aktiv - // false werden durch die v15→v16-Migration auf den neuen Default - // gehoben (Heuristik: wer in v1.2.0 false hatte, hatte den damals - // neu eingeführten Default — kaum jemand hat aktiv abgeschaltet). + // v1.2.1: default flipped false → true. Compact single-line layout is + // more readable than the card-rows layout introduced in v1.2.0. public bool UseCompactDensity = true; - // Hellion Chat — Privacy filter (DSGVO Art. 25 Privacy by Default). - // Master-switch defaults to true; set false to restore upstream behavior. + // Privacy by Default master switch. Set false to restore upstream behaviour. public bool PrivacyFilterEnabled = true; // Empty set means the migration has not run yet — see Plugin.cs v6→v7. public HashSet PrivacyPersistChannels = []; - // Failsafe for ChatTypes added by future FFXIV patches we don't know about. + // Failsafe for ChatTypes added by future FFXIV patches. public bool PrivacyPersistUnknownChannels; public bool IsAllowedForStorage(ChatType type) @@ -76,79 +69,23 @@ public class Configuration : IPluginConfiguration return PrivacyPersistUnknownChannels; } - // Hellion Chat — Message retention (GDPR data minimization, time axis). - // Master switch defaults to false; the plugin will not delete history - // until the user explicitly opts in. + // Retention master switch defaults to false — plugin will not delete + // history until the user explicitly opts in. public bool RetentionEnabled; public int RetentionDefaultDays = 30; public Dictionary RetentionPerChannelDays = []; public DateTimeOffset RetentionLastRunAt = DateTimeOffset.MinValue; - - // Hellion Chat first-run wizard — opens once on a fresh install. Existing - // ChatTwo users skip it because the v6→v7 migration sets the flag. public bool FirstRunCompleted; - - // Use the bundled Exo 2 font (OFL-1.1) for the regular plugin font - // instead of whatever GlobalFontV2.FontId points at. Default ON so a - // fresh install gets the Hellion typography out-of-the-box; flip OFF - // to fall back to the user's chosen system or Dalamud font. public bool UseHellionFont = true; - - // Cycle 1 of the plugin-integration roadmap. When Honorific is installed - // and reports a custom title, render it in the chat header above the - // message log. Auto-hides regardless when Honorific is missing or the - // active title is original/empty, so leaving this on is safe even for - // users who don't run Honorific. public bool ShowHonorificTitleInHeader = true; - - // Hellion Chat — Auto-Tell-Tabs. When enabled, an incoming or outgoing - // /tell spawns a session-only tab dedicated to that conversation - // partner. See spec: Hellion Chat Auto-Tell-Tabs Spec (Obsidian). public bool EnableAutoTellTabs = true; - - // Hard cap on simultaneously open auto tell tabs. Range enforced by the - // settings slider (1–50). LRU drop favors greeted tabs first. public int AutoTellTabsLimit = 15; - - // When true the sidebar shows only a thin separator before the temp - // tabs; when false a section header "Active Tells (n)" is rendered. public bool AutoTellTabsCompactDisplay; - - // Number of prior tells to preload from the message store when an - // auto tell tab is spawned. Range 0–100; 0 disables preload. public int AutoTellTabsHistoryPreload = 20; - - // Show the greeter "marked-as-greeted" toggle button next to each - // temp tab and dim the tab name when set. Off by default because the - // workflow is specific to club-greeter use cases — most users just - // want the auto tabs themselves without the extra UI affordance. public bool AutoTellTabsShowGreetedToggle; - - // Hellion Chat — One-Time-Hint-Banner that introduces the v0.6.0 pop-out - // input feature. Set to true once the user dismisses the banner from a - // pop-out window; never reset after that. public bool SeenPopOutInputHint; - - // Hellion Chat — v0.6.0 master switch for the pop-out input bar. - // Global on purpose: per-tab makes no sense for Auto-Tell-Tabs which - // are session-only and would force the user to re-enable it for every - // new conversation. Default flipped to ON in v0.6.1 (was OFF in v0.6.0) - // because tester feedback called the manual toggle "umständlich, wirkt - // unfertig". v11 → v12 migration applies the same flip to existing users. public bool PopOutInputEnabled = true; - - // Hellion Chat — v0.6.1 One-Time-Hint-Banner that introduces the - // chat-header pop-out toolbar button and reminds about the pop-out - // input default flip. Set to true once the user dismisses the banner - // from the main chat window; never reset after that. public bool SeenPopOutHeaderHint; - - // Hellion Chat — v0.6.1 opt-in: when true, AutoTellTabsService.SpawnTempTab - // sets tab.PopOut = true on every new auto-tell tab so the conversation - // pops out as its own window directly. Closing the pop-out returns the - // tab to the sidebar via the standard Popout.OnClose() flow. Default OFF - // because the existing sidebar workflow is what most users (especially - // club greeters tracking many parallel tells) expect by default. public bool AutoTellTabsOpenAsPopout; public int GetRetentionDays(ChatType type) @@ -167,10 +104,7 @@ public class Configuration : IPluginConfiguration public bool HideInLoadingScreens; public bool HideInBattle; - // v1.2.1 — Default geflippt false → true. Hellion-UI im NG+-Menü - // versteckt zu halten ist konsistent mit den anderen Hide-Defaults - // (Cutscenes, Logged-out, UI-Hidden) — UI-out-of-the-way bei Story- - // Sequenzen. + // v1.2.1: default flipped false → true for consistency with other hide defaults. public bool HideInNewGamePlusMenu = true; public bool HideWhenInactive; public int InactivityHideTimeout = 10; @@ -186,18 +120,8 @@ public class Configuration : IPluginConfiguration public bool NativeItemTooltips = true; public bool PrettierTimestamps = true; public bool MoreCompactPretty; - - // v1.2.1 — Default geflippt false → true. Wiederholte Zeitstempel - // innerhalb derselben Minute lesen sich als Rauschen; ein einziger - // Timestamp pro Minute reicht aus um die Konversation zu verorten. public bool HideSameTimestamps = true; public bool ShowNoviceNetwork; - - // Hellion Chat — vertical sidebar tab layout reads better than the - // horizontal tab strip in the company of Auto-Tell-Tabs (a club - // greeter typically tracks 5–15 simultaneous conversations). Bestand - // users keep their saved value untouched — only fresh installs pick - // up the new default. public bool SidebarTabView = true; public bool PrintChangelog = true; public bool OnlyPreviewIf; @@ -218,22 +142,10 @@ public class Configuration : IPluginConfiguration public bool CollapseKeepUniqueLinks; public bool PlaySounds = true; public bool KeepInputFocus = true; - - // v1.2.1 — Default gesenkt 5000 → 2500. 5000 ist auf Mid-Range- - // Hardware bei langen Sessions spürbar langsamer (Card-Layout - // re-Layout pro Frame), 2500 deckt eine typische Stunde Chat ab - // und bleibt smooth. User die mehr brauchen können bis 10000 hoch. public int MaxLinesToRender = 2_500; // 1-10000 - - // Default ON to match a German / European 24h locale. The - // ChatLogWindow.cs format-flip in v0.5.1 honours this strictly via - // CultureInfo.InvariantCulture so the result is consistent across - // host locales. public bool Use24HourClock = true; - public bool ShowEmotes = true; public HashSet BlockedEmotes = []; - public bool FontsEnabled = true; public ExtraGlyphRanges ExtraGlyphRanges = 0; public float FontSizeV2 = 12.75f; @@ -258,12 +170,6 @@ public class Configuration : IPluginConfiguration public float TooltipOffset; - // v1.2.1 — Default-Chat-Farben sind das Hellion-Brand-Preset. Der - // First-Run-Wizard bietet keine Theme-/Preset-Wahl an, daher kriegen - // neue User die Hellion-Brand-Farben out-of-the-box (Cyan-Familie für - // Standard/Tell, Ember/Warning für laute Channels). Bestand-User mit - // leerem ChatColours-Dict werden durch die v15→v16-Migration auf das - // Preset gehoben; User die bereits Custom-Farben haben, bleiben. public Dictionary ChatColours = BuildDefaultChatColours(); private static Dictionary BuildDefaultChatColours() @@ -333,9 +239,7 @@ public class Configuration : IPluginConfiguration MaxLinesToRender = other.MaxLinesToRender; Use24HourClock = other.Use24HourClock; ShowEmotes = other.ShowEmotes; - // 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. + // Deep-copy so settings window edits don't leak into live config before Save. BlockedEmotes = new HashSet(other.BlockedEmotes); FontsEnabled = other.FontsEnabled; ItalicEnabled = other.ItalicEnabled; @@ -349,22 +253,11 @@ public class Configuration : IPluginConfiguration ChatColours = other.ChatColours.ToDictionary(entry => entry.Key, entry => entry.Value); ColorSelectedInputChannelButton = other.ColorSelectedInputChannelButton; - // Hellion Chat — Auto-Tell-Tabs are session-only and therefore - // never present in a disk-loaded copy. Keep the live temp tabs of - // *this* configuration alive across an UpdateFrom so a settings - // save (or sidebar-mode toggle) does not silently destroy the - // user's open tell conversations. - // - // For persistent tabs we go through Tab.Clone() which intentionally - // does NOT copy the NonSerialized Messages list (avoids shared - // mutable state on disk-load). On a settings save that means the - // chat history for every persistent tab would be wiped — bug - // reported by Flo 2026-05-05. We work around it by capturing the - // live MessageList (and LastSendUnread counter) by Identifier - // before the replace, then restoring it onto the freshly cloned - // tabs whose Identifier survives Tab.Clone(). New tabs added in - // settings get a fresh empty MessageList; deleted tabs lose their - // history (intended). + // Keep live temp tabs alive across UpdateFrom — a settings save must + // not destroy open tell conversations. For persistent tabs, capture + // the live MessageList and LastSendUnread by Identifier before the + // replace and restore them onto the freshly cloned tabs; new tabs + // get an empty MessageList, deleted tabs lose their history (intended). var liveTempTabs = Tabs.Where(t => t.IsTempTab).ToList(); var livePersistentSession = Tabs.Where(t => !t.IsTempTab) .ToDictionary(t => t.Identifier, t => (t.Messages, t.LastSendUnread)); @@ -456,9 +349,7 @@ public class Tab { public string Name = Language.Tab_DefaultName; - // v1.2.0 — optionaler FontAwesome-Glyph-Name. Null bedeutet: - // Default-Mapping aus TabIconMapping greift (basiert auf Tab-Name). - // User können hier per Settings → Tabs einen eigenen Glyph setzen. + // Optional FontAwesome glyph name; null falls back to TabIconMapping default. public string? Icon = null; [Obsolete("Removed in favor of SelectedChannels")] @@ -510,15 +401,12 @@ public class Tab [NonSerialized] public Guid Identifier = Guid.NewGuid(); - // Hellion Chat — Auto-Tell-Tabs greeted flag. Toggled manually from the - // sidebar to mark a tell partner as already greeted in the current - // session. NonSerialized because the temp tab itself is session-only. + // Session-only greeted flag for club-greeter workflows. [NonSerialized] public bool IsGreeted; - // v1.4.2 — TabTintCache uses separate validation keys per cache so a - // TellTarget change picked up by GetTint can't strand GetIcon (or vice - // versa) with a stale entry that looks fresh on the shared key. + // Separate validation keys per cache so TellTarget changes don't + // cause GetTint and GetIcon to strand each other with stale entries. [NonSerialized] internal string? _cachedTintTellName; @@ -540,17 +428,12 @@ public class Tab public bool Matches(Message message) { if (!message.Matches(SelectedChannels, ExtraChatAll, ExtraChatChannels)) - { return false; - } - // Auto-tell temp tabs are bound to a single conversation partner; - // every other tell that matches the channel filter must NOT land - // here, otherwise all temp tabs would mirror "Tell Exclusive". + // Temp tabs are bound to a single conversation partner — other tells + // matching the channel filter must not land here. if (IsTempTab && TellTarget?.IsSet() == true) - { return ChunkUtil.MatchesSender(message, TellTarget.Name, TellTarget.World); - } return true; } @@ -610,10 +493,7 @@ public class Tab }; } - /// - /// MessageList provides an ordered list of messages with duplicate ID - /// tracking, sorting and mutex protection. - /// + /// Ordered message list with duplicate ID tracking, sorting and mutex protection. public class MessageList { private readonly SemaphoreSlim LockSlim = new(1, 1); @@ -701,10 +581,7 @@ public class Tab } } - /// - /// Aktuelle Anzahl der gespeicherten Messages. Lock-acquire pro Read - /// ist OK für 1×/sec Status-Bar-Polling (v1.2.0). - /// + /// Current message count. Lock-per-read is acceptable for 1×/sec status bar polling. public int Count { get @@ -721,9 +598,7 @@ public class Tab } } - /// - /// Returns an array copy of the message list for usage outside of main thread - /// + /// Returns an array copy of the message list for usage outside of main thread. public async Task GetCopy(int millisecondsTimeout = -1) { await LockSlim.WaitAsync(millisecondsTimeout); @@ -737,10 +612,7 @@ public class Tab } } - /// - /// GetReadOnly returns a read-only list of messages while holding a - /// reader lock. The list should be used with a using statement. - /// + /// Returns a read-only list while holding a reader lock. Use with a using statement. public RLockedMessageList GetReadOnly(int millisecondsTimeout = -1) { LockSlim.Wait(millisecondsTimeout); diff --git a/HellionChat/EmoteCache.cs b/HellionChat/EmoteCache.cs index e47143d..320006f 100644 --- a/HellionChat/EmoteCache.cs +++ b/HellionChat/EmoteCache.cs @@ -79,7 +79,7 @@ public static class EmoteCache Done, } - // All of this data is uninitalized while State is not `LoadingState.Done` + // All fields below are uninitialised while State != Done. public static LoadingState State = LoadingState.Unloaded; private static readonly Dictionary Cache = new(); @@ -87,15 +87,11 @@ public static class EmoteCache public static string[] SortedCodeArray = []; - // Plugin-scoped cancellation source for in-flight emote loads. Dispose - // cancels every running download/texture-create so the workers don't - // touch a torn-down TextureProvider on plugin reload. Replaced with a - // fresh source on the next LoadData() call so a re-enable still works. + // Cancelled on Dispose to stop in-flight downloads; replaced on re-enable. private static CancellationTokenSource Cts = new(); internal static CancellationToken Token => Cts.Token; - // Drain target for in-flight loads on Dispose; without this an orphan - // continuation could still write to a torn-down Texture/Frames field. + // Tracks in-flight loads so Dispose can drain them before teardown. private static readonly ConcurrentBag PendingLoads = new(); internal static void TrackLoad(Task loadTask, string emoteCode) @@ -117,8 +113,7 @@ public static class EmoteCache if (State is not LoadingState.Unloaded) return; - // Refresh the CTS in case Dispose was called and we're being re-enabled - // in the same process (Dalamud /xlplugins toggle). + // Reset CTS if Dispose was called and the plugin is being re-enabled. if (Cts.IsCancellationRequested) Cts = new CancellationTokenSource(); @@ -140,11 +135,8 @@ public static class EmoteCache var topList = await top.Content.ReadAsStringAsync(ct); var jsonList = JsonSerializer.Deserialize>(topList)!; - // BetterTTV occasionally returns entries with a null Code; the - // upstream code passed those straight into Dictionary.TryAdd - // and tripped ArgumentNullException, killing the whole emote - // load. Skip them defensively so a single bad row no longer - // breaks the cache for everyone else. + // BetterTTV occasionally returns entries with a null Code; + // skip them so a single bad row doesn't break the whole cache. foreach (var emote in jsonList) if ( !string.IsNullOrEmpty(emote.Emote.Code) @@ -160,16 +152,11 @@ public static class EmoteCache } catch (OperationCanceledException) { - // Plugin disposed while the cache was loading; leave State on - // Loading so a subsequent re-enable can re-issue LoadData with - // a fresh CTS (handled above). + // Plugin disposed mid-load; State stays on Loading so re-enable can retry. } catch (Exception ex) { - // Reset to Unloaded so a later trigger (e.g. the user reopening - // the Emotes tab after the network recovers) can retry. Without - // this the State stays on Loading and the early-out at the top - // of LoadData blocks every further attempt until plugin reload. + // Reset to Unloaded so a later trigger can retry without a plugin reload. State = LoadingState.Unloaded; Plugin.Log.Error(ex, "BetterTTV cache wasn't initialized"); } @@ -248,11 +235,8 @@ public static class EmoteCache internal async Task LoadAsync(Emote emote, CancellationToken ct) { - // BetterTTV-supplied Id and ImageType are interpolated straight - // into the filename. HTTPS protects the wire, but a compromised - // upstream could still hand us "../foo" and write into the - // pluginConfigs root (or worse). Resolve the candidate path and - // refuse anything that escapes the cache directory. + // Path-traversal guard: resolve and verify the candidate path stays + // inside the cache directory before reading or writing. var dir = Path.GetFullPath( Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1") ); @@ -397,7 +381,7 @@ public static class EmoteCache var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f; - // Follows the same pattern as browsers, anything under 0.02s delay will be rounded up to 0.1s + // Match browser behaviour: anything under 20ms rounds up to 100ms. if (delay < 0.02f) delay = 0.1f; @@ -416,8 +400,7 @@ public static class EmoteCache } catch (OperationCanceledException) { - // Plugin disposed mid-load; partial frames are released by - // InnerDispose on the next dispose pass. + // Plugin disposed mid-load; release any partial frames. foreach (var f in Frames) f.Texture.Dispose(); Frames = []; diff --git a/HellionChat/FontManager.cs b/HellionChat/FontManager.cs index 80d0535..6a86efa 100644 --- a/HellionChat/FontManager.cs +++ b/HellionChat/FontManager.cs @@ -41,12 +41,7 @@ public class FontManager 90f, ]; - /// - /// Backing bytes for the bundled Hellion font (Exo 2, OFL-1.1). Lazily - /// extracted from the assembly's manifest resources on first use; the - /// load happens inside the font atlas build callback so we keep the - /// allocation off the plugin constructor's hot path. - /// + // Hellion font bytes (Exo 2, OFL-1.1); lazily loaded from manifest resources private static byte[]? HellionFontBytes; private static byte[] GetHellionFontBytes() @@ -70,11 +65,9 @@ public class FontManager ushort[] BuildRange(IReadOnlyList? chars, params nint[] ranges) { var builder = new ImFontGlyphRangesBuilderPtr(ImGuiNative.ImFontGlyphRangesBuilder()); - // text foreach (var range in ranges) builder.AddRanges((ushort*)range); - // chars if (chars != null) { for (var i = 0; i < chars.Count; i += 2) @@ -116,13 +109,7 @@ public class FontManager JpRange = BuildRange(GlyphRangesJapanese.GlyphRanges); } - /// - /// Async wrapper around for the Phase-1 LoadAsync - /// path. The font-atlas build is CPU-bound, so we offload via Task.Run and - /// honour the cancellation token at the scheduling boundary; this lets the - /// font build run in parallel with the theme init without blocking the - /// loader. Settings-driven manual rebuilds keep using the sync entry point. - /// + // CPU-bound build offloaded to Task.Run; runs parallel with theme init public async Task BuildFontsAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -154,12 +141,7 @@ public class FontManager RegularFont = Plugin.Interface.UiBuilder.FontAtlas.NewDelegateFontHandle(e => e.OnPreBuild(tk => { - // v1.2.0 — Bei aktiver Hellion-Schrift (Exo 2 ist Variable-Font) - // wird die User-Schriftgröße aus FontSizeV2 als SizePt angewendet. - // Der Bestand-Pfad nutzt weiter GlobalFontV2.SizePt aus dem - // Custom-Font-Stack. Ohne diese Verzweigung war FontSizeV2 bei - // UseHellionFont=true wirkungslos, was 4K-User mit größerer - // Skalierung blockierte (Settings → Erscheinungsbild → Schriftarten). + // v1.2.0: UseHellionFont controls font size selection var basePt = Plugin.Config.UseHellionFont ? Plugin.Config.FontSizeV2 : Plugin.Config.GlobalFontV2.SizePt; @@ -218,13 +200,7 @@ public class FontManager } } - /// - /// Try to add a user-configured font to the build toolkit, falling back to - /// the bundled NotoSansCjkRegular asset if the configured font isn't - /// available on the system. Without this guard a stale SystemFontId - /// pointing at a font the user uninstalled or that never existed on - /// Linux (e.g. "Crimson Text") tears down the entire font atlas build. - /// + // Add font with fallback to NotoSansCjkRegular if unavailable private static ImFontPtr AddFontWithFallback( IFontAtlasBuildToolkitPreBuild tk, IFontId fontId, diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj index 50d8d5d..67d73d1 100644 --- a/HellionChat/HellionChat.csproj +++ b/HellionChat/HellionChat.csproj @@ -1,36 +1,21 @@ - + 1.4.3 enable enable - + true - + HellionChat HellionChat - + - + @@ -38,9 +23,7 @@ - + @@ -59,15 +42,7 @@ - - - + HellionFont.ttf @@ -80,14 +55,7 @@ - + PreserveNewest diff --git a/HellionChat/HellionChat.yaml b/HellionChat/HellionChat.yaml index fe6b000..2e41e34 100755 --- a/HellionChat/HellionChat.yaml +++ b/HellionChat/HellionChat.yaml @@ -31,26 +31,6 @@ description: |- - Independent plugin state — own config file and database directory, so Hellion Chat does not share state with upstream Chat 2 - v1.3.0 First plugin integration cycle. Honorific custom titles - are shown in the chat header above the message log, with auto-detect - and silent fallback when Honorific is not installed. - - v1.4.0 — Critical Lifecycle Fixes. Plugin reload and shutdown - are cleaner: SQLite no longer leans on GC pressure to release - its file, worker threads are explicitly background, deferred - config saves no longer get lost mid-disable, and pre-v13 config - backups carry the user's custom theme opacity into the v14 schema - instead of falling back to the default. - - v1.4.1 — Theme Engine Performance plus a tenth built-in. - HellionStyle.PushGlobal reads pre-computed ABGR values from a - per-theme cache instead of converting RGBA per slot per frame - (~13 % render-time recovery in typical scenes). Custom-theme - hot-reload survives transient file locks (editor mid-save - keeps the last-known-good snapshot). Synthwave Sunset joins - as the tenth built-in theme — Hot Magenta + Cyan on midnight - violet, 80s neon-grid vibes. - v1.4.2 — ChatLog Frame-Hot-Path. Three per-frame allocation patterns gone from the chat-log render path: card-mode borders hoist invariants out of the per-message loop, auto-tell tab @@ -184,38 +164,6 @@ changelog: |- Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2). - **Hellion Chat 1.4.0 — Critical Lifecycle Fixes** - - First sub-patch of the v1.4.x Polish Sweep series. Seven - known lifecycle and race bugs eliminated before any - performance refactor sits on top. - - - MessageStore disposal no longer triggers GC.Collect - globally; Pooling=false on the SQLite connection means - there's nothing left to clean up by hand - - PendingMessage and RetentionSweep worker threads are - explicitly marked IsBackground=true so the plugin domain - can unload during XIVLauncher reload without waiting - for them - - EmoteCache image and gif loaders moved from async-void - to async Task with a shared task tracker, draining - on Dispose so an in-flight load can no longer write - to a disposed EmoteImages entry - - DisposeAsync 10s timeout now warns loudly instead of - silently leaving the worker behind - - Plugin.Dispose flushes any pending DeferredSaveFrames - before tearing services down, so settings changes - made in the last few frames before disable are no - longer lost - - The v13→v14 config migration now reads the pre-v13 - backup and carries HellionThemeWindowOpacity into the - new WindowOpacity field instead of falling back to - the default 0.85 - - Modding & support: join Hellion Forge — https://discord.gg/X9V7Kcv5gR - - Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2). - --- Earlier history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases diff --git a/HellionChat/InputHistoryService.cs b/HellionChat/InputHistoryService.cs index cc653db..d7d9300 100644 --- a/HellionChat/InputHistoryService.cs +++ b/HellionChat/InputHistoryService.cs @@ -2,14 +2,8 @@ using System.Collections.Generic; namespace HellionChat; -// Hellion Chat — v0.6.0 shared input history. Replaces the embedded -// ChatLogWindow.InputBacklog so that pop-out windows with their own -// ChatInputBar can navigate the same Up/Down history as the main window. -// Index semantics are kept identical to the v0.5.x InputBacklog: -// index 0 = oldest entry -// index Count - 1 = newest entry -// Push performs move-to-newest deduplication: existing entries are -// removed before the new one is appended at the end. +// Shared input history for all ChatInputBars (main and pop-out windows). +// Push deduplicates: existing entries are moved to the end when re-added. public static class InputHistoryService { private const int MaxSize = 30; @@ -26,8 +20,7 @@ public static class InputHistoryService var trimmed = entry.Trim(); - // Move-to-newest: existing entries are removed before the append - // so the same line typed twice does not occupy two history slots. + // Move-to-newest: remove existing entry before adding at the end for (var i = 0; i < _entries.Count; i++) { if (_entries[i] == trimmed) diff --git a/HellionChat/Integrations/HonorificService.cs b/HellionChat/Integrations/HonorificService.cs index a251560..a4467af 100644 --- a/HellionChat/Integrations/HonorificService.cs +++ b/HellionChat/Integrations/HonorificService.cs @@ -6,25 +6,17 @@ using Newtonsoft.Json; namespace HellionChat.Integrations; -// We pull Newtonsoft.Json into this single file for IPC compatibility: -// Honorific serialises its TitleData with Newtonsoft (see -// Honorific-master/IpcProvider.cs:9 and CustomTitle.cs:12). Using the -// same library guarantees identical handling of System.Numerics.Vector3? -// and the enum fields we ignore. Newtonsoft is a transitive dependency -// via Dalamud, so no new NuGet entry is needed. The rest of HellionChat -// keeps using System.Text.Json. +// Newtonsoft.Json is used here for IPC compatibility with Honorific, which +// serialises TitleData with it. It's a transitive Dalamud dependency — no +// new NuGet entry needed. The rest of HellionChat uses System.Text.Json. internal sealed class HonorificService : IDisposable { private const string IpcNamespace = "Honorific"; - // Major version of the Honorific IPC contract HellionChat is built against. - // Used both by the runtime compatibility check and by the settings tab when - // it tells the user which major version we expected, so the literal lives - // in exactly one place. + // Major version of the Honorific IPC contract we're built against. internal const uint ExpectedApiMajor = 3; - // IPC gates we subscribe to. Keep them as fields so Dispose can - // unsubscribe the same instances we subscribed in the constructor. + // IPC gates — kept as fields so Dispose can unsubscribe the same instances. private readonly ICallGateSubscriber<(uint, uint)> _apiVersion; private readonly ICallGateSubscriber _getLocalCharacterTitle; private readonly ICallGateSubscriber _localCharacterTitleChanged; @@ -48,23 +40,11 @@ internal sealed class HonorificService : IDisposable _framework = framework; _log = log; - // Dalamud caches gate objects per-name for the lifetime of the - // plugin interface, so we can register subscribers even when - // Honorific isn't loaded yet — the gate just won't fire. Calling - // InvokeFunc before Honorific is up will throw, which is why the - // initial pull below is wrapped in try-catch. - // - // Thread-context: plugin constructors run on Dalamud's plugin-loader - // thread, NOT the framework thread. Honorific's IPC handlers read - // ObjectTable.LocalPlayer (Honorific IpcProvider.cs:61), which throws - // "Not on main thread!" outside the framework thread. If Honorific is - // already loaded when HellionChat starts, a synchronous InvokeFunc - // here would surface that exception, the broad catch below would - // mark IsAvailable=false, and OnTitleChanged's `if (!IsAvailable)` - // gate would block every subsequent title update. We therefore - // schedule the initial pull onto the framework thread via - // IFramework.RunOnFrameworkThread so the IPC call sees the right - // thread context. + // Gate objects are cached per-name by Dalamud and safe to register + // before Honorific loads — they just won't fire until it does. + // Initial pull is scheduled on the framework thread because plugin + // constructors run on the loader thread, and Honorific's IPC handlers + // read ObjectTable.LocalPlayer which throws off the framework thread. _apiVersion = pluginInterface.GetIpcSubscriber<(uint, uint)>($"{IpcNamespace}.ApiVersion"); _getLocalCharacterTitle = pluginInterface.GetIpcSubscriber( $"{IpcNamespace}.GetLocalCharacterTitle" @@ -84,11 +64,8 @@ internal sealed class HonorificService : IDisposable public void Dispose() { - // Honorific may already be gone by the time we dispose. Wrap each - // unsubscribe so a missing gate doesn't prevent the others from - // unsubscribing — leaking even one subscription leaves a callback - // alive that captures `this`, which keeps the whole service alive - // and breaks plugin reload. + // Wrap each unsubscribe — a missing gate must not block the others. + // Leaking a subscription keeps this service alive across plugin reloads. TryUnsubscribe(() => _localCharacterTitleChanged.Unsubscribe(OnTitleChanged)); TryUnsubscribe(() => _ready.Unsubscribe(OnReady)); TryUnsubscribe(() => _disposing.Unsubscribe(OnDisposing)); @@ -119,34 +96,21 @@ internal sealed class HonorificService : IDisposable IsAvailable = true; _versionWarningLogged = false; - // Pull the current title once at startup; from here on we rely - // on LocalCharacterTitleChanged events. var json = _getLocalCharacterTitle.InvokeFunc(); CurrentTitle = ParseTitleJson(json); } catch (Exception ex) { - // Honorific isn't installed or hasn't initialised yet. The Ready - // event will give us a second chance later. Log at Debug so - // users without Honorific don't see noise on every reload. + // Honorific not installed or not yet initialised — Ready will retry. _log.Debug(ex, "Honorific not available at HellionChat startup; awaiting Ready."); IsAvailable = false; CurrentTitle = null; } } - // Honorific fires LocalCharacterTitleChanged through its nameplate hook - // (Honorific-master/Plugin.cs:665), which means we get title updates on - // character switches automatically as soon as the new character is - // rendered. While the user is in the character-select menu, HellionChat's - // window is hidden by default via HideWhenNotLoggedIn (Configuration.cs:152), - // so the stale-title window between logout and login isn't user-visible. private void OnTitleChanged(string json) { - // Don't update cached state when we've already decided we can't trust - // Honorific (e.g. version mismatch). Subscription stays live in case a - // compatible Honorific reloads, in which case Ready triggers TryInitialPull - // and sets IsAvailable back to true. + // Skip updates on version mismatch; subscription stays live for reload. if (!IsAvailable) return; CurrentTitle = ParseTitleJson(json); @@ -154,28 +118,16 @@ internal sealed class HonorificService : IDisposable private void OnReady() { - // Honorific loaded after HellionChat; redo the version check and - // initial pull. Idempotent on purpose — Honorific can fire Ready - // more than once across reloads. - // - // Honorific's NotifyReady may dispatch from any thread, and - // TryInitialPull eventually calls IPC handlers that read - // ObjectTable.LocalPlayer — same "Not on main thread!" hazard as - // the constructor path. Schedule onto the framework thread. + // Schedule on framework thread — NotifyReady can dispatch from any thread. _framework.RunOnFrameworkThread(TryInitialPull); } private void OnDisposing() { - // Honorific is unloading. Drop our cached state so the header - // hides on the next frame; subscriptions stay registered because - // the gates may come back later (Honorific reload). - // - // Race-note: Honorific's NotifyDisposing calls ChangedLocalCharacterTitle(null) - // BEFORE SendMessage on the Disposing gate (IpcProvider.cs:109-111), - // so OnTitleChanged is expected to fire first and already null out - // CurrentTitle. We re-clear here as belt-and-braces; should the - // ordering ever flip, ShouldRenderSlot would still gate on IsAvailable. + // Honorific unloading — clear cached state so the header hides next frame. + // Subscriptions stay registered in case Honorific reloads. + // CurrentTitle is already nulled by OnTitleChanged before this fires, + // re-clearing here is belt-and-braces. CurrentTitle = null; IsAvailable = false; DetectedApiVersion = null; @@ -193,28 +145,15 @@ internal sealed class HonorificService : IDisposable } } - // Threading note: Dalamud fires IPC events on the framework thread and - // ImGui renders on the framework thread, so OnTitleChanged and the - // render path that reads CurrentTitle never race — OnTitleChanged is - // safe to keep direct (no RunOnFrameworkThread wrap needed) because - // LocalCharacterTitleChanged delivery is framework-thread by Dalamud - // contract. If a future change moves either side onto a worker thread, - // switch to volatile/Interlocked for the CurrentTitle field. + // Threading: IPC events and ImGui both run on the framework thread, so + // OnTitleChanged and the render path never race — no volatile/Interlocked + // needed as long as Dalamud's framework-thread delivery contract holds. // - // The constructor's initial pull and OnReady, on the other hand, are - // explicitly scheduled via IFramework.RunOnFrameworkThread because - // they run outside that contract: the constructor executes on the - // plugin-loader thread, and Honorific's NotifyReady can dispatch from - // any thread. Both call paths eventually invoke IPC handlers that read - // ObjectTable.LocalPlayer, which throws "Not on main thread!" off the - // framework thread — see the constructor comment block for context. - // - // Divergence from ChatTwo/Ipc/ExtraChat.cs: that file uses `volatile` - // on its state fields out of caution. We don't, because the framework- - // thread delivery is the documented Dalamud contract. If the two files - // ever need to share a threading audit, this is the place to revisit. + // Constructor and OnReady are exceptions: they run outside that contract + // (plugin-loader thread and Honorific's NotifyReady respectively), so both + // use RunOnFrameworkThread to safely reach ObjectTable.LocalPlayer. - // --- Pure-logic helpers below; tested via HellionChat.Tests/Integrations. --- + // --- Pure-logic helpers; tested via HellionChat.Tests/Integrations. --- internal static HonorificTitleData? ParseTitleJson(string json) { diff --git a/HellionChat/Integrations/HonorificTitleData.cs b/HellionChat/Integrations/HonorificTitleData.cs index 3f67e32..5363851 100644 --- a/HellionChat/Integrations/HonorificTitleData.cs +++ b/HellionChat/Integrations/HonorificTitleData.cs @@ -2,13 +2,9 @@ using System.Numerics; namespace HellionChat.Integrations; -// Local DTO mirroring Honorific's TitleData shape. We replicate the structure -// instead of referencing Honorific.dll because a hard build-time dependency -// would couple the two assemblies and break HellionChat at load time when -// Honorific is missing. Glow, Color3, GradientColourSet and GradientAnimationStyle -// are intentionally omitted — Cycle 1 renders text in the primary Color only; -// the "Honorific Full Fidelity" backlog item adds them later as a pure -// extension that won't break this DTO's existing consumers. +// Local DTO mirroring Honorific's TitleData — no hard reference to Honorific.dll +// so HellionChat loads cleanly when Honorific is absent. +// Glow/gradient fields omitted; Cycle 1 renders primary Color only. internal sealed record HonorificTitleData( string? Title, bool IsPrefix, diff --git a/HellionChat/Integrations/IntegrationLinks.cs b/HellionChat/Integrations/IntegrationLinks.cs index fd21bc2..84253a7 100644 --- a/HellionChat/Integrations/IntegrationLinks.cs +++ b/HellionChat/Integrations/IntegrationLinks.cs @@ -1,10 +1,6 @@ namespace HellionChat.Integrations; -// External URLs for the third-party plugins HellionChat integrates with. -// Kept separate from BrandingLinks (which is for Hellion-owned URLs) so -// future cycles can extend this file with maintainer attribution links -// for Moodles, NotificationMaster, ExtraChat, etc. without polluting the -// brand-links class. +// Third-party plugin URLs — separate from BrandingLinks (Hellion-owned URLs). internal static class IntegrationLinks { public const string HonorificRepo = "https://github.com/Caraxi/Honorific"; diff --git a/HellionChat/MessageManager.cs b/HellionChat/MessageManager.cs index f64f13d..7d74ede 100644 --- a/HellionChat/MessageManager.cs +++ b/HellionChat/MessageManager.cs @@ -27,16 +27,7 @@ internal class MessageManager : IAsyncDisposable private Dictionary Formats { get; } = []; private ulong LastContentId { get; set; } - // Messages go into the PendingSync queue first, which will be consumed one - // at a time in the main thread. This is to delay the async processing until - // after we've received the content ID from the ContentIdResolver hook. - // - // After that, the message is enqueued in the PendingAsync queue, which will - // be consumed in a separate thread and perform more processing (emotes, - // URLs) as well as inserting the message into the database. - // LinkedList instead of Queue: ContentIdResolver hits PendingSync.Last - // every hook call. Queue.Last() is the LINQ extension and walks the - // whole queue (O(n)); LinkedList.Last is an O(1) node reference. + // PendingSync (main thread) → PendingAsync (worker thread); LinkedList for O(1) Last access private LinkedList PendingSync { get; } = []; private ConcurrentQueue PendingAsync { get; } = []; private readonly Thread PendingMessageThread; @@ -53,11 +44,8 @@ internal class MessageManager : IAsyncDisposable } } - // Hellion Chat — Auto-Tell-Tabs hook. Fires after a fully processed - // message has been routed to all matching persistent tabs and stored - // in the database. The AutoTellTabsService subscribes to spawn or - // refresh temp tabs without having to wedge itself into ProcessMessage - // directly. + // Auto-Tell-Tabs hook: fires after a message is processed and stored, allowing + // AutoTellTabsService to spawn or refresh temp tabs without coupling. public event Action? MessageProcessed; internal unsafe MessageManager(Plugin plugin) @@ -66,8 +54,6 @@ internal class MessageManager : IAsyncDisposable Store = new MessageStore(DatabasePath()); - // IsBackground so a stuck worker never blocks plugin unload. - // Cooperative cancel via PendingThreadCancellationToken first, background flag is the safety net. PendingMessageThread = new Thread(() => ProcessPendingMessages(PendingThreadCancellationToken.Token) ) @@ -107,12 +93,9 @@ internal class MessageManager : IAsyncDisposable if (PendingMessageThread.IsAlive) Plugin.Log.Warning( "PendingMessageThread did not observe cancellation within 10s. " - + "Worker remains on a background thread; next plugin reload releases it. " - + "If this recurs, file a bug with /xllog after the previous reload." + + "Worker remains on background thread; next plugin reload releases it." ); - // CTS owns an unmanaged WaitHandle; dispose even if the worker is - // alive — it checks IsCancellationRequested via the linked token. PendingThreadCancellationToken.Dispose(); Store.Dispose(); @@ -166,12 +149,7 @@ internal class MessageManager : IAsyncDisposable internal void ClearAllTabs() { - // Hellion Chat — TempTabs haben keine DB-Persistenz (session-only, - // direkt vom AutoTellTabsService befüllt). Ein Clear+Refilter würde - // sie leer hinterlassen weil FilterAllTabs nichts aus der DB - // findet — Tells sind oft durch Privacy-Filter blockiert oder - // schlicht session-flüchtig. TempTabs vom Clear-Pfad ausschließen - // damit Settings-Save den Tell-Verlauf nicht zerstört. + // TempTabs are session-only (not persisted); exclude them to preserve Tell history foreach (var tab in Plugin.Config.Tabs.Where(t => !t.IsTempTab)) tab.Clear(); } @@ -184,12 +162,7 @@ internal class MessageManager : IAsyncDisposable using var messages = Store.GetMostRecentMessages(CurrentContentId, since); - // We store the pending messages to be added to the chat log in a - // temporary list, and apply them all at once after filtering. - // TempTabs werden ausgeschlossen — sie bleiben live-state aus dem - // AutoTellTabsService, ein DB-Refilter würde sie nur partial - // wiederherstellen falls Tells in DB liegen, oder leer lassen wenn - // Privacy-Filter sie blockiert hat. + // TempTabs are excluded; they maintain live state from AutoTellTabsService var pendingTabs = Plugin .Config.Tabs.Where(t => !t.IsTempTab) .Select(tab => (tab, new List())) @@ -198,7 +171,7 @@ internal class MessageManager : IAsyncDisposable foreach (var (_, pendingMessages) in pendingTabs.Where(ptab => ptab.Item1.Matches(message))) pendingMessages.Add(message); - // Apply the messages to the chat log in one go. + // Apply messages to chat log all at once. foreach (var (tab, pendingMessages) in pendingTabs) tab.Messages.AddSortPrune(pendingMessages, MessageDisplayLimit); @@ -207,8 +180,7 @@ internal class MessageManager : IAsyncDisposable WrapperUtil.AddNotification(Language.LoadMessages_Error, NotificationType.Error); - // Mark the failed messages as deleted so we don't try to load them - // again. + // Mark failed messages as deleted to prevent retry attempts var failedIds = messages.FailedMessageIds(); Plugin.Log.Info($"Marking {failedIds.Count} messages as deleted due to parse failures"); foreach (var msgId in messages.FailedMessageIds()) @@ -256,16 +228,10 @@ internal class MessageManager : IAsyncDisposable // Update colour codes. GlobalParametersCache.Refresh(); - // We delay messages to be handed off to the async processing thread - // in the next tick, otherwise we can't get the content ID from the hook - // below. + // Delay to next tick to get content ID from ContentIdResolver hook PendingSync.AddLast(pendingMessage); } - // This hook is called immediately after receiving a message with the - // message's content ID. If multiple messages are received in the same tick, - // this will be called for each message immediately after ChatMessage is - // called for each message. private unsafe void ContentIdResolver( RaptureLogModule* agent, ulong contentId, @@ -408,7 +374,7 @@ internal class MessageManager : IAsyncDisposable var after = formats .GetRange(firstStringParam + 1, secondStringParam - firstStringParam) .Where(payload => payload.Type == ReadOnlySePayloadType.Text) - .Select(text => Encoding.UTF8.GetString(text.Body.Span)); // Can't use `ToString()` as it defaults to macro + .Select(text => Encoding.UTF8.GetString(text.Body.Span)); var nameFormatting = NameFormatting.Of(string.Join("", before), string.Join("", after)); Formats[type] = nameFormatting; diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 19d9cf2..a8411f5 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -90,10 +90,8 @@ public sealed class Plugin : IAsyncDalamudPlugin public readonly WindowSystem WindowSystem = new(PluginName); - // v1.4.3: properties moved from { get; } to { get; private set; } = null!; - // because LoadAsync now owns construction of the Phase-2 services. - // Phase-1 services use the same shape for consistency, even though - // they're still allocated in the ctor. + // Phase-2 services are constructed in LoadAsync; null! shape is kept + // consistent across all properties for clarity. public SettingsWindow SettingsWindow { get; private set; } = null!; public ChatLogWindow ChatLogWindow { get; private set; } = null!; public DbViewer DbViewer { get; private set; } = null!; @@ -115,27 +113,20 @@ public sealed class Plugin : IAsyncDalamudPlugin internal Ui.StatusBar StatusBar { get; private set; } = null!; internal Integrations.HonorificService HonorificService { get; private set; } = null!; - // (B3) Lightless idempotency guard — Dalamud may fire DisposeAsync twice - // in a reload race; second call short-circuits. + // Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race. private int _disposeStarted; internal int DeferredSaveFrames = -1; - // Serialises retention sweeps. The 24h auto-sweep on plugin load and - // the manual button in the Privacy tab both run on background threads; - // without this gate, hitting the manual button moments after a fresh - // plugin start would launch two sweeps in parallel and the second one - // would just re-do work the first one already finished. The lock guards - // the flag — the flag check itself bails before we touch the database. - // Volatile because the ImGui thread reads the flag outside the lock to - // gate the manual button; without it the JIT may cache the value in a - // register and miss the background-thread update. + // Serialises retention sweeps so a manual trigger and the 24h auto-sweep + // can't run in parallel. Volatile because the ImGui thread reads it outside + // the lock to gate the manual button. internal readonly object RetentionSweepLock = new(); internal volatile bool RetentionSweepRunning; internal DateTime GameStarted { get; } - // Tab management needs to happen outside the chatlog window class for access reasons + // Tab management lives here rather than in ChatLogWindow for access reasons. internal int LastTab { get; set; } internal int? WantedTab { get; set; } internal Tab CurrentTab @@ -149,31 +140,22 @@ public sealed class Plugin : IAsyncDalamudPlugin public Plugin() { - // Phase-1 ctor stays minimal: bootstrap-essentials only (conflict - // gate, config load, language + ImGui init, WindowSystem skeleton). - // Schema migrations and every service / window allocation moved to - // LoadAsync so the sync ctor returns fast. On failure here nothing - // is initialized yet, so just throw — there is nothing to clean up. + // Phase-1 ctor: bootstrap-essentials only (conflict gate, config load, + // language + ImGui init). All service/window allocation lives in LoadAsync. - // Refuse to start if upstream Chat 2 is loaded — prevents IPC - // channel collisions and double-replacement of the in-game chat - // window. Throwing here makes Dalamud abort the load cleanly with - // our localized message instead of crashing FFXIV mid-frame. + // Block load if upstream Chat 2 is active — prevents IPC collisions + // and double-replacement of the in-game chat window. ChatTwoConflictDetector.ThrowIfChatTwoIsLoaded(Interface); GameStarted = Process.GetCurrentProcess().StartTime.ToUniversalTime(); - // Hellion Chat: take over config + database from upstream ChatTwo - // before Dalamud loads our plugin config. Idempotent: only acts on - // the first start where the legacy paths exist and ours don't. + // Migrate config + database from upstream ChatTwo on first start. MigrateFromChatTwoLayout(); Config = Interface.GetPluginConfig() as Configuration ?? new Configuration(); - // Schema-gate: v1.4.3 only supports config schema v16. Older configs - // went through their migrations in v1.2.1 (v15→v16) and earlier; users - // who skipped past those releases need to install v1.4.2 first to run - // the migration chain, then upgrade to v1.4.3. + // Schema gate: v1.4.3 requires config v16. Users on older schemas + // must install v1.4.2 first to run the migration chain. if (Config.Version < 16) { throw new InvalidOperationException( @@ -182,19 +164,13 @@ public sealed class Plugin : IAsyncDalamudPlugin ); } - // Hellion Chat — Auto-Tell-Tabs Defense-in-Depth. SaveConfig - // already strips temp tabs before persistence, but a previous - // crash or external write could have left them in the JSON. - // Drop them on load to guarantee the session-only invariant. + // Drop session-only Auto-Tell-Tabs that a previous crash may have persisted. Config.Tabs.RemoveAll(t => t.IsTempTab); LanguageChanged(Interface.UiLanguage); ImGuiUtil.Initialize(this); DeferredSaveFrames = -1; - - // WindowSystem skeleton is initialised by the readonly field above — - // no AddWindow yet; window construction lives in LoadAsync. } public async Task LoadAsync(CancellationToken cancellationToken) @@ -203,14 +179,8 @@ public sealed class Plugin : IAsyncDalamudPlugin try { - // Hellion v1.0.0 default tab layout. Five thematically separated - // tabs: General catches the immediate-surroundings public chat - // (Say/Yell/Shout) only; System absorbs the rest of the technical - // and gameplay-event noise; FreeCompany, Group and Linkshell each - // own their respective channel set. Tells are not in a static - // tab anymore — Auto-Tell-Tabs spawns dedicated per-conversation - // tabs on demand. Novice-Network gets no preset tab; users who - // want it can add HellionBeginner from Settings → Tabs. + // Default tab layout on fresh install. Tells are handled by + // Auto-Tell-Tabs; Novice Network has no preset tab by design. if (Config.Tabs.Count == 0) { Config.Tabs.Add(TabsUtil.VanillaGeneral); @@ -222,19 +192,12 @@ public sealed class Plugin : IAsyncDalamudPlugin cancellationToken.ThrowIfCancellationRequested(); - // Sync allocation + handle registration. BuildFonts() registers - // IFontHandles with Dalamud's UiBuilder.FontAtlas — registration - // itself is non-blocking (handles stored, lambdas queued). Dalamud - // rebuilds the atlas on its own pipeline a few frames later; first - // frames render with the default font until the rebuild lands and - // ImGui switches to Hellion-Exo2 / NotoSans (visible "font-pop"). - // Mirrors ChatTwo Plugin.cs:152. + // BuildFonts registers handles with Dalamud's FontAtlas; the atlas + // rebuilds async a few frames later (visible "font-pop" on first load). FontManager = new FontManager(); FontManager.BuildFonts(); - // Theme init stays sync on the LoadAsync continuation — cheap, - // and Active is read every Draw frame, so the registry must be - // wired before the first hook fires. + // ThemeRegistry must be wired before the first Draw tick. var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes"); Directory.CreateDirectory(customThemesDir); SeedExampleThemeIfEmpty(customThemesDir); @@ -243,11 +206,9 @@ public sealed class Plugin : IAsyncDalamudPlugin cancellationToken.ThrowIfCancellationRequested(); - // Service allocations: order encodes dependencies. Commands is - // alloc-only here; Initialise() runs after windows exist so the - // slash-commands can toggle their visibility. HonorificService - // registers IPC subscribers up-front so Ready/Disposing events - // are caught from the very first frame. + // Service allocations — order encodes dependencies. + // HonorificService registers IPC subscribers early to catch + // Ready/Disposing events from the first frame. FileDialogManager = new FileDialogManager(); Commands = new Commands(); Functions = new GameFunctions.GameFunctions(this); @@ -258,9 +219,6 @@ public sealed class Plugin : IAsyncDalamudPlugin StatusBar = new Ui.StatusBar(); MessageManager = new MessageManager(this); - // Auto-Tell-Tabs subscribes to MessageManager.MessageProcessed for - // live tells and to ClientState.Logout for cleanup; needs the live - // store handed in at construction. AutoTellTabsService = new AutoTellTabsService( this, MessageManager, @@ -268,7 +226,6 @@ public sealed class Plugin : IAsyncDalamudPlugin ); AutoTellTabsService.Initialize(); - // SelfTest steps poll Active per frame and need the registry wired. SelfTestRegistry.RegisterTestSteps([new SelfTests.ThemeSwitchSelfTestStep(this)]); ChatLogWindow = new ChatLogWindow(this); @@ -289,22 +246,19 @@ public sealed class Plugin : IAsyncDalamudPlugin WindowSystem.AddWindow(DebuggerWindow); WindowSystem.AddWindow(FirstRunWizard); - // Open the wizard on a fresh install. Existing ChatTwo users have - // FirstRunCompleted set to true by the v6→v7 migration above. if (!Config.FirstRunCompleted) FirstRunWizard.IsOpen = true; cancellationToken.ThrowIfCancellationRequested(); - // let all the other components register, then initialize commands Commands.Initialise(); - // Daily retention sweep, fire-and-forget. Skips itself when - // disabled or when it already ran within the past 24 hours. + // Daily retention sweep — fire-and-forget, skips when disabled + // or already ran within the past 24 hours. RunRetentionSweepIfDue(); if (Config.ShowEmotes) - _ = EmoteCache.LoadData(); // Fire-and-forget, exceptions caught inside + _ = EmoteCache.LoadData(); if (Interface.Reason is not PluginLoadReason.Boot) MessageManager.FilterAllTabsAsync(); @@ -313,33 +267,22 @@ public sealed class Plugin : IAsyncDalamudPlugin Interface.UiBuilder.DisableGposeUiHide = true; #if !DEBUG - // Fire-and-forget on a worker thread. The first auto-translate use of - // a session may have a sub-second hitch if the cache hasn't filled yet, - // but that's preferable to making every user wait ~300 ms during - // plugin load for a cache they may never touch. ChatTwo (upstream) - // does this sync; we trade load-time for first-use latency. + // Fire-and-forget — first auto-translate use may have a sub-second + // hitch if the cache hasn't filled yet, but avoids blocking load. _ = Task.Run(AutoTranslate.PreloadCache, cancellationToken); #endif cancellationToken.ThrowIfCancellationRequested(); - // (B1) Hooks last: every service and window must be live before - // Dalamud fires our first Draw / FrameworkUpdate tick. Anything - // earlier risks rendering against null FontManager / ThemeRegistry. + // Hooks last — all services and windows must be live before + // the first Draw / FrameworkUpdate tick fires. Framework.Update += FrameworkUpdate; Interface.UiBuilder.Draw += Draw; Interface.LanguageChanged += LanguageChanged; - // Hellion Chat — surface a "main UI" entry point so Dalamud's - // plugin list shows the Open-Plugin button. Settings is the - // most useful landing place; OpenConfigUi is already wired to - // the same toggle inside SettingsWindow. Interface.UiBuilder.OpenMainUi += OpenMainUi; } catch { - // Mirror the v1.4.0 load-failure recovery: hand off to DisposeAsync - // so partially-built services are torn down. Swallow the cleanup - // exception so the original load failure stays the visible cause. try { await DisposeAsync().ConfigureAwait(false); @@ -351,28 +294,22 @@ public sealed class Plugin : IAsyncDalamudPlugin } } - // Suppressing this warning because DisposeAsync may run after a partial - // LoadAsync, so some properties may not be initialized. [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract")] public async ValueTask DisposeAsync() { - // (B3) Idempotency guard — Dalamud may reload-race us; second - // call short-circuits so we don't double-dispose services. + // Idempotency guard — second call short-circuits on reload race. if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return; Exception? failure = null; - // Hooks unsubscribe FIRST so no Draw / FrameworkUpdate / LanguageChanged - // tick can fire while we're tearing services down. Mirrors the - // hooks-last subscribe order in LoadAsync. + // Unsubscribe hooks first — mirrors the hooks-last subscribe order in LoadAsync. failure = CaptureFailure(failure, () => Interface.UiBuilder.OpenMainUi -= OpenMainUi); failure = CaptureFailure(failure, () => Interface.LanguageChanged -= LanguageChanged); failure = CaptureFailure(failure, () => Interface.UiBuilder.Draw -= Draw); failure = CaptureFailure(failure, () => Framework.Update -= FrameworkUpdate); - // v1.4.0 F5.3 — flush a pending DeferredSave before service teardown, - // since FrameworkUpdate just got unsubscribed and won't fire it. + // Flush a pending DeferredSave — FrameworkUpdate won't fire it anymore. failure = CaptureFailure( failure, () => @@ -385,13 +322,10 @@ public sealed class Plugin : IAsyncDalamudPlugin } ); - // Auto-Tell-Tabs unsubscribes from MessageProcessed before MessageManager - // goes away. Pure-memory cleanup, no framework-thread requirement. + // Unsubscribe AutoTellTabs before MessageManager goes away. failure = CaptureFailure(failure, () => AutoTellTabsService?.Dispose()); - // v1.4.0 F6.2 — MessageManager has its own async dispose path - // (DB flush, pending-message thread shutdown). Run it before the - // framework-block so the worker threads are quiesced first. + // MessageManager has its own async dispose path (DB flush, thread shutdown). if (MessageManager is not null) { failure = await CaptureFailureAsync( @@ -401,36 +335,24 @@ public sealed class Plugin : IAsyncDalamudPlugin .ConfigureAwait(false); } - // (B4) Game-Function / IPC / UI-Window cleanup MUST run on the - // framework thread. WindowSystem mutations and IPC subscriber - // disposes touch Dalamud state that's only safe from the framework. - // Worker-thread DisposeAsync would race the next Draw tick. - // Per-line CaptureFailure so a single throw can't strand the lines - // behind it; mirrors Lightless DisposeFrameworkBoundServicesAsync. + // Game-function / IPC / window cleanup must run on the framework thread. try { await Framework .RunOnFrameworkThread(() => { - // Game-Functions first — other services may still query - // chat-interactable state during their Dispose. failure = CaptureFailure( failure, () => GameFunctions.GameFunctions.SetChatInteractable(true) ); - // IPC subscribers — dispose before windows so any final - // event firing from the IPC source can't reach a half-torn - // ChatLogWindow. + // IPC subscribers before windows — prevents a final IPC event + // from reaching a half-torn ChatLogWindow. failure = CaptureFailure(failure, () => HonorificService?.Dispose()); failure = CaptureFailure(failure, () => TypingIpc?.Dispose()); failure = CaptureFailure(failure, () => ExtraChat?.Dispose()); failure = CaptureFailure(failure, () => Ipc?.Dispose()); - // Windows — RemoveAllWindows first, then per-window Dispose. - // Order matches the pre-v1.4.3 Dispose body byte-for-byte. - // CommandHelpWindow and FirstRunWizard don't implement - // IDisposable; their resources are reclaimed via WindowSystem. failure = CaptureFailure(failure, () => WindowSystem?.RemoveAllWindows()); failure = CaptureFailure(failure, () => ChatLogWindow?.Dispose()); failure = CaptureFailure(failure, () => DbViewer?.Dispose()); @@ -446,8 +368,7 @@ public sealed class Plugin : IAsyncDalamudPlugin failure ??= ex; } - // Pure-memory cleanups — no Framework / UI / IPC touch, so they - // run on whatever thread DisposeAsync resumes on. + // Pure-memory cleanups — no Framework / UI / IPC touch. failure = CaptureFailure(failure, () => Functions?.Dispose()); failure = CaptureFailure(failure, () => Commands?.Dispose()); failure = CaptureFailure(failure, () => EmoteCache.Dispose()); @@ -456,9 +377,8 @@ public sealed class Plugin : IAsyncDalamudPlugin ExceptionDispatchInfo.Capture(failure).Throw(); } - // Lightless-pattern capture helpers: run cleanup, remember the FIRST - // exception, keep going. Without these one mid-teardown failure would - // skip every cleanup behind it and leave services half-torn. + // Run cleanup actions individually so a single failure doesn't strand + // the remaining teardown steps. private static Exception? CaptureFailure(Exception? failure, Action action) { try @@ -499,9 +419,6 @@ public sealed class Plugin : IAsyncDalamudPlugin var ourConfigFile = Path.Combine(pluginConfigsDir, "HellionChat.json"); var ourConfigDir = Interface.ConfigDirectory.FullName; - // Track whether anything legitimately blocked us. The most common - // cause is upstream Chat 2 still being loaded — its SQLite handle - // keeps chat-sqlite.db locked and File.Move throws IOException. var lockedBlocker = false; try @@ -523,13 +440,6 @@ public sealed class Plugin : IAsyncDalamudPlugin lockedBlocker = true; } - // The plugin's ConfigDirectory may already exist on first load - // (Dalamud creates it), so check at the file level instead of - // skipping when the directory is present. Move every legacy - // entry whose target name is not occupied yet, then remove the - // source dir if it ends up empty. Each move is wrapped on its - // own so a single locked file (the SQLite db while ChatTwo still - // runs) does not abandon the rest of the migration. if (!Directory.Exists(legacyConfigDir)) return; @@ -537,6 +447,8 @@ public sealed class Plugin : IAsyncDalamudPlugin { Directory.CreateDirectory(ourConfigDir); + // Move each file individually so a single locked file (e.g. the + // SQLite db while ChatTwo is still loaded) doesn't abort the rest. foreach (var file in Directory.EnumerateFiles(legacyConfigDir)) { var target = Path.Combine(ourConfigDir, Path.GetFileName(file)); @@ -590,9 +502,6 @@ public sealed class Plugin : IAsyncDalamudPlugin if (lockedBlocker) { - // Surface the most common cause to the user as a notification - // so they don't think Hellion Chat lost their history when in - // fact upstream Chat 2 was still holding the database file. Notification.AddNotification( new Dalamud.Interface.ImGuiNotification.Notification { @@ -610,10 +519,6 @@ public sealed class Plugin : IAsyncDalamudPlugin private void OpenMainUi() { - // Settings is the most useful landing surface — same target as the - // Configure button. SettingsWindow.Toggle is internal and already - // wired to OpenConfigUi, so re-using IsOpen keeps both entry points - // behaviourally identical. SettingsWindow.IsOpen = !SettingsWindow.IsOpen; } @@ -624,8 +529,7 @@ public sealed class Plugin : IAsyncDalamudPlugin if (DateTimeOffset.UtcNow - Config.RetentionLastRunAt < TimeSpan.FromHours(24)) return; - // Snapshot the policy so the user can edit settings while we run. - // Spec defaults form the baseline; explicit user overrides win. + // Snapshot the policy so the user can edit settings while the sweep runs. var policy = new Dictionary(); foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays) policy[(int)(ushort)type] = days; @@ -633,16 +537,10 @@ public sealed class Plugin : IAsyncDalamudPlugin policy[(int)(ushort)type] = days; var defaultDays = Config.RetentionDefaultDays; - // IsBackground = true for the same reason as PendingMessageThread: - // a stuck sweep must never block plugin unload. RunRetentionSweepIfDue - // guards the run-frequency, and the sweep itself uses the framework's - // cooperative cancellation pattern. The background flag is the safety - // net if the sweep ever takes longer than expected. + // IsBackground = true so a stuck sweep never blocks plugin unload. new Thread(() => { - // Bail out cheaply if a manual sweep is already in flight; the - // lock around the actual work would queue us up otherwise and - // we would just re-do whatever the manual run already did. + // Bail early if a manual sweep is already in flight. lock (RetentionSweepLock) { if (RetentionSweepRunning) @@ -659,11 +557,8 @@ public sealed class Plugin : IAsyncDalamudPlugin if (deleted > 0) { 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]. + // Run clear+refilter on the framework thread — FilterAllTabsAsync + // is fire-and-forget and would race the next sweep cycle. Framework .Run(() => { @@ -694,9 +589,7 @@ public sealed class Plugin : IAsyncDalamudPlugin private void Draw() { - // Theme-Engine ist ab v14 immer aktiv; Klassik ist jetzt ein eigenes - // Theme statt einem deaktivierten Hellion-Theme. Active wird einmal - // pro Frame aus der Registry gelesen. + // Theme engine is always active; Classic is a theme, not a disabled state. using IDisposable _style = HellionStyle.PushGlobal( ThemeRegistry.Active, Config.WindowOpacity @@ -711,9 +604,7 @@ public sealed class Plugin : IAsyncDalamudPlugin return; } - // v1.0.2 — global skip while the New Game+ menu (QuestRedo addon) is - // open. Hides every plugin window in one shot (chat log, pop-outs, - // settings, db viewer, etc.), matching the LoadingScreens pattern. + // Hide all plugin windows while the New Game+ menu is open. if ( Config.HideInNewGamePlusMenu && GameFunctions.GameFunctions.IsAddonInteractable( @@ -742,10 +633,7 @@ public sealed class Plugin : IAsyncDalamudPlugin internal void SaveConfig() { - // Hellion Chat — Auto-Tell-Tabs are session-only. Strip them out - // before serialization so a crash mid-session can never persist - // them. We snapshot the full tab list first and restore it after - // the save, preserving the user's order and open conversations. + // Strip session-only Auto-Tell-Tabs before serialization; restore after. var snapshot = Config.Tabs.ToList(); Config.Tabs.RemoveAll(t => t.IsTempTab); @@ -794,9 +682,8 @@ public sealed class Plugin : IAsyncDalamudPlugin Condition[ConditionFlag.OccupiedInCutSceneEvent] || Condition[ConditionFlag.WatchingCutscene78]; - // v1.1.0 — wenn der themes/-Ordner leer ist, schreiben wir die embedded - // example-theme.json als Vorlage rein. Bestehende User-Customs werden - // nicht angefasst (existing JSONs lassen den Block überspringen). + // Seeds example-theme.json into the themes dir on first run. + // Skipped if any custom JSON already exists. private static void SeedExampleThemeIfEmpty(string dir) { if (Directory.EnumerateFiles(dir, "*.json").Any()) diff --git a/HellionChat/Resources/ChatColourPresets.cs b/HellionChat/Resources/ChatColourPresets.cs index 9ea4881..4008bdf 100644 --- a/HellionChat/Resources/ChatColourPresets.cs +++ b/HellionChat/Resources/ChatColourPresets.cs @@ -4,11 +4,8 @@ using HellionChat.Util; namespace HellionChat.Resources; -// Hellion Chat — v0.6.0 built-in colour presets for the ChatColours -// settings section. Read-only static data; users apply a preset via the -// settings UI which overwrites Configuration.ChatColours immediately. -// Battle-channel types are intentionally NOT covered by the stylistic -// presets so that combat-log tuning the user has done stays intact. +// Built-in colour presets applied via Settings UI → ChatColours. +// Battle-channel types are intentionally excluded to preserve combat-log tuning. public sealed record ChatColourPreset( string DisplayName, string LocalizationKey, @@ -69,9 +66,7 @@ public static class ChatColourPresets }; } - // The Default preset spiegelt 1:1 die Werte aus ChatTypeExt.DefaultColor. - // Channels ohne Default-Wert (return null) werden ausgelassen — wer sie - // anwenden will, behält seine aktuelle Farbe. + // Mirrors ChatTypeExt.DefaultColor; channels without a default are skipped. private static IReadOnlyDictionary BuildDefault() { var dict = new Dictionary(); @@ -183,33 +178,22 @@ public static class ChatColourPresets }; } - // Hellion brand preset — Arctic Cyan + Ember Orange palette aus - // /mnt/ssd-fast/Projekte/hellion-media/hellion-media-website/BRANDING.md - // (Schema-Stand 2026-04-16). Channels sind über das ganze Brand-Spektrum - // verteilt damit jede Zeile auf einen Glance unterscheidbar ist: - // Cyan-Familie für Standard/Tell, Ember + Warning für laute Channels, - // Status-Farben (Success, Danger) für Linkshells. CrossLinkshells - // nutzen die dunkleren/sattersten Varianten derselben Hue-Familien. + // Hellion brand preset — Arctic Cyan + Ember Orange palette. + // Cyan family for Standard/Tell, Ember/Warning for loud channels, + // Status colours for Linkshells, darker variants for CrossLinkshells. private static IReadOnlyDictionary BuildHellion() { return new Dictionary { - // Standard / Tell — Cyan-Familie (Brand-Primary) [ChatType.Say] = ColourUtil.ComponentsToRgba(77, 217, 232), // Cyan-light #4DD9E8 [ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(0, 190, 210), // Brand Cyan #00BED2 [ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(0, 151, 167), // Cyan-dark #0097A7 - - // Laute Channels — Ember/Warning [ChatType.Yell] = ColourUtil.ComponentsToRgba(240, 173, 78), // Warning #F0AD4E [ChatType.Shout] = ColourUtil.ComponentsToRgba(249, 115, 22), // Brand Ember #F97316 - - // Gruppen-Channels — Success/Ember-dark/Cyan [ChatType.Party] = ColourUtil.ComponentsToRgba(92, 184, 92), // Success #5CB85C [ChatType.Alliance] = ColourUtil.ComponentsToRgba(232, 93, 4), // Ember-dark #E85D04 [ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(0, 190, 210), // Brand Cyan [ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(77, 217, 232), // Cyan-light - - // Linkshells 1-8 — über das ganze Brand-Spektrum verteilt [ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(251, 146, 60), // Ember-light #FB923C [ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(240, 173, 78), // Warning [ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(92, 184, 92), // Success @@ -218,8 +202,6 @@ public static class ChatColourPresets [ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(0, 151, 167), // Cyan-dark [ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(249, 115, 22), // Brand Ember [ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(217, 83, 79), // Danger #D9534F - - // CrossWorld-Linkshells 1-8 — dunklere/sattersere Varianten [ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(232, 93, 4), // Ember-dark [ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(200, 140, 50), // Warning-dark [ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(60, 140, 60), // Success-dark @@ -231,31 +213,20 @@ public static class ChatColourPresets }; } - // Bonus preset — Night Blue, KAZAMA-Stimmungs-Theme aus - // /mnt/HDD-Data1/Obsidian/Vault/Systeme/KAZAMA/Theming/Night Blue + Indigo Violet Themes.md - // Klassisch, kühl, technisch — Marineblau-Tiefe ohne Lila-Anteil. - // Bewusst NICHT als Brand-Preset markiert (Vault-Boundary): die KAZAMA-Themes - // sind persönliche Stimmungs-Themes, nicht Teil des Hellion-Brand-Systems. + // Night Blue — cool nautical theme, deep navy without purple. private static IReadOnlyDictionary BuildNightBlue() { return new Dictionary { - // Standard / Tell — Royal Blue Akzent-Familie [ChatType.Say] = ColourUtil.ComponentsToRgba(230, 237, 247), // text-primary [ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(106, 176, 255), // akzent-hot [ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(74, 144, 226), // akzent-primary - - // Laute Channels — Warning/Danger Status-Töne [ChatType.Yell] = ColourUtil.ComponentsToRgba(255, 184, 74), // warning [ChatType.Shout] = ColourUtil.ComponentsToRgba(255, 92, 122), // danger - - // Gruppen — Success/Akzent-Variations [ChatType.Party] = ColourUtil.ComponentsToRgba(61, 220, 151), // success [ChatType.Alliance] = ColourUtil.ComponentsToRgba(255, 144, 100), // warm-orange-light [ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(74, 144, 226), // akzent-primary [ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(140, 160, 191), // text-dim - - // Linkshells 1-8 — über Spektrum verteilt [ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(255, 184, 74), [ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(255, 144, 100), [ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(255, 220, 130), @@ -264,8 +235,6 @@ public static class ChatColourPresets [ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(100, 200, 220), [ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(106, 176, 255), [ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(140, 160, 191), - - // CrossWorld-Linkshells — gedämpfte Variants [ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(200, 130, 50), [ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(220, 110, 80), [ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(200, 180, 60), @@ -277,30 +246,20 @@ public static class ChatColourPresets }; } - // Bonus preset — Indigo Violet, KAZAMA-Stimmungs-Theme aus demselben - // Vault-Doc. Warm-mystisch, "Galaxy/Glitter/Nordlicht" — tiefes Indigo - // mit kräftigem Violet-Akzent. Persönlicher Favorit (siehe Vault). - // Auch nicht als Brand-Preset (siehe NightBlue-Note oben). + // Indigo Violet — warm-mystic theme, deep indigo with violet accent. private static IReadOnlyDictionary BuildIndigoViolet() { return new Dictionary { - // Standard / Tell — Royal Violet Akzent-Familie - [ChatType.Say] = ColourUtil.ComponentsToRgba(240, 230, 255), // text-primary (light lavender) + [ChatType.Say] = ColourUtil.ComponentsToRgba(240, 230, 255), // text-primary [ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(176, 124, 255), // akzent-hot [ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(139, 77, 222), // akzent-primary - - // Laute Channels — geteilt mit Night Blue (Status-Farben) [ChatType.Yell] = ColourUtil.ComponentsToRgba(255, 184, 74), [ChatType.Shout] = ColourUtil.ComponentsToRgba(255, 92, 122), - - // Gruppen [ChatType.Party] = ColourUtil.ComponentsToRgba(61, 220, 151), [ChatType.Alliance] = ColourUtil.ComponentsToRgba(255, 144, 100), [ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(139, 77, 222), // akzent-primary [ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(168, 144, 208), // text-dim - - // Linkshells 1-8 [ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(255, 184, 74), [ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(255, 144, 100), [ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(255, 220, 130), @@ -309,8 +268,6 @@ public static class ChatColourPresets [ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(139, 77, 222), [ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(130, 90, 200), [ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(168, 144, 208), - - // CrossWorld-Linkshells [ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(200, 130, 50), [ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(220, 110, 80), [ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(200, 180, 60), diff --git a/HellionChat/SelfTests/ThemeSwitchSelfTestStep.cs b/HellionChat/SelfTests/ThemeSwitchSelfTestStep.cs index 3eb6652..7210bad 100644 --- a/HellionChat/SelfTests/ThemeSwitchSelfTestStep.cs +++ b/HellionChat/SelfTests/ThemeSwitchSelfTestStep.cs @@ -4,13 +4,9 @@ using HellionChat.Themes; namespace HellionChat.SelfTests; -// Validates the runtime theme-switch contract from the user side. The -// caller toggles the active theme via Settings -> Theme & Layout, the -// step polls ThemeRegistry.Active per frame and only passes once the -// slug has moved away from the initial value and back. The ABGR cache -// is sanity-checked on every frame: a freshly switched theme must carry -// a populated cache, otherwise Switch() forgot the recompute and the UI -// would still draw, just with all-transparent slots. +// Validates the runtime theme-switch contract: polls ThemeRegistry.Active +// per frame until the slug moves away and back, then sanity-checks that +// the ABGR cache was recomputed on switch. internal sealed class ThemeSwitchSelfTestStep : ISelfTestStep { private readonly Plugin plugin; @@ -73,9 +69,8 @@ internal sealed class ThemeSwitchSelfTestStep : ISelfTestStep this.switchedAway = false; } - // Any non-zero slot proves the cache was actually recomputed for the - // current theme. We don't compare against a reference, because custom - // themes can legitimately share slot values with a built-in. + // Any non-zero slot confirms the cache was recomputed — no reference + // comparison since custom themes can share slot values with built-ins. private static bool HasPopulatedCache(Theme theme) { var cache = theme.AbgrCache; diff --git a/HellionChat/Themes/Builtin/SynthwaveSunset.cs b/HellionChat/Themes/Builtin/SynthwaveSunset.cs index 3fe4426..6fa0492 100644 --- a/HellionChat/Themes/Builtin/SynthwaveSunset.cs +++ b/HellionChat/Themes/Builtin/SynthwaveSunset.cs @@ -10,7 +10,7 @@ internal static class SynthwaveSunset new( Slug: Slug, Name: "Synthwave Sunset", - Author: "Hellion Forge", + Author: "Zoe Moon", Description: "Hot Magenta + Cyan on midnight violet. 80s neon-grid vibes for late-night raids.", Colors: new ThemeColors( PrimaryDark: ColourUtil.HexToRgba("#C71585"), diff --git a/HellionChat/Themes/ThemeChatColors.cs b/HellionChat/Themes/ThemeChatColors.cs index c1518b4..0d2ed6a 100644 --- a/HellionChat/Themes/ThemeChatColors.cs +++ b/HellionChat/Themes/ThemeChatColors.cs @@ -2,8 +2,6 @@ using HellionChat.Code; namespace HellionChat.Themes; -// Optional pro Theme. Wenn ein Theme ChatColors mitliefert, kann der -// User sie per Klick im Themes-Tab auf Configuration.ChatColours anwenden. -// Ein Theme ohne ChatColors (z.B. chat2-classic) lässt die User-Channel- -// Farben unverändert. +// Optional per-theme chat colours applied to Configuration.ChatColours on user request. +// Themes without this leave channel colours untouched. public sealed record ThemeChatColors(IReadOnlyDictionary Channels); diff --git a/HellionChat/Themes/ThemeColors.cs b/HellionChat/Themes/ThemeColors.cs index 96f99d4..c131d43 100644 --- a/HellionChat/Themes/ThemeColors.cs +++ b/HellionChat/Themes/ThemeColors.cs @@ -1,6 +1,6 @@ namespace HellionChat.Themes; -// Color-Werte als 0xRRGGBBAA, RgbaToAbgr handled den Byte-Swap zu ImGui. +// Colour values as 0xRRGGBBAA — RgbaToAbgr handles the byte-swap for ImGui. public sealed record ThemeColors( uint PrimaryDark, uint Primary, diff --git a/HellionChat/Themes/ThemeJsonLoader.cs b/HellionChat/Themes/ThemeJsonLoader.cs index 4c53c0e..88a4c32 100644 --- a/HellionChat/Themes/ThemeJsonLoader.cs +++ b/HellionChat/Themes/ThemeJsonLoader.cs @@ -66,10 +66,8 @@ internal static class ThemeJsonLoader var dict = new Dictionary(); foreach (var prop in el.EnumerateObject()) { - // Property-Name ist der ChatType-Name als String (z.B. "Say", "Tell"), - // Value ist Hex wie bei den Theme-Colors. Unbekannte Channel-Names - // werden still übersprungen — Forward-Compat falls SE neue Channels - // einführt. + // Property name is the ChatType name (e.g. "Say", "Tell"), value is hex like theme colours. + // Unknown channel names are silently skipped for forward-compat with future SE channels. if ( !Enum.TryParse( prop.Name, diff --git a/HellionChat/Themes/ThemeLayout.cs b/HellionChat/Themes/ThemeLayout.cs index 116c2a3..d195e91 100644 --- a/HellionChat/Themes/ThemeLayout.cs +++ b/HellionChat/Themes/ThemeLayout.cs @@ -1,6 +1,6 @@ namespace HellionChat.Themes; -// Layout-Werte spiegeln die ImGuiStyleVar-Slots, die HellionStyle pusht. +// Layout values mirror the ImGuiStyleVar slots pushed by HellionStyle. public sealed record ThemeLayout( float WindowRounding, float ChildRounding, diff --git a/HellionChat/Themes/ThemeRegistry.cs b/HellionChat/Themes/ThemeRegistry.cs index 5ff7407..ac26dd7 100644 --- a/HellionChat/Themes/ThemeRegistry.cs +++ b/HellionChat/Themes/ThemeRegistry.cs @@ -29,7 +29,7 @@ public sealed class ThemeRegistry { SynthwaveSunset.Slug, SynthwaveSunset.Build() }, }; - // Centralised so the ten .Build() factories stay free of cache plumbing. + // Centralised so Build() factories stay free of cache plumbing. foreach (var theme in _builtIns.Values) theme.RecomputeAbgrCache(); @@ -58,14 +58,13 @@ public sealed class ThemeRegistry public void Switch(string slug) { var theme = Get(slug); - // Defensive — idempotent and cheap, so any future theme source - // that forgets the cache fill still ends up with a populated one. + // Defensive — ensures any future theme source always gets a populated cache. theme.RecomputeAbgrCache(); _active = theme; } - // 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION. Other - // IO failures are permanent and get the theme dropped instead of retried. + // 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION. + // Other IO failures are permanent — theme is dropped instead of retried. internal static bool IsRecoverableFileLock(Exception? ex) { if (ex is not IOException io) @@ -74,9 +73,8 @@ public sealed class ThemeRegistry return code == 0x80070020u || code == 0x80070021u; } - // Custom-Themes werden lazy aus dem Verzeichnis geladen, Cache mit - // LastWriteTime-Token. Eine geänderte JSON wird beim nächsten Lookup - // neu eingelesen. + // Custom themes are loaded lazily, cached by LastWriteTime. + // A changed JSON is reloaded on the next lookup. private Theme? LoadCustomBySlug(string slug) { if (_customThemesDir is null) @@ -115,8 +113,7 @@ public sealed class ThemeRegistry } catch (Exception ex) when (IsRecoverableFileLock(ex)) { - // Editor mid-save: keep the cached snapshot, leave the stamp - // alone so the next refresh retries automatically. + // Editor mid-save: keep last known good, retry on next refresh. Plugin.Log.Debug( $"Custom theme {Path.GetFileName(path)} is locked, keeping last known good" ); diff --git a/HellionChat/Themes/ThemeTypography.cs b/HellionChat/Themes/ThemeTypography.cs index b1fbf3a..9f7a981 100644 --- a/HellionChat/Themes/ThemeTypography.cs +++ b/HellionChat/Themes/ThemeTypography.cs @@ -1,7 +1,6 @@ namespace HellionChat.Themes; -// Optional pro Theme. v1.1.0 nutzt das nicht aktiv; ist als Erweiterungspunkt -// für zukünftige Theme-Slots vorbereitet. +// Optional per-theme; reserved as an extension point for future theme slots. public sealed record ThemeTypography( float? OverrideGlobalFontSizePt = null, float? OverrideSymbolsFontSizePt = null