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.
///