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 01/17] 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. /// From b2397efb25075b0b939f5eb5def9cde86b6e971a Mon Sep 17 00:00:00 2001 From: RedworkDE <10944644+RedworkDE@users.noreply.github.com> Date: Mon, 22 Dec 2025 20:21:07 +0100 Subject: [PATCH 02/17] Add Self Test --- .../SelfTest/Steps/ChatSelfTestStep.cs | 78 +++++++++++++++++-- 1 file changed, 72 insertions(+), 6 deletions(-) diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/Steps/ChatSelfTestStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/Steps/ChatSelfTestStep.cs index 7a2631fbf..16fd3b01e 100644 --- a/Dalamud/Interface/Internal/Windows/SelfTest/Steps/ChatSelfTestStep.cs +++ b/Dalamud/Interface/Internal/Windows/SelfTest/Steps/ChatSelfTestStep.cs @@ -1,4 +1,5 @@ using Dalamud.Bindings.ImGui; +using Dalamud.Game.Chat; using Dalamud.Game.Gui; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; @@ -12,8 +13,12 @@ namespace Dalamud.Interface.Internal.Windows.SelfTest.Steps; internal class ChatSelfTestStep : ISelfTestStep { private int step = 0; - private bool subscribed = false; + private bool subscribedChatMessage = false; + private bool subscribedLogMessage = false; private bool hasPassed = false; + private bool hasTeleportGil = false; + private bool hasTeleportTicket = false; + private int teleportCount = 0; /// public string Name => "Test Chat"; @@ -34,20 +39,63 @@ internal class ChatSelfTestStep : ISelfTestStep case 1: ImGui.Text("Type \"/e DALAMUD\" in chat..."); - if (!this.subscribed) + if (!this.subscribedChatMessage) { - this.subscribed = true; + this.subscribedChatMessage = true; chatGui.ChatMessage += this.ChatOnOnChatMessage; } if (this.hasPassed) { chatGui.ChatMessage -= this.ChatOnOnChatMessage; - this.subscribed = false; - return SelfTestStepResult.Pass; + this.subscribedChatMessage = false; + this.step++; } break; + + case 2: + ImGui.Text("Teleport somewhere..."); + + if (!this.subscribedLogMessage) + { + this.subscribedLogMessage = true; + chatGui.LogMessage += this.ChatOnLogMessage; + } + + if (this.hasTeleportGil) + { + ImGui.Text($"You spent {this.teleportCount} gil to teleport."); + } + if (this.hasTeleportTicket) + { + ImGui.Text($"You used a ticket to teleport and have {this.teleportCount} remaining."); + } + + if (this.hasTeleportGil || this.hasTeleportTicket) + { + ImGui.Text("Is this correct?"); + + if (ImGui.Button("Yes")) + { + chatGui.LogMessage -= this.ChatOnLogMessage; + this.subscribedLogMessage = false; + this.step++; + } + + ImGui.SameLine(); + if (ImGui.Button("No")) + { + chatGui.LogMessage -= this.ChatOnLogMessage; + this.subscribedLogMessage = false; + return SelfTestStepResult.Fail; + } + } + + break; + + default: + return SelfTestStepResult.Pass; } return SelfTestStepResult.Waiting; @@ -59,7 +107,9 @@ internal class ChatSelfTestStep : ISelfTestStep var chatGui = Service.Get(); chatGui.ChatMessage -= this.ChatOnOnChatMessage; - this.subscribed = false; + chatGui.LogMessage -= this.ChatOnLogMessage; + this.subscribedChatMessage = false; + this.subscribedLogMessage = false; } private void ChatOnOnChatMessage( @@ -70,4 +120,20 @@ internal class ChatSelfTestStep : ISelfTestStep this.hasPassed = true; } } + + private void ChatOnLogMessage(ILogMessage message) + { + if (message.LogMessageId == 4590 && message.TryGetIntParameter(0, out var value)) + { + this.hasTeleportGil = true; + this.hasTeleportTicket = false; + this.teleportCount = value; + } + if (message.LogMessageId == 4591 && message.TryGetIntParameter(0, out var item) && item == 7569 && message.TryGetIntParameter(1, out var remaining)) + { + this.hasTeleportGil = false; + this.hasTeleportTicket = true; + this.teleportCount = remaining; + } + } } From 3aca09d0fb7fa94c769a898b68d96f7d6e630983 Mon Sep 17 00:00:00 2001 From: RedworkDE <10944644+RedworkDE@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:28:44 +0100 Subject: [PATCH 03/17] review --- Dalamud/Game/Chat/LogMessage.cs | 10 ++++++---- Dalamud/Game/Gui/ChatGui.cs | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Dalamud/Game/Chat/LogMessage.cs b/Dalamud/Game/Chat/LogMessage.cs index cf423bd6e..92217e1c6 100644 --- a/Dalamud/Game/Chat/LogMessage.cs +++ b/Dalamud/Game/Chat/LogMessage.cs @@ -12,7 +12,7 @@ using Lumina.Excel; using System.Diagnostics.CodeAnalysis; -using TerraFX.Interop.Windows; +using Lumina.Text.ReadOnly; namespace Dalamud.Game.Chat; @@ -72,7 +72,7 @@ public interface ILogMessage : IEquatable /// /// This can cause side effects such as playing sound effects and thus should only be used for debugging. /// The formatted string. - SeString FormatLogMessageForDebugging(); + ReadOnlySeString FormatLogMessageForDebugging(); } /// @@ -144,7 +144,7 @@ internal unsafe readonly struct LogMessage(LogMessageQueueItem* ptr) : ILogMessa } /// - public SeString FormatLogMessageForDebugging() + public ReadOnlySeString FormatLogMessageForDebugging() { var logModule = RaptureLogModule.Instance(); @@ -155,7 +155,9 @@ internal unsafe readonly struct LogMessage(LogMessageQueueItem* ptr) : ILogMessa SetName(logModule, this.TargetEntity); logModule->RaptureTextModule->FormatString(this.GameData.Value.Text.ToDalamudString().EncodeWithNullTerminator(), &ptr->Parameters, &utf8); - return SeString.Parse(utf8.AsSpan()); + var result = new ReadOnlySeString(utf8.AsSpan()); + utf8.Dtor(); + return result; void SetName(RaptureLogModule* self, LogMessageEntity item) { diff --git a/Dalamud/Game/Gui/ChatGui.cs b/Dalamud/Game/Gui/ChatGui.cs index 5208c019b..c6405fb35 100644 --- a/Dalamud/Game/Gui/ChatGui.cs +++ b/Dalamud/Game/Gui/ChatGui.cs @@ -42,7 +42,7 @@ 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 List seenLogMessageObjects = new(); private readonly Hook printMessageHook; private readonly Hook inventoryItemCopyHook; @@ -534,7 +534,7 @@ internal sealed unsafe class ChatGui : IInternalDisposableService, IChatGui this.seenLogMessageObjects.Clear(); foreach (ref var item in thisPtr->LogMessageQueue) { - this.seenLogMessageObjects.Add((IntPtr)Unsafe.AsPointer(ref item)); + this.seenLogMessageObjects.Add((nint)Unsafe.AsPointer(ref item)); } } catch (Exception ex) From 9da178ad569f6ff0809e1c6e023b54b962521b0e Mon Sep 17 00:00:00 2001 From: RedworkDE <10944644+RedworkDE@users.noreply.github.com> Date: Tue, 23 Dec 2025 12:34:04 +0100 Subject: [PATCH 04/17] review (2) --- Dalamud/Game/Chat/LogMessage.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Dalamud/Game/Chat/LogMessage.cs b/Dalamud/Game/Chat/LogMessage.cs index 92217e1c6..fbe3dec3f 100644 --- a/Dalamud/Game/Chat/LogMessage.cs +++ b/Dalamud/Game/Chat/LogMessage.cs @@ -150,14 +150,12 @@ internal unsafe readonly struct LogMessage(LogMessageQueueItem* ptr) : ILogMessa // the formatting logic is taken from RaptureLogModule_Update - var utf8 = new Utf8String(); + using var utf8 = new Utf8String(); SetName(logModule, this.SourceEntity); SetName(logModule, this.TargetEntity); logModule->RaptureTextModule->FormatString(this.GameData.Value.Text.ToDalamudString().EncodeWithNullTerminator(), &ptr->Parameters, &utf8); - var result = new ReadOnlySeString(utf8.AsSpan()); - utf8.Dtor(); - return result; + return new ReadOnlySeString(utf8.AsSpan()); void SetName(RaptureLogModule* self, LogMessageEntity item) { From f76d77f79d09972d07cfd8aa030f2a560af17dc1 Mon Sep 17 00:00:00 2001 From: RedworkDE <10944644+RedworkDE@users.noreply.github.com> Date: Tue, 23 Dec 2025 12:41:46 +0100 Subject: [PATCH 05/17] rewview (3) and fix some copy docs comments --- Dalamud/Game/Chat/LogMessage.cs | 4 ++-- Dalamud/Game/Chat/LogMessageEntity.cs | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Dalamud/Game/Chat/LogMessage.cs b/Dalamud/Game/Chat/LogMessage.cs index fbe3dec3f..931b3fb3a 100644 --- a/Dalamud/Game/Chat/LogMessage.cs +++ b/Dalamud/Game/Chat/LogMessage.cs @@ -76,9 +76,9 @@ public interface ILogMessage : IEquatable } /// -/// This struct represents a status effect an actor is afflicted by. +/// This struct represents log message in the queue to be added to the chat. /// -/// A pointer to the Status. +/// A pointer to the log message. internal unsafe readonly struct LogMessage(LogMessageQueueItem* ptr) : ILogMessage { /// diff --git a/Dalamud/Game/Chat/LogMessageEntity.cs b/Dalamud/Game/Chat/LogMessageEntity.cs index e4c81c16a..b465dc158 100644 --- a/Dalamud/Game/Chat/LogMessageEntity.cs +++ b/Dalamud/Game/Chat/LogMessageEntity.cs @@ -1,13 +1,13 @@ 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; +using Lumina.Text.ReadOnly; namespace Dalamud.Game.Chat; @@ -19,7 +19,7 @@ public interface ILogMessageEntity : IEquatable /// /// Gets the name of this entity. /// - SeString Name { get; } + ReadOnlySeString Name { get; } /// /// Gets the ID of the homeworld of this entity, if it is a player. @@ -44,14 +44,15 @@ public interface ILogMessageEntity : IEquatable /// -/// This struct represents a status effect an actor is afflicted by. +/// This struct represents an entity related to a log message. /// -/// A pointer to the Status. +/// A pointer to the log message item. +/// If represents the source entity of the log message, otherwise represents the target entity 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 ReadOnlySeString Name => new ReadOnlySeString(this.NameSpan); public ushort HomeWorldId => source ? ptr->SourceHomeWorld : ptr->TargetHomeWorld; From 186b1b83765cd17d58fd70dc6c53a26af70b85ff Mon Sep 17 00:00:00 2001 From: RedworkDE <10944644+RedworkDE@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:58:47 +0100 Subject: [PATCH 06/17] Use SeStringEvaluator instead of RaptureTextModule for the debug display --- Dalamud/Game/Chat/LogMessage.cs | 13 +++++-------- Dalamud/Game/Text/Evaluator/SeStringParameter.cs | 10 ++++++++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/Dalamud/Game/Chat/LogMessage.cs b/Dalamud/Game/Chat/LogMessage.cs index 931b3fb3a..37b341428 100644 --- a/Dalamud/Game/Chat/LogMessage.cs +++ b/Dalamud/Game/Chat/LogMessage.cs @@ -1,18 +1,18 @@ using Dalamud.Data; +using Dalamud.Game.Text.Evaluator; 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 Lumina.Text.ReadOnly; using System.Diagnostics.CodeAnalysis; - -using Lumina.Text.ReadOnly; +using System.Linq; namespace Dalamud.Game.Chat; @@ -150,12 +150,9 @@ internal unsafe readonly struct LogMessage(LogMessageQueueItem* ptr) : ILogMessa // the formatting logic is taken from RaptureLogModule_Update - using 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 new ReadOnlySeString(utf8.AsSpan()); + return Service.Get().EvaluateFromLogMessage(this.LogMessageId, ptr->Parameters.Select(p => (SeStringParameter)p).ToArray()); void SetName(RaptureLogModule* self, LogMessageEntity item) { diff --git a/Dalamud/Game/Text/Evaluator/SeStringParameter.cs b/Dalamud/Game/Text/Evaluator/SeStringParameter.cs index 036d1c921..a8fe3b3b9 100644 --- a/Dalamud/Game/Text/Evaluator/SeStringParameter.cs +++ b/Dalamud/Game/Text/Evaluator/SeStringParameter.cs @@ -1,5 +1,7 @@ using System.Globalization; +using FFXIVClientStructs.FFXIV.Component.Text; + using Lumina.Text.ReadOnly; using DSeString = Dalamud.Game.Text.SeStringHandling.SeString; @@ -75,4 +77,12 @@ public readonly struct SeStringParameter public static implicit operator SeStringParameter(string value) => new(value); public static implicit operator SeStringParameter(ReadOnlySpan value) => new(value); + + public static unsafe implicit operator SeStringParameter(TextParameter value) => value.Type switch + { + TextParameterType.Uninitialized => default, + TextParameterType.Integer => new((uint)value.IntValue), + TextParameterType.ReferencedUtf8String => new(new ReadOnlySeString(value.ReferencedUtf8StringValue->Utf8String.AsSpan())), + TextParameterType.String => new(value.StringValue), + }; } From bf75937cc05d542504039d375efdf477d6f2e435 Mon Sep 17 00:00:00 2001 From: RedworkDE <10944644+RedworkDE@users.noreply.github.com> Date: Tue, 23 Dec 2025 21:01:29 +0100 Subject: [PATCH 07/17] Don't go through SeString to null terminate a string --- Dalamud/Game/Chat/LogMessage.cs | 12 ++++++++---- Dalamud/Game/Text/Evaluator/SeStringParameter.cs | 10 ---------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/Dalamud/Game/Chat/LogMessage.cs b/Dalamud/Game/Chat/LogMessage.cs index 37b341428..5d59a84ee 100644 --- a/Dalamud/Game/Chat/LogMessage.cs +++ b/Dalamud/Game/Chat/LogMessage.cs @@ -1,8 +1,8 @@ using Dalamud.Data; -using Dalamud.Game.Text.Evaluator; 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; @@ -12,7 +12,6 @@ using Lumina.Excel; using Lumina.Text.ReadOnly; using System.Diagnostics.CodeAnalysis; -using System.Linq; namespace Dalamud.Game.Chat; @@ -150,9 +149,14 @@ internal unsafe readonly struct LogMessage(LogMessageQueueItem* ptr) : ILogMessa // the formatting logic is taken from RaptureLogModule_Update + using var utf8 = new Utf8String(); SetName(logModule, this.SourceEntity); SetName(logModule, this.TargetEntity); - return Service.Get().EvaluateFromLogMessage(this.LogMessageId, ptr->Parameters.Select(p => (SeStringParameter)p).ToArray()); + + using var rssb = new RentedSeStringBuilder(); + logModule->RaptureTextModule->FormatString(rssb.Builder.Append(this.GameData.Value.Text).GetViewAsSpan(), &ptr->Parameters, &utf8); + + return new ReadOnlySeString(utf8.AsSpan()); void SetName(RaptureLogModule* self, LogMessageEntity item) { diff --git a/Dalamud/Game/Text/Evaluator/SeStringParameter.cs b/Dalamud/Game/Text/Evaluator/SeStringParameter.cs index a8fe3b3b9..036d1c921 100644 --- a/Dalamud/Game/Text/Evaluator/SeStringParameter.cs +++ b/Dalamud/Game/Text/Evaluator/SeStringParameter.cs @@ -1,7 +1,5 @@ using System.Globalization; -using FFXIVClientStructs.FFXIV.Component.Text; - using Lumina.Text.ReadOnly; using DSeString = Dalamud.Game.Text.SeStringHandling.SeString; @@ -77,12 +75,4 @@ public readonly struct SeStringParameter public static implicit operator SeStringParameter(string value) => new(value); public static implicit operator SeStringParameter(ReadOnlySpan value) => new(value); - - public static unsafe implicit operator SeStringParameter(TextParameter value) => value.Type switch - { - TextParameterType.Uninitialized => default, - TextParameterType.Integer => new((uint)value.IntValue), - TextParameterType.ReferencedUtf8String => new(new ReadOnlySeString(value.ReferencedUtf8StringValue->Utf8String.AsSpan())), - TextParameterType.String => new(value.StringValue), - }; } From 65c604f8272b6083175cd42945b4983b2f5cce98 Mon Sep 17 00:00:00 2001 From: RedworkDE <10944644+RedworkDE@users.noreply.github.com> Date: Wed, 24 Dec 2025 11:13:06 +0100 Subject: [PATCH 08/17] More ReadOnlySeString things --- Dalamud/Game/Chat/LogMessage.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Dalamud/Game/Chat/LogMessage.cs b/Dalamud/Game/Chat/LogMessage.cs index 5d59a84ee..93b928d48 100644 --- a/Dalamud/Game/Chat/LogMessage.cs +++ b/Dalamud/Game/Chat/LogMessage.cs @@ -1,5 +1,4 @@ using Dalamud.Data; -using Dalamud.Game.Text.SeStringHandling; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.System.String; @@ -64,7 +63,7 @@ public interface ILogMessage : IEquatable /// 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); + bool TryGetStringParameter(int index, out ReadOnlySeString value); /// /// Formats this log message into an approximation of the string that will eventually be shown in the log. @@ -124,18 +123,18 @@ internal unsafe readonly struct LogMessage(LogMessageQueueItem* ptr) : ILogMessa } /// - public bool TryGetStringParameter(int index, [NotNullWhen(true)] out SeString? value) + public bool TryGetStringParameter(int index, out ReadOnlySeString value) { - value = null; + value = default; if (!this.TryGetParameter(index, out var parameter)) return false; if (parameter.Type == TextParameterType.String) { - value = SeString.Parse(parameter.StringValue.Value); + value = new(parameter.StringValue.AsSpan()); return true; } if (parameter.Type == TextParameterType.ReferencedUtf8String) { - value = SeString.Parse(parameter.ReferencedUtf8StringValue->Utf8String.AsSpan()); + value = new(parameter.ReferencedUtf8StringValue->Utf8String.AsSpan()); return true; } From 31cbf4d8eb47782cd332f41e0af8b74d10ebfb8a Mon Sep 17 00:00:00 2001 From: RedworkDE <10944644+RedworkDE@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:17:57 +0100 Subject: [PATCH 09/17] Respect null-termination of entity names --- Dalamud/Game/Chat/LogMessageEntity.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dalamud/Game/Chat/LogMessageEntity.cs b/Dalamud/Game/Chat/LogMessageEntity.cs index b465dc158..4294e4898 100644 --- a/Dalamud/Game/Chat/LogMessageEntity.cs +++ b/Dalamud/Game/Chat/LogMessageEntity.cs @@ -52,7 +52,7 @@ internal unsafe readonly struct LogMessageEntity(LogMessageQueueItem* ptr, bool { public Span NameSpan => source ? ptr->SourceName : ptr->TargetName; - public ReadOnlySeString Name => new ReadOnlySeString(this.NameSpan); + public ReadOnlySeString Name => new ReadOnlySeString(this.NameSpan[..this.NameSpan.IndexOf((byte)0)]); public ushort HomeWorldId => source ? ptr->SourceHomeWorld : ptr->TargetHomeWorld; From c559426d8b6da76952b04a4cefc18fa6494d2c91 Mon Sep 17 00:00:00 2001 From: RedworkDE <10944644+RedworkDE@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:23:24 +0100 Subject: [PATCH 10/17] Add Data Widget --- .../Internal/Windows/Data/DataWindow.cs | 1 + .../Data/Widgets/LogMessageMonitorWidget.cs | 156 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 Dalamud/Interface/Internal/Windows/Data/Widgets/LogMessageMonitorWidget.cs diff --git a/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs b/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs index eb0589d59..154fc8c02 100644 --- a/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs +++ b/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs @@ -44,6 +44,7 @@ internal class DataWindow : Window, IDisposable new ImGuiWidget(), new InventoryWidget(), new KeyStateWidget(), + new LogMessageMonitorWidget(), new MarketBoardWidget(), new NetworkMonitorWidget(), new NounProcessorWidget(), diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/LogMessageMonitorWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/LogMessageMonitorWidget.cs new file mode 100644 index 000000000..fde46f0c7 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/LogMessageMonitorWidget.cs @@ -0,0 +1,156 @@ +using System.Buffers; +using System.Collections.Concurrent; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; + +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Chat; +using Dalamud.Game.Gui; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; + +using Lumina.Text.ReadOnly; + +using ImGuiTable = Dalamud.Interface.Utility.ImGuiTable; + +namespace Dalamud.Interface.Internal.Windows.Data.Widgets; + +/// +/// Widget to display the LogMessages. +/// +internal class LogMessageMonitorWidget : IDataWindowWidget +{ + private readonly ConcurrentQueue messages = new(); + + private bool trackMessages; + private int trackedMessages; + private Regex? filterRegex; + private string filterString = string.Empty; + + /// + public string[]? CommandShortcuts { get; init; } = ["logmessage"]; + + /// + public string DisplayName { get; init; } = "LogMessage Monitor"; + + /// + public bool Ready { get; set; } + + /// + public void Load() + { + this.trackMessages = false; + this.trackedMessages = 20; + this.filterRegex = null; + this.filterString = string.Empty; + this.messages.Clear(); + this.Ready = true; + } + + /// + public void Draw() + { + var network = Service.Get(); + if (ImGui.Checkbox("Track LogMessages"u8, ref this.trackMessages)) + { + if (this.trackMessages) + { + network.LogMessage += this.OnLogMessage; + } + else + { + network.LogMessage -= this.OnLogMessage; + } + } + + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X / 2); + if (ImGui.DragInt("Stored Number of Messages"u8, ref this.trackedMessages, 0.1f, 1, 512)) + { + this.trackedMessages = Math.Clamp(this.trackedMessages, 1, 512); + } + + if (ImGui.Button("Clear Stored Messages"u8)) + { + this.messages.Clear(); + } + + this.DrawFilterInput(); + + ImGuiTable.DrawTable(string.Empty, this.messages.Where(m => this.filterRegex == null || this.filterRegex.IsMatch(m.Formatted.ExtractText())), this.DrawNetworkPacket, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingStretchProp, "LogMessageId", "Source", "Target", "Parameters", "Formatted"); + } + + private void DrawNetworkPacket(LogMessageData data) + { + ImGui.TableNextColumn(); + ImGui.Text(data.LogMessageId.ToString()); + + ImGui.TableNextColumn(); + ImGuiHelpers.SeStringWrapped(data.Source); + + ImGui.TableNextColumn(); + ImGuiHelpers.SeStringWrapped(data.Target); + + ImGui.TableNextColumn(); + ImGui.Text(data.Parameters); + + ImGui.TableNextColumn(); + ImGuiHelpers.SeStringWrapped(data.Formatted); + } + + private void DrawFilterInput() + { + var invalidRegEx = this.filterString.Length > 0 && this.filterRegex == null; + using var style = ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, 2 * ImGuiHelpers.GlobalScale, invalidRegEx); + using var color = ImRaii.PushColor(ImGuiCol.Border, 0xFF0000FF, invalidRegEx); + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + if (!ImGui.InputTextWithHint("##Filter"u8, "Regex Filter..."u8, ref this.filterString, 1024)) + { + return; + } + + if (this.filterString.Length == 0) + { + this.filterRegex = null; + } + else + { + try + { + this.filterRegex = new Regex(this.filterString, RegexOptions.Compiled | RegexOptions.ExplicitCapture); + } + catch + { + this.filterRegex = null; + } + } + } + + private void OnLogMessage(ILogMessage message) + { + var buffer = new ArrayBufferWriter(); + var writer = new Utf8JsonWriter(buffer); + + writer.WriteStartArray(); + for (var i = 0; i < message.ParameterCount; i++) + { + if (message.TryGetStringParameter(i, out var str)) + writer.WriteStringValue(str.ExtractText()); + else if (message.TryGetIntParameter(i, out var num)) + writer.WriteNumberValue(num); + else + writer.WriteNullValue(); + } + + writer.WriteEndArray(); + writer.Flush(); + + this.messages.Enqueue(new LogMessageData(message.LogMessageId, message.SourceEntity?.Name ?? default, message.TargetEntity?.Name ?? default, buffer.WrittenMemory, message.FormatLogMessageForDebugging())); + while (this.messages.Count > this.trackedMessages) + { + this.messages.TryDequeue(out _); + } + } + + private readonly record struct LogMessageData(uint LogMessageId, ReadOnlySeString Source, ReadOnlySeString Target, ReadOnlyMemory Parameters, ReadOnlySeString Formatted); +} From 9b55b020ca32b0482b1306aebbbd6701ccd5fd47 Mon Sep 17 00:00:00 2001 From: RedworkDE <10944644+RedworkDE@users.noreply.github.com> Date: Sat, 3 Jan 2026 23:11:50 +0100 Subject: [PATCH 11/17] Switch selftest to using mounts instead of teleporting --- .../SelfTest/Steps/ChatSelfTestStep.cs | 54 +++++++++---------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/Steps/ChatSelfTestStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/Steps/ChatSelfTestStep.cs index 16fd3b01e..73f0405ef 100644 --- a/Dalamud/Interface/Internal/Windows/SelfTest/Steps/ChatSelfTestStep.cs +++ b/Dalamud/Interface/Internal/Windows/SelfTest/Steps/ChatSelfTestStep.cs @@ -1,10 +1,13 @@ using Dalamud.Bindings.ImGui; +using Dalamud.Data; using Dalamud.Game.Chat; using Dalamud.Game.Gui; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Plugin.SelfTest; +using Lumina.Excel.Sheets; + namespace Dalamud.Interface.Internal.Windows.SelfTest.Steps; /// @@ -15,10 +18,10 @@ internal class ChatSelfTestStep : ISelfTestStep private int step = 0; private bool subscribedChatMessage = false; private bool subscribedLogMessage = false; - private bool hasPassed = false; - private bool hasTeleportGil = false; - private bool hasTeleportTicket = false; - private int teleportCount = 0; + private bool hasSeenEchoMessage = false; + private bool hasSeenMountMessage = false; + private string mountName = ""; + private string mountUser = ""; /// public string Name => "Test Chat"; @@ -45,7 +48,7 @@ internal class ChatSelfTestStep : ISelfTestStep chatGui.ChatMessage += this.ChatOnOnChatMessage; } - if (this.hasPassed) + if (this.hasSeenEchoMessage) { chatGui.ChatMessage -= this.ChatOnOnChatMessage; this.subscribedChatMessage = false; @@ -55,7 +58,7 @@ internal class ChatSelfTestStep : ISelfTestStep break; case 2: - ImGui.Text("Teleport somewhere..."); + ImGui.Text("Use any mount..."); if (!this.subscribedLogMessage) { @@ -63,18 +66,11 @@ internal class ChatSelfTestStep : ISelfTestStep chatGui.LogMessage += this.ChatOnLogMessage; } - if (this.hasTeleportGil) + if (this.hasSeenMountMessage) { - ImGui.Text($"You spent {this.teleportCount} gil to teleport."); - } - if (this.hasTeleportTicket) - { - ImGui.Text($"You used a ticket to teleport and have {this.teleportCount} remaining."); - } - - if (this.hasTeleportGil || this.hasTeleportTicket) - { - ImGui.Text("Is this correct?"); + ImGui.Text($"{this.mountUser} mounted {this.mountName}."); + + ImGui.Text("Is this correct? It is correct if this triggers on other players around you."); if (ImGui.Button("Yes")) { @@ -117,23 +113,25 @@ internal class ChatSelfTestStep : ISelfTestStep { if (type == XivChatType.Echo && message.TextValue == "DALAMUD") { - this.hasPassed = true; + this.hasSeenEchoMessage = true; } } private void ChatOnLogMessage(ILogMessage message) { - if (message.LogMessageId == 4590 && message.TryGetIntParameter(0, out var value)) + if (message.LogMessageId == 646 && message.TryGetIntParameter(0, out var value)) { - this.hasTeleportGil = true; - this.hasTeleportTicket = false; - this.teleportCount = value; - } - if (message.LogMessageId == 4591 && message.TryGetIntParameter(0, out var item) && item == 7569 && message.TryGetIntParameter(1, out var remaining)) - { - this.hasTeleportGil = false; - this.hasTeleportTicket = true; - this.teleportCount = remaining; + this.hasSeenMountMessage = true; + this.mountUser = message.SourceEntity?.Name.ExtractText() ?? ""; + try + { + this.mountName = Service.Get().GetExcelSheet().GetRow((uint)value).Singular.ExtractText(); + } + catch + { + // ignore any errors with retrieving the mount name, they are probably not related to this test + this.mountName = $"Mount ID: {value} (failed to retrieve mount name)"; + } } } } From 790669e60abdc32e49e59c65315950340140b1a3 Mon Sep 17 00:00:00 2001 From: RedworkDE <10944644+RedworkDE@users.noreply.github.com> Date: Sun, 4 Jan 2026 08:12:06 +0100 Subject: [PATCH 12/17] Battle log exists, selftest with the use action message --- .../SelfTest/Steps/ChatSelfTestStep.cs | 34 ++++++------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/Steps/ChatSelfTestStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/Steps/ChatSelfTestStep.cs index 73f0405ef..d4311176e 100644 --- a/Dalamud/Interface/Internal/Windows/SelfTest/Steps/ChatSelfTestStep.cs +++ b/Dalamud/Interface/Internal/Windows/SelfTest/Steps/ChatSelfTestStep.cs @@ -1,13 +1,10 @@ using Dalamud.Bindings.ImGui; -using Dalamud.Data; using Dalamud.Game.Chat; using Dalamud.Game.Gui; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Plugin.SelfTest; -using Lumina.Excel.Sheets; - namespace Dalamud.Interface.Internal.Windows.SelfTest.Steps; /// @@ -19,9 +16,9 @@ internal class ChatSelfTestStep : ISelfTestStep private bool subscribedChatMessage = false; private bool subscribedLogMessage = false; private bool hasSeenEchoMessage = false; - private bool hasSeenMountMessage = false; - private string mountName = ""; - private string mountUser = ""; + private bool hasSeenActionMessage = false; + private string actionName = ""; + private string actionUser = ""; /// public string Name => "Test Chat"; @@ -58,7 +55,7 @@ internal class ChatSelfTestStep : ISelfTestStep break; case 2: - ImGui.Text("Use any mount..."); + ImGui.Text("Use any action (for example Sprint) or be near a player using an action."); if (!this.subscribedLogMessage) { @@ -66,11 +63,10 @@ internal class ChatSelfTestStep : ISelfTestStep chatGui.LogMessage += this.ChatOnLogMessage; } - if (this.hasSeenMountMessage) + if (this.hasSeenActionMessage) { - ImGui.Text($"{this.mountUser} mounted {this.mountName}."); - - ImGui.Text("Is this correct? It is correct if this triggers on other players around you."); + ImGui.Text($"{this.actionUser} used {this.actionName}."); + ImGui.Text("Is this correct?"); if (ImGui.Button("Yes")) { @@ -119,19 +115,11 @@ internal class ChatSelfTestStep : ISelfTestStep private void ChatOnLogMessage(ILogMessage message) { - if (message.LogMessageId == 646 && message.TryGetIntParameter(0, out var value)) + if (message.LogMessageId == 533 && message.TryGetStringParameter(0, out var value)) { - this.hasSeenMountMessage = true; - this.mountUser = message.SourceEntity?.Name.ExtractText() ?? ""; - try - { - this.mountName = Service.Get().GetExcelSheet().GetRow((uint)value).Singular.ExtractText(); - } - catch - { - // ignore any errors with retrieving the mount name, they are probably not related to this test - this.mountName = $"Mount ID: {value} (failed to retrieve mount name)"; - } + this.hasSeenActionMessage = true; + this.actionUser = message.SourceEntity?.Name.ExtractText() ?? ""; + this.actionName = value.ExtractText(); } } } From 6e19aca481a9c49ea0a46b94bdc8671c230f3f82 Mon Sep 17 00:00:00 2001 From: RedworkDE <10944644+RedworkDE@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:00:10 +0100 Subject: [PATCH 13/17] Fix StyleCop warnings --- Dalamud/Game/Chat/LogMessage.cs | 75 ++++++++++--------- Dalamud/Game/Chat/LogMessageEntity.cs | 48 ++++++++---- .../SelfTest/Steps/ChatSelfTestStep.cs | 4 +- Dalamud/Plugin/Services/IChatGui.cs | 1 - 4 files changed, 72 insertions(+), 56 deletions(-) diff --git a/Dalamud/Game/Chat/LogMessage.cs b/Dalamud/Game/Chat/LogMessage.cs index 93b928d48..c772783a1 100644 --- a/Dalamud/Game/Chat/LogMessage.cs +++ b/Dalamud/Game/Chat/LogMessage.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + using Dalamud.Data; using Dalamud.Utility; @@ -10,8 +12,6 @@ using FFXIVClientStructs.Interop; using Lumina.Excel; using Lumina.Text.ReadOnly; -using System.Diagnostics.CodeAnalysis; - namespace Dalamud.Game.Chat; /// @@ -88,30 +88,41 @@ internal unsafe readonly struct LogMessage(LogMessageQueueItem* ptr) : ILogMessa /// 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; - } + private LogMessageEntity SourceEntity => new(ptr, true); - value = ptr->Parameters[index]; - return true; + private LogMessageEntity TargetEntity => new(ptr, false); + + public static bool operator ==(LogMessage x, LogMessage y) => x.Equals(y); + + public static bool operator !=(LogMessage x, LogMessage y) => !(x == y); + + /// + 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); + } + /// public bool TryGetIntParameter(int index, out int value) { @@ -132,6 +143,7 @@ internal unsafe readonly struct LogMessage(LogMessageQueueItem* ptr) : ILogMessa value = new(parameter.StringValue.AsSpan()); return true; } + if (parameter.Type == TextParameterType.ReferencedUtf8String) { value = new(parameter.ReferencedUtf8StringValue->Utf8String.AsSpan()); @@ -157,7 +169,7 @@ internal unsafe readonly struct LogMessage(LogMessageQueueItem* ptr) : ILogMessa return new ReadOnlySeString(utf8.AsSpan()); - void SetName(RaptureLogModule* self, LogMessageEntity item) + static void SetName(RaptureLogModule* self, LogMessageEntity item) { var name = item.NameSpan.GetPointer(0); @@ -190,31 +202,20 @@ internal unsafe readonly struct LogMessage(LogMessageQueueItem* ptr) : ILogMessa } } + private bool TryGetParameter(int index, out TextParameter value) + { + if (index < 0 || index >= ptr->Parameters.Count) + { + value = default; + return false; + } - public static bool operator ==(LogMessage x, LogMessage y) => x.Equals(y); + value = ptr->Parameters[index]; + return true; + } - public static bool operator !=(LogMessage x, LogMessage y) => !(x == y); - - public bool Equals(LogMessage other) + private 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 index 4294e4898..91e905928 100644 --- a/Dalamud/Game/Chat/LogMessageEntity.cs +++ b/Dalamud/Game/Chat/LogMessageEntity.cs @@ -37,46 +37,57 @@ public interface ILogMessageEntity : IEquatable uint ObjStrId { get; } /// - /// Gets a boolean indicating if this entity is a player. + /// Gets a value indicating whether this entity is a player. /// bool IsPlayer { get; } } - /// /// This struct represents an entity related to a log message. /// /// A pointer to the log message item. -/// If represents the source entity of the log message, otherwise represents the target entity +/// If represents the source entity of the log message, otherwise represents the target entity. internal unsafe readonly struct LogMessageEntity(LogMessageQueueItem* ptr, bool source) : ILogMessageEntity { - public Span NameSpan => source ? ptr->SourceName : ptr->TargetName; - - public ReadOnlySeString Name => new ReadOnlySeString(this.NameSpan[..this.NameSpan.IndexOf((byte)0)]); + /// + public ReadOnlySeString Name => new(this.NameSpan[..this.NameSpan.IndexOf((byte)0)]); + /// 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; + /// + /// Gets the Span containing the raw name of this entity. + /// + internal Span NameSpan => source ? ptr->SourceName : ptr->TargetName; + + /// + /// Gets the kind of the entity. + /// + internal byte Kind => source ? (byte)ptr->SourceKind : (byte)ptr->TargetKind; + + /// + /// Gets the Sex of this entity. + /// + internal byte Sex => source ? ptr->SourceSex : ptr->TargetSex; + + /// + /// Gets a value indicating whether this entity is the source entity of a log message. + /// + internal 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) { @@ -94,4 +105,9 @@ internal unsafe readonly struct LogMessageEntity(LogMessageQueueItem* ptr, bool { return HashCode.Combine(this.Name, this.HomeWorldId, this.ObjStrId, this.Sex, this.IsPlayer); } + + private 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; + } } diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/Steps/ChatSelfTestStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/Steps/ChatSelfTestStep.cs index d4311176e..851957b4b 100644 --- a/Dalamud/Interface/Internal/Windows/SelfTest/Steps/ChatSelfTestStep.cs +++ b/Dalamud/Interface/Internal/Windows/SelfTest/Steps/ChatSelfTestStep.cs @@ -17,8 +17,8 @@ internal class ChatSelfTestStep : ISelfTestStep private bool subscribedLogMessage = false; private bool hasSeenEchoMessage = false; private bool hasSeenActionMessage = false; - private string actionName = ""; - private string actionUser = ""; + private string actionName = string.Empty; + private string actionUser = string.Empty; /// public string Name => "Test Chat"; diff --git a/Dalamud/Plugin/Services/IChatGui.cs b/Dalamud/Plugin/Services/IChatGui.cs index eec25cb5a..2bb7b6913 100644 --- a/Dalamud/Plugin/Services/IChatGui.cs +++ b/Dalamud/Plugin/Services/IChatGui.cs @@ -51,7 +51,6 @@ 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. /// From 13980542168073087f38cf6da09516d8aa0d8ced Mon Sep 17 00:00:00 2001 From: MidoriKami Date: Sun, 4 Jan 2026 14:03:15 -0800 Subject: [PATCH 14/17] Push AddonLifecycle event register/unregister to main thread --- .../Game/Addon/Lifecycle/AddonLifecycle.cs | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs b/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs index 78cea1a0f..4da8e429c 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs @@ -27,6 +27,9 @@ internal unsafe class AddonLifecycle : IInternalDisposableService private static readonly ModuleLog Log = new("AddonLifecycle"); + [ServiceManager.ServiceDependency] + private readonly Framework framework = Service.Get(); + private Hook? onInitializeAddonHook; [ServiceManager.ServiceConstructor] @@ -58,20 +61,23 @@ internal unsafe class AddonLifecycle : IInternalDisposableService /// The listener to register. internal void RegisterListener(AddonLifecycleEventListener listener) { - if (!this.EventListeners.ContainsKey(listener.EventType)) + this.framework.RunOnFrameworkThread(() => { - if (!this.EventListeners.TryAdd(listener.EventType, [])) - return; - } + if (!this.EventListeners.ContainsKey(listener.EventType)) + { + if (!this.EventListeners.TryAdd(listener.EventType, [])) + return; + } - // Note: string.Empty is a valid addon name, as that will trigger on any addon for this event type - if (!this.EventListeners[listener.EventType].ContainsKey(listener.AddonName)) - { - if (!this.EventListeners[listener.EventType].TryAdd(listener.AddonName, [])) - return; - } + // Note: string.Empty is a valid addon name, as that will trigger on any addon for this event type + if (!this.EventListeners[listener.EventType].ContainsKey(listener.AddonName)) + { + if (!this.EventListeners[listener.EventType].TryAdd(listener.AddonName, [])) + return; + } - this.EventListeners[listener.EventType][listener.AddonName].Add(listener); + this.EventListeners[listener.EventType][listener.AddonName].Add(listener); + }); } /// @@ -80,13 +86,16 @@ internal unsafe class AddonLifecycle : IInternalDisposableService /// The listener to unregister. internal void UnregisterListener(AddonLifecycleEventListener listener) { - if (this.EventListeners.TryGetValue(listener.EventType, out var addonListeners)) + this.framework.RunOnFrameworkThread(() => { - if (addonListeners.TryGetValue(listener.AddonName, out var addonListener)) + if (this.EventListeners.TryGetValue(listener.EventType, out var addonListeners)) { - addonListener.Remove(listener); + if (addonListeners.TryGetValue(listener.AddonName, out var addonListener)) + { + addonListener.Remove(listener); + } } - } + }); } /// From 36c3429566478cffa01a2aa4677299d528c4b48b Mon Sep 17 00:00:00 2001 From: MidoriKami Date: Sun, 4 Jan 2026 14:41:30 -0800 Subject: [PATCH 15/17] Force to next tick instead of running immediately --- Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs b/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs index 4da8e429c..d07821149 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs @@ -31,6 +31,7 @@ internal unsafe class AddonLifecycle : IInternalDisposableService private readonly Framework framework = Service.Get(); private Hook? onInitializeAddonHook; + private bool isInvokingListeners = false; [ServiceManager.ServiceConstructor] private AddonLifecycle() @@ -61,7 +62,7 @@ internal unsafe class AddonLifecycle : IInternalDisposableService /// The listener to register. internal void RegisterListener(AddonLifecycleEventListener listener) { - this.framework.RunOnFrameworkThread(() => + this.framework.RunOnTick(() => { if (!this.EventListeners.ContainsKey(listener.EventType)) { @@ -77,7 +78,7 @@ internal unsafe class AddonLifecycle : IInternalDisposableService } this.EventListeners[listener.EventType][listener.AddonName].Add(listener); - }); + }, delayTicks: this.isInvokingListeners ? 1 : 0); } /// @@ -86,7 +87,7 @@ internal unsafe class AddonLifecycle : IInternalDisposableService /// The listener to unregister. internal void UnregisterListener(AddonLifecycleEventListener listener) { - this.framework.RunOnFrameworkThread(() => + this.framework.RunOnTick(() => { if (this.EventListeners.TryGetValue(listener.EventType, out var addonListeners)) { @@ -95,7 +96,7 @@ internal unsafe class AddonLifecycle : IInternalDisposableService addonListener.Remove(listener); } } - }); + }, delayTicks: this.isInvokingListeners ? 1 : 0); } /// @@ -106,6 +107,8 @@ internal unsafe class AddonLifecycle : IInternalDisposableService /// What to blame on errors. internal void InvokeListenersSafely(AddonEvent eventType, AddonArgs args, [CallerMemberName] string blame = "") { + this.isInvokingListeners = true; + // Early return if we don't have any listeners of this type if (!this.EventListeners.TryGetValue(eventType, out var addonListeners)) return; @@ -140,6 +143,8 @@ internal unsafe class AddonLifecycle : IInternalDisposableService } } } + + this.isInvokingListeners = false; } /// From e94ded628adcfcbd12f207eb6443b00482e02e6e Mon Sep 17 00:00:00 2001 From: Loskh <1020612624@qq.com> Date: Mon, 5 Jan 2026 22:42:04 +0800 Subject: [PATCH 16/17] fix: respect system dark mode setting --- .../Interface/Internal/InterfaceManager.cs | 43 ++++++++++++++++++- .../Windows/Settings/Tabs/SettingsTabLook.cs | 9 +++- Dalamud/Memory/MemoryHelper.cs | 25 +++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/Dalamud/Interface/Internal/InterfaceManager.cs b/Dalamud/Interface/Internal/InterfaceManager.cs index 96fcb7dfd..72a9eb0f2 100644 --- a/Dalamud/Interface/Internal/InterfaceManager.cs +++ b/Dalamud/Interface/Internal/InterfaceManager.cs @@ -32,6 +32,7 @@ using Dalamud.Interface.Windowing; using Dalamud.Interface.Windowing.Persistence; using Dalamud.IoC.Internal; using Dalamud.Logging.Internal; +using Dalamud.Memory; using Dalamud.Plugin.Services; using Dalamud.Utility; using Dalamud.Utility.Timing; @@ -500,6 +501,34 @@ internal partial class InterfaceManager : IInternalDisposableService ImGuiHelpers.ClearStacksOnContext(); } + /// + /// Applies immersive dark mode to the game window based on the current system theme setting. + /// + internal void SetImmersiveModeFromSystemTheme() + { + bool useDark = this.IsSystemInDarkMode(); + this.SetImmersiveMode(useDark); + } + + /// + /// Checks whether the system use dark mode. + /// + /// Returns true if dark mode is preferred. + internal bool IsSystemInDarkMode() + { + try + { + using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey( + @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); + var value = key?.GetValue("AppsUseLightTheme") as int?; + return value != 1; + } + catch + { + return false; + } + } + /// /// Toggle Windows 11 immersive mode on the game window. /// @@ -744,6 +773,18 @@ internal partial class InterfaceManager : IInternalDisposableService private void WndProcHookManagerOnPreWndProc(WndProcEventArgs args) { + if (args.Message == WM.WM_SETTINGCHANGE) + { + if (this.dalamudConfiguration.WindowIsImmersive) + { + if (MemoryHelper.EqualsZeroTerminatedWideString("ImmersiveColorSet", args.LParam) || + MemoryHelper.EqualsZeroTerminatedWideString("VisualStyleChanged", args.LParam)) + { + this.SetImmersiveModeFromSystemTheme(); + } + } + } + var r = this.backend?.ProcessWndProcW(args.Hwnd, args.Message, args.WParam, args.LParam); if (r is not null) args.SuppressWithValue(r.Value); @@ -858,7 +899,7 @@ internal partial class InterfaceManager : IInternalDisposableService { // Requires that game window to be there, which will be the case once game swap chain is initialized. if (Service.Get().WindowIsImmersive) - this.SetImmersiveMode(true); + this.SetImmersiveModeFromSystemTheme(); } catch (Exception ex) { diff --git a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs index 9b2c418b6..3bb16ca74 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs @@ -65,7 +65,14 @@ internal sealed class SettingsTabLook : SettingsTab { try { - Service.GetNullable()?.SetImmersiveMode(b); + if (b) + { + Service.GetNullable()?.SetImmersiveModeFromSystemTheme(); + } + else + { + Service.GetNullable()?.SetImmersiveMode(false); + } } catch (Exception ex) { diff --git a/Dalamud/Memory/MemoryHelper.cs b/Dalamud/Memory/MemoryHelper.cs index 2eae1be6d..f0f4c991f 100644 --- a/Dalamud/Memory/MemoryHelper.cs +++ b/Dalamud/Memory/MemoryHelper.cs @@ -264,6 +264,31 @@ public static unsafe class MemoryHelper } } + /// + /// Compares a UTF-16 character span with a null-terminated UTF-16 string at . + /// + /// UTF-16 character span (e.g., from a string literal). + /// Address of null-terminated UTF-16 (wide) string, as used by Windows APIs. + /// if equal; otherwise, . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static unsafe bool EqualsZeroTerminatedWideString( + scoped ReadOnlySpan charSpan, + nint memoryAddress) + { + if (memoryAddress == 0) + return charSpan.Length == 0; + + char* p = (char*)memoryAddress; + + foreach (char c in charSpan) + { + if (*p++ != c) + return false; + } + + return *p == '\0'; + } + /// /// Read a UTF-8 encoded string from a specified memory address. /// From b2fb6949d2b9bafefcd35c1ac7dcbb9d7321539c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 10 Jan 2026 06:37:41 +0000 Subject: [PATCH 17/17] Update ClientStructs --- lib/FFXIVClientStructs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/FFXIVClientStructs b/lib/FFXIVClientStructs index ae1917bf1..d83e0c13d 160000 --- a/lib/FFXIVClientStructs +++ b/lib/FFXIVClientStructs @@ -1 +1 @@ -Subproject commit ae1917bf103926bfd157c7d911efac58c0e28666 +Subproject commit d83e0c13d3c802d4a483f373edcd129bc4802073