/* Copyright(c) 2021 attickdoor (https://github.com/attickdoor/MOActionPlugin) Modifications Copyright(c) 2021 HSUI 09/21/2021 - Used original's code hooks and action validations while using HSUI's own logic to select a target. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Hooking; using Dalamud.Plugin.Services; using HSUI.Config; using HSUI.Interface.GeneralElements; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game.Object; using FFXIVClientStructs.FFXIV.Client.System.Framework; using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Client.UI.Misc; using FFXIVClientStructs.FFXIV.Component.GUI; using System.Runtime.CompilerServices; using Dalamud.Bindings.ImGui; using Lumina.Excel; using System; using System.Diagnostics; using System.Runtime.InteropServices; using static FFXIVClientStructs.FFXIV.Client.Game.ActionManager; using Action = Lumina.Excel.Sheets.Action; using BattleNpcSubKind = Dalamud.Game.ClientState.Objects.Enums.BattleNpcSubKind; namespace HSUI.Helpers { public unsafe class InputsHelper : IDisposable { private delegate bool UseActionDelegate(ActionManager* manager, ActionType actionType, uint actionId, ulong targetId, uint extraParam, UseActionMode mode, uint comboRouteId, bool* outOptAreaTargeted); private delegate byte ExecuteSlotByIdDelegate(RaptureHotbarModule* module, uint hotbarId, uint slotId); #region Singleton private InputsHelper() { _sheet = Plugin.DataManager.GetExcelSheet(); //try //{ // /* // Part of setUIMouseOverActorId disassembly signature // .text:00007FF64830FD70 sub_7FF64830FD70 proc near // .text:00007FF64830FD70 48 89 91 90 02 00+mov [rcx+290h], rdx // .text:00007FF64830FD70 00 // */ // _uiMouseOverActorHook = Plugin.GameInteropProvider.HookFromSignature( // "E8 ?? ?? ?? ?? 48 8B 7C 24 ?? 4C 8B 74 24 ?? 83 FD 02", // HandleUIMouseOverActorId // ); //} //catch //{ // Plugin.Logger.Error("InputsHelper OnSetUIMouseoverActor Hook failed!!!"); //} try { _requestActionHook = Plugin.GameInteropProvider.HookFromSignature( ActionManager.Addresses.UseAction.String, HandleRequestAction ); _requestActionHook?.Enable(); } catch { Plugin.Logger.Error("InputsHelper UseActionDelegate Hook failed!!!"); } try { nint addr = (nint)RaptureHotbarModule.Addresses.ExecuteSlotById.Value; if (addr != IntPtr.Zero) { _executeSlotByIdHook = Plugin.GameInteropProvider.HookFromAddress(addr, HandleExecuteSlotById); _executeSlotByIdHook.Enable(); Plugin.Logger.Info("[HSUI] ExecuteSlotById hook installed (drag-drop overwrite protection)"); } else { _executeSlotByIdHook = Plugin.GameInteropProvider.HookFromSignature( "4C 8B C9 41 83 F8 10 73 45", HandleExecuteSlotById ); _executeSlotByIdHook?.Enable(); Plugin.Logger.Info("[HSUI] ExecuteSlotById hook installed via signature"); } } catch (Exception ex) { Plugin.Logger.Error($"InputsHelper ExecuteSlotById Hook failed: {ex.Message}"); } // mouseover setting ConfigurationManager.Instance.ResetEvent += OnConfigReset; Plugin.Framework.Update += OnFrameworkUpdate; OnConfigReset(ConfigurationManager.Instance); } public static void Initialize() { Instance = new InputsHelper(); } public static InputsHelper Instance { get; private set; } = null!; public static int InitializationDelay = 5; ~InputsHelper() { Dispose(false); } public void Dispose() { Plugin.Logger.Info("\tDisposing InputsHelper..."); Dispose(true); GC.SuppressFinalize(this); } protected void Dispose(bool disposing) { if (!disposing) { return; } ConfigurationManager.Instance.ResetEvent -= OnConfigReset; Plugin.Framework.Update -= OnFrameworkUpdate; Plugin.Logger.Info("\t\tDisposing _requestActionHook: " + (_requestActionHook?.Address.ToString("X") ?? "null")); _requestActionHook?.Disable(); _requestActionHook?.Dispose(); _executeSlotByIdHook?.Disable(); _executeSlotByIdHook?.Dispose(); // give imgui the control of inputs again RestoreWndProc(); Instance = null!; } #endregion private HUDOptionsConfig _config = null!; //private Hook? _uiMouseOverActorHook; private Hook? _requestActionHook; private Hook? _executeSlotByIdHook; private ExcelSheet? _sheet; public bool HandlingMouseInputs { get; private set; } = false; private IGameObject? _target = null; private bool _ignoringMouseover = false; public bool IsProxyEnabled => _config.InputsProxyEnabled; public void ToggleProxy(bool enabled) { _config.InputsProxyEnabled = enabled; ConfigurationManager.Instance.SaveConfigurations(); } public void SetTarget(IGameObject? target, bool ignoreMouseover = false) { if (!IsProxyEnabled && ClipRectsHelper.Instance?.IsPointClipped(ImGui.GetMousePos()) == false) { ImGui.SetNextFrameWantCaptureMouse(true); } _target = target; HandlingMouseInputs = true; _ignoringMouseover = ignoreMouseover; if (!_ignoringMouseover) { long address = _target != null && _target.GameObjectId != 0 ? (long)_target.Address : 0; SetGameMouseoverTarget(address); } } public void ClearTarget() { _target = null; HandlingMouseInputs = false; SetGameMouseoverTarget(0); } public void StartHandlingInputs() { HandlingMouseInputs = true; } public void StopHandlingInputs() { HandlingMouseInputs = false; _ignoringMouseover = false; } private unsafe void SetGameMouseoverTarget(long address) { if (!_config.MouseoverEnabled || _config.MouseoverAutomaticMode || _ignoringMouseover) { return; } UIModule* uiModule = Framework.Instance()->GetUIModule(); if (uiModule == null) { return; } PronounModule* pronounModule = uiModule->GetPronounModule(); if (pronounModule == null) { return; } pronounModule->UiMouseOverTarget = (GameObject*)address; } private void OnConfigReset(ConfigurationManager sender) { _config = sender.GetConfigObject(); } //private void HandleUIMouseOverActorId(long arg1, long arg2) //{ //Plugin.Logger.Log("MO: {0} - {1}", arg1.ToString("X"), arg2.ToString("X")); //_uiMouseOverActorHook?.Original(arg1, arg2); //} private bool HandleRequestAction( ActionManager* manager, ActionType actionType, uint actionId, ulong targetId, uint extraParam, UseActionMode mode, uint comboRouteId, bool* outOptAreaTargeted ) { if (_requestActionHook == null) { return false; } // Block UseAction when we just placed this action via drag-drop (game may execute from drop via path that bypasses WndProc) var (suppressActionId, suppressUntil) = _suppressUseActionForDrop; if (suppressActionId != 0 && actionId == suppressActionId && ImGui.GetTime() < suppressUntil) { if (IsActionBarDragDropDebugEnabled()) Plugin.Logger.Information($"[HSUI DragDrop DBG] UseAction SUPPRESSED actionId={actionId} (drop cooldown)"); _suppressUseActionForDrop = (0, 0); return false; } if (ImGui.GetTime() >= suppressUntil) _suppressUseActionForDrop = (0, 0); if (_config.MouseoverEnabled && _config.MouseoverAutomaticMode && _target != null && IsActionValid(actionId, _target) && !_ignoringMouseover) { return _requestActionHook.Original(manager, actionType, actionId, _target.GameObjectId, extraParam, mode, comboRouteId, outOptAreaTargeted); } return _requestActionHook.Original(manager, actionType, actionId, targetId, extraParam, mode, comboRouteId, outOptAreaTargeted); } private byte HandleExecuteSlotById(RaptureHotbarModule* module, uint hotbarId, uint slotId) { var (suppressBar, suppressSlot, suppressUntil) = _suppressExecuteSlotByIdForDrop; if (suppressBar < 10 && hotbarId == suppressBar && slotId == suppressSlot && ImGui.GetTime() < suppressUntil) { if (IsActionBarDragDropDebugEnabled()) Plugin.Logger.Information($"[HSUI DragDrop DBG] ExecuteSlotById SUPPRESSED bar={hotbarId} slot={slotId} (drop cooldown)"); _suppressExecuteSlotByIdForDrop = (99, 99, 0); return 0; } if (ImGui.GetTime() >= suppressUntil) _suppressExecuteSlotByIdForDrop = (99, 99, 0); // Record for keypress flash (hotbarId 0-9 = bars 1-10, slotId 0-11) LastExecutedBar = (int)hotbarId + 1; LastExecutedSlot = (int)slotId; LastExecutedTime = ImGui.GetTime(); return _executeSlotByIdHook != null ? _executeSlotByIdHook.Original(module, hotbarId, slotId) : (byte)0; } /// Bar 1-10, Slot 0-11. Set when ExecuteSlotById is invoked (keybind or click). public static int LastExecutedBar { get; private set; } = -1; public static int LastExecutedSlot { get; private set; } = -1; public static double LastExecutedTime { get; private set; } /// True if this slot was executed within the given duration (seconds). public static bool WasSlotJustExecuted(int hotbarIndex, int slotIndex, double durationSec = 0.2) { if (LastExecutedBar != hotbarIndex || LastExecutedSlot != slotIndex) return false; return (ImGui.GetTime() - LastExecutedTime) <= durationSec; } /// 0=just pressed, 1=fully faded. For smooth flash decay. public static float GetKeypressFlashAlpha(int hotbarIndex, int slotIndex, double durationSec = 0.2) { if (!WasSlotJustExecuted(hotbarIndex, slotIndex, durationSec)) return 0f; double elapsed = ImGui.GetTime() - LastExecutedTime; return (float)(1.0 - (elapsed / durationSec)); } private bool IsActionValid(ulong actionID, IGameObject? target) { if (target == null || actionID == 0 || _sheet == null) { return false; } bool found = _sheet.TryGetRow((uint)actionID, out Action action); if (!found) { return false; } // handle actions that automatically switch to other actions // ie GNB Continuation or SMN Egi Assaults // these actions dont have an attack type or animation so in these cases // we assume its a hostile spell // if this doesn't work on all cases we can switch to a hardcoded list // of special cases later if (action.AttackType.RowId == 0 && action.AnimationStart.RowId == 0 && (!action.CanTargetAlly && !action.CanTargetHostile && !action.CanTargetParty && action.CanTargetSelf)) { // special case for AST cards and SMN rekindle if (actionID is 37019 or 37020 or 37021 or 25822) { return target is IPlayerCharacter or IBattleNpc { BattleNpcKind: BattleNpcSubKind.Chocobo }; } return target is IBattleNpc npcTarget && npcTarget.BattleNpcKind == BattleNpcSubKind.Enemy; } // friendly player (TODO: pvp? lol) if (target is IPlayerCharacter) { return action.CanTargetAlly || action.CanTargetParty || action.CanTargetSelf; } // friendly npc if (target is IBattleNpc npc) { if (npc.BattleNpcKind != BattleNpcSubKind.Enemy) { return action.CanTargetAlly || action.CanTargetParty || action.CanTargetSelf; } } return action.CanTargetHostile; } #region mouseover inputs proxy private bool? _leftButtonClicked = null; public bool LeftButtonClicked => _leftButtonClicked.HasValue ? _leftButtonClicked.Value : (IsProxyEnabled ? false : ImGui.IsMouseClicked(ImGuiMouseButton.Left)); private bool? _rightButtonClicked = null; public bool RightButtonClicked => _rightButtonClicked.HasValue ? _rightButtonClicked.Value : (IsProxyEnabled ? false : ImGui.IsMouseClicked(ImGuiMouseButton.Right)); private bool _leftButtonWasDown = false; private bool _rightButtonWasDown = false; public void ClearClicks() { if (IsProxyEnabled) { WndProcDetour(_wndHandle, WM_LBUTTONUP, 0, 0); WndProcDetour(_wndHandle, WM_RBUTTONUP, 0, 0); } } // wnd proc detour // if we're "eating" inputs, we only process left and right clicks // any other message is passed along to the ImGui scene private IntPtr WndProcDetour(IntPtr hWnd, uint msg, ulong wParam, long lParam) { // When the game has an active hotbar-relevant drag (Action, Macro, Item, etc.) AND the cursor is over // an HSUI hotbar, eat LBUTTONUP so the game doesn't interpret it as a click on the (hidden) default // hotbar and execute the ability. Do NOT eat when cursor is over game UI (Character Config, etc.) — // the game uses the same icon/drag system for config submenus, so we must only intercept when we're // actually dropping on our hotbars. if (msg == WM_LBUTTONUP && IsHotbarRelevantGameDrag() && ActionBarsHitTestHelper.IsMouseOverAnyHSUIHotbar()) { if (IsActionBarDragDropDebugEnabled()) Plugin.Logger.Information("[HSUI DragDrop DBG] WndProc: EATING LBUTTONUP (hotbar drag over HSUI bar)"); TryCancelGameDragDrop(); ImGui.GetIO().AddMouseButtonEvent((int)ImGuiMouseButton.Left, false); return (IntPtr)0; } // eat left and right clicks? if (HandlingMouseInputs && IsProxyEnabled) { switch (msg) { // mouse clicks case WM_LBUTTONDOWN: case WM_RBUTTONDOWN: case WM_LBUTTONUP: case WM_RBUTTONUP: // if there's not a game window covering the cursor location // we eat the message and handle the inputs manually if (ClipRectsHelper.Instance?.IsPointClipped(ImGui.GetMousePos()) == false) { _leftButtonClicked = _leftButtonWasDown && msg == WM_LBUTTONUP; _rightButtonClicked = _rightButtonWasDown && msg == WM_RBUTTONUP; _leftButtonWasDown = msg == WM_LBUTTONDOWN; _rightButtonWasDown = msg == WM_RBUTTONDOWN; // never eat BUTTONUP messages to prevent clicks from getting stuck!!! if (msg != WM_LBUTTONUP && msg != WM_RBUTTONUP) { // INPUT EATEN!!! return (IntPtr)0; } } // otherwise we let imgui handle the inputs else { _leftButtonClicked = null; _rightButtonClicked = null; } break; } } // call imgui's wnd proc return (IntPtr)CallWindowProc(_imguiWndProcPtr, hWnd, msg, wParam, lParam); } public void OnFrameworkUpdate(IFramework framework) { // Keep WndProc hooked when: proxy mode (for mouseover) OR we need to block game drag // release (so dropping on HSUI action bar doesn't execute the ability). bool needHook = IsProxyEnabled || ShouldBlockGameDragRelease(); if (needHook && _wndProcPtr == IntPtr.Zero) { HookWndProc(); // Only log when we actually installed (HookWndProc can return early during init delay) if (_wndProcPtr != IntPtr.Zero && IsActionBarDragDropDebugEnabled()) Plugin.Logger.Information("[HSUI DragDrop DBG] WndProc hook INSTALLED (needHook=true for drag-block)"); } else if (!needHook && _wndProcPtr != IntPtr.Zero) RestoreWndProc(); } private static bool ShouldBlockGameDragRelease() { try { var hotbarsConfig = ConfigurationManager.Instance?.GetConfigObject(); return hotbarsConfig != null && hotbarsConfig.Enabled; } catch { return false; } } private static bool IsActionBarDragDropDebugEnabled() { try { var configs = ConfigurationManager.Instance?.GetObjects(); return configs != null && configs.Exists(c => c.DebugDragDrop); } catch { return false; } } /// When we place an action via drag-drop, suppress the next UseAction for that action to prevent /// the game from executing it (game may interpret drop as click via a path that bypasses WndProc). private static (uint ActionId, double SuppressUntil) _suppressUseActionForDrop = (0, 0); public static void SuppressUseActionAfterDrop(uint actionId, int durationMs = 300) { _suppressUseActionForDrop = (actionId, ImGui.GetTime() + durationMs / 1000.0); } /// Suppress ExecuteSlotById when we just placed via drag-drop (game hotbar click goes through this). private static (uint HotbarId, uint SlotId, double SuppressUntil) _suppressExecuteSlotByIdForDrop = (99, 99, 0); public static void SuppressExecuteSlotByIdAfterDrop(uint hotbarId, uint slotId, int durationMs = 300) { _suppressExecuteSlotByIdForDrop = (hotbarId, slotId, ImGui.GetTime() + durationMs / 1000.0); } public void OnFrameEnd() { _leftButtonClicked = null; _rightButtonClicked = null; } private void HookWndProc() { if (Plugin.LoadTime <= 0 || ImGui.GetTime() - Plugin.LoadTime < InitializationDelay) { return; } ulong processId = (ulong)Process.GetCurrentProcess().Id; IntPtr hWnd = IntPtr.Zero; do { hWnd = FindWindowExW(IntPtr.Zero, hWnd, "FFXIVGAME", null); if (hWnd == IntPtr.Zero) { return; } ulong wndProcessId = 0; GetWindowThreadProcessId(hWnd, ref wndProcessId); if (wndProcessId == processId) { break; } } while (hWnd != IntPtr.Zero); if (hWnd == IntPtr.Zero) { return; } _wndHandle = hWnd; _wndProcDelegate = WndProcDetour; _wndProcPtr = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate); _imguiWndProcPtr = SetWindowLongPtr(hWnd, GWL_WNDPROC, _wndProcPtr); Plugin.Logger.Info("Initializing HSUI Inputs v" + Plugin.Version); Plugin.Logger.Info("\tHooking WndProc for window: " + hWnd.ToString("X")); Plugin.Logger.Info("\tOld WndProc: " + _imguiWndProcPtr.ToString("X")); } private void RestoreWndProc() { if (_wndHandle != IntPtr.Zero && _imguiWndProcPtr != IntPtr.Zero) { Plugin.Logger.Info("\t\tRestoring WndProc"); Plugin.Logger.Info("\t\t\tOld _wndHandle = " + _wndHandle.ToString("X")); Plugin.Logger.Info("\t\t\tOld _imguiWndProcPtr = " + _imguiWndProcPtr.ToString("X")); SetWindowLongPtr(_wndHandle, GWL_WNDPROC, _imguiWndProcPtr); Plugin.Logger.Info("\t\t\tDone!"); _wndHandle = IntPtr.Zero; _imguiWndProcPtr = IntPtr.Zero; } } private IntPtr _wndHandle = IntPtr.Zero; private WndProcDelegate _wndProcDelegate = null!; private IntPtr _wndProcPtr = IntPtr.Zero; private IntPtr _imguiWndProcPtr = IntPtr.Zero; public delegate IntPtr WndProcDelegate(IntPtr hWnd, uint msg, ulong wParam, long lParam); [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)] public static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); [DllImport("user32.dll", EntryPoint = "CallWindowProcW")] public static extern long CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, uint Msg, ulong wParam, long lParam); [DllImport("user32.dll", EntryPoint = "FindWindowExW", SetLastError = true)] public static extern IntPtr FindWindowExW(IntPtr hWndParent, IntPtr hWndChildAfter, [MarshalAs(UnmanagedType.LPWStr)] string? lpszClass, [MarshalAs(UnmanagedType.LPWStr)] string? lpszWindow); [DllImport("user32.dll", EntryPoint = "GetWindowThreadProcessId", SetLastError = true)] public static extern ulong GetWindowThreadProcessId(IntPtr hWnd, ref ulong id); private const uint WM_LBUTTONDOWN = 513; private const uint WM_LBUTTONUP = 514; private const uint WM_RBUTTONDOWN = 516; private const uint WM_RBUTTONUP = 517; private const int GWL_WNDPROC = -4; [MethodImpl(MethodImplOptions.AggressiveInlining)] private static unsafe bool IsGameDragDropActive() { try { var stage = AtkStage.Instance(); if (stage == null) return false; var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager); return dm != null && dm->IsDragging; } catch { return false; } } /// True when the game has an active drag that can be placed on a hotbar (Action, Macro, Item, etc.). /// Used to avoid eating LBUTTONUP for other UI drags (e.g. Character Config menus). [MethodImpl(MethodImplOptions.AggressiveInlining)] private static unsafe bool IsHotbarRelevantGameDrag() { try { if (!IsGameDragDropActive()) return false; var stage = AtkStage.Instance(); if (stage == null) return false; var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager); var dd = dm->DragDrop1; if (dd == null) return false; var slotType = UIGlobals.GetHotbarSlotTypeFromDragDropType(dd->DragDropType); return slotType != RaptureHotbarModule.HotbarSlotType.Empty; } catch { return false; } } private static unsafe void TryCancelGameDragDrop() { try { var stage = AtkStage.Instance(); if (stage == null) return; var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager); if (dm != null) dm->CancelDragDrop(true, true); } catch { /* best-effort */ } } #endregion } }