feat: Controller hotbars with cross layout, separate storage, and sync with game

- Add controller hotbars: 8 cross bars (L2/R2 style), separate from normal hotbars 1-8
- Controller bar slot data stored in config (not game StandardHotbars) so layouts can differ per mode
- Drag-and-drop on controller bars: from game, shift+drag rearrange, release outside to clear
- Independent controller bar keybinds with modifier+trigger combinations (e.g. L2+South)
- Optional 'Sync bar mode with game client': follow Character Config Mouse/Gamepad toggle (PadMode)
- Clone/copy actions: normal hotbars ↔ controller bars
- Restore controller bar layout button; deploy to devPlugins on Release build

Made-with: Cursor
This commit is contained in:
Jorg
2026-02-26 22:18:40 -06:00
parent 369a770162
commit f3e10f27d2
13 changed files with 1706 additions and 21 deletions
+161 -1
View File
@@ -7,6 +7,8 @@ using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.System.String;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using HSUI.Config;
using HSUI.Interface.GeneralElements;
using KamiToolKit.Controllers;
using static FFXIVClientStructs.FFXIV.Client.Game.ActionManager;
@@ -192,6 +194,148 @@ namespace HSUI.Helpers
return list;
}
/// <summary>Scratch bar/slot used to build SlotInfo or execute from (type, id) without touching game bars 1-8. Bar 10 slot 0.</summary>
internal const int ScratchBarIndex = 10;
internal const int ScratchSlotIndex = 0;
/// <summary>Get SlotInfo for a single (type, id) by temporarily writing to scratch slot and reading. Used for controller bars.</summary>
private unsafe SlotInfo GetSlotInfoViaScratch(RaptureHotbarModule.HotbarSlotType slotType, uint commandId)
{
var module = RaptureHotbarModule.Instance();
if (module == null || !module->ModuleReady)
return new SlotInfo(0, true, false, 0, 0, 0, 0, "", 0, 0);
int barIdx = ScratchBarIndex - 1;
var slotPtr = module->GetSlotById((uint)barIdx, (uint)ScratchSlotIndex);
if (slotPtr == null)
return new SlotInfo(0, true, false, 0, 0, 0, 0, "", 0, 0);
uint saveType = (uint)slotPtr->CommandType;
uint saveId = slotPtr->CommandId;
try
{
slotPtr->Set(slotType, commandId);
slotPtr->LoadIconId();
if (slotType == RaptureHotbarModule.HotbarSlotType.Item)
slotPtr->LoadCostDataForSlot(true);
var list = GetSlotData(ScratchBarIndex, 1);
return list.Count > 0 ? list[0] : new SlotInfo(0, true, false, 0, 0, 0, 0, "", 0, 0);
}
finally
{
slotPtr->Set((RaptureHotbarModule.HotbarSlotType)saveType, saveId);
slotPtr->LoadIconId();
}
}
/// <summary>Get slot data for a controller cross bar (1-8) from persisted controller storage. Returns 8 SlotInfos.</summary>
public unsafe List<SlotInfo> GetControllerSlotData(int barIndex, int slotCount)
{
var list = new List<SlotInfo>(slotCount);
var barConfig = ControllerBarConfig.GetBarConfig(barIndex);
if (barConfig == null)
{
for (int i = 0; i < slotCount; i++)
list.Add(new SlotInfo(0, true, false, 0, 0, 0, 0, "", 0, 0));
return list;
}
barConfig.EnsureSlotsPadded();
int count = Math.Min(slotCount, barConfig.Slots.Count);
for (int i = 0; i < count; i++)
{
var entry = barConfig.Slots[i];
var type = (RaptureHotbarModule.HotbarSlotType)entry.SlotType;
uint id = entry.CommandId;
if (entry.IsEmpty)
{
list.Add(new SlotInfo(0, true, false, 0, 0, 0, 0, "", 0, 0));
continue;
}
list.Add(GetSlotInfoViaScratch(type, id));
}
while (list.Count < slotCount)
list.Add(new SlotInfo(0, true, false, 0, 0, 0, 0, "", 0, 0));
return list;
}
/// <summary>Place payload on a controller bar slot (persisted in config only). Bar 1-8, slot 0-7.</summary>
public void PlacePayloadOnControllerSlot(int barIndex, int slotIndex, RaptureHotbarModule.HotbarSlotType slotType, uint commandId)
{
var barConfig = ControllerBarConfig.GetBarConfig(barIndex);
if (barConfig == null) return;
barConfig.EnsureSlotsPadded();
int s = Math.Clamp(slotIndex, 0, ControllerBarConfig.CrossSlotCount - 1);
while (barConfig.Slots.Count <= s)
barConfig.Slots.Add(new ControllerSlotData { SlotType = (int)RaptureHotbarModule.HotbarSlotType.Empty, CommandId = 0 });
barConfig.Slots[s] = new ControllerSlotData { SlotType = (int)slotType, CommandId = commandId };
ConfigurationManager.Instance?.SaveConfigurations();
}
/// <summary>Clear a controller bar slot. Bar 1-8, slot 0-7.</summary>
public bool ClearControllerSlot(int barIndex, int slotIndex)
{
var barConfig = ControllerBarConfig.GetBarConfig(barIndex);
if (barConfig == null) return false;
barConfig.EnsureSlotsPadded();
int s = Math.Clamp(slotIndex, 0, ControllerBarConfig.CrossSlotCount - 1);
if (s >= barConfig.Slots.Count) return true;
barConfig.Slots[s] = new ControllerSlotData { SlotType = (int)RaptureHotbarModule.HotbarSlotType.Empty, CommandId = 0 };
ConfigurationManager.Instance?.SaveConfigurations();
return true;
}
/// <summary>Swap two controller bar slots. Bars 1-8, slots 0-7.</summary>
public bool SwapControllerSlots(int barA, int slotA, int barB, int slotB)
{
var configA = ControllerBarConfig.GetBarConfig(barA);
var configB = ControllerBarConfig.GetBarConfig(barB);
if (configA == null || configB == null) return false;
configA.EnsureSlotsPadded();
configB.EnsureSlotsPadded();
int a = Math.Clamp(slotA, 0, ControllerBarConfig.CrossSlotCount - 1);
int b = Math.Clamp(slotB, 0, ControllerBarConfig.CrossSlotCount - 1);
if (barA == barB && a == b) return true;
if (a >= configA.Slots.Count || b >= configB.Slots.Count) return false;
var tmp = configA.Slots[a];
configA.Slots[a] = configB.Slots[b];
configB.Slots[b] = tmp;
ConfigurationManager.Instance?.SaveConfigurations();
return true;
}
/// <summary>Execute a controller bar slot by (type, id) via scratch slot. Bar 1-8, slot 0-7.</summary>
public unsafe bool ExecuteControllerSlot(int barIndex, int slotIndex)
{
var barConfig = ControllerBarConfig.GetBarConfig(barIndex);
if (barConfig == null) return false;
barConfig.EnsureSlotsPadded();
int s = Math.Clamp(slotIndex, 0, ControllerBarConfig.CrossSlotCount - 1);
if (s >= barConfig.Slots.Count) return false;
var entry = barConfig.Slots[s];
if (entry.IsEmpty) return false;
var slotType = (RaptureHotbarModule.HotbarSlotType)entry.SlotType;
uint commandId = entry.CommandId;
var module = RaptureHotbarModule.Instance();
if (module == null || !module->ModuleReady) return false;
var slotPtr = module->GetSlotById((uint)(ScratchBarIndex - 1), (uint)ScratchSlotIndex);
if (slotPtr == null) return false;
uint saveType = (uint)slotPtr->CommandType;
uint saveId = slotPtr->CommandId;
try
{
slotPtr->Set(slotType, commandId);
slotPtr->LoadIconId();
if (slotType == RaptureHotbarModule.HotbarSlotType.Item)
slotPtr->LoadCostDataForSlot(true);
module->ExecuteSlotById((uint)(ScratchBarIndex - 1), (uint)ScratchSlotIndex);
return true;
}
finally
{
slotPtr->Set((RaptureHotbarModule.HotbarSlotType)saveType, saveId);
slotPtr->LoadIconId();
}
}
/// <summary>
/// Gets cooldown for a hotbar slot. For Action/GeneralAction/PetAction, uses ActionManager recast API
/// (more accurate for adjusted IDs, recast groups). Falls back to slot's GetSlotActionCooldownPercentage for Items/Macros.
@@ -581,7 +725,23 @@ namespace HSUI.Helpers
}
}
/// <summary>Clear a hotbar slot. hotbarIndex 1-10, slotIndex 0-based.</summary>
/// <summary>Place a drag-drop payload (game or HSUI) onto a slot. hotbarIndex 1-10, slotIndex 0-based.</summary>
public static unsafe void PlacePayloadOnSlot(int hotbarIndex, int slotIndex, RaptureHotbarModule.HotbarSlotType slotType, uint commandId)
{
var module = RaptureHotbarModule.Instance();
if (module == null || !module->ModuleReady) return;
int barIdx = Math.Clamp(hotbarIndex, 1, 10) - 1;
int slot = Math.Clamp(slotIndex, 0, 11);
ref var displayBar = ref module->StandardHotbars[barIdx];
var slotPtr = displayBar.GetHotbarSlot((uint)slot);
if (slotPtr == null) return;
slotPtr->Set(slotType, commandId);
slotPtr->LoadIconId();
if (slotType == RaptureHotbarModule.HotbarSlotType.Item)
slotPtr->LoadCostDataForSlot(true);
SetAndSaveSlotInternal(module, (uint)barIdx, (uint)slot, slotType, commandId, slotPtr);
}
public unsafe bool ClearSlot(int hotbarIndex, int slotIndex)
{
var module = RaptureHotbarModule.Instance();
+106
View File
@@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using Dalamud.Game.ClientState.GamePad;
using Dalamud.Game.ClientState.Keys;
using Dalamud.Plugin.Services;
using HSUI.Config;
using HSUI.Interface.GeneralElements;
namespace HSUI.Helpers
{
/// <summary>Polls controller bar keybinds and executes slots when keys/buttons are pressed. Supports combinations (e.g. L2+South).</summary>
public static class ControllerBarKeybindExecutor
{
private static readonly Dictionary<string, bool> _triggerWasDown = new Dictionary<string, bool>();
public static void Process(IFramework framework)
{
try
{
if (!ControllerHotbarsConfig.GetEffectiveControllerHotbarsEnabled())
return;
var keybindsConfig = ConfigurationManager.Instance?.GetConfigObject<ControllerBarKeybindsConfig>();
if (keybindsConfig == null)
return;
if (ActionBarsManager.Instance == null)
return;
for (int bar = 1; bar <= ControllerBarKeybindsConfig.Bars; bar++)
{
for (int slot = 0; slot < ControllerBarKeybindsConfig.SlotsPerBar; slot++)
{
string binding = keybindsConfig.GetKeybind(bar, slot);
if (string.IsNullOrEmpty(binding)) continue;
bool triggered = IsKeybindTriggered(binding);
if (triggered)
ActionBarsManager.Instance.ExecuteControllerSlot(bar, slot);
}
}
}
catch (Exception ex)
{
Plugin.Logger?.Warning($"[HSUI ControllerBarKeybinds] Process: {ex.Message}");
}
}
/// <summary>True when all modifiers are held and the trigger key/button was just pressed (edge).</summary>
private static bool IsKeybindTriggered(string binding)
{
if (string.IsNullOrEmpty(binding)) return false;
string[] parts = binding.Split('+');
for (int i = 0; i < parts.Length; i++)
parts[i] = parts[i].Trim();
if (parts.Length == 0) return false;
if (parts.Length == 1)
{
bool down = IsSingleKeybindDown(parts[0]);
if (!_triggerWasDown.TryGetValue(binding, out bool wasDown)) wasDown = false;
_triggerWasDown[binding] = down;
return down && !wasDown;
}
string triggerPart = parts[parts.Length - 1];
for (int i = 0; i < parts.Length - 1; i++)
{
if (!IsSingleKeybindDown(parts[i]))
return false;
}
bool triggerDown = IsSingleKeybindDown(triggerPart);
if (!_triggerWasDown.TryGetValue(binding, out bool triggerWasDown))
triggerWasDown = false;
_triggerWasDown[binding] = triggerDown;
return triggerDown && !triggerWasDown;
}
private static bool IsSingleKeybindDown(string part)
{
if (string.IsNullOrEmpty(part)) return false;
if (part.StartsWith("Key:", StringComparison.OrdinalIgnoreCase))
{
string vkStr = part.Substring(4).Trim();
if (Plugin.KeyState == null) return false;
if (Enum.TryParse<VirtualKey>(vkStr, true, out var vk)
&& Plugin.KeyState.IsVirtualKeyValid((int)vk))
return Plugin.KeyState[vk];
return false;
}
if (part.StartsWith("Pad:", StringComparison.OrdinalIgnoreCase))
{
string padStr = part.Substring(4).Trim();
if (Plugin.GamepadState == null) return false;
if (Enum.TryParse<GamepadButtons>(padStr, true, out var btn))
return Plugin.GamepadState.Raw(btn) > 0.5f;
return false;
}
return false;
}
}
}
+68
View File
@@ -0,0 +1,68 @@
using System;
using System.Runtime.InteropServices;
using Dalamud.Game.ClientState.GamePad;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.System.Framework;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using HSUI.Interface.GeneralElements;
namespace HSUI.Helpers
{
/// <summary>Provides current cross hotbar trigger state (L2/R2) and game controller-mode detection for sync with game client.</summary>
public static class ControllerHotbarHelper
{
private static IGamepadState? _gamepadState;
public static void SetGamepadState(IGamepadState? gamepadState)
{
_gamepadState = gamepadState;
}
/// <summary>True when the game's Character Configuration has controller (gamepad) mode enabled. Reads the PadMode config option so bar mode follows the toggle and does not switch on input.</summary>
public static unsafe bool IsGameInControllerMode()
{
try
{
Framework* framework = Framework.Instance();
if (framework == null) return false;
int option = (int)ConfigOption.PadMode;
if (framework->SystemConfig.SystemConfigBase.UiConfig.ConfigCount <= option)
return false;
uint value = framework->SystemConfig.SystemConfigBase.UiConfig.ConfigEntry[option].Value.UInt;
// PadMode: 0 = keyboard/mouse, 1 = gamepad
return value != 0;
}
catch
{
return false;
}
}
/// <summary>Returns the current trigger combo for which cross bar should be visible. Uses L2/R2 from gamepad.</summary>
public static CrossBarTrigger GetCurrentTrigger()
{
if (_gamepadState == null)
return CrossBarTrigger.None;
try
{
// Raw returns float (analog); treat as pressed when > 0.5
bool l2 = _gamepadState.Raw(GamepadButtons.L2) > 0.5f;
bool r2 = _gamepadState.Raw(GamepadButtons.R2) > 0.5f;
if (l2 && r2)
{
// Both held - could distinguish L2 then R2 vs R2 then L2 by order; for now use L2R2
return CrossBarTrigger.L2R2;
}
if (l2) return CrossBarTrigger.L2;
if (r2) return CrossBarTrigger.R2;
return CrossBarTrigger.None;
}
catch
{
return CrossBarTrigger.None;
}
}
}
}
+7
View File
@@ -475,6 +475,9 @@ namespace HSUI.Helpers
// 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();
@@ -567,6 +570,7 @@ namespace HSUI.Helpers
_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);
@@ -593,6 +597,8 @@ namespace HSUI.Helpers
}
finally
{
if (_wndProcHandle.IsAllocated)
_wndProcHandle.Free();
_wndHandle = IntPtr.Zero;
_imguiWndProcPtr = IntPtr.Zero;
_wndProcPtr = IntPtr.Zero;
@@ -614,6 +620,7 @@ namespace HSUI.Helpers
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;