using System; using System.Numerics; using System.Runtime.InteropServices; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Hooking; using Dalamud.Interface; using Dalamud.IoC; using Dalamud.IoC.Internal; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; using FFXIVClientStructs.FFXIV.Client.System.String; using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Component.GUI; using ImGuiNET; using Serilog; namespace Dalamud.Game.Gui; /// /// A class handling many aspects of the in-game UI. /// [PluginInterface] [InterfaceVersion("1.0")] [ServiceManager.BlockingEarlyLoadedService] public sealed unsafe class GameGui : IDisposable, IServiceType { private readonly GameGuiAddressResolver address; private readonly GetMatrixSingletonDelegate getMatrixSingleton; private readonly ScreenToWorldNativeDelegate screenToWorldNative; private readonly Hook setGlobalBgmHook; private readonly Hook handleItemHoverHook; private readonly Hook handleItemOutHook; private readonly Hook handleActionHoverHook; private readonly Hook handleActionOutHook; private readonly Hook handleImmHook; private readonly Hook toggleUiHideHook; private readonly Hook utf8StringFromSequenceHook; private GetUIMapObjectDelegate getUIMapObject; private OpenMapWithFlagDelegate openMapWithFlag; [ServiceManager.ServiceConstructor] private GameGui(SigScanner sigScanner) { this.address = new GameGuiAddressResolver(); this.address.Setup(sigScanner); Log.Verbose("===== G A M E G U I ====="); Log.Verbose($"GameGuiManager address 0x{this.address.BaseAddress.ToInt64():X}"); Log.Verbose($"SetGlobalBgm address 0x{this.address.SetGlobalBgm.ToInt64():X}"); Log.Verbose($"HandleItemHover address 0x{this.address.HandleItemHover.ToInt64():X}"); Log.Verbose($"HandleItemOut address 0x{this.address.HandleItemOut.ToInt64():X}"); Log.Verbose($"HandleImm address 0x{this.address.HandleImm.ToInt64():X}"); this.setGlobalBgmHook = Hook.FromAddress(this.address.SetGlobalBgm, this.HandleSetGlobalBgmDetour); this.handleItemHoverHook = Hook.FromAddress(this.address.HandleItemHover, this.HandleItemHoverDetour); this.handleItemOutHook = Hook.FromAddress(this.address.HandleItemOut, this.HandleItemOutDetour); this.handleActionHoverHook = Hook.FromAddress(this.address.HandleActionHover, this.HandleActionHoverDetour); this.handleActionOutHook = Hook.FromAddress(this.address.HandleActionOut, this.HandleActionOutDetour); this.handleImmHook = Hook.FromAddress(this.address.HandleImm, this.HandleImmDetour); this.getMatrixSingleton = Marshal.GetDelegateForFunctionPointer(this.address.GetMatrixSingleton); this.screenToWorldNative = Marshal.GetDelegateForFunctionPointer(this.address.ScreenToWorld); this.toggleUiHideHook = Hook.FromAddress(this.address.ToggleUiHide, this.ToggleUiHideDetour); this.utf8StringFromSequenceHook = Hook.FromAddress(this.address.Utf8StringFromSequence, this.Utf8StringFromSequenceDetour); } // Marshaled delegates [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate IntPtr GetMatrixSingletonDelegate(); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private unsafe delegate bool ScreenToWorldNativeDelegate(float* camPos, float* clipPos, float rayDistance, float* worldPos, int* unknown); // Hooked delegates [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate Utf8String* Utf8StringFromSequenceDelegate(Utf8String* thisPtr, byte* sourcePtr, nuint sourceLen); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate IntPtr GetUIMapObjectDelegate(IntPtr uiObject); [UnmanagedFunctionPointer(CallingConvention.ThisCall, CharSet = CharSet.Ansi)] private delegate bool OpenMapWithFlagDelegate(IntPtr uiMapObject, string flag); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate IntPtr SetGlobalBgmDelegate(ushort bgmKey, byte a2, uint a3, uint a4, uint a5, byte a6); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate IntPtr HandleItemHoverDelegate(IntPtr hoverState, IntPtr a2, IntPtr a3, ulong a4); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate IntPtr HandleItemOutDelegate(IntPtr hoverState, IntPtr a2, IntPtr a3, ulong a4); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void HandleActionHoverDelegate(IntPtr hoverState, HoverActionKind a2, uint a3, int a4, byte a5); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate IntPtr HandleActionOutDelegate(IntPtr agentActionDetail, IntPtr a2, IntPtr a3, int a4); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate char HandleImmDelegate(IntPtr framework, char a2, byte a3); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate IntPtr ToggleUiHideDelegate(IntPtr thisPtr, byte unknownByte); /// /// Event which is fired when the game UI hiding is toggled. /// public event EventHandler UiHideToggled; /// /// Event that is fired when the currently hovered item changes. /// public event EventHandler HoveredItemChanged; /// /// Event that is fired when the currently hovered action changes. /// public event EventHandler HoveredActionChanged; /// /// Gets a value indicating whether the game UI is hidden. /// public bool GameUiHidden { get; private set; } /// /// Gets or sets the item ID that is currently hovered by the player. 0 when no item is hovered. /// If > 1.000.000, subtract 1.000.000 and treat it as HQ. /// public ulong HoveredItem { get; set; } /// /// Gets the action ID that is current hovered by the player. 0 when no action is hovered. /// public HoveredAction HoveredAction { get; } = new HoveredAction(); /// /// Opens the in-game map with a flag on the location of the parameter. /// /// Link to the map to be opened. /// True if there were no errors and it could open the map. public bool OpenMapWithMapLink(MapLinkPayload mapLink) { var uiModule = this.GetUIModule(); if (uiModule == IntPtr.Zero) { Log.Error("OpenMapWithMapLink: Null pointer returned from getUIObject()"); return false; } this.getUIMapObject = this.address.GetVirtualFunction(uiModule, 0, 8); var uiMapObjectPtr = this.getUIMapObject(uiModule); if (uiMapObjectPtr == IntPtr.Zero) { Log.Error("OpenMapWithMapLink: Null pointer returned from GetUIMapObject()"); return false; } this.openMapWithFlag = this.address.GetVirtualFunction(uiMapObjectPtr, 0, 63); var mapLinkString = mapLink.DataString; Log.Debug($"OpenMapWithMapLink: Opening Map Link: {mapLinkString}"); return this.openMapWithFlag(uiMapObjectPtr, mapLinkString); } /// /// Converts in-world coordinates to screen coordinates (upper left corner origin). /// /// Coordinates in the world. /// Converted coordinates. /// True if worldPos corresponds to a position in front of the camera. public bool WorldToScreen(Vector3 worldPos, out Vector2 screenPos) { // Get base object with matrices var matrixSingleton = this.getMatrixSingleton(); // Read current ViewProjectionMatrix plus game window size var windowPos = ImGuiHelpers.MainViewport.Pos; var viewProjectionMatrix = *(Matrix4x4*)(matrixSingleton + 0x1b4); var device = Device.Instance(); float width = device->Width; float height = device->Height; var pCoords = Vector3.Transform(worldPos, viewProjectionMatrix); screenPos = new Vector2(pCoords.X / MathF.Abs(pCoords.Z), pCoords.Y / MathF.Abs(pCoords.Z)); screenPos.X = (0.5f * width * (screenPos.X + 1f)) + windowPos.X; screenPos.Y = (0.5f * height * (1f - screenPos.Y)) + windowPos.Y; return pCoords.Z > 0 && screenPos.X > windowPos.X && screenPos.X < windowPos.X + width && screenPos.Y > windowPos.Y && screenPos.Y < windowPos.Y + height; } /// /// Converts screen coordinates to in-world coordinates via raycasting. /// /// Screen coordinates. /// Converted coordinates. /// How far to search for a collision. /// True if successful. On false, worldPos's contents are undefined. public bool ScreenToWorld(Vector2 screenPos, out Vector3 worldPos, float rayDistance = 100000.0f) { // The game is only visible in the main viewport, so if the cursor is outside // of the game window, do not bother calculating anything var windowPos = ImGuiHelpers.MainViewport.Pos; var windowSize = ImGuiHelpers.MainViewport.Size; if (screenPos.X < windowPos.X || screenPos.X > windowPos.X + windowSize.X || screenPos.Y < windowPos.Y || screenPos.Y > windowPos.Y + windowSize.Y) { worldPos = default; return false; } // Get base object with matrices var matrixSingleton = this.getMatrixSingleton(); // Read current ViewProjectionMatrix plus game window size var viewProjectionMatrix = default(SharpDX.Matrix); float width, height; unsafe { var rawMatrix = (float*)(matrixSingleton + 0x1b4).ToPointer(); for (var i = 0; i < 16; i++, rawMatrix++) viewProjectionMatrix[i] = *rawMatrix; width = *rawMatrix; height = *(rawMatrix + 1); } viewProjectionMatrix.Invert(); var localScreenPos = new SharpDX.Vector2(screenPos.X - windowPos.X, screenPos.Y - windowPos.Y); var screenPos3D = new SharpDX.Vector3 { X = (localScreenPos.X / width * 2.0f) - 1.0f, Y = -((localScreenPos.Y / height * 2.0f) - 1.0f), Z = 0, }; SharpDX.Vector3.TransformCoordinate(ref screenPos3D, ref viewProjectionMatrix, out var camPos); screenPos3D.Z = 1; SharpDX.Vector3.TransformCoordinate(ref screenPos3D, ref viewProjectionMatrix, out var camPosOne); var clipPos = camPosOne - camPos; clipPos.Normalize(); bool isSuccess; unsafe { var camPosArray = camPos.ToArray(); var clipPosArray = clipPos.ToArray(); // This array is larger than necessary because it contains more info than we currently use var worldPosArray = stackalloc float[32]; // Theory: this is some kind of flag on what type of things the ray collides with var unknown = stackalloc int[3] { 0x4000, 0x4000, 0x0, }; fixed (float* pCamPos = camPosArray) { fixed (float* pClipPos = clipPosArray) { isSuccess = this.screenToWorldNative(pCamPos, pClipPos, rayDistance, worldPosArray, unknown); } } worldPos = new Vector3 { X = worldPosArray[0], Y = worldPosArray[1], Z = worldPosArray[2], }; } return isSuccess; } /// /// Gets a pointer to the game's UI module. /// /// IntPtr pointing to UI module. public unsafe IntPtr GetUIModule() { var framework = FFXIVClientStructs.FFXIV.Client.System.Framework.Framework.Instance(); if (framework == null) return IntPtr.Zero; var uiModule = framework->GetUiModule(); if (uiModule == null) return IntPtr.Zero; return (IntPtr)uiModule; } /// /// Gets the pointer to the Addon with the given name and index. /// /// Name of addon to find. /// Index of addon to find (1-indexed). /// IntPtr.Zero if unable to find UI, otherwise IntPtr pointing to the start of the addon. public unsafe IntPtr GetAddonByName(string name, int index = 1) { var atkStage = FFXIVClientStructs.FFXIV.Component.GUI.AtkStage.GetSingleton(); if (atkStage == null) return IntPtr.Zero; var unitMgr = atkStage->RaptureAtkUnitManager; if (unitMgr == null) return IntPtr.Zero; var addon = unitMgr->GetAddonByName(name, index); if (addon == null) return IntPtr.Zero; return (IntPtr)addon; } /// /// Find the agent associated with an addon, if possible. /// /// The addon name. /// A pointer to the agent interface. public IntPtr FindAgentInterface(string addonName) { var addon = this.GetAddonByName(addonName, 1); return this.FindAgentInterface(addon); } /// /// Find the agent associated with an addon, if possible. /// /// The addon address. /// A pointer to the agent interface. public unsafe IntPtr FindAgentInterface(void* addon) => this.FindAgentInterface((IntPtr)addon); /// /// Find the agent associated with an addon, if possible. /// /// The addon address. /// A pointer to the agent interface. public IntPtr FindAgentInterface(IntPtr addonPtr) { if (addonPtr == IntPtr.Zero) return IntPtr.Zero; var uiModule = (UIModule*)this.GetUIModule(); if (uiModule == null) return IntPtr.Zero; var agentModule = uiModule->GetAgentModule(); if (agentModule == null) return IntPtr.Zero; var addon = (AtkUnitBase*)addonPtr; var addonId = addon->ParentID == 0 ? addon->ID : addon->ParentID; if (addonId == 0) return IntPtr.Zero; var index = 0; while (true) { var agent = agentModule->GetAgentByInternalID((uint)index++); if (agent == uiModule || agent == null) break; if (agent->AddonId == addonId) return new IntPtr(agent); } return IntPtr.Zero; } /// /// Disables the hooks and submodules of this module. /// void IDisposable.Dispose() { this.setGlobalBgmHook.Dispose(); this.handleItemHoverHook.Dispose(); this.handleItemOutHook.Dispose(); this.handleImmHook.Dispose(); this.toggleUiHideHook.Dispose(); this.handleActionHoverHook.Dispose(); this.handleActionOutHook.Dispose(); this.utf8StringFromSequenceHook.Dispose(); } /// /// Indicates if the game is on the title screen. /// /// A value indicating whether or not the game is on the title screen. internal bool IsOnTitleScreen() { var charaSelect = this.GetAddonByName("CharaSelect", 1); var charaMake = this.GetAddonByName("CharaMake", 1); var titleDcWorldMap = this.GetAddonByName("TitleDCWorldMap", 1); if (charaMake != nint.Zero || charaSelect != nint.Zero || titleDcWorldMap != nint.Zero) return false; return !Service.Get().IsLoggedIn; } /// /// Set the current background music. /// /// The background music key. internal void SetBgm(ushort bgmKey) => this.setGlobalBgmHook.Original(bgmKey, 0, 0, 0, 0, 0); /// /// Reset the stored "UI hide" state. /// internal void ResetUiHideState() { this.GameUiHidden = false; } [ServiceManager.CallWhenServicesReady] private void ContinueConstruction() { this.setGlobalBgmHook.Enable(); this.handleItemHoverHook.Enable(); this.handleItemOutHook.Enable(); this.handleImmHook.Enable(); this.toggleUiHideHook.Enable(); this.handleActionHoverHook.Enable(); this.handleActionOutHook.Enable(); this.utf8StringFromSequenceHook.Enable(); } private IntPtr HandleSetGlobalBgmDetour(ushort bgmKey, byte a2, uint a3, uint a4, uint a5, byte a6) { var retVal = this.setGlobalBgmHook.Original(bgmKey, a2, a3, a4, a5, a6); Log.Verbose("SetGlobalBgm: {0} {1} {2} {3} {4} {5} -> {6}", bgmKey, a2, a3, a4, a5, a6, retVal); return retVal; } private IntPtr HandleItemHoverDetour(IntPtr hoverState, IntPtr a2, IntPtr a3, ulong a4) { var retVal = this.handleItemHoverHook.Original(hoverState, a2, a3, a4); if (retVal.ToInt64() == 22) { var itemId = (ulong)Marshal.ReadInt32(hoverState, 0x138); this.HoveredItem = itemId; this.HoveredItemChanged?.InvokeSafely(this, itemId); Log.Verbose("HoverItemId:{0} this:{1}", itemId, hoverState.ToInt64().ToString("X")); } return retVal; } private IntPtr HandleItemOutDetour(IntPtr hoverState, IntPtr a2, IntPtr a3, ulong a4) { var retVal = this.handleItemOutHook.Original(hoverState, a2, a3, a4); if (a3 != IntPtr.Zero && a4 == 1) { var a3Val = Marshal.ReadByte(a3, 0x8); if (a3Val == 255) { this.HoveredItem = 0ul; try { this.HoveredItemChanged?.Invoke(this, 0ul); } catch (Exception e) { Log.Error(e, "Could not dispatch HoveredItemChanged event."); } Log.Verbose("HoverItemId: 0"); } } return retVal; } private void HandleActionHoverDetour(IntPtr hoverState, HoverActionKind actionKind, uint actionId, int a4, byte a5) { this.handleActionHoverHook.Original(hoverState, actionKind, actionId, a4, a5); this.HoveredAction.ActionKind = actionKind; this.HoveredAction.BaseActionID = actionId; this.HoveredAction.ActionID = (uint)Marshal.ReadInt32(hoverState, 0x3C); this.HoveredActionChanged?.InvokeSafely(this, this.HoveredAction); Log.Verbose("HoverActionId: {0}/{1} this:{2}", actionKind, actionId, hoverState.ToInt64().ToString("X")); } private IntPtr HandleActionOutDetour(IntPtr agentActionDetail, IntPtr a2, IntPtr a3, int a4) { var retVal = this.handleActionOutHook.Original(agentActionDetail, a2, a3, a4); if (a3 != IntPtr.Zero && a4 == 1) { var a3Val = Marshal.ReadByte(a3, 0x8); if (a3Val == 255) { this.HoveredAction.ActionKind = HoverActionKind.None; this.HoveredAction.BaseActionID = 0; this.HoveredAction.ActionID = 0; try { this.HoveredActionChanged?.Invoke(this, this.HoveredAction); } catch (Exception e) { Log.Error(e, "Could not dispatch HoveredActionChanged event."); } Log.Verbose("HoverActionId: 0"); } } return retVal; } private IntPtr ToggleUiHideDetour(IntPtr thisPtr, byte unknownByte) { // TODO(goat): We should read this from memory directly, instead of relying on catching every toggle. this.GameUiHidden = !this.GameUiHidden; this.UiHideToggled?.InvokeSafely(this, this.GameUiHidden); Log.Debug("UiHide toggled: {0}", this.GameUiHidden); return this.toggleUiHideHook.Original(thisPtr, unknownByte); } private char HandleImmDetour(IntPtr framework, char a2, byte a3) { var result = this.handleImmHook.Original(framework, a2, a3); return ImGui.GetIO().WantTextInput ? (char)0 : result; } private Utf8String* Utf8StringFromSequenceDetour(Utf8String* thisPtr, byte* sourcePtr, nuint sourceLen) { if (sourcePtr != null) this.utf8StringFromSequenceHook.Original(thisPtr, sourcePtr, sourceLen); else thisPtr->Ctor(); // this is in clientstructs but you could do it manually too return thisPtr; // this function shouldn't need to return but the original asm moves this into rax before returning so be safe? } }