using Dalamud.Hooking; using Lumina.Excel.GeneratedSheets; using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.InteropServices; using System.Text; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Memory; using Dalamud.Utility.Signatures; using FFXIVClientStructs.FFXIV.Client.System.Framework; using XIVChatCommon.Message; using XIVChatCommon.Message.Server; namespace XIVChatPlugin { internal unsafe class GameFunctions : IDisposable { private static class Signatures { internal const string ProcessChat = "48 89 5C 24 ?? 57 48 83 EC 20 48 8B FA 48 8B D9 45 84 C9"; internal const string Input = "80 B9 ?? ?? ?? ?? ?? 0F 9C C0"; internal const string InputAfk = "E8 ?? ?? ?? ?? 0F 28 74 24 ?? 0F B6 F0"; internal const string FriendList = "40 53 48 81 EC 80 0F 00 00 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 48 8B D9 48 8B 0D ?? ?? ?? ?? E8 ?? ?? ?? ?? 48 85 C0 0F 84 ?? ?? ?? ?? 44 0F B6 43 ?? 33 C9"; internal const string Format = "48 89 5C 24 ?? 48 89 6C 24 ?? 48 89 7C 24 ?? 41 56 48 83 EC 30 48 8B 6C 24"; internal const string ReceiveChunk = "48 89 5C 24 ?? 56 48 83 EC 20 48 8B 0D ?? ?? ?? ?? 48 8B F2"; internal const string GetColour = "48 89 5C 24 ?? 48 89 6C 24 ?? 48 89 74 24 ?? 57 48 83 EC 20 8B F2 48 8D B9"; internal const string Channel = "E8 ?? ?? ?? ?? E9 ?? ?? ?? ?? 85 D2 BB"; internal const string ChannelCommand = "E8 ?? ?? ?? ?? 0F B7 44 37"; internal const string ChannelNameChange = "E8 ?? ?? ?? ?? BA ?? ?? ?? ?? 48 8D 4D B0 48 8B F8 E8 ?? ?? ?? ?? 41 8B D6"; internal const string XivStringCtor = "E8 ?? ?? ?? ?? 44 2B F7"; internal const string XivStringDtor = "E8 ?? ?? ?? ?? B0 6E"; internal const string ColourLookup = "48 8D 0D ?? ?? ?? ?? 8B 14 ?? 85 D2 7E ?? 48 8B 0D ?? ?? ?? ?? 48 83 C1 10 E8 ?? ?? ?? ?? 8B 70 ?? 41 8D 4D"; } private Plugin Plugin { get; } #region Delegates private delegate void EasierProcessChatBoxDelegate(IntPtr uiModule, IntPtr message, IntPtr unused, byte a4); private delegate byte IsInputDelegate(IntPtr a1); private delegate byte IsInputAfkDelegate(); private delegate byte RequestFriendListDelegate(IntPtr manager); private delegate int FormatFriendListNameDelegate(long a1, long a2, long a3, int a4, IntPtr data, long a6); private delegate IntPtr OnReceiveFriendListChunkDelegate(IntPtr a1, IntPtr data); private delegate IntPtr GetColourInfoDelegate(IntPtr handler, uint lookupResult); private delegate byte ChatChannelChangeDelegate(IntPtr a1, uint channel); private delegate IntPtr ChatChannelChangeNameDelegate(IntPtr a1); private delegate IntPtr ChannelChangeCommandDelegate(IntPtr a1, int inputChannel, uint linkshellIdx, IntPtr tellTarget, char canChangeChannel); private delegate IntPtr XivStringCtorDelegate(IntPtr memory); private delegate IntPtr XivStringDtorDelegate(IntPtr memory); #endregion #region Hooks [Signature(Signatures.Input, DetourName = nameof(IsInputDetour))] private readonly Hook? _isInputHook; [Signature(Signatures.InputAfk, DetourName = nameof(IsInputAfkDetour))] private readonly Hook? _isInputAfkHook; [Signature(Signatures.FriendList, DetourName = nameof(OnRequestFriendList))] private readonly Hook? _friendListHook; [Signature(Signatures.Format, DetourName = nameof(OnFormatFriendList))] private readonly Hook? _formatHook; [Signature(Signatures.ReceiveChunk, DetourName = nameof(OnReceiveFriendList))] private readonly Hook? _receiveChunkHook; [Signature(Signatures.Channel, DetourName = nameof(ChangeChatChannelDetour))] private readonly Hook? _chatChannelChangeHook; [Signature(Signatures.ChannelNameChange, DetourName = nameof(ChangeChatChannelNameDetour))] private readonly Hook? _chatChannelChangeNameHook; #endregion #region Functions [Signature(Signatures.ProcessChat)] private readonly EasierProcessChatBoxDelegate? _easierProcessChatBox; [Signature(Signatures.GetColour)] private readonly GetColourInfoDelegate? _getColourInfo; [Signature(Signatures.ChannelCommand)] private readonly ChannelChangeCommandDelegate? _channelChangeCommand; [Signature(Signatures.XivStringCtor)] private readonly XivStringCtorDelegate? _xivStringCtor; [Signature(Signatures.XivStringDtor)] private readonly XivStringDtorDelegate? _xivStringDtor; #endregion #region Pointers [Signature(Signatures.ColourLookup, ScanType = ScanType.StaticAddress)] private IntPtr ColourLookup { get; init; } #endregion public ServerHousingLocation HousingLocation { get { var info = this.Plugin.Common.Functions.Housing.Location; if (info == null) { return new ServerHousingLocation(null, null, false, null); } var ward = info.Ward; var plot = info.Plot ?? info.Yard ?? info.Apartment; var wing = (byte?) info.ApartmentWing; var exterior = info.Yard != null; return new ServerHousingLocation(ward, plot, exterior, wing); } } [Flags] private enum InputSetters { None = 0, Normal = 1 << 0, Afk = 1 << 1, } private InputSetters HadInput { get; set; } = InputSetters.None; private IntPtr _friendListManager = IntPtr.Zero; private IntPtr _chatManager = IntPtr.Zero; private readonly IntPtr _emptyXivString = IntPtr.Zero; internal bool RequestingFriendList { get; private set; } private readonly List _friends = new(); internal delegate void ReceiveFriendListHandler(List friends); internal event ReceiveFriendListHandler? ReceiveFriendList; internal GameFunctions(Plugin plugin) { this.Plugin = plugin; SignatureHelper.Initialise(this); this._friendListHook?.Enable(); this._formatHook?.Enable(); this._receiveChunkHook?.Enable(); this._chatChannelChangeHook?.Enable(); this._chatChannelChangeNameHook?.Enable(); this._isInputHook?.Enable(); this._isInputAfkHook?.Enable(); if (this._xivStringCtor != null && this._xivStringDtor != null) { this._emptyXivString = Marshal.AllocHGlobal(0x68); this._xivStringCtor(this._emptyXivString); } } private byte IsInputDetour(IntPtr a1) { if (!this.Plugin.Config.MessagesCountAsInput || this.HadInput == InputSetters.None) { return this._isInputHook!.Original(a1); } this.HadInput &= ~InputSetters.Normal; return 1; } private byte IsInputAfkDetour() { if (!this.Plugin.Config.MessagesCountAsInput || this.HadInput == InputSetters.None) { return this._isInputAfkHook!.Original(); } this.HadInput &= ~InputSetters.Afk; return 1; } internal void ChangeChatChannel(InputChannel channel) { if (this._chatManager == IntPtr.Zero || this._channelChangeCommand == null || this._emptyXivString == IntPtr.Zero) { return; } this._channelChangeCommand(this._chatManager, (int) channel, channel.LinkshellIndex(), this._emptyXivString, '\x01'); } // This function looks up a channel's user-defined colour. // // If this function would ever return 0, it returns null instead. internal uint? GetChannelColour(ChatCode channel) { if (this._getColourInfo == null || this.ColourLookup == IntPtr.Zero) { return null; } // Colours are retrieved by looking up their code in a lookup table. Some codes share a colour, so they're lumped into a parent code here. // Only codes >= 10 (say) have configurable colours. // After getting the lookup value for the code, it is passed into a function with a handler which returns a pointer. // This pointer + 32 is the RGB value. This functions returns RGBA with A always max. var parent = channel.Parent(); switch (parent) { case ChatType.Debug: case ChatType.Urgent: case ChatType.Notice: return channel.DefaultColour(); } var framework = (IntPtr) Framework.Instance(); var lookupResult = *(uint*) (this.ColourLookup + (int) parent * 4); var info = this._getColourInfo(framework + 16, lookupResult); var rgb = *(uint*) (info + 32) & 0xFFFFFF; if (rgb == 0) { return null; } return 0xFF | (rgb << 8); } internal void ProcessChatBox(string message) { if (this._easierProcessChatBox == null) { return; } this.HadInput = InputSetters.Normal | InputSetters.Afk; var uiModule = Framework.Instance()->GetUiModule(); using var payload = new ChatPayload(message); var mem1 = Marshal.AllocHGlobal(400); Marshal.StructureToPtr(payload, mem1, false); this._easierProcessChatBox((IntPtr) uiModule, mem1, IntPtr.Zero, 0); Marshal.FreeHGlobal(mem1); } internal bool RequestFriendList() { if (this._friendListManager == IntPtr.Zero || this._friendListHook == null) { return false; } this.RequestingFriendList = true; this._friendListHook.Original(this._friendListManager); return true; } private byte ChangeChatChannelDetour(IntPtr a1, uint channel) { this._chatManager = a1; // Last ShB patch // a1 + 0xfd0 is the chat channel byte (including for when clicking on shout) return this._chatChannelChangeHook!.Original(a1, channel); } private IntPtr ChangeChatChannelNameDetour(IntPtr a1) { // Last ShB patch // +0x40 = chat channel (byte or uint?) // channel is 17 (maybe 18?) for tells // +0x48 = pointer to channel name string var ret = this._chatChannelChangeNameHook!.Original(a1); if (a1 == IntPtr.Zero) { return ret; } var channel = *(uint*) (a1 + 0x40); if (channel is 17 or 18) { channel = 0; } SeString? name = null; var namePtrPtr = (byte**) (a1 + 0x48); if (namePtrPtr != null) { var namePtr = *namePtrPtr; name = MemoryHelper.ReadSeStringNullTerminated((IntPtr) namePtr); if (name.Payloads.Count == 0) { name = null; } } if (name == null) { return ret; } this.Plugin.Server.OnChatChannelChange(channel, name); return ret; } private byte OnRequestFriendList(IntPtr manager) { this._friendListManager = manager; // NOTE: if this is being called, hook isn't null return this._friendListHook!.Original(manager); } private int OnFormatFriendList(long a1, long a2, long a3, int a4, IntPtr data, long a6) { // have to call this first to populate cross-world info // NOTE: if this is being called, hook isn't null var ret = this._formatHook!.Original(a1, a2, a3, a4, data, a6); if (!this.RequestingFriendList) { return ret; } var entry = Marshal.PtrToStructure(data); string? jobName = null; if (entry.job > 0) { jobName = this.Plugin.DataManager.GetExcelSheet()!.GetRow(entry.job)?.Name?.ToString(); } // FIXME: remove this try/catch when lumina fixes bug with .Value string? territoryName; try { territoryName = this.Plugin.DataManager.GetExcelSheet()!.GetRow(entry.territoryId)?.PlaceName?.Value?.Name?.ToString(); } catch (NullReferenceException) { territoryName = null; } var player = new Player { Name = entry.Name(), FreeCompany = entry.FreeCompany(), Status = entry.flags, CurrentWorld = entry.currentWorldId, CurrentWorldName = this.Plugin.DataManager.GetExcelSheet()!.GetRow(entry.currentWorldId)?.Name?.ToString(), HomeWorld = entry.homeWorldId, HomeWorldName = this.Plugin.DataManager.GetExcelSheet()!.GetRow(entry.homeWorldId)?.Name?.ToString(), Territory = entry.territoryId, TerritoryName = territoryName, Job = entry.job, JobName = jobName, GrandCompany = entry.grandCompany, GrandCompanyName = this.Plugin.DataManager.GetExcelSheet()!.GetRow(entry.grandCompany)?.Name?.ToString(), Languages = entry.langsEnabled, MainLanguage = entry.mainLanguage, }; this._friends.Add(player); return ret; } private IntPtr OnReceiveFriendList(IntPtr a1, IntPtr data) { // NOTE: if this is being called, hook isn't null var ret = this._receiveChunkHook!.Original(a1, data); // + 0xc // 1 = party // 2 = friends // 3 = linkshell // doesn't run (though same memory gets updated) for cwl or blacklist // + 0x8 is current number of results returned or 0 when end of list if (!this.RequestingFriendList) { goto Return; } if (*(byte*) (data + 0xc) != 2 || *(ushort*) (data + 0x8) != 0) { goto Return; } this.ReceiveFriendList?.Invoke(this._friends); this._friends.Clear(); this.RequestingFriendList = false; Return: return ret; } public void Dispose() { this._friendListHook?.Dispose(); this._formatHook?.Dispose(); this._receiveChunkHook?.Dispose(); this._chatChannelChangeHook?.Dispose(); this._chatChannelChangeNameHook?.Dispose(); this._isInputHook?.Dispose(); this._isInputAfkHook?.Dispose(); if (this._emptyXivString != IntPtr.Zero) { this._xivStringDtor?.Invoke(this._emptyXivString); Marshal.FreeHGlobal(this._emptyXivString); } } } [StructLayout(LayoutKind.Explicit)] [SuppressMessage("ReSharper", "PrivateFieldCanBeConvertedToLocalVariable")] internal readonly struct ChatPayload : IDisposable { [FieldOffset(0)] private readonly IntPtr textPtr; [FieldOffset(16)] private readonly ulong textLen; [FieldOffset(8)] private readonly ulong unk1; [FieldOffset(24)] private readonly ulong unk2; internal ChatPayload(string text) { var stringBytes = Encoding.UTF8.GetBytes(text); this.textPtr = Marshal.AllocHGlobal(stringBytes.Length + 30); Marshal.Copy(stringBytes, 0, this.textPtr, stringBytes.Length); Marshal.WriteByte(this.textPtr + stringBytes.Length, 0); this.textLen = (ulong) (stringBytes.Length + 1); this.unk1 = 64; this.unk2 = 0; } public void Dispose() { Marshal.FreeHGlobal(this.textPtr); } } [StructLayout(LayoutKind.Sequential)] internal struct FriendListEntryRaw { private readonly ulong unk1; internal readonly ulong flags; private readonly uint unk2; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 2)] private readonly byte[] unk3; internal readonly ushort currentWorldId; internal readonly ushort homeWorldId; internal readonly ushort territoryId; internal readonly byte grandCompany; internal readonly byte mainLanguage; internal readonly byte langsEnabled; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 2)] private readonly byte[] unk4; internal readonly byte job; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)] private readonly byte[] name; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 5)] private readonly byte[] fc; private static string? HandleString(IEnumerable bytes) { var nonNull = bytes.TakeWhile(b => b != 0).ToArray(); return nonNull.Length == 0 ? null : Encoding.UTF8.GetString(nonNull); } internal string? Name() => HandleString(this.name); internal string? FreeCompany() => HandleString(this.fc); } }