using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Hooking; using Dalamud.Memory; using Dalamud.Plugin.Services; using Dalamud.Utility.Signatures; using FFXIVClientStructs.FFXIV.Client.System.String; using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Client.UI.Agent; using FFXIVClientStructs.FFXIV.Client.UI.Misc; using FFXIVClientStructs.FFXIV.Client.UI.Shell; using FFXIVClientStructs.FFXIV.Component.Shell; using MareSynchronos.Services; using Microsoft.Extensions.Logging; namespace MareSynchronos.Interop; public record ChatChannelOverride { public string ChannelName = string.Empty; public Action? ChatMessageHandler; } public unsafe sealed class GameChatHooks : IDisposable { // Based on https://git.anna.lgbt/anna/ExtraChat/src/branch/main/client/ExtraChat/GameFunctions.cs private readonly ILogger _logger; private readonly Action _ssCommandHandler; #region signatures #pragma warning disable CS0649 // I do not know what kind of black magic this function performs // Client::UI::Misc::PronounModule::??? [Signature("E8 ?? ?? ?? ?? 44 88 74 24 ?? 4C 8D 45")] private readonly delegate* unmanaged _processStringStep2; // Component::Shell::ShellCommandModule::ExecuteCommandInner private delegate void SendMessageDelegate(ShellCommandModule* module, Utf8String* message, UIModule* uiModule); [Signature( "E8 ?? ?? ?? ?? FE 87 ?? ?? ?? ?? C7 87", DetourName = nameof(SendMessageDetour) )] private Hook? SendMessageHook { get; init; } // Client::UI::Shell::RaptureShellModule::SetChatChannel private delegate void SetChatChannelDelegate(RaptureShellModule* module, uint channel); [Signature( "E8 ?? ?? ?? ?? 33 C0 EB ?? 85 D2", DetourName = nameof(SetChatChannelDetour) )] private Hook? SetChatChannelHook { get; init; } // Component::Shell::ShellCommandModule::ChangeChannelName private delegate byte* ChangeChannelNameDelegate(AgentChatLog* agent); [Signature( "E8 ?? ?? ?? ?? BA ?? ?? ?? ?? 48 8D 4D B0 48 8B F8 E8 ?? ?? ?? ?? 41 8B D6", DetourName = nameof(ChangeChannelNameDetour) )] private Hook? ChangeChannelNameHook { get; init; } // Client::UI::Agent::AgentChatLog::??? private delegate byte ShouldDoNameLookupDelegate(AgentChatLog* agent); [Signature( "48 89 5C 24 ?? 57 48 83 EC ?? 48 8B D9 40 32 FF 48 8B 49 ?? ?? ?? ?? FF 50", DetourName = nameof(ShouldDoNameLookupDetour) )] private Hook? ShouldDoNameLookupHook { get; init; } // Temporary chat channel change (via hotkey) // Client::UI::Shell::RaptureShellModule::??? private delegate ulong TempChatChannelDelegate(RaptureShellModule* module, uint x, uint y, ulong z); [Signature( "48 89 5C 24 ?? 48 89 6C 24 ?? 48 89 74 24 ?? 57 48 83 EC ?? 83 B9 ?? ?? ?? ?? ?? 49 8B F9 41 8B F0", DetourName = nameof(TempChatChannelDetour) )] private Hook? TempChatChannelHook { get; init; } // Temporary tell target change (via hotkey) // Client::UI::Shell::RaptureShellModule::SetContextTellTargetInForay private delegate ulong TempTellTargetDelegate(RaptureShellModule* module, ulong a, ulong b, ulong c, ushort d, ulong e, ulong f, ushort g); [Signature( "48 89 5C 24 ?? 48 89 6C 24 ?? 48 89 74 24 ?? 57 48 83 EC ?? 83 B9 ?? ?? ?? ?? ?? 41 0F B7 F9", DetourName = nameof(TempTellTargetDetour) )] private Hook? TempTellTargetHook { get; init; } // Called every frame while the chat bar is not focused private delegate void UnfocusTickDelegate(RaptureShellModule* module); [Signature( "40 53 48 83 EC ?? 83 B9 ?? ?? ?? ?? ?? 48 8B D9 0F 84 ?? ?? ?? ?? 48 8D 91", DetourName = nameof(UnfocusTickDetour) )] private Hook? UnfocusTickHook { get; init; } #pragma warning restore CS0649 #endregion private ChatChannelOverride? _chatChannelOverride; private ChatChannelOverride? _chatChannelOverrideTempBuffer; private bool _shouldForceNameLookup = false; private DateTime _nextMessageIsReply = DateTime.UnixEpoch; public ChatChannelOverride? ChatChannelOverride { get => _chatChannelOverride; set { _chatChannelOverride = value; _shouldForceNameLookup = true; } } private void StashChatChannel() { if (_chatChannelOverride != null) { _logger.LogTrace("Stashing chat channel"); _chatChannelOverrideTempBuffer = _chatChannelOverride; ChatChannelOverride = null; } } private void UnstashChatChannel() { if (_chatChannelOverrideTempBuffer != null) { _logger.LogTrace("Unstashing chat channel"); ChatChannelOverride = _chatChannelOverrideTempBuffer; _chatChannelOverrideTempBuffer = null; } } public GameChatHooks(ILogger logger, IGameInteropProvider gameInteropProvider, Action ssCommandHandler) { _logger = logger; _ssCommandHandler = ssCommandHandler; logger.LogInformation("Initializing GameChatHooks"); gameInteropProvider.InitializeFromAttributes(this); SendMessageHook?.Enable(); SetChatChannelHook?.Enable(); ChangeChannelNameHook?.Enable(); ShouldDoNameLookupHook?.Enable(); TempChatChannelHook?.Enable(); TempTellTargetHook?.Enable(); UnfocusTickHook?.Enable(); } public void Dispose() { SendMessageHook?.Dispose(); SetChatChannelHook?.Dispose(); ChangeChannelNameHook?.Dispose(); ShouldDoNameLookupHook?.Dispose(); TempChatChannelHook?.Dispose(); TempTellTargetHook?.Dispose(); UnfocusTickHook?.Dispose(); } private byte[] ProcessChatMessage(Utf8String* message) { var pronounModule = UIModule.Instance()->GetPronounModule(); var chatString1 = pronounModule->ProcessString(message, true); var chatString2 = _processStringStep2(pronounModule, chatString1, 1); return MemoryHelper.ReadRaw((nint)chatString2->StringPtr.Value, chatString2->Length); } private void SendMessageDetour(ShellCommandModule* thisPtr, Utf8String* message, UIModule* uiModule) { try { var messageLength = message->Length; var messageSpan = message->AsSpan(); bool isCommand = false; bool isReply = false; var utcNow = DateTime.UtcNow; // Check if chat input begins with a command (or auto-translated command) // Or if we think we're being called to send text via the /r command if (_nextMessageIsReply >= utcNow) { isCommand = true; } else if (messageLength == 0 || messageSpan[0] == (byte)'/' || !messageSpan.ContainsAnyExcept((byte)' ')) { isCommand = true; if (messageSpan.StartsWith(System.Text.Encoding.ASCII.GetBytes("/r ")) || messageSpan.StartsWith(System.Text.Encoding.ASCII.GetBytes("/reply "))) isReply = true; } else if (messageSpan[0] == (byte)0x02) /* Payload.START_BYTE */ { var payload = Payload.Decode(new BinaryReader(new UnmanagedMemoryStream(message->StringPtr, message->BufSize))) as AutoTranslatePayload; // Auto-translate text begins with / if (payload != null && payload.Text.Length > 2 && payload.Text[2] == '/') { isCommand = true; if (payload.Text[2..].StartsWith("/r ", StringComparison.Ordinal) || payload.Text[2..].StartsWith("/reply ", StringComparison.Ordinal)) isReply = true; } } // When using /r the game will set a flag and then call this function a second time // The next call to this function will be raw text intended for the IM recipient // This flag's validity is time-limited as a fail-safe if (isReply) _nextMessageIsReply = utcNow + TimeSpan.FromMilliseconds(100); // If it is a command, check if it begins with /ss first so we can handle the message directly // Letting Dalamud handle the commands causes all of the special payloads to be dropped if (isCommand && messageSpan.StartsWith(System.Text.Encoding.ASCII.GetBytes("/ss"))) { for (int i = 1; i <= ChatService.CommandMaxNumber; ++i) { var cmdString = $"/ss{i} "; if (messageSpan.StartsWith(System.Text.Encoding.ASCII.GetBytes(cmdString))) { var ssChatBytes = ProcessChatMessage(message); ssChatBytes = ssChatBytes.Skip(cmdString.Length).ToArray(); _ssCommandHandler?.Invoke(i, ssChatBytes); return; } } } // If not a command, or no override is set, then call the original chat handler if (isCommand || _chatChannelOverride == null) { SendMessageHook!.OriginalDisposeSafe(thisPtr, message, uiModule); return; } // Otherwise, the text is to be sent to the emulated chat channel handler // The chat input string is rendered in to a payload for display first var chatBytes = ProcessChatMessage(message); if (chatBytes.Length > 0) _chatChannelOverride.ChatMessageHandler?.Invoke(chatBytes); } catch (Exception e) { _logger.LogError(e, "Exception thrown during SendMessageDetour"); } } private void SetChatChannelDetour(RaptureShellModule* module, uint channel) { try { if (_chatChannelOverride != null) { _chatChannelOverride = null; _shouldForceNameLookup = true; } } catch (Exception e) { _logger.LogError(e, "Exception thrown during SetChatChannelDetour"); } SetChatChannelHook!.OriginalDisposeSafe(module, channel); } private ulong TempChatChannelDetour(RaptureShellModule* module, uint x, uint y, ulong z) { var result = TempChatChannelHook!.OriginalDisposeSafe(module, x, y, z); if (result != 0) StashChatChannel(); return result; } private ulong TempTellTargetDetour(RaptureShellModule* module, ulong a, ulong b, ulong c, ushort d, ulong e, ulong f, ushort g) { var result = TempTellTargetHook!.OriginalDisposeSafe(module, a, b, c, d, e, f, g); if (result != 0) StashChatChannel(); return result; } private void UnfocusTickDetour(RaptureShellModule* module) { UnfocusTickHook!.OriginalDisposeSafe(module); UnstashChatChannel(); } private byte* ChangeChannelNameDetour(AgentChatLog* agent) { var originalResult = ChangeChannelNameHook!.OriginalDisposeSafe(agent); try { // Replace the chat channel name on the UI if active if (_chatChannelOverride != null) { agent->ChannelLabel.SetString(_chatChannelOverride.ChannelName); } } catch (Exception e) { _logger.LogError(e, "Exception thrown during ChangeChannelNameDetour"); } return originalResult; } private byte ShouldDoNameLookupDetour(AgentChatLog* agent) { var originalResult = ShouldDoNameLookupHook!.OriginalDisposeSafe(agent); try { // Force the chat channel name to update when required if (_shouldForceNameLookup) { _shouldForceNameLookup = false; return 1; } } catch (Exception e) { _logger.LogError(e, "Exception thrown during ShouldDoNameLookupDetour"); } return originalResult; } }