The first sweep matched a character class that swallowed the digit, so a bare B1 slipped through while B1-2 was caught. Searching the whole A-Z space instead of guessing prefixes turned up 130-odd more: B0 through B6, C2, C3, D1, H2, M6, P7, P8, T2, W2, plus GP-04, KB-01, OD-1, PM-1, PM-3, SEC-01, TR-4, TR-7, UI-11, UI-12, XC-8 and API-3. Kept deliberately: 41 B4 01 is a byte signature, "N0" a format string, #L119-L128 a source anchor, LS4/LS6 are linkshells, and A=FF B=0C G=41 R=C2 explains a colour-channel order. Those look like codes and are not. Also translated the eight German comments left in the theme files and ImGuiUtil. Seven of them described what a palette does to which channel, which is worth reading -- just not in a second language in an otherwise English codebase.
557 lines
19 KiB
C#
557 lines
19 KiB
C#
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<MessageManager> _logger;
|
|
internal MessageStore Store { get; }
|
|
|
|
private Dictionary<ChatType, NameFormatting> Formats { get; } = [];
|
|
private ulong LastContentId { get; set; }
|
|
|
|
// PendingSync (main thread) → PendingAsync (worker thread); LinkedList for O(1) Last access
|
|
private LinkedList<PendingMessage> PendingSync { get; } = [];
|
|
private ConcurrentQueue<PendingMessage> PendingAsync { get; } = [];
|
|
private readonly Thread PendingMessageThread;
|
|
private readonly CancellationTokenSource PendingThreadCancellationToken = new();
|
|
|
|
private Hook<RaptureLogModule.Delegates.AddMsgSourceEntry>? 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<Message>? MessageProcessed;
|
|
|
|
internal unsafe MessageManager(
|
|
Plugin plugin,
|
|
ILogger<MessageManager> logger,
|
|
ILoggerFactory loggerFactory
|
|
)
|
|
{
|
|
Plugin = plugin;
|
|
_logger = logger;
|
|
|
|
Store = new MessageStore(
|
|
DatabasePath(),
|
|
Plugin.PlatformUtil,
|
|
loggerFactory.CreateLogger<MessageStore>(),
|
|
loggerFactory
|
|
);
|
|
|
|
PendingMessageThread = new Thread(() =>
|
|
ProcessPendingMessages(PendingThreadCancellationToken.Token)
|
|
)
|
|
{
|
|
IsBackground = true,
|
|
};
|
|
PendingMessageThread.Start();
|
|
|
|
ContentIdResolverHook =
|
|
Plugin.GameInteropProvider.HookFromAddress<RaptureLogModule.Delegates.AddMsgSourceEntry>(
|
|
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()
|
|
{
|
|
// 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<Tab> 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.
|
|
// 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<Tab> 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<Tab, List<Message>> MapMessagesToTabs(
|
|
IReadOnlyList<Tab> tabs,
|
|
IEnumerable<Message> messages
|
|
)
|
|
{
|
|
var buckets = new Dictionary<Tab, List<Message>>(tabs.Count);
|
|
foreach (var tab in tabs)
|
|
buckets[tab] = new List<Message>();
|
|
|
|
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");
|
|
}
|
|
|
|
// Information, not Debug, so the xllog tail surfaces this without a
|
|
// filter. Kept as a guard against future plugin-load regressions.
|
|
_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<Chunk>();
|
|
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<Tab> 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). The "active tab"
|
|
// used to be pinned to Tabs[0], so this fired against the wrong one until
|
|
// CurrentTab was recoupled to the real active tab, and currentTabMatches is
|
|
// now measured against the tab you 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<Tab> 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<Tab> 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;
|
|
}
|
|
}
|