using System.Collections.Concurrent; using System.Diagnostics; using System.Text; using Dalamud.Game.Chat; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Hooking; using Dalamud.Interface.ImGuiNotification; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Client.UI.Misc; using HellionChat._Helpers; using HellionChat.Code; using HellionChat.Resources; using HellionChat.Util; using Lumina.Text.Expressions; using Lumina.Text.Payloads; using Lumina.Text.ReadOnly; using Microsoft.Extensions.Logging; namespace HellionChat; internal class MessageManager : IAsyncDisposable { internal const int MessageDisplayLimit = 10_000; private Plugin Plugin { get; } private readonly ILogger _logger; internal MessageStore Store { get; } private Dictionary Formats { get; } = []; private ulong LastContentId { get; set; } // PendingSync (main thread) → PendingAsync (worker thread); LinkedList for O(1) Last access private LinkedList PendingSync { get; } = []; private ConcurrentQueue PendingAsync { get; } = []; private readonly Thread PendingMessageThread; private readonly CancellationTokenSource PendingThreadCancellationToken = new(); private Hook? ContentIdResolverHook { get; init; } internal ulong CurrentContentId { get { var contentId = Plugin.PlayerState.ContentId; return contentId == 0 ? LastContentId : contentId; } } // 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, ILogger logger, ILoggerFactory loggerFactory ) { Plugin = plugin; _logger = logger; Store = new MessageStore( DatabasePath(), Plugin.PlatformUtil, loggerFactory.CreateLogger(), loggerFactory ); PendingMessageThread = new Thread(() => ProcessPendingMessages(PendingThreadCancellationToken.Token) ) { IsBackground = true, }; PendingMessageThread.Start(); ContentIdResolverHook = Plugin.GameInteropProvider.HookFromAddress( RaptureLogModule.MemberFunctionPointers.AddMsgSourceEntry, ContentIdResolver ); ContentIdResolverHook.Enable(); Plugin.ChatGui.ChatMessageUnhandled += ChatMessage; Plugin.Framework.Update += OnFrameworkUpdate; Plugin.ClientState.Logout += Logout; } public async ValueTask DisposeAsync() { ContentIdResolverHook?.Dispose(); Plugin.ClientState.Logout -= Logout; Plugin.Framework.Update -= OnFrameworkUpdate; Plugin.ChatGui.ChatMessageUnhandled -= ChatMessage; await PendingThreadCancellationToken.CancelAsync(); // 10s cooperative window; Thread.Abort is gone since .NET 5, so a // stuck worker has to ride out the next AppDomain unload. var deadline = TimeSpan.FromSeconds(10); var stopwatch = Stopwatch.StartNew(); while (stopwatch.Elapsed < deadline && PendingMessageThread.IsAlive) await Task.Delay(100); if (PendingMessageThread.IsAlive) _logger.LogWarning( "PendingMessageThread did not observe cancellation within 10s. " + "Worker remains on background thread; next plugin reload releases it." ); PendingThreadCancellationToken.Dispose(); Store.Dispose(); } internal static string DatabasePath() { return Path.Join(Plugin.Interface.ConfigDirectory.FullName, "chat-sqlite.db"); } private void Logout(int _, int __) { LastContentId = 0; } private void OnFrameworkUpdate(IFramework framework) { var contentId = Plugin.PlayerState.ContentId; if (contentId != 0) LastContentId = contentId; // Drain the PendingSync queue into the PendingAsync queue. while (PendingSync.First is { } first) { PendingSync.RemoveFirst(); PendingAsync.Enqueue(first.Value); } } private void ProcessPendingMessages(CancellationToken token) { while (!token.IsCancellationRequested) { if (PendingAsync.TryDequeue(out var pendingMessage)) { try { ProcessMessage(pendingMessage); } catch (Exception ex) { _logger.LogError(ex, "Error processing pending message"); } } else { Thread.Sleep(1); } } } internal void ClearAllTabs() { // B3: snapshot the tab LIST under the shared lock so the worker-thread // add/remove can't tear the enumeration; tab.Clear() then runs lock-free // (each tab's Messages has its own SemaphoreSlim — lock order: list outer). List tabsSnapshot; lock (Plugin.TabsListLock) tabsSnapshot = Plugin.Config.Tabs.ToList(); // TempTabs are session-only (not persisted); exclude them to preserve Tell history foreach (var tab in tabsSnapshot.Where(t => !t.IsTempTab)) tab.Clear(); } internal void FilterAllTabs() { DateTimeOffset? since = null; if (!Plugin.Config.FilterIncludePreviousSessions) since = Plugin.GameStarted; using var messages = Store.GetMostRecentMessages(CurrentContentId, since); // TempTabs excluded (live state from AutoTellTabsService). Bucket via the // pure MapMessagesToTabs so the assignment stays testable outside Dalamud (B3-1). // B3: snapshot under the shared lock (list copy only — short critical // section). The Store query above and the AddSortPrune writes below stay // OUTSIDE the lock (lock order: list outer, MessageList inner). List nonTempTabs; lock (Plugin.TabsListLock) nonTempTabs = Plugin.Config.Tabs.Where(t => !t.IsTempTab).ToList(); var buckets = MapMessagesToTabs(nonTempTabs, messages); // Apply messages to chat log all at once. foreach (var tab in nonTempTabs) tab.Messages.AddSortPrune(buckets[tab], MessageDisplayLimit); if (!messages.DidError) return; WrapperUtil.AddNotification(Language.LoadMessages_Error, NotificationType.Error); // Mark failed messages as deleted to prevent retry attempts var failedIds = messages.FailedMessageIds(); _logger.LogInformation( $"Marking {failedIds.Count} messages as deleted due to parse failures" ); foreach (var msgId in messages.FailedMessageIds()) { _logger.LogDebug($"Marking message '{msgId}' as deleted due to parse failure"); Store.DeleteMessage(msgId); } } // Pure message->tab bucketing for the refilter. Dalamud-free + static so the // assignment can be unit-pinned in the build suite; the live caller owns the // Store query, the snapshot and the SemaphoreSlim writes. internal static Dictionary> MapMessagesToTabs( IReadOnlyList tabs, IEnumerable messages ) { var buckets = new Dictionary>(tabs.Count); foreach (var tab in tabs) buckets[tab] = new List(); foreach (var message in messages) foreach (var tab in tabs) if (tab.Matches(message)) buckets[tab].Add(message); return buckets; } internal void FilterAllTabsAsync() { Task.Run(() => { var stopwatch = Stopwatch.StartNew(); try { FilterAllTabs(); } catch (Exception ex) { _logger.LogError(ex, "Error in FilterAllTabs"); } // v1.4.9 R3 profiling: Information so the xllog tail surfaces this // without a Debug filter. Belt-and-suspenders for future plugin-load // regressions; remains in place after Sub-Task 3.4 Befund. _logger.LogInformation($"FilterAllTabs took {stopwatch.ElapsedMilliseconds}ms"); }); } public (SeString? Sender, SeString? Message) LastMessage = (null, null); private void ChatMessage(IChatMessage message) { LastMessage = (message.Sender, message.Message); var pendingMessage = new PendingMessage { ContentId = 0, AccountId = 0, LogKind = message.LogKind, SourceKind = message.SourceKind, TargetKind = message.TargetKind, Sender = message.Sender, Content = message.Message, }; // Update colour codes. GlobalParametersCache.Refresh(); // Delay to next tick to get content ID from ContentIdResolver hook PendingSync.AddLast(pendingMessage); } private unsafe void ContentIdResolver( RaptureLogModule* agent, ulong contentId, ulong accountId, int messageIndex, ushort worldId, ushort chatType ) { try { ContentIdResolverHook?.Original( agent, contentId, accountId, messageIndex, worldId, chatType ); if (PendingSync.Last is not { } last) return; last.Value.ContentId = contentId; last.Value.AccountId = accountId; } catch (Exception ex) { _logger.LogError(ex, "Error in ContentIdResolver"); } } private void ProcessMessage(PendingMessage pendingMessage) { var chatCode = new ChatCode( pendingMessage.LogKind, pendingMessage.SourceKind, pendingMessage.TargetKind ); NameFormatting? formatting = null; if (pendingMessage.Sender.Payloads.Count > 0) formatting = FormatFor(chatCode.Type); var senderChunks = new List(); if (formatting is { IsPresent: true }) { senderChunks.Add( new TextChunk(ChunkSource.None, null, formatting.Before) { FallbackColour = chatCode.Type, } ); senderChunks.AddRange( ChunkUtil.ToChunks(pendingMessage.Sender, ChunkSource.Sender, chatCode.Type) ); senderChunks.Add( new TextChunk(ChunkSource.None, null, formatting.After) { FallbackColour = chatCode.Type, } ); } var contentChunks = ChunkUtil .ToChunks(pendingMessage.Content, ChunkSource.Content, chatCode.Type) .ToList(); var message = new Message( CurrentContentId, pendingMessage.ContentId, pendingMessage.AccountId, chatCode, senderChunks, contentChunks, pendingMessage.Sender, pendingMessage.Content ); if (Plugin.Config.DatabaseBattleMessages || !message.Code.IsBattle()) Store.UpsertMessage(message); // Snapshot the list, not just the active tab. This loop runs on the worker // thread while SaveConfig's strip and the auto-tell spawn mutate Config.Tabs // under TabsListLock — enumerating it live throws "collection was modified", // and the catch in ProcessPendingMessages swallows that, silently dropping // the whole message: no tab entry, no sound, no MessageProcessed. List tabsSnapshot; lock (Plugin.TabsListLock) tabsSnapshot = Plugin.Config.Tabs.ToList(); // Snapshot the active tab and whether it shows this message ONCE, so the // whole loop sees a consistent value (the getter is a cross-thread read of // MainWindow.ActiveTab). var currentTab = Plugin.CurrentTab; var currentTabMatches = currentTab.Matches(message); foreach (var tab in tabsSnapshot) { if (tab.Matches(message)) tab.AddMessage(message, ShouldCountUnread(tab, currentTab, currentTabMatches)); } // Deliberate O(2n): the sound pick re-walks the tab list so the selection // stays pure and SelfTest-able; AddMessage above and playback below keep // the side effects. var notificationSound = SelectNotificationSound( tabsSnapshot, currentTab, message, Plugin.Config.PlaySounds, out var soundSource ); // The snapshot can outlive a tab (eviction, logout). Playing its sound would // be an audible artefact for a tab that is already gone, so re-check first. if (notificationSound is not null && soundSource is not null) { bool sourceStillPresent; lock (Plugin.TabsListLock) sourceStillPresent = Plugin.Config.Tabs.Contains(soundSource); if (!sourceStillPresent) notificationSound = null; } if (notificationSound is { } soundId) { if (soundId is >= 1 and <= 16) { // ProcessMessage runs on the PendingMessageThread worker; the native // UIGlobals.PlaySoundEffect must be marshalled onto the framework // thread (reference_dalamud_framework_thread). Plugin.Framework.RunOnFrameworkThread(() => { unsafe { UIGlobals.PlaySoundEffect(soundId); } }); } else if (soundId >= 17) { // Custom bundled sounds (ids 17-19) go through NAudio WaveOutEvent. // NAudio manages its own playback thread, so no framework marshalling needed. Plugin.CustomAudioPlayer.Play((int)soundId - 16, Plugin.Config.CustomSoundVolume); } // soundId == 0 (hand-edited config) falls through: plays nothing. } MessageProcessed?.Invoke(message); } // Pure: picks the sound id for the first inactive tab that wants one, or null. // No AddMessage, no store write — those stay in the ProcessMessage loop so this // is exercisable from the SelfTest without polluting tab state. The "first // match wins" semantics live here via the running 'picked is null' guard, // keeping a message matching several background tabs from stacking sounds. // TEST-MIRROR: ../_Helpers/TabSoundDecision.cs // Unseen ("count only what you haven't seen") suppresses unread on an inactive // tab when the active tab ALSO shows this message — you already saw it in the // tab you're looking at (1.5.6 / upstream ChatTwo behavior). Pre-F2 the "active // tab" was wrongly pinned to Tabs[0], so this fired against the wrong tab; F2 // recoupled CurrentTab to the REAL active tab, so currentTabMatches is now // measured against the tab you actually see. All -> always counts; None -> // counts here and is gated out at the display layer. Pure + SelfTest-able. internal static bool ShouldCountUnread(Tab tab, Tab currentTab, bool currentTabMatches) => !( tab.UnreadMode == UnreadMode.Unseen && !ReferenceEquals(currentTab, tab) && currentTabMatches ); // Reports the tab the sound came from, so the caller can drop it if that tab // disappeared between snapshot and playback. internal static uint? SelectNotificationSound( IEnumerable tabs, Tab currentTab, Message probe, bool playSounds, out Tab? source ) { uint? picked = null; source = null; foreach (var tab in tabs) { if (!tab.Matches(probe)) continue; if ( picked is null && TabSoundDecision.ShouldPlay( currentTab == tab, tab.EnableNotificationSound, playSounds ) ) { picked = tab.NotificationSoundId; source = tab; } } return picked; } // SelfTest hook — same name discipline as InputBar.TestBuildOutgoingForSelfTest. internal static uint? TestSelectNotificationSoundForSelfTest( IEnumerable tabs, Tab currentTab, Message probe, bool playSounds ) => SelectNotificationSound(tabs, currentTab, probe, playSounds, out _); internal class NameFormatting { internal string Before { get; private set; } = string.Empty; internal string After { get; private set; } = string.Empty; internal bool IsPresent { get; private set; } = true; internal static NameFormatting Empty() { return new NameFormatting { IsPresent = false }; } internal static NameFormatting Of(string before, string after) { return new NameFormatting { Before = before, After = after }; } } private NameFormatting FormatFor(ChatType type) { if (Formats.TryGetValue(type, out var cached)) return cached; var formats = Sheets.LogKindSheet.GetRow((uint)type).Format.ToList(); static bool IsStringParam(ReadOnlySePayload payload, byte num) { if (payload.MacroCode != MacroCode.String) return false; return payload.TryGetExpression(out var expr1) && expr1.TryGetParameterExpression(out var expressionType, out var operand) && expressionType == (byte)ExpressionType.LocalString && operand.TryGetInt(out var lstrIndex) && lstrIndex == num; } var firstStringParam = formats.FindIndex(payload => IsStringParam(payload, 1)); var secondStringParam = formats.FindIndex(payload => IsStringParam(payload, 2)); if (firstStringParam == -1 || secondStringParam == -1) return NameFormatting.Empty(); var before = formats .GetRange(0, firstStringParam) .Where(payload => payload.Type == ReadOnlySePayloadType.Text) .Select(text => Encoding.UTF8.GetString(text.Body.Span)); var after = formats .GetRange(firstStringParam + 1, secondStringParam - firstStringParam) .Where(payload => payload.Type == ReadOnlySePayloadType.Text) .Select(text => Encoding.UTF8.GetString(text.Body.Span)); var nameFormatting = NameFormatting.Of(string.Join("", before), string.Join("", after)); Formats[type] = nameFormatting; return nameFormatting; } private class PendingMessage { public ulong ContentId; // 0 if unknown public ulong AccountId; // 0 if unknown public XivChatType LogKind; public XivChatRelationKind SourceKind; public XivChatRelationKind TargetKind; public required SeString Sender; public required SeString Content; } }