From 282fa87571c10b589c7a0b438db5cadb6d9d8533 Mon Sep 17 00:00:00 2001 From: RedworkDE <10944644+RedworkDE@users.noreply.github.com> Date: Mon, 22 Dec 2025 19:44:00 +0100 Subject: [PATCH] Add event for LogMessages being added to the chat --- Dalamud/Game/Chat/LogMessage.cs | 220 ++++++++++++++++++++++++++ Dalamud/Game/Chat/LogMessageEntity.cs | 96 +++++++++++ Dalamud/Game/Gui/ChatGui.cs | 58 +++++++ Dalamud/Plugin/Services/IChatGui.cs | 13 ++ 4 files changed, 387 insertions(+) create mode 100644 Dalamud/Game/Chat/LogMessage.cs create mode 100644 Dalamud/Game/Chat/LogMessageEntity.cs diff --git a/Dalamud/Game/Chat/LogMessage.cs b/Dalamud/Game/Chat/LogMessage.cs new file mode 100644 index 000000000..cf423bd6e --- /dev/null +++ b/Dalamud/Game/Chat/LogMessage.cs @@ -0,0 +1,220 @@ +using Dalamud.Data; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Utility; + +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using FFXIVClientStructs.FFXIV.Component.Text; +using FFXIVClientStructs.Interop; + +using Lumina.Excel; + +using System.Diagnostics.CodeAnalysis; + +using TerraFX.Interop.Windows; + +namespace Dalamud.Game.Chat; + +/// +/// Interface representing a log message. +/// +public interface ILogMessage : IEquatable +{ + /// + /// Gets the address of the log message in memory. + /// + nint Address { get; } + + /// + /// Gets the ID of this log message. + /// + uint LogMessageId { get; } + + /// + /// Gets the GameData associated with this log message. + /// + RowRef GameData { get; } + + /// + /// Gets the entity that is the source of this log message, if any. + /// + ILogMessageEntity? SourceEntity { get; } + + /// + /// Gets the entity that is the target of this log message, if any. + /// + ILogMessageEntity? TargetEntity { get; } + + /// + /// Gets the number of parameters. + /// + int ParameterCount { get; } + + /// + /// Retrieves the value of a parameter for the log message if it is an int. + /// + /// The index of the parameter to retrieve. + /// The value of the parameter. + /// if the parameter was retrieved successfully. + bool TryGetIntParameter(int index, out int value); + + /// + /// Retrieves the value of a parameter for the log message if it is a string. + /// + /// The index of the parameter to retrieve. + /// The value of the parameter. + /// if the parameter was retrieved successfully. + bool TryGetStringParameter(int index, [NotNullWhen(true)] out SeString? value); + + /// + /// Formats this log message into an approximation of the string that will eventually be shown in the log. + /// + /// This can cause side effects such as playing sound effects and thus should only be used for debugging. + /// The formatted string. + SeString FormatLogMessageForDebugging(); +} + +/// +/// This struct represents a status effect an actor is afflicted by. +/// +/// A pointer to the Status. +internal unsafe readonly struct LogMessage(LogMessageQueueItem* ptr) : ILogMessage +{ + /// + public nint Address => (nint)ptr; + + /// + public uint LogMessageId => ptr->LogMessageId; + + /// + public RowRef GameData => LuminaUtils.CreateRef(ptr->LogMessageId); + + public LogMessageEntity SourceEntity => new LogMessageEntity(ptr, true); + /// + ILogMessageEntity? ILogMessage.SourceEntity => ptr->SourceKind == EntityRelationKind.None ? null : this.SourceEntity; + + public LogMessageEntity TargetEntity => new LogMessageEntity(ptr, false); + + /// + ILogMessageEntity? ILogMessage.TargetEntity => ptr->TargetKind == EntityRelationKind.None ? null : this.TargetEntity; + + /// + public int ParameterCount => ptr->Parameters.Count; + + public bool TryGetParameter(int index, out TextParameter value) + { + if (index < 0 || index >= ptr->Parameters.Count) + { + value = default; + return false; + } + + value = ptr->Parameters[index]; + return true; + } + + /// + public bool TryGetIntParameter(int index, out int value) + { + value = 0; + if (!this.TryGetParameter(index, out var parameter)) return false; + if (parameter.Type != TextParameterType.Integer) return false; + value = parameter.IntValue; + return true; + } + + /// + public bool TryGetStringParameter(int index, [NotNullWhen(true)] out SeString? value) + { + value = null; + if (!this.TryGetParameter(index, out var parameter)) return false; + if (parameter.Type == TextParameterType.String) + { + value = SeString.Parse(parameter.StringValue.Value); + return true; + } + if (parameter.Type == TextParameterType.ReferencedUtf8String) + { + value = SeString.Parse(parameter.ReferencedUtf8StringValue->Utf8String.AsSpan()); + return true; + } + + return false; + } + + /// + public SeString FormatLogMessageForDebugging() + { + var logModule = RaptureLogModule.Instance(); + + // the formatting logic is taken from RaptureLogModule_Update + + var utf8 = new Utf8String(); + SetName(logModule, this.SourceEntity); + SetName(logModule, this.TargetEntity); + logModule->RaptureTextModule->FormatString(this.GameData.Value.Text.ToDalamudString().EncodeWithNullTerminator(), &ptr->Parameters, &utf8); + + return SeString.Parse(utf8.AsSpan()); + + void SetName(RaptureLogModule* self, LogMessageEntity item) + { + var name = item.NameSpan.GetPointer(0); + + if (item.IsPlayer) + { + var str = self->TempParseMessage.GetPointer(item.IsSourceEntity ? 8 : 9); + self->FormatPlayerLink(name, str, null, 0, item.Kind != 1 /* LocalPlayer */, item.HomeWorldId, false, null, false); + + if (item.HomeWorldId != 0 && item.HomeWorldId != AgentLobby.Instance()->LobbyData.HomeWorldId) + { + var crossWorldSymbol = self->RaptureTextModule->UnkStrings0.GetPointer(3); + if (!crossWorldSymbol->StringPtr.HasValue) + self->RaptureTextModule->ProcessMacroCode(crossWorldSymbol, "\0"u8); + str->Append(crossWorldSymbol); + if (self->UIModule->GetWorldHelper()->AllWorlds.TryGetValuePointer(item.HomeWorldId, out var world)) + str->ConcatCStr(world->Name); + } + + name = str->StringPtr; + } + + if (item.IsSourceEntity) + { + self->RaptureTextModule->SetGlobalTempEntity1(name, item.Sex, item.ObjStrId); + } + else + { + self->RaptureTextModule->SetGlobalTempEntity2(name, item.Sex, item.ObjStrId); + } + } + } + + + public static bool operator ==(LogMessage x, LogMessage y) => x.Equals(y); + + public static bool operator !=(LogMessage x, LogMessage y) => !(x == y); + + public bool Equals(LogMessage other) + { + return this.LogMessageId == other.LogMessageId && this.SourceEntity == other.SourceEntity && this.TargetEntity == other.TargetEntity; + } + + /// + public bool Equals(ILogMessage? other) + { + return other is LogMessage logMessage && this.Equals(logMessage); + } + + /// + public override bool Equals([NotNullWhen(true)] object? obj) + { + return obj is LogMessage logMessage && this.Equals(logMessage); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(this.LogMessageId, this.SourceEntity, this.TargetEntity); + } +} diff --git a/Dalamud/Game/Chat/LogMessageEntity.cs b/Dalamud/Game/Chat/LogMessageEntity.cs new file mode 100644 index 000000000..e4c81c16a --- /dev/null +++ b/Dalamud/Game/Chat/LogMessageEntity.cs @@ -0,0 +1,96 @@ +using System.Diagnostics.CodeAnalysis; + +using Dalamud.Data; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Plugin.Services; + +using FFXIVClientStructs.FFXIV.Client.UI.Misc; + +using Lumina.Excel; +using Lumina.Excel.Sheets; + +namespace Dalamud.Game.Chat; + +/// +/// Interface representing an entity related to a log message. +/// +public interface ILogMessageEntity : IEquatable +{ + /// + /// Gets the name of this entity. + /// + SeString Name { get; } + + /// + /// Gets the ID of the homeworld of this entity, if it is a player. + /// + ushort HomeWorldId { get; } + + /// + /// Gets the homeworld of this entity, if it is a player. + /// + RowRef HomeWorld { get; } + + /// + /// Gets the ObjStr ID of this entity, if not a player. See . + /// + uint ObjStrId { get; } + + /// + /// Gets a boolean indicating if this entity is a player. + /// + bool IsPlayer { get; } +} + + +/// +/// This struct represents a status effect an actor is afflicted by. +/// +/// A pointer to the Status. +internal unsafe readonly struct LogMessageEntity(LogMessageQueueItem* ptr, bool source) : ILogMessageEntity +{ + public Span NameSpan => source ? ptr->SourceName : ptr->TargetName; + + public SeString Name => SeString.Parse(this.NameSpan); + + public ushort HomeWorldId => source ? ptr->SourceHomeWorld : ptr->TargetHomeWorld; + + public RowRef HomeWorld => LuminaUtils.CreateRef(this.HomeWorldId); + + public uint ObjStrId => source ? ptr->SourceObjStrId : ptr->TargetObjStrId; + + public byte Kind => source ? (byte)ptr->SourceKind : (byte)ptr->TargetKind; + + public byte Sex => source ? ptr->SourceSex : ptr->TargetSex; + + public bool IsPlayer => source ? ptr->SourceIsPlayer : ptr->TargetIsPlayer; + + public bool IsSourceEntity => source; + + public static bool operator ==(LogMessageEntity x, LogMessageEntity y) => x.Equals(y); + + public static bool operator !=(LogMessageEntity x, LogMessageEntity y) => !(x == y); + + public bool Equals(LogMessageEntity other) + { + return this.Name == other.Name && this.HomeWorldId == other.HomeWorldId && this.ObjStrId == other.ObjStrId && this.Kind == other.Kind && this.Sex == other.Sex && this.IsPlayer == other.IsPlayer; + } + + /// + public bool Equals(ILogMessageEntity other) + { + return other is LogMessageEntity entity && this.Equals(entity); + } + + /// + public override bool Equals([NotNullWhen(true)] object? obj) + { + return obj is LogMessageEntity entity && this.Equals(entity); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(this.Name, this.HomeWorldId, this.ObjStrId, this.Sex, this.IsPlayer); + } +} diff --git a/Dalamud/Game/Gui/ChatGui.cs b/Dalamud/Game/Gui/ChatGui.cs index 30e2b676c..5208c019b 100644 --- a/Dalamud/Game/Gui/ChatGui.cs +++ b/Dalamud/Game/Gui/ChatGui.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Dalamud.Configuration.Internal; @@ -41,10 +42,12 @@ internal sealed unsafe class ChatGui : IInternalDisposableService, IChatGui private readonly Queue chatQueue = new(); private readonly Dictionary<(string PluginName, uint CommandId), Action> dalamudLinkHandlers = new(); + private readonly List seenLogMessageObjects = new(); private readonly Hook printMessageHook; private readonly Hook inventoryItemCopyHook; private readonly Hook handleLinkClickHook; + private readonly Hook handleLogModuleUpdate; [ServiceManager.ServiceDependency] private readonly DalamudConfiguration configuration = Service.Get(); @@ -58,10 +61,12 @@ internal sealed unsafe class ChatGui : IInternalDisposableService, IChatGui this.printMessageHook = Hook.FromAddress(RaptureLogModule.Addresses.PrintMessage.Value, this.HandlePrintMessageDetour); this.inventoryItemCopyHook = Hook.FromAddress((nint)InventoryItem.StaticVirtualTablePointer->Copy, this.InventoryItemCopyDetour); this.handleLinkClickHook = Hook.FromAddress(LogViewer.Addresses.HandleLinkClick.Value, this.HandleLinkClickDetour); + this.handleLogModuleUpdate = Hook.FromAddress(RaptureLogModule.Addresses.Update.Value, this.UpdateDetour); this.printMessageHook.Enable(); this.inventoryItemCopyHook.Enable(); this.handleLinkClickHook.Enable(); + this.handleLogModuleUpdate.Enable(); } [UnmanagedFunctionPointer(CallingConvention.ThisCall)] @@ -79,6 +84,9 @@ internal sealed unsafe class ChatGui : IInternalDisposableService, IChatGui /// public event IChatGui.OnMessageUnhandledDelegate? ChatMessageUnhandled; + /// + public event IChatGui.OnLogMessageDelegate? LogMessage; + /// public uint LastLinkedItemId { get; private set; } @@ -110,6 +118,7 @@ internal sealed unsafe class ChatGui : IInternalDisposableService, IChatGui this.printMessageHook.Dispose(); this.inventoryItemCopyHook.Dispose(); this.handleLinkClickHook.Dispose(); + this.handleLogModuleUpdate.Dispose(); } #region DalamudSeString @@ -493,6 +502,46 @@ internal sealed unsafe class ChatGui : IInternalDisposableService, IChatGui Log.Error(ex, "Exception in HandleLinkClickDetour"); } } + + private void UpdateDetour(RaptureLogModule* thisPtr) + { + try + { + foreach (ref var item in thisPtr->LogMessageQueue) + { + var logMessage = new Chat.LogMessage((LogMessageQueueItem*)Unsafe.AsPointer(ref item)); + + // skip any entries that survived the previous Update call as the event was already called for them + if (this.seenLogMessageObjects.Contains(logMessage.Address)) + continue; + + foreach (var action in Delegate.EnumerateInvocationList(this.LogMessage)) + { + try + { + action(logMessage); + } + catch (Exception e) + { + Log.Error(e, "Could not invoke registered OnLogMessageDelegate for {Name}", action.Method); + } + } + } + + this.handleLogModuleUpdate.Original(thisPtr); + + // record the log messages for that we already called the event, but are still in the queue + this.seenLogMessageObjects.Clear(); + foreach (ref var item in thisPtr->LogMessageQueue) + { + this.seenLogMessageObjects.Add((IntPtr)Unsafe.AsPointer(ref item)); + } + } + catch (Exception ex) + { + Log.Error(ex, "Exception in UpdateDetour"); + } + } } /// @@ -521,6 +570,7 @@ internal class ChatGuiPluginScoped : IInternalDisposableService, IChatGui this.chatGuiService.CheckMessageHandled += this.OnCheckMessageForward; this.chatGuiService.ChatMessageHandled += this.OnMessageHandledForward; this.chatGuiService.ChatMessageUnhandled += this.OnMessageUnhandledForward; + this.chatGuiService.LogMessage += this.OnLogMessageForward; } /// @@ -535,6 +585,9 @@ internal class ChatGuiPluginScoped : IInternalDisposableService, IChatGui /// public event IChatGui.OnMessageUnhandledDelegate? ChatMessageUnhandled; + /// + public event IChatGui.OnLogMessageDelegate? LogMessage; + /// public uint LastLinkedItemId => this.chatGuiService.LastLinkedItemId; @@ -551,11 +604,13 @@ internal class ChatGuiPluginScoped : IInternalDisposableService, IChatGui this.chatGuiService.CheckMessageHandled -= this.OnCheckMessageForward; this.chatGuiService.ChatMessageHandled -= this.OnMessageHandledForward; this.chatGuiService.ChatMessageUnhandled -= this.OnMessageUnhandledForward; + this.chatGuiService.LogMessage -= this.OnLogMessageForward; this.ChatMessage = null; this.CheckMessageHandled = null; this.ChatMessageHandled = null; this.ChatMessageUnhandled = null; + this.LogMessage = null; } /// @@ -609,4 +664,7 @@ internal class ChatGuiPluginScoped : IInternalDisposableService, IChatGui private void OnMessageUnhandledForward(XivChatType type, int timestamp, SeString sender, SeString message) => this.ChatMessageUnhandled?.Invoke(type, timestamp, sender, message); + + private void OnLogMessageForward(Chat.ILogMessage message) + => this.LogMessage?.Invoke(message); } diff --git a/Dalamud/Plugin/Services/IChatGui.cs b/Dalamud/Plugin/Services/IChatGui.cs index 572ac6c95..eec25cb5a 100644 --- a/Dalamud/Plugin/Services/IChatGui.cs +++ b/Dalamud/Plugin/Services/IChatGui.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; +using Dalamud.Game.Chat; using Dalamud.Game.Gui; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; @@ -50,6 +51,13 @@ public interface IChatGui : IDalamudService /// The message sent. public delegate void OnMessageUnhandledDelegate(XivChatType type, int timestamp, SeString sender, SeString message); + + /// + /// A delegate type used with the event. + /// + /// The message sent. + public delegate void OnLogMessageDelegate(ILogMessage message); + /// /// Event that will be fired when a chat message is sent to chat by the game. /// @@ -70,6 +78,11 @@ public interface IChatGui : IDalamudService /// public event OnMessageUnhandledDelegate ChatMessageUnhandled; + /// + /// Event that will be fired when a log message, that is a chat message based on entries in the LogMessage sheet, is sent. + /// + public event OnLogMessageDelegate LogMessage; + /// /// Gets the ID of the last linked item. ///