/*
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;
}
// Restore WndProc first so left-click works again immediately after unload.
// After a game/Dalamud patch, other singletons may be disposed before us;
// if we unsub or touch config first and that throws, we would never restore and LButton stays broken.
RestoreWndProc();
try
{
ConfigurationManager.Instance.ResetEvent -= OnConfigReset;
}
catch { /* Instance may already be disposed */ }
try
{
Plugin.Framework.Update -= OnFrameworkUpdate;
}
catch { /* Framework may already be disposed */ }
Plugin.Logger.Info("\t\tDisposing _requestActionHook: " + (_requestActionHook?.Address.ToString("X") ?? "null"));
_requestActionHook?.Disable();
_requestActionHook?.Dispose();
_executeSlotByIdHook?.Disable();
_executeSlotByIdHook?.Dispose();
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)
{
try
{
// When leaving PvP, force load PvE hotbars so HSUI bars don't keep showing PvP actions.
ActionBarsManager.TryRestorePvEHotbarsAfterLeavePvP();
// Controller bar custom keybinds (when controller hotbars enabled)
ControllerBarKeybindExecutor.Process(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();
}
catch (Exception ex)
{
Plugin.Logger.Warning($"[HSUI InputsHelper] OnFrameworkUpdate: {ex.Message}");
}
}
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;
_wndProcHandle = GCHandle.Alloc(_wndProcDelegate, GCHandleType.Normal); // Prevent GC of delegate while hooked
_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"));
}
/// Restore the game window's WndProc so left-click works again. Idempotent.
internal void RestoreWndProc()
{
if (_wndHandle == IntPtr.Zero || _imguiWndProcPtr == IntPtr.Zero)
return;
try
{
Plugin.Logger.Info("\t\tRestoring WndProc");
SetWindowLongPtr(_wndHandle, GWL_WNDPROC, _imguiWndProcPtr);
Plugin.Logger.Info("\t\t\tWndProc restored.");
}
catch (Exception ex)
{
Plugin.Logger.Warning($"\t\tRestoreWndProc failed: {ex.Message}");
}
finally
{
if (_wndProcHandle.IsAllocated)
_wndProcHandle.Free();
_wndHandle = IntPtr.Zero;
_imguiWndProcPtr = IntPtr.Zero;
_wndProcPtr = IntPtr.Zero;
}
}
/// Call at the very start of plugin disable so left-click is restored even if disposal fails later.
public static void RestoreWndProcIfNeeded()
{
try
{
Instance?.RestoreWndProc();
}
catch (Exception ex)
{
Plugin.Logger?.Warning($"[HSUI] RestoreWndProcIfNeeded: {ex.Message}");
}
}
private IntPtr _wndHandle = IntPtr.Zero;
private WndProcDelegate _wndProcDelegate = null!;
private GCHandle _wndProcHandle; // Keeps delegate alive while WndProc is hooked; freed in RestoreWndProc
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
}
}