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();