334 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			334 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| 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<byte[]>? 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<GameChatHooks> _logger;
 | |
|     private readonly Action<int, byte[]> _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<PronounModule*, Utf8String*, byte, Utf8String*> _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<SendMessageDelegate>? 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<SetChatChannelDelegate>? 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<ChangeChannelNameDelegate>? 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<ShouldDoNameLookupDelegate>? 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<TempChatChannelDelegate>? 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<TempTellTargetDelegate>? 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<UnfocusTickDelegate>? 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<GameChatHooks> logger, IGameInteropProvider gameInteropProvider, Action<int, byte[]> 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;
 | |
|     }
 | |
| }
 | 
