using System.Text; using Dalamud.Game.Config; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Hooking; using Dalamud.Memory; using Dalamud.Utility.Signatures; using FFXIVClientStructs.FFXIV.Application.Network; using FFXIVClientStructs.FFXIV.Client.System.Framework; using FFXIVClientStructs.FFXIV.Client.System.String; using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Client.UI.Agent; using FFXIVClientStructs.FFXIV.Client.UI.Info; using FFXIVClientStructs.FFXIV.Client.UI.Misc; using FFXIVClientStructs.FFXIV.Client.UI.Shell; using FFXIVClientStructs.FFXIV.Component.GUI; using HellionChat.Code; using HellionChat.GameFunctions.Types; using HellionChat.Resources; using HellionChat.Util; using InteropGenerator.Runtime; using Lumina.Text.ReadOnly; using Microsoft.Extensions.Logging; using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.AtkValueType; namespace HellionChat.GameFunctions; internal sealed unsafe class Chat : IDisposable { // Functions [Signature("48 89 5C 24 ?? 48 89 74 24 ?? 57 48 83 EC ?? 48 8D B9 ?? ?? ?? ?? 33 C0")] private readonly delegate* unmanaged< RaptureLogModule*, ushort, Utf8String*, Utf8String*, ulong, ulong, ushort, byte, int, byte, void> PrintTellNative = null!; [Signature( "E8 ?? ?? ?? ?? 48 8D 4C 24 ?? E8 ?? ?? ?? ?? 48 8D 8C 24 ?? ?? ?? ?? E8 ?? ?? ?? ?? B0 ?? 48 8B 8C 24" )] private readonly delegate* unmanaged< NetworkModule*, ulong, ushort, Utf8String*, Utf8String*, ushort, ushort, byte> SendTellNative = null!; // Client::UI::AddonChatLog.OnRefresh [Signature( "40 53 57 41 57 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 4D 8B F8", DetourName = nameof(ChatLogRefreshDetour) )] private Hook? ChatLogRefreshHook = null!; private delegate byte ChatLogRefreshDelegate(nint log, ushort eventId, AtkValue* value); // Replace with CS version later [Signature( "48 89 5C 24 ?? 55 56 57 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 83 B9", DetourName = nameof(ContextMenuTellInForayDetour) )] private Hook? ContextMenuTellInForayHook = null!; private delegate void ContextMenuTellInForayDelegate( RaptureShellModule* module, Utf8String* playerName, Utf8String* worldName, ushort worldId, ulong accountId, ulong contentId, ushort reason ); private readonly Hook? ChangeChannelNameHook; private readonly Hook? ReplyInSelectedChatModeHook; private readonly Hook? SetChatLogTellTargetHook; // Pointers [Signature("48 8D 1D ?? ?? ?? ?? 8B 05", ScanType = ScanType.StaticAddress)] private readonly char* LastTypedCharacter = null!; private Plugin Plugin { get; } private enum PlayerNameDisplayType : uint { FullName = 0, SurnameAbbreviated = 1, ForenameAbbreviated = 2, Initials = 3, } private long LastPlayerNameDisplayTypeRefresh; private PlayerNameDisplayType CurrentPlayerNameDisplayType = PlayerNameDisplayType.FullName; private readonly ILogger _logger; public Chat(Plugin plugin, ILogger logger) { Plugin = plugin; _logger = logger; Plugin.GameInteropProvider.InitializeFromAttributes(this); ChatLogRefreshHook?.Enable(); ContextMenuTellInForayHook?.Enable(); ChangeChannelNameHook = Plugin.GameInteropProvider.HookFromAddress( AgentChatLog.MemberFunctionPointers.ChangeChannelName, ChangeChannelNameDetour ); ChangeChannelNameHook.Enable(); ReplyInSelectedChatModeHook = Plugin.GameInteropProvider.HookFromAddress( RaptureShellModule.MemberFunctionPointers.ReplyInSelectedChatMode, ReplyInSelectedChatModeDetour ); ReplyInSelectedChatModeHook.Enable(); SetChatLogTellTargetHook = Plugin.GameInteropProvider.HookFromAddress( RaptureShellModule.MemberFunctionPointers.SetContextTellTarget, SetContextTellTarget ); SetChatLogTellTargetHook.Enable(); Plugin.ClientState.Login += Login; Login(); } public void Dispose() { Plugin.ClientState.Login -= Login; SetChatLogTellTargetHook?.Dispose(); ReplyInSelectedChatModeHook?.Dispose(); ChangeChannelNameHook?.Dispose(); ChatLogRefreshHook?.Dispose(); ContextMenuTellInForayHook?.Dispose(); } internal string? GetLinkshellName(uint idx) { var utf = InfoProxyChat.Instance()->GetLinkShellName(idx); return utf.HasValue ? utf.ToString() : null; } internal string? GetCrossLinkshellName(uint idx) { var utf = InfoProxyCrossWorldLinkshell.Instance()->GetCrossworldLinkshellName(idx); return utf == null ? null : utf->ToString(); } private static int GetRotateIdx(RotateMode mode) => mode switch { RotateMode.Forward => 1, RotateMode.Reverse => -1, _ => 0, }; internal static void RotateLinkshellHistory(RotateMode mode) { var uiModule = UIModule.Instance(); if (mode == RotateMode.None) uiModule->LinkshellCycle = -1; uiModule->RotateLinkshellHistory(GetRotateIdx(mode)); } internal static void RotateCrossLinkshellHistory(RotateMode mode) => UIModule.Instance()->RotateCrossLinkshellHistory(GetRotateIdx(mode)); // Look up a channel's user-defined color, returns null if 0 internal uint? GetChannelColor(ChatType type) { var parent = type.Parent(); switch (parent) { case ChatType.Debug: case ChatType.Urgent: case ChatType.Notice: return type.DefaultColor(); } Plugin.GameConfig.TryGet(parent.ToConfigEntry(), out uint color); var rgb = color & 0xFFFFFF; if (rgb == 0) return null; return 0xFF | (rgb << 8); } private void Login() { var agent = AgentChatLog.Instance(); if (agent == null) return; ChangeChannelNameDetour(agent); } private byte ChatLogRefreshDetour(nint log, ushort eventId, AtkValue* value) { if (Plugin.CurrentTab.InputDisabled) return ChatLogRefreshHook!.Original(log, eventId, value); if (eventId != 0x31 || value == null || value->UInt is not (0x05 or 0x0C)) return ChatLogRefreshHook!.Original(log, eventId, value); if (Plugin.Functions.KeybindManager.DirectChat && LastTypedCharacter != null) { // Capture the just-typed character input Plugin.Framework.RunOnTick(() => { string? input = null; var utf8Bytes = MemoryHelper.ReadRaw((nint)LastTypedCharacter + 0x4, 2); var chars = Encoding.UTF8.GetString(utf8Bytes).ToCharArray(); if (chars.Length == 0) return; var c = chars[0]; if (c != '\0' && !char.IsControl(c)) input = c.ToString(); // Seed the just-typed character into our input field and focus it, the // same InputBar.AppendPending + Activate prefill path inventory item-links // use. Prefill only, deliberately: no tab switch. if (input != null) { Plugin.InputBar.AppendPending(input); Plugin.InputBar.Activate = true; } }); } string? addIfNotPresent = null; var str = value + 2; if (str != null && ((int)str->Type & 0xF) == (int)ValueType.String && str->String.HasValue) { var add = str->String.ToString(); if (add.Length > 0) addIfNotPresent = add; } // Route the addIfNotPresent token into the InputBar so inventory // right-click "Link item" reaches our input field instead of being lost. if (addIfNotPresent != null && !Plugin.InputBar.PendingMessage.Contains(addIfNotPresent)) { Plugin.InputBar.AppendPending(addIfNotPresent); Plugin.InputBar.Activate = true; } return 1; // Prevent vanilla chat log from gaining focus } private CStringPointer ChangeChannelNameDetour(AgentChatLog* agent) { var ret = ChangeChannelNameHook!.Original(agent); if (agent == null) return ret; var channel = (uint)RaptureShellModule.Instance()->ChatType; if (channel is 17 or 18) channel = (uint)InputChannel.Tell; var name = SeString.Parse(agent->ChannelLabel); if (name.Payloads.Count == 0) name = null; if (name == null) return ret; var nameChunks = ChunkUtil.ToChunks(name, ChunkSource.None, null).ToList(); if (nameChunks.Count > 0 && nameChunks[0] is TextChunk text) text.Content = text.Content.TrimStart('\uE01E').TrimStart(); string? playerName = null; ushort worldId = 0; if (channel == (uint)InputChannel.Tell) { playerName = SeString.Parse(agent->TellPlayerName).TextValue; worldId = agent->TellWorldId; _logger.LogDebug($"Detected tell target '[redacted]'@{worldId}"); } Plugin.CurrentTab.CurrentChannel = new UsedChannel { Channel = (InputChannel)channel, Name = nameChunks, TellTarget = playerName != null ? new TellTarget(playerName, worldId, 0, 0) : null, }; return ret; } private void ReplyInSelectedChatModeDetour(RaptureShellModule* agent) { var replyMode = AgentChatLog.Instance()->ReplyChannel; if (replyMode == -2) { ReplyInSelectedChatModeHook!.Original(agent); return; } SetChannel((InputChannel)replyMode); ReplyInSelectedChatModeHook!.Original(agent); } // Pure /tell-prefill command builder, shared by the two native SetTellTarget // detours and the PayloadHandler Send-Tell payload. Empty/null world drops the // @World suffix (matches the old IsNullOrEmpty guard); trailing space lets the // user type straight after. internal static so the Build-Suite can pin it // frame-free. TEST-MIRROR: ../../../Hellion Build test/GameFunctions/PrefillTellCommandTests.cs internal static string BuildTellCommand(string name, string? world) { var command = $"/tell {name}"; if (!string.IsNullOrEmpty(world)) command += $"@{world}"; command += " "; return command; } // Prefills + focuses our own input bar with a /tell command. The DI/Dalamud // plumbing (Plugin.InputBar) lives here; the string assembly is BuildTellCommand. // The in-foray TellSpecial routing (SetEurekaTellChannel) is NOT this helper's // job — it stays at the call-site (v1.8.1 deferral). private void PrefillTellInput(string name, string? world) { Plugin.InputBar.SetPendingMessage(BuildTellCommand(name, world)); Plugin.InputBar.Activate = true; } private bool SetContextTellTarget( RaptureShellModule* a1, Utf8String* playerName, Utf8String* worldName, ushort worldId, ulong accountId, ulong contentId, ushort reason, bool setChatType ) { if (playerName != null) { // Right-click -> Send Tell: prefill our input the same way our own // "Send Tell" payload menu does (PayloadHandler), then focus. Prefill // only, deliberately: no tab switch, no ChatActivatedArgs revival. // The game supplies worldName here, so no sheet lookup. PrefillTellInput( playerName->ToString(), worldName != null ? worldName->ToString() : null ); } return SetChatLogTellTargetHook!.Original( a1, playerName, worldName, worldId, accountId, contentId, reason, setChatType ); } private void ContextMenuTellInForayDetour( RaptureShellModule* a1, Utf8String* playerName, Utf8String* worldName, ushort worldId, ulong accountId, ulong contentId, ushort reason ) { if (!Plugin.CurrentTab.CurrentChannel.UseTempChannel) Plugin.CurrentTab.CurrentChannel.UseTempChannel = true; if (playerName != null) { // In-foray right-click -> Send Tell: same prefill path as the non-foray // tell. The foray-specific TellSpecial channel routing stays deferred // (v1.8.1, SetEurekaTellChannel) -- prefill only here as well. PrefillTellInput( playerName->ToString(), worldName != null ? worldName->ToString() : null ); } ContextMenuTellInForayHook!.Original( a1, playerName, worldName, worldId, accountId, contentId, reason ); } // --------------------------------------------------------------- // Cherry-picked from ChatTwo upstream f35b7d3 (Infiziert90, 2026-05-12) // - Renamed ValidAnyLinkshell -> IsChannelOrExistingLinkshell. The // name now states intent: returns true for any non-linkshell // channel, or a linkshell index that actually exists. // --------------------------------------------------------------- internal static bool IsChannelOrExistingLinkshell(InputChannel channel) { var idx = channel.LinkshellIndex(); if (idx == uint.MaxValue || channel.IsExtraChatLinkshell()) return true; if (channel.IsLinkshell()) return ValidLinkshell(idx); if (channel.IsCrossLinkshell()) return ValidCrossLinkshell(idx); return false; } internal static bool ValidLinkshell(uint idx) { if (idx > 7) return false; return InfoProxyLinkshell.Instance()->LinkShells[(int)idx].Id != 0; } internal static bool ValidCrossLinkshell(uint idx) { if (idx > 7) return false; return InfoProxyCrossWorldLinkshell.Instance()->CrossWorldLinkshells[(int)idx].Name.Length > 0; } private static uint? RotateLinkshell( uint currentIndex, RotateMode rotate, Func validFn ) => RotateLinkshellIndex(currentIndex, rotate, validFn); // Pure index-stepper (Dalamud-free): wrap (8 + currentIndex + delta) % 8 and return the // first index validFn accepts within 8 iterations, else null. Extracted so the // modulo/termination logic is unit-testable with a synthetic predicate; the // production caller binds validFn to InfoProxyLinkshell (in-game only). // TEST-MIRROR: ../../../Hellion Build test/_Helpers/RotateLinkshellIndexTests.cs internal static uint? RotateLinkshellIndex( uint currentIndex, RotateMode rotate, Func validFn ) { if (rotate == RotateMode.None) return null; var delta = rotate switch { RotateMode.Forward => 1, RotateMode.Reverse => -1, _ => 1, }; for (var i = 0; i < 8; i++) // Find valid linkshell within 8 iterations { currentIndex = (uint)((8 + currentIndex + delta) % 8); if (validFn(currentIndex)) return currentIndex; } return null; } internal static InputChannel? ResolveTempInputChannel( InputChannel? currentTempChannel, InputChannel channel, RotateMode rotate ) { switch (channel) { case InputChannel.Linkshell1 or InputChannel.CrossLinkshell1 when rotate != RotateMode.None: { var module = UIModule.Instance(); var currentIndex = channel is InputChannel.Linkshell1 ? (uint)module->LinkshellCycle : (uint)module->CrossWorldLinkshellCycle; if (currentTempChannel != null) { switch (channel) { case InputChannel.Linkshell1 when currentTempChannel.Value.IsLinkshell(): case InputChannel.CrossLinkshell1 when currentTempChannel.Value.IsCrossLinkshell(): currentIndex = currentTempChannel.Value.LinkshellIndex(); break; } } var idx = RotateLinkshell( currentIndex, rotate, channel == InputChannel.Linkshell1 ? ValidLinkshell : ValidCrossLinkshell ); // RotateLinkshell returns null when no valid linkshell is found within 8 iterations. // Forward the null so the caller can keep the existing channel instead of crashing on nullable arithmetic. return idx is null ? null : channel + idx.Value; // null if not found, otherwise new channel } default: return channel; } } internal void SetChannel(InputChannel channel, TellTarget? tellTarget = null) { // Ignore ExtraChat linkshells (use ChatLogWindow.SetChannel() instead) if (channel.IsExtraChatLinkshell()) return; var target = Utf8String.FromString(tellTarget?.ToTargetString() ?? ""); var idx = channel.LinkshellIndex(); if (idx == uint.MaxValue) idx = 0; // --------------------------------------------------------------- // Cherry-picked from ChatTwo upstream f35b7d3 (Infiziert90, 2026-05-12) // - Wrap ChangeChatChannel in the validity check instead of // early-returning. The previous early return skipped Dtor and // leaked the native Utf8String allocated a few lines above. // --------------------------------------------------------------- if (IsChannelOrExistingLinkshell(channel)) RaptureShellModule .Instance() ->ChangeChatChannel(tellTarget != null ? 17 : (int)channel, idx, target, true); target->Dtor(true); } internal void SetEurekaTellChannel( string name, string worldName, ushort worldId, ulong accountId, ulong objectId, ushort reason, bool setChatType ) { if (!Plugin.CurrentTab.CurrentChannel.UseTempChannel) Plugin.CurrentTab.CurrentChannel.UseTempChannel = true; // Send tell via CommandInner later and let the game handle it. // TellSpecial gate is offline until the new chat layer reads it. var utfName = Utf8String.FromString(name); var utfWorld = Utf8String.FromString(worldName); RaptureShellModule .Instance() ->SetTellTargetInForay( utfName, utfWorld, worldId, accountId, objectId, reason, setChatType ); utfName->Dtor(true); utfWorld->Dtor(true); } internal TellHistoryInfo? GetTellHistoryInfo(int index) { var acquaintance = AcquaintanceModule.Instance()->GetTellHistory(index); if (acquaintance == null || acquaintance->ContentId == 0) return null; var name = new ReadOnlySeStringSpan(acquaintance->Name.AsSpan()).ExtractText(); var world = acquaintance->WorldId; var contentId = acquaintance->ContentId; return new TellHistoryInfo(name, world, contentId); } internal void SendTellUsingCommandInner(byte[] message) { var mes = Utf8String.FromSequence(message.NullTerminate()); RaptureShellModule.Instance()->ExecuteCommandInner(mes, UIModule.Instance()); RaptureAtkModule.Instance()->ClearFocus(); // Clear the focus of vanilla chat that was still active mes->Dtor(true); } internal void SendTell( TellReason reason, ulong contentId, string name, ushort homeWorld, byte[] message, string rawText ) { if (contentId == 0) { Plugin.ChatGui.PrintError(Language.Chat_SendTell_Error); _logger.LogWarning( "Tried to send a tell with ContentId being 0, sorry this is an internal error." ); return; } var uName = Utf8String.FromString(name); var uMessage = Utf8String.FromSequence(message.NullTerminate()); var encoded = Utf8String.FromUtf8String( PronounModule.Instance()->ProcessString(uMessage, true) ); var decoded = EncodeMessage(rawText); AutoTranslate.ReplaceWithPayload(ref decoded); using var decodedUtf8String = new Utf8String(decoded.NullTerminate()); var logModule = RaptureLogModule.Instance(); var networkModule = Framework.Instance()->GetNetworkModuleProxy()->NetworkModule; // // TODO: Remap TellReasons if (reason == TellReason.Direct) reason = TellReason.Friend; var ok = SendTellNative( networkModule, contentId, homeWorld, uName, encoded, (ushort)reason, homeWorld ); if (ok == 1) PrintTellNative( logModule, 33, uName, &decodedUtf8String, 0, contentId, homeWorld, 255, 0, 0 ); else Plugin.ChatGui.PrintError(Language.Chat_SendTell_Error); encoded->Dtor(true); uName->Dtor(true); uMessage->Dtor(true); } private static byte[] EncodeMessage(string str) { using var input = new Utf8String(str); using var output = new Utf8String(); input.Copy(PronounModule.Instance()->ProcessString(&input, true)); output.Copy(PronounModule.Instance()->ProcessString(&input, false)); return output.AsSpan().ToArray(); } internal bool IsCharValid(char c) { var uC = Utf8String.FromString(c.ToString()); uC->SanitizeString((AllowedEntities)0x27F); var wasValid = uC->ToString().Length > 0; uC->Dtor(true); return wasValid; } private PlayerNameDisplayType GetNameDisplayType() { var ok = Plugin.GameConfig.TryGet(UiConfigOption.LogNameType, out uint type); if (!ok || !Enum.IsDefined(typeof(PlayerNameDisplayType), type)) return PlayerNameDisplayType.FullName; return (PlayerNameDisplayType)type; } internal string AbbreviatePlayerName(string playerName) { if (LastPlayerNameDisplayTypeRefresh + 5_000 < Environment.TickCount64) { LastPlayerNameDisplayTypeRefresh = Environment.TickCount64; CurrentPlayerNameDisplayType = GetNameDisplayType(); } if (CurrentPlayerNameDisplayType == PlayerNameDisplayType.FullName) return playerName; var split = playerName.Split(' '); if (split.Length != 2) return playerName; return CurrentPlayerNameDisplayType switch { PlayerNameDisplayType.SurnameAbbreviated => $"{split.First()} {split.Last().FirstOrDefault('A')}.", PlayerNameDisplayType.ForenameAbbreviated => $"{split.First().FirstOrDefault('A')}. {split.Last()}", PlayerNameDisplayType.Initials => $"{split.First().FirstOrDefault('A')}. {split.Last().FirstOrDefault('A')}.", _ => playerName, }; } internal bool CheckHideFlags() { // Only hide chat in cutscene when vanilla chat would also be hidden var raptureAtkUnitManager = RaptureAtkUnitManager.Instance(); return raptureAtkUnitManager == null || raptureAtkUnitManager->UiFlags.HasFlag(UiFlags.Chat); } }