diff --git a/ChatTwo/GameFunctions.cs b/ChatTwo/GameFunctions.cs index 7051170..bbb44d8 100755 --- a/ChatTwo/GameFunctions.cs +++ b/ChatTwo/GameFunctions.cs @@ -8,7 +8,9 @@ using Dalamud.Logging; using Dalamud.Memory; 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.Misc; using FFXIVClientStructs.FFXIV.Client.UI.Shell; using FFXIVClientStructs.FFXIV.Component.GUI; @@ -19,6 +21,17 @@ internal unsafe class GameFunctions : IDisposable { internal const string ChatLogRefresh = "40 53 56 57 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 49 8B F0 8B FA"; internal const string ChangeChannelName = "E8 ?? ?? ?? ?? BA ?? ?? ?? ?? 48 8D 4D B0 48 8B F8 E8 ?? ?? ?? ?? 41 8B D6"; internal const string ChangeChatChannel = "E8 ?? ?? ?? ?? 0F B7 44 37 ??"; + + // Context menu + internal const string CurrentChatEntryOffset = "8B 77 ?? 8D 46 01 89 47 14 81 FE ?? ?? ?? ?? 72 03 FF 47"; + internal const string GetContentIdForChatEntry = "4C 8B 81 ?? ?? ?? ?? 4D 85 C0 74 17"; + + internal const string Indexer = "E8 ?? ?? ?? ?? 8B FD 8B CD"; + internal const string InviteToParty = "E8 ?? ?? ?? ?? 33 C0 EB 51"; + + internal const string FriendRequestBool = "40 53 48 83 EC 20 48 8B D9 48 8B 49 10 48 8B 01 FF 90 ?? ?? ?? ?? 48 8B 48 48"; + internal const string AgentContextYesNo = "E8 ?? ?? ?? ?? E9 ?? ?? ?? ?? 48 8B CB E8 ?? ?? ?? ?? 84 C0 74 3A"; + internal const string InviteToNoviceNetwork = "E8 ?? ?? ?? ?? E9 ?? ?? ?? ?? 48 8B CB E8 ?? ?? ?? ?? 45 33 C9"; } private delegate byte ChatLogRefreshDelegate(IntPtr log, ushort eventId, AtkValue* value); @@ -27,6 +40,14 @@ internal unsafe class GameFunctions : IDisposable { private delegate IntPtr ChangeChatChannelDelegate(RaptureShellModule* shell, int channel, uint linkshellIdx, Utf8String* tellTarget, byte one); + private delegate ulong GetContentIdForChatEntryDelegate(RaptureLogModule* log, uint index); + + private delegate IntPtr InviteToPartyDelegate(IntPtr a1, ulong contentId, byte* playerName, ushort playerWorld); + + private delegate IntPtr AgentContextYesNoDelegate(AgentInterface* context, uint a2, byte* playerName, ushort playerWorld, uint a5, byte a6); + + private delegate byte InviteToNoviceNetworkDelegate(IntPtr a1, ulong contentId, ushort playerWorld, byte* playerName); + internal delegate void ChatActivatedEventDelegate(string? input); private Plugin Plugin { get; } @@ -34,6 +55,17 @@ internal unsafe class GameFunctions : IDisposable { private Hook? ChangeChannelNameHook { get; } private readonly ChangeChatChannelDelegate? _changeChatChannel; + private readonly GetContentIdForChatEntryDelegate? _getContentIdForChatEntry; + private readonly int? _currentChatEntryOffset; + + private readonly delegate* unmanaged _indexer; + private readonly InviteToPartyDelegate? _inviteToParty; + + private readonly delegate* unmanaged _friendRequestBool; + private readonly AgentContextYesNoDelegate? _agentContextYesNo; + + private readonly InviteToNoviceNetworkDelegate? _inviteToNoviceNetwork; + internal event ChatActivatedEventDelegate? ChatActivated; internal (InputChannel channel, List name) ChatChannel { get; private set; } @@ -55,6 +87,34 @@ internal unsafe class GameFunctions : IDisposable { this._changeChatChannel = Marshal.GetDelegateForFunctionPointer(changeChannelPtr); } + if (this.Plugin.SigScanner.TryScanText(Signatures.CurrentChatEntryOffset, out var entryOffsetPtr)) { + this._currentChatEntryOffset = *(byte*) (entryOffsetPtr + 2); + } + + if (this.Plugin.SigScanner.TryScanText(Signatures.Indexer, out var indexerPtr)) { + this._indexer = (delegate* unmanaged) indexerPtr; + } + + if (this.Plugin.SigScanner.TryScanText(Signatures.GetContentIdForChatEntry, out var getContentIdPtr)) { + this._getContentIdForChatEntry = Marshal.GetDelegateForFunctionPointer(getContentIdPtr); + } + + if (this.Plugin.SigScanner.TryScanText(Signatures.InviteToParty, out var invitePtr)) { + this._inviteToParty = Marshal.GetDelegateForFunctionPointer(invitePtr); + } + + if (this.Plugin.SigScanner.TryScanText(Signatures.FriendRequestBool, out var frBoolPtr)) { + this._friendRequestBool = (delegate* unmanaged) frBoolPtr; + } + + if (this.Plugin.SigScanner.TryScanText(Signatures.AgentContextYesNo, out var sendFriendRequestPtr)) { + this._agentContextYesNo = Marshal.GetDelegateForFunctionPointer(sendFriendRequestPtr); + } + + if (this.Plugin.SigScanner.TryScanText(Signatures.InviteToNoviceNetwork, out var nnPtr)) { + this._inviteToNoviceNetwork = Marshal.GetDelegateForFunctionPointer(nnPtr); + } + this.Plugin.ClientState.Login += this.Login; this.Login(null, null); } @@ -79,6 +139,68 @@ internal unsafe class GameFunctions : IDisposable { this.ChangeChannelNameDetour((IntPtr) agent); } + internal uint? GetCurrentChatLogEntryIndex() { + if (this._currentChatEntryOffset == null) { + return null; + } + + var log = (IntPtr) Framework.Instance()->GetUiModule()->GetRaptureLogModule(); + return *(uint*) (log + this._currentChatEntryOffset.Value); + } + + internal ulong? GetContentIdForChatLogEntry(uint index) { + return this._getContentIdForChatEntry?.Invoke(Framework.Instance()->GetUiModule()->GetRaptureLogModule(), index); + } + + internal void InviteToParty(string name, ushort world) { + if (this._inviteToParty == null || this._indexer == null) { + return; + } + + var uiModule = Framework.Instance()->GetUiModule(); + // 6.05: 20D722 + var func = (delegate*) uiModule->vfunc[33]; + var toIndex = func(uiModule); + var a1 = this._indexer(toIndex, 1); + + fixed (byte* namePtr = name.ToTerminatedBytes()) { + // can specify content id if we have it, but there's no need + this._inviteToParty(a1, 0, namePtr, world); + } + } + + internal void SendFriendRequest(string name, ushort world) { + if (this._agentContextYesNo == null || this._friendRequestBool == null) { + return; + } + + var agent = Framework.Instance()->GetUiModule()->GetAgentModule()->GetAgentByInternalId(AgentId.Context); + var a6 = this._friendRequestBool(agent); + + fixed (byte* namePtr = name.ToTerminatedBytes()) { + // 6.05: 20DA57 + this._agentContextYesNo(agent, 149, namePtr, world, 10_955, a6); + } + } + + internal void InviteToNoviceNetwork(string name, ushort world) { + if (this._inviteToNoviceNetwork == null || this._indexer == null) { + return; + } + + var uiModule = Framework.Instance()->GetUiModule(); + // 6.05: 20D722 + var func = (delegate*) uiModule->vfunc[33]; + var toIndex = func(uiModule); + // 6.05: 20E4CB + var a1 = this._indexer(toIndex, 0x11); + + fixed (byte* namePtr = name.ToTerminatedBytes()) { + // can specify content id if we have it, but there's no need + this._inviteToNoviceNetwork(a1, 0, world, namePtr); + } + } + internal void SetChatChannel(InputChannel channel, string? tellTarget = null) { if (this._changeChatChannel == null) { return; diff --git a/ChatTwo/Message.cs b/ChatTwo/Message.cs index 17cf241..4b21d9f 100755 --- a/ChatTwo/Message.cs +++ b/ChatTwo/Message.cs @@ -3,6 +3,8 @@ namespace ChatTwo; internal class Message { + internal ulong ContentId; + internal DateTime Date { get; } internal ChatCode Code { get; } internal List Sender { get; } diff --git a/ChatTwo/PayloadHandler.cs b/ChatTwo/PayloadHandler.cs index 0ccf9be..59e63ed 100755 --- a/ChatTwo/PayloadHandler.cs +++ b/ChatTwo/PayloadHandler.cs @@ -56,8 +56,6 @@ internal sealed class PayloadHandler { } internal void Click(Chunk chunk, Payload payload, ImGuiMouseButton button) { - PluginLog.Log($"clicked {payload} with {button}"); - switch (button) { case ImGuiMouseButton.Left: this.LeftClickPayload(chunk, payload); @@ -213,24 +211,50 @@ internal sealed class PayloadHandler { this.Log.Activate = true; } - if (ImGui.Selectable("Target")) { - foreach (var obj in this.Ui.Plugin.ObjectTable) { - if (obj is not PlayerCharacter character) { - continue; - } - - if (character.Name.TextValue != player.PlayerName) { - continue; - } - - if (player.World.IsPublic && character.HomeWorld.Id != player.World.RowId) { - continue; - } - - this.Ui.Plugin.TargetManager.SetTarget(obj); - break; - } + if (player.World.IsPublic && ImGui.Selectable("Invite to Party")) { + // FIXME: don't show if player is in your party or if you're in their party + // FIXME: don't show if in party and not leader + this.Ui.Plugin.Functions.InviteToParty(player.PlayerName, (ushort) player.World.RowId); } + + if (player.World.IsPublic && ImGui.Selectable("Send Friend Request")) { + // FIXME: this shows window, clicking yes doesn't work + // FIXME: only show if not already friend + this.Ui.Plugin.Functions.SendFriendRequest(player.PlayerName, (ushort) player.World.RowId); + } + + if (player.World.IsPublic && ImGui.Selectable("Invite to Novice Network")) { + // FIXME: only show if character is mentor and target is sprout/returner + this.Ui.Plugin.Functions.InviteToNoviceNetwork(player.PlayerName, (ushort) player.World.RowId); + } + + if (ImGui.Selectable("Target") && this.FindCharacterForPayload(player) is { } obj) { + this.Ui.Plugin.TargetManager.SetTarget(obj); + } + + // Add to Blacklist 0x1C + // View Party Finder 0x2E + // Reply in Selected Chat Mode 0x64 + } + + private PlayerCharacter? FindCharacterForPayload(PlayerPayload payload) { + foreach (var obj in this.Ui.Plugin.ObjectTable) { + if (obj is not PlayerCharacter character) { + continue; + } + + if (character.Name.TextValue != payload.PlayerName) { + continue; + } + + if (payload.World.IsPublic && character.HomeWorld.Id != payload.World.RowId) { + continue; + } + + return character; + } + + return null; } private static string PopupId(PlayerPayload player) { diff --git a/ChatTwo/Store.cs b/ChatTwo/Store.cs index 5c6008e..8266f24 100755 --- a/ChatTwo/Store.cs +++ b/ChatTwo/Store.cs @@ -1,5 +1,7 @@ -using ChatTwo.Code; +using System.Collections.Concurrent; +using ChatTwo.Code; using ChatTwo.Util; +using Dalamud.Game; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; using Lumina.Excel.GeneratedSheets; @@ -27,6 +29,7 @@ internal class Store : IDisposable { private Mutex MessagesMutex { get; } = new(); private List Messages { get; } = new(); + private ConcurrentQueue<(uint, Message)> Pending { get; } = new(); private Dictionary Formats { get; } = new(); @@ -34,14 +37,25 @@ internal class Store : IDisposable { this.Plugin = plugin; this.Plugin.ChatGui.ChatMessageUnhandled += this.ChatMessage; + this.Plugin.Framework.Update += this.GetMessageInfo; } public void Dispose() { + this.Plugin.Framework.Update -= this.GetMessageInfo; this.Plugin.ChatGui.ChatMessageUnhandled -= this.ChatMessage; this.MessagesMutex.Dispose(); } + private void GetMessageInfo(Framework framework) { + if (!this.Pending.TryDequeue(out var entry)) { + return; + } + + var contentId = this.Plugin.Functions.GetContentIdForChatLogEntry(entry.Item1); + entry.Item2.ContentId = contentId ?? 0; + } + internal MessagesLock GetMessages() { return new MessagesLock(this.Messages, this.MessagesMutex); } @@ -97,7 +111,13 @@ internal class Store : IDisposable { var messageChunks = ChunkUtil.ToChunks(message, chatCode.Type).ToList(); - this.AddMessage(new Message(chatCode, senderChunks, messageChunks)); + var msg = new Message(chatCode, senderChunks, messageChunks); + this.AddMessage(msg); + + var idx = this.Plugin.Functions.GetCurrentChatLogEntryIndex(); + if (idx != null) { + this.Pending.Enqueue((idx.Value - 1, msg)); + } } internal class NameFormatting { diff --git a/ChatTwo/Util/StringUtil.cs b/ChatTwo/Util/StringUtil.cs new file mode 100755 index 0000000..a400e98 --- /dev/null +++ b/ChatTwo/Util/StringUtil.cs @@ -0,0 +1,13 @@ +using System.Text; + +namespace ChatTwo.Util; + +internal static class StringUtil { + internal static byte[] ToTerminatedBytes(this string s) { + var unterminated = Encoding.UTF8.GetBytes(s); + var bytes = new byte[unterminated.Length + 1]; + Array.Copy(unterminated, bytes, unterminated.Length); + bytes[^1] = 0; + return bytes; + } +}