using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Runtime.InteropServices; using Dalamud.Configuration.Internal; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Hooking; using Dalamud.IoC; using Dalamud.IoC.Internal; using Dalamud.Logging.Internal; using Dalamud.Memory; using Dalamud.Plugin.Services; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.System.String; using FFXIVClientStructs.FFXIV.Client.UI.Misc; namespace Dalamud.Game.Gui; // TODO(api10): Update IChatGui, ChatGui and XivChatEntry to use correct types and names: // "uint SenderId" should be "int Timestamp". // "IntPtr Parameters" should be something like "bool Silent". It suppresses new message sounds in certain channels. // This has to be a 1 byte boolean, so only change it to bool if marshalling is disabled. /// /// This class handles interacting with the native chat UI. /// [InterfaceVersion("1.0")] [ServiceManager.BlockingEarlyLoadedService] internal sealed unsafe class ChatGui : IDisposable, IServiceType, IChatGui { private static readonly ModuleLog Log = new("ChatGui"); private readonly ChatGuiAddressResolver address; private readonly Queue chatQueue = new(); private readonly Dictionary<(string PluginName, uint CommandId), Action> dalamudLinkHandlers = new(); private readonly Hook printMessageHook; private readonly Hook populateItemLinkHook; private readonly Hook interactableLinkClickedHook; [ServiceManager.ServiceDependency] private readonly DalamudConfiguration configuration = Service.Get(); private ImmutableDictionary<(string PluginName, uint CommandId), Action>? dalamudLinkHandlersCopy; [ServiceManager.ServiceConstructor] private ChatGui(TargetSigScanner sigScanner) { this.address = new ChatGuiAddressResolver(); this.address.Setup(sigScanner); this.printMessageHook = Hook.FromAddress((nint)RaptureLogModule.Addresses.PrintMessage.Value, this.HandlePrintMessageDetour); this.populateItemLinkHook = Hook.FromAddress(this.address.PopulateItemLinkObject, this.HandlePopulateItemLinkDetour); this.interactableLinkClickedHook = Hook.FromAddress(this.address.InteractableLinkClicked, this.InteractableLinkClickedDetour); this.printMessageHook.Enable(); this.populateItemLinkHook.Enable(); this.interactableLinkClickedHook.Enable(); } [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate uint PrintMessageDelegate(RaptureLogModule* manager, XivChatType chatType, Utf8String* sender, Utf8String* message, int timestamp, byte silent); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void PopulateItemLinkDelegate(IntPtr linkObjectPtr, IntPtr itemInfoPtr); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void InteractableLinkClickedDelegate(IntPtr managerPtr, IntPtr messagePtr); /// public event IChatGui.OnMessageDelegate? ChatMessage; /// public event IChatGui.OnCheckMessageHandledDelegate? CheckMessageHandled; /// public event IChatGui.OnMessageHandledDelegate? ChatMessageHandled; /// public event IChatGui.OnMessageUnhandledDelegate? ChatMessageUnhandled; /// public int LastLinkedItemId { get; private set; } /// public byte LastLinkedItemFlags { get; private set; } /// public IReadOnlyDictionary<(string PluginName, uint CommandId), Action> RegisteredLinkHandlers { get { var copy = this.dalamudLinkHandlersCopy; if (copy is not null) return copy; lock (this.dalamudLinkHandlers) { return this.dalamudLinkHandlersCopy ??= this.dalamudLinkHandlers.ToImmutableDictionary(x => x.Key, x => x.Value); } } } /// /// Dispose of managed and unmanaged resources. /// void IDisposable.Dispose() { this.printMessageHook.Dispose(); this.populateItemLinkHook.Dispose(); this.interactableLinkClickedHook.Dispose(); } /// public void Print(XivChatEntry chat) { this.chatQueue.Enqueue(chat); } /// public void Print(string message, string? messageTag = null, ushort? tagColor = null) { this.PrintTagged(message, this.configuration.GeneralChatType, messageTag, tagColor); } /// public void Print(SeString message, string? messageTag = null, ushort? tagColor = null) { this.PrintTagged(message, this.configuration.GeneralChatType, messageTag, tagColor); } /// public void PrintError(string message, string? messageTag = null, ushort? tagColor = null) { this.PrintTagged(message, XivChatType.Urgent, messageTag, tagColor); } /// public void PrintError(SeString message, string? messageTag = null, ushort? tagColor = null) { this.PrintTagged(message, XivChatType.Urgent, messageTag, tagColor); } /// /// Process a chat queue. /// public void UpdateQueue() { while (this.chatQueue.Count > 0) { var chat = this.chatQueue.Dequeue(); var sender = Utf8String.FromSequence(chat.Name.Encode()); var message = Utf8String.FromSequence(chat.Message.Encode()); this.HandlePrintMessageDetour(RaptureLogModule.Instance(), chat.Type, sender, message, (int)chat.SenderId, (byte)(chat.Parameters != 0 ? 1 : 0)); sender->Dtor(true); message->Dtor(true); } } /// /// Create a link handler. /// /// The name of the plugin handling the link. /// The ID of the command to run. /// The command action itself. /// A payload for handling. internal DalamudLinkPayload AddChatLinkHandler(string pluginName, uint commandId, Action commandAction) { var payload = new DalamudLinkPayload { Plugin = pluginName, CommandId = commandId }; lock (this.dalamudLinkHandlers) { this.dalamudLinkHandlers.Add((pluginName, commandId), commandAction); this.dalamudLinkHandlersCopy = null; } return payload; } /// /// Remove all handlers owned by a plugin. /// /// The name of the plugin handling the links. internal void RemoveChatLinkHandler(string pluginName) { lock (this.dalamudLinkHandlers) { var changed = false; foreach (var handler in this.RegisteredLinkHandlers.Keys.Where(k => k.PluginName == pluginName)) changed |= this.dalamudLinkHandlers.Remove(handler); if (changed) this.dalamudLinkHandlersCopy = null; } } /// /// Remove a registered link handler. /// /// The name of the plugin handling the link. /// The ID of the command to be removed. internal void RemoveChatLinkHandler(string pluginName, uint commandId) { lock (this.dalamudLinkHandlers) { if (this.dalamudLinkHandlers.Remove((pluginName, commandId))) this.dalamudLinkHandlersCopy = null; } } private void PrintTagged(string message, XivChatType channel, string? tag, ushort? color) { var builder = new SeStringBuilder(); if (!tag.IsNullOrEmpty()) { if (color is not null) { builder.AddUiForeground($"[{tag}] ", color.Value); } else { builder.AddText($"[{tag}] "); } } this.Print(new XivChatEntry { Message = builder.AddText(message).Build(), Type = channel, }); } private void PrintTagged(SeString message, XivChatType channel, string? tag, ushort? color) { var builder = new SeStringBuilder(); if (!tag.IsNullOrEmpty()) { if (color is not null) { builder.AddUiForeground($"[{tag}] ", color.Value); } else { builder.AddText($"[{tag}] "); } } this.Print(new XivChatEntry { Message = builder.Build().Append(message), Type = channel, }); } private void HandlePopulateItemLinkDetour(IntPtr linkObjectPtr, IntPtr itemInfoPtr) { try { this.populateItemLinkHook.Original(linkObjectPtr, itemInfoPtr); this.LastLinkedItemId = Marshal.ReadInt32(itemInfoPtr, 8); this.LastLinkedItemFlags = Marshal.ReadByte(itemInfoPtr, 0x14); // Log.Verbose($"HandlePopulateItemLinkDetour {linkObjectPtr} {itemInfoPtr} - linked:{this.LastLinkedItemId}"); } catch (Exception ex) { Log.Error(ex, "Exception onPopulateItemLink hook."); this.populateItemLinkHook.Original(linkObjectPtr, itemInfoPtr); } } private uint HandlePrintMessageDetour(RaptureLogModule* manager, XivChatType chatType, Utf8String* sender, Utf8String* message, int timestamp, byte silent) { var messageId = 0u; try { var originalSenderData = sender->AsSpan().ToArray(); var originalMessageData = message->AsSpan().ToArray(); var parsedSender = SeString.Parse(originalSenderData); var parsedMessage = SeString.Parse(originalMessageData); // Call events var isHandled = false; var invocationList = this.CheckMessageHandled!.GetInvocationList(); foreach (var @delegate in invocationList) { try { var messageHandledDelegate = @delegate as IChatGui.OnCheckMessageHandledDelegate; messageHandledDelegate!.Invoke(chatType, (uint)timestamp, ref parsedSender, ref parsedMessage, ref isHandled); } catch (Exception e) { Log.Error(e, "Could not invoke registered OnCheckMessageHandledDelegate for {Name}", @delegate.Method.Name); } } if (!isHandled) { invocationList = this.ChatMessage!.GetInvocationList(); foreach (var @delegate in invocationList) { try { var messageHandledDelegate = @delegate as IChatGui.OnMessageDelegate; messageHandledDelegate!.Invoke(chatType, (uint)timestamp, ref parsedSender, ref parsedMessage, ref isHandled); } catch (Exception e) { Log.Error(e, "Could not invoke registered OnMessageDelegate for {Name}", @delegate.Method.Name); } } } var possiblyModifiedSenderData = parsedSender.Encode(); var possiblyModifiedMessageData = parsedMessage.Encode(); if (!Util.FastByteArrayCompare(originalSenderData, possiblyModifiedSenderData)) { Log.Verbose($"HandlePrintMessageDetour Sender modified: {SeString.Parse(originalSenderData)} -> {parsedSender}"); sender->SetString(possiblyModifiedSenderData); } if (!Util.FastByteArrayCompare(originalMessageData, possiblyModifiedMessageData)) { Log.Verbose($"HandlePrintMessageDetour Message modified: {SeString.Parse(originalMessageData)} -> {parsedMessage}"); message->SetString(possiblyModifiedMessageData); } // Print the original chat if it's handled. if (isHandled) { this.ChatMessageHandled?.Invoke(chatType, (uint)timestamp, parsedSender, parsedMessage); } else { messageId = this.printMessageHook.Original(manager, chatType, sender, message, timestamp, silent); this.ChatMessageUnhandled?.Invoke(chatType, (uint)timestamp, parsedSender, parsedMessage); } } catch (Exception ex) { Log.Error(ex, "Exception on OnChatMessage hook."); messageId = this.printMessageHook.Original(manager, chatType, sender, message, timestamp, silent); } return messageId; } private void InteractableLinkClickedDetour(IntPtr managerPtr, IntPtr messagePtr) { try { var interactableType = (Payload.EmbeddedInfoType)(Marshal.ReadByte(messagePtr, 0x1B) + 1); if (interactableType != Payload.EmbeddedInfoType.DalamudLink) { this.interactableLinkClickedHook.Original(managerPtr, messagePtr); return; } Log.Verbose($"InteractableLinkClicked: {Payload.EmbeddedInfoType.DalamudLink}"); var payloadPtr = Marshal.ReadIntPtr(messagePtr, 0x10); var seStr = MemoryHelper.ReadSeStringNullTerminated(payloadPtr); var terminatorIndex = seStr.Payloads.IndexOf(RawPayload.LinkTerminator); var payloads = terminatorIndex >= 0 ? seStr.Payloads.Take(terminatorIndex + 1).ToList() : seStr.Payloads; if (payloads.Count == 0) return; var linkPayload = payloads[0]; if (linkPayload is DalamudLinkPayload link) { if (this.RegisteredLinkHandlers.TryGetValue((link.Plugin, link.CommandId), out var value)) { Log.Verbose($"Sending DalamudLink to {link.Plugin}: {link.CommandId}"); value.Invoke(link.CommandId, new SeString(payloads)); } else { Log.Debug($"No DalamudLink registered for {link.Plugin} with ID of {link.CommandId}"); } } } catch (Exception ex) { Log.Error(ex, "Exception on InteractableLinkClicked hook"); } } } /// /// Plugin scoped version of ChatGui. /// [PluginInterface] [InterfaceVersion("1.0")] [ServiceManager.ScopedService] #pragma warning disable SA1015 [ResolveVia] #pragma warning restore SA1015 internal class ChatGuiPluginScoped : IDisposable, IServiceType, IChatGui { [ServiceManager.ServiceDependency] private readonly ChatGui chatGuiService = Service.Get(); /// /// Initializes a new instance of the class. /// internal ChatGuiPluginScoped() { this.chatGuiService.ChatMessage += this.OnMessageForward; this.chatGuiService.CheckMessageHandled += this.OnCheckMessageForward; this.chatGuiService.ChatMessageHandled += this.OnMessageHandledForward; this.chatGuiService.ChatMessageUnhandled += this.OnMessageUnhandledForward; } /// public event IChatGui.OnMessageDelegate? ChatMessage; /// public event IChatGui.OnCheckMessageHandledDelegate? CheckMessageHandled; /// public event IChatGui.OnMessageHandledDelegate? ChatMessageHandled; /// public event IChatGui.OnMessageUnhandledDelegate? ChatMessageUnhandled; /// public int LastLinkedItemId => this.chatGuiService.LastLinkedItemId; /// public byte LastLinkedItemFlags => this.chatGuiService.LastLinkedItemFlags; /// public IReadOnlyDictionary<(string PluginName, uint CommandId), Action> RegisteredLinkHandlers => this.chatGuiService.RegisteredLinkHandlers; /// public void Dispose() { this.chatGuiService.ChatMessage -= this.OnMessageForward; this.chatGuiService.CheckMessageHandled -= this.OnCheckMessageForward; this.chatGuiService.ChatMessageHandled -= this.OnMessageHandledForward; this.chatGuiService.ChatMessageUnhandled -= this.OnMessageUnhandledForward; this.ChatMessage = null; this.CheckMessageHandled = null; this.ChatMessageHandled = null; this.ChatMessageUnhandled = null; } /// public void Print(XivChatEntry chat) => this.chatGuiService.Print(chat); /// public void Print(string message, string? messageTag = null, ushort? tagColor = null) => this.chatGuiService.Print(message, messageTag, tagColor); /// public void Print(SeString message, string? messageTag = null, ushort? tagColor = null) => this.chatGuiService.Print(message, messageTag, tagColor); /// public void PrintError(string message, string? messageTag = null, ushort? tagColor = null) => this.chatGuiService.PrintError(message, messageTag, tagColor); /// public void PrintError(SeString message, string? messageTag = null, ushort? tagColor = null) => this.chatGuiService.PrintError(message, messageTag, tagColor); private void OnMessageForward(XivChatType type, uint senderId, ref SeString sender, ref SeString message, ref bool isHandled) => this.ChatMessage?.Invoke(type, senderId, ref sender, ref message, ref isHandled); private void OnCheckMessageForward(XivChatType type, uint senderId, ref SeString sender, ref SeString message, ref bool isHandled) => this.CheckMessageHandled?.Invoke(type, senderId, ref sender, ref message, ref isHandled); private void OnMessageHandledForward(XivChatType type, uint senderId, SeString sender, SeString message) => this.ChatMessageHandled?.Invoke(type, senderId, sender, message); private void OnMessageUnhandledForward(XivChatType type, uint senderId, SeString sender, SeString message) => this.ChatMessageUnhandled?.Invoke(type, senderId, sender, message); }