Files
HSUI/Helpers/ActionBarsManager.cs
Jorg f3e10f27d2 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
2026-02-26 22:18:40 -06:00

791 lines
39 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
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;
namespace HSUI.Helpers
{
public sealed class ActionBarsManager : IDisposable
{
public static ActionBarsManager Instance { get; private set; } = null!;
private AddonController? _addonController;
private ActionBarsManager()
{
_addonController = new AddonController("_ActionBar");
_addonController.Enable();
}
public static void Initialize()
{
Instance = new ActionBarsManager();
}
public void Dispose()
{
_addonController?.Disable();
_addonController = null;
Instance = null!;
}
/// <summary>
/// Slot data for ImGui drawing. Updated each call from game state.
/// </summary>
public readonly struct SlotInfo
{
public uint IconId { get; }
public bool IsEmpty { get; }
public bool IsUsable { get; }
public int CooldownPercent { get; }
public int CooldownSecondsLeft { get; }
public uint ActionId { get; }
public RaptureHotbarModule.HotbarSlotType SlotType { get; }
/// <summary>Keybind hint from game (user's keybind settings). Empty if unavailable.</summary>
public string KeybindHint { get; }
/// <summary>Current charges for charge-based actions; 0 when not applicable.</summary>
public int CurrentCharges { get; }
/// <summary>Max charges for charge-based actions; &gt;1 only for actions with charges.</summary>
public int MaxCharges { get; }
public SlotInfo(uint iconId, bool isEmpty, bool usable, int cooldownPct, int cooldownSecs, uint actionId = 0, RaptureHotbarModule.HotbarSlotType slotType = 0, string keybindHint = "", int currentCharges = 0, int maxCharges = 0)
{
IconId = iconId;
IsEmpty = isEmpty;
IsUsable = usable;
CooldownPercent = cooldownPct;
CooldownSecondsLeft = cooldownSecs;
ActionId = actionId;
SlotType = slotType;
KeybindHint = keybindHint ?? "";
CurrentCharges = currentCharges;
MaxCharges = maxCharges;
}
}
/// <summary>
/// Reads keybind hint from HotbarSlot. Uses _keybindHint (slot display) then _popUpKeybindHint (trimmed) as fallback.
/// Offsets match RaptureHotbarModule.HotbarSlot: _keybindHint 0xA8, _popUpKeybindHint 0x88.
/// </summary>
private static unsafe string ReadKeybindHintFromSlot(RaptureHotbarModule.HotbarSlot* slot)
{
if (slot == null) return "";
var sb = (byte*)slot;
string? h = Marshal.PtrToStringUTF8((IntPtr)(sb + 0xA8));
if (!string.IsNullOrWhiteSpace(h))
return h.Trim();
string? p = Marshal.PtrToStringUTF8((IntPtr)(sb + 0x88));
if (string.IsNullOrWhiteSpace(p)) return "";
p = p!.Trim();
if (p.StartsWith(" [", StringComparison.Ordinal) && p.EndsWith("]", StringComparison.Ordinal))
p = p[2..^1].Trim();
return p;
}
/// <summary>Track PvP state so we can restore PvE bars when leaving PvP (game sometimes leaves PvP data in StandardHotbars).</summary>
private static bool _pvpHotbarsActiveLastFrame;
private static bool _clientStatePvPLastFrame;
/// <summary>After leaving PvP, keep re-applying PvE load for this many frames in case the game overwrites.</summary>
private static int _restorePvEFramesLeft;
/// <summary>
/// Call once per frame (e.g. from Framework Update). When we detect leaving PvP, loads saved PvE hotbars into the live bars
/// and keeps re-applying for a short window so PvE actions are not overwritten by stale PvP state.
/// </summary>
public static unsafe void TryRestorePvEHotbarsAfterLeavePvP()
{
var module = RaptureHotbarModule.Instance();
if (module == null || !module->ModuleReady)
return;
bool pvpActive = module->PvPHotbarsActive;
bool clientPvP = Plugin.ClientState.IsPvP;
if (!pvpActive && !clientPvP)
{
bool justLeftPvP = _pvpHotbarsActiveLastFrame || _clientStatePvPLastFrame;
if (justLeftPvP)
_restorePvEFramesLeft = 120; // ~2s at 60fps
if (_restorePvEFramesLeft > 0)
{
uint classJobId = (uint)(module->ActiveHotbarClassJobId & 0x7F);
for (uint barId = 0; barId < 10; barId++)
module->LoadSavedHotbar(classJobId, barId);
_restorePvEFramesLeft--;
}
}
_pvpHotbarsActiveLastFrame = pvpActive;
_clientStatePvPLastFrame = clientPvP;
}
/// <summary>
/// Reads hotbar slot data from RaptureHotbarModule. Returns up to slotCount slots.
/// hotbarIndex 1-10 maps to StandardHotbars 0-9.
/// Always reads from live Hotbars; when leaving PvP, TryRestorePvEHotbarsAfterLeavePvP loads saved PvE into live so we then show PvE.
/// </summary>
public unsafe List<SlotInfo> GetSlotData(int hotbarIndex, int slotCount)
{
var list = new List<SlotInfo>(slotCount);
var module = RaptureHotbarModule.Instance();
if (module == null || !module->ModuleReady)
return list;
int barIdx = Math.Clamp(hotbarIndex, 1, 10) - 1;
int count = Math.Clamp(slotCount, 1, 12);
var hotbars = module->StandardHotbars;
ref var bar = ref hotbars[barIdx];
for (int i = 0; i < count; i++)
{
var slot = bar.GetHotbarSlot((uint)i);
string keybind = ReadKeybindHintFromSlot(slot);
if (slot == null)
{
list.Add(new SlotInfo(0, true, false, 0, 0, 0, 0, keybind, 0, 0));
continue;
}
bool isEmpty = slot->IsEmpty && slot->CommandType != RaptureHotbarModule.HotbarSlotType.GearSet;
if (isEmpty)
{
list.Add(new SlotInfo(0, true, false, 0, 0, 0, 0, keybind, 0, 0));
continue;
}
var slotType = slot->CommandType == RaptureHotbarModule.HotbarSlotType.GearSet
? RaptureHotbarModule.HotbarSlotType.GearSet
: slot->ApparentSlotType;
uint actionId = slot->CommandType == RaptureHotbarModule.HotbarSlotType.GearSet
? slot->CommandId
: slot->ApparentActionId;
if (slotType == RaptureHotbarModule.HotbarSlotType.GearSet)
slot->LoadIconId();
bool usable = slot->IsSlotUsable(slotType, actionId);
uint iconId = slot->IconId;
if (slotType == RaptureHotbarModule.HotbarSlotType.GearSet && iconId == 0)
{
int resolved = slot->GetIconIdForSlot(slotType, actionId);
if (resolved > 0)
iconId = (uint)resolved;
}
(int pct, int secsLeft) = GetSlotCooldown(slot);
(int currentCharges, int maxCharges) = GetSlotCharges(slotType, actionId);
uint apparentCharges = slotType == RaptureHotbarModule.HotbarSlotType.Action ? slot->GetApparentIconRecastCharges() : 0;
if (maxCharges > 1 && (apparentCharges > 0 || currentCharges > 0))
usable = true;
list.Add(new SlotInfo(iconId, false, usable, pct, secsLeft, actionId, slotType, keybind, currentCharges, maxCharges));
}
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.
/// </summary>
/// <summary>
/// Gets current and max charges for a slot. Only applies to Action type; returns (0,0) otherwise.
/// </summary>
private static unsafe (int CurrentCharges, int MaxCharges) GetSlotCharges(RaptureHotbarModule.HotbarSlotType slotType, uint actionId)
{
if (slotType != RaptureHotbarModule.HotbarSlotType.Action || actionId == 0)
return (0, 0);
var actionManager = ActionManager.Instance();
if (actionManager == null)
return (0, 0);
uint effectiveId = actionManager->GetAdjustedActionId(actionId);
ushort maxCh = ActionManager.GetMaxCharges(effectiveId, 0);
if (maxCh <= 1)
return (0, 0);
uint currentCh = actionManager->GetCurrentCharges(effectiveId);
return ((int)currentCh, (int)maxCh);
}
private static unsafe (int CooldownPercent, int SecondsLeft) GetSlotCooldown(RaptureHotbarModule.HotbarSlot* slot)
{
if (slot == null) return (0, 0);
var slotType = slot->ApparentSlotType;
uint actionId = slot->ApparentActionId;
if (actionId == 0) return GetSlotCooldownFromSlot(slot);
var actionManager = ActionManager.Instance();
if (actionManager == null) return GetSlotCooldownFromSlot(slot);
ActionType? actionType = slotType switch
{
RaptureHotbarModule.HotbarSlotType.Action => ActionType.Action,
RaptureHotbarModule.HotbarSlotType.GeneralAction => ActionType.GeneralAction,
RaptureHotbarModule.HotbarSlotType.PetAction => ActionType.PetAction,
RaptureHotbarModule.HotbarSlotType.CraftAction => ActionType.CraftAction,
RaptureHotbarModule.HotbarSlotType.Item => ActionType.Item,
_ => null
};
if (actionType.HasValue)
{
// GetAdjustedActionId resolves Continuation, Egi Assaults, etc. Only applies to Action type
uint effectiveId = actionType.Value == ActionType.Action
? actionManager->GetAdjustedActionId(actionId)
: actionId;
float total = actionManager->GetRecastTime(actionType.Value, effectiveId);
float elapsed = actionManager->GetRecastTimeElapsed(actionType.Value, effectiveId);
if (total > 0.001f && elapsed < total)
{
float remaining = total - elapsed;
int pct = (int)Math.Clamp((remaining / total) * 100f, 0, 100);
int secsLeft = (int)Math.Ceiling(remaining);
return (pct, secsLeft);
}
// Cooldown complete (elapsed >= total). Return 0 — do NOT fall through to GetSlotCooldownFromSlot,
// as the slot can report a stale/false cooldown (e.g. shared recast group or cached state).
if (total > 0.001f)
return (0, 0);
}
return GetSlotCooldownFromSlot(slot);
}
private static unsafe (int CooldownPercent, int SecondsLeft) GetSlotCooldownFromSlot(RaptureHotbarModule.HotbarSlot* slot)
{
if (slot == null) return (0, 0);
int secsLeft = 0;
int pct = slot->GetSlotActionCooldownPercentage(&secsLeft, 0);
return (pct, secsLeft);
}
/// <summary>
/// Returns the default game keybind label for a hotbar slot (Hotbar 1: 1,2,...,0,-,=; Bar 2: Ctrl+1..12; etc.).
/// hotbarIndex 110, slotIndex 011. Used to mirror the default hotbar keybind display.
/// </summary>
public static string GetDefaultKeybindLabel(int hotbarIndex, int slotIndex)
{
int s = Math.Clamp(slotIndex, 0, 11);
string k = s switch
{
0 => "1", 1 => "2", 2 => "3", 3 => "4", 4 => "5", 5 => "6",
6 => "7", 7 => "8", 8 => "9", 9 => "0", 10 => "-", 11 => "=",
_ => (s + 1).ToString()
};
int b = Math.Clamp(hotbarIndex, 1, 10);
return b switch
{
1 => k,
2 => "Ctrl+" + k,
3 => "Shift+" + k,
4 => "Alt+" + k,
_ => $"{b}-{s + 1}"
};
}
/// <summary>
/// Execute a hotbar slot. hotbarIndex 1-10, slotIndex 0-based.
/// </summary>
public unsafe bool ExecuteSlot(int hotbarIndex, int slotIndex)
{
var module = RaptureHotbarModule.Instance();
if (module == null || !module->ModuleReady)
return false;
int barIdx = Math.Clamp(hotbarIndex, 1, 10) - 1;
int slot = Math.Clamp(slotIndex, 0, 11);
module->ExecuteSlotById((uint)barIdx, (uint)slot);
return true;
}
/// <summary>
/// Swap two hotbar slots. Supports cross-hotbar swap when hotbarA != hotbarB.
/// Uses CommandType/CommandId from the slot (not Apparent*) so items and macros swap correctly.
/// hotbarIndex 1-10, slot indices 0-based.
/// </summary>
/// <param name="debugLog">When non-null, receives diagnostic info for logging.</param>
public unsafe bool SwapSlots(int hotbarA, int slotA, int hotbarB, int slotB, Action<string>? debugLog = null)
{
var module = RaptureHotbarModule.Instance();
if (module == null || !module->ModuleReady)
{
debugLog?.Invoke($"[SwapSlots] FAIL: module null or not ready");
return false;
}
int barA = Math.Clamp(hotbarA, 1, 10);
int barB = Math.Clamp(hotbarB, 1, 10);
int a = Math.Clamp(slotA, 0, 11);
int b = Math.Clamp(slotB, 0, 11);
if (barA == barB && a == b)
{
debugLog?.Invoke($"[SwapSlots] NOOP: same slot bar={barA} slot={a}");
return true;
}
var slotPtrA = module->GetSlotById((uint)(barA - 1), (uint)a);
var slotPtrB = module->GetSlotById((uint)(barB - 1), (uint)b);
if (slotPtrA == null || slotPtrB == null)
{
debugLog?.Invoke($"[SwapSlots] FAIL: slotPtr null A={slotPtrA != null} B={slotPtrB != null}");
return false;
}
var typeA = slotPtrA->IsEmpty ? RaptureHotbarModule.HotbarSlotType.Empty : slotPtrA->CommandType;
var idA = slotPtrA->IsEmpty ? 0u : slotPtrA->CommandId;
var typeB = slotPtrB->IsEmpty ? RaptureHotbarModule.HotbarSlotType.Empty : slotPtrB->CommandType;
var idB = slotPtrB->IsEmpty ? 0u : slotPtrB->CommandId;
debugLog?.Invoke($"[SwapSlots] BEFORE: A(bar{barA} slot{a}) type={typeA} id={idA} | B(bar{barB} slot{b}) type={typeB} id={idB}");
// Update live slots first (like game drag-drop), then persist
slotPtrA->Set(typeB, idB);
slotPtrA->LoadIconId();
if (typeB == RaptureHotbarModule.HotbarSlotType.Item)
slotPtrA->LoadCostDataForSlot(true);
slotPtrB->Set(typeA, idA);
slotPtrB->LoadIconId();
if (typeA == RaptureHotbarModule.HotbarSlotType.Item)
slotPtrB->LoadCostDataForSlot(true);
SetAndSaveSlotInternal(module, (uint)(barA - 1), (uint)a, typeB, idB, slotPtrA);
SetAndSaveSlotInternal(module, (uint)(barB - 1), (uint)b, typeA, idA, slotPtrB);
// Read back to verify
slotPtrA = module->GetSlotById((uint)(barA - 1), (uint)a);
slotPtrB = module->GetSlotById((uint)(barB - 1), (uint)b);
if (slotPtrA != null && slotPtrB != null)
{
var afterA = slotPtrA->IsEmpty ? "empty" : $"{slotPtrA->CommandType} id={slotPtrA->CommandId}";
var afterB = slotPtrB->IsEmpty ? "empty" : $"{slotPtrB->CommandType} id={slotPtrB->CommandId}";
debugLog?.Invoke($"[SwapSlots] AFTER: A(bar{barA} slot{a}) {afterA} | B(bar{barB} slot{b}) {afterB}");
}
return true;
}
/// <summary>Dump HotbarSlot and RaptureMacroModule.Macro memory for a slot (for macro persistence debugging).</summary>
/// <param name="hotbarIndex">1-10</param>
/// <param name="slotIndex">0-11</param>
public unsafe void DumpMacroSlotMemoryToLog(int hotbarIndex, int slotIndex)
{
var hotbarModule = RaptureHotbarModule.Instance();
if (hotbarModule == null || !hotbarModule->ModuleReady)
{
Plugin.Logger.Information("[HSUI Macro DBG] RaptureHotbarModule not ready");
return;
}
int barIdx = Math.Clamp(hotbarIndex, 1, 10) - 1;
int slot = Math.Clamp(slotIndex, 0, 11);
var slotPtr = hotbarModule->GetSlotById((uint)barIdx, (uint)slot);
if (slotPtr == null)
{
Plugin.Logger.Information($"[HSUI Macro DBG] Bar {hotbarIndex} slot {slotIndex}: slot is null");
return;
}
var ct = slotPtr->CommandType;
var cid = slotPtr->CommandId;
Plugin.Logger.Information($"[HSUI Macro DBG] Bar {hotbarIndex} slot {slotIndex}: CommandType={ct} CommandId={cid} ApparentActionId={slotPtr->ApparentActionId} ApparentSlotType={slotPtr->ApparentSlotType} IconId={slotPtr->IconId}");
if (ct == RaptureHotbarModule.HotbarSlotType.Macro && cid is >= 1 and <= 200)
{
byte macroSet = (byte)((cid - 1) / 100);
byte macroIdx0Based = (byte)((cid - 1) % 100);
uint macroIdx1Based = (uint)(macroIdx0Based + 1); // GetMacro expects 1-based index
var macroModule = RaptureMacroModule.Instance();
if (macroModule != null)
{
var macro = macroModule->GetMacro(macroSet, macroIdx1Based);
if (macro != null)
{
string name = macro->Name.ToString() ?? "(null)";
Plugin.Logger.Information($"[HSUI Macro DBG] RaptureMacroModule.Macro set={macroSet} idx1Based={macroIdx1Based}: IconId={macro->IconId} MacroIconRowId={macro->MacroIconRowId} Name='{name}'");
// Raw byte dump (first 0x80 bytes: IconId, MacroIconRowId, start of Name/Utf8String)
var sb = new StringBuilder();
var ptr = (byte*)macro;
for (int i = 0; i < 0x80 && i < 0x688; i += 16)
{
sb.Clear();
sb.Append($"[HSUI Macro DBG] Macro+0x{i:X3}:");
for (int j = 0; j < 16 && i + j < 0x80; j++)
sb.Append($" {(ptr[i + j]):X2}");
Plugin.Logger.Information(sb.ToString());
}
// Try AddonMacro _macroName (offset 0x798, Utf8String=0x68 each, 100 entries)
try
{
var addonAddr = Plugin.GameGui?.GetAddonByName("Macro", 1).Address ?? IntPtr.Zero;
if (addonAddr != IntPtr.Zero)
{
var addon = (AddonMacro*)addonAddr;
int utf8Size = 0x68;
int nameBase = 0x798;
var namePtr = (Utf8String*)((byte*)addon + nameBase + macroIdx0Based * utf8Size);
string addonName = namePtr->ToString() ?? "(null)";
Plugin.Logger.Information($"[HSUI Macro DBG] AddonMacro._macroName[{macroIdx0Based}]: '{addonName}'");
}
else
Plugin.Logger.Information("[HSUI Macro DBG] AddonMacro not found or not open");
}
catch (Exception ex)
{
Plugin.Logger.Information($"[HSUI Macro DBG] AddonMacro read failed: {ex.Message}");
}
// Try AgentMacro SelectedMacroSet/Index (only relevant when that macro is selected)
try
{
var agentModule = FFXIVClientStructs.FFXIV.Client.UI.Agent.AgentModule.Instance();
if (agentModule != null)
{
var macroAgent = agentModule->GetAgentByInternalId(FFXIVClientStructs.FFXIV.Client.UI.Agent.AgentId.Macro);
if (macroAgent != null)
{
var agentMacro = (FFXIVClientStructs.FFXIV.Client.UI.Agent.AgentMacro*)macroAgent;
Plugin.Logger.Information($"[HSUI Macro DBG] AgentMacro SelectedMacroSet={agentMacro->SelectedMacroSet} SelectedMacroIndex={agentMacro->SelectedMacroIndex}");
if (agentMacro->SelectedMacroSet == macroSet && agentMacro->SelectedMacroIndex == macroIdx1Based)
{
string rawStr = agentMacro->RawMacroString.ToString() ?? "(null)";
Plugin.Logger.Information($"[HSUI Macro DBG] AgentMacro (matches): RawMacroString(len={rawStr.Length})='{(rawStr.Length > 60 ? rawStr[..60] + "..." : rawStr)}'");
}
}
else
Plugin.Logger.Information("[HSUI Macro DBG] AgentMacro agent is null");
}
else
Plugin.Logger.Information("[HSUI Macro DBG] AgentModule.Instance() is null");
}
catch (Exception ex)
{
Plugin.Logger.Information($"[HSUI Macro DBG] AgentMacro read failed: {ex.Message}");
}
}
else
Plugin.Logger.Information($"[HSUI Macro DBG] RaptureMacroModule.GetMacro({macroSet},{macroIdx1Based}) returned null");
}
else
Plugin.Logger.Information("[HSUI Macro DBG] RaptureMacroModule.Instance() is null");
}
}
/// <summary>Dump AddonMacro and AgentMacro state (open Macro menu first for best data).</summary>
public unsafe void DumpMacroMenuToLog()
{
Plugin.Logger.Information("[HSUI MacroMenu DBG] === Macro menu debug ===");
// AddonMacro
try
{
var addonAddr = Plugin.GameGui?.GetAddonByName("Macro", 1).Address ?? IntPtr.Zero;
if (addonAddr != IntPtr.Zero)
{
var addon = (AddonMacro*)addonAddr;
Plugin.Logger.Information($"[HSUI MacroMenu DBG] AddonMacro: SelectedPage={addon->SelectedPage} SelectedMacroIndex={addon->SelectedMacroIndex} DefaultIcon={addon->DefaultIcon}");
const int utf8Size = 0x68;
const int nameBase = 0x798;
const int iconBase = 0x604;
const int createdBase = 0x3038;
for (int i = 0; i < 10; i++)
{
var namePtr = (Utf8String*)((byte*)addon + nameBase + i * utf8Size);
int icon = *(int*)((byte*)addon + iconBase + i * 4);
bool created = *((byte*)addon + createdBase + i) != 0;
string name = namePtr->ToString() ?? "(empty)";
Plugin.Logger.Information($"[HSUI MacroMenu DBG] AddonMacro[{i}]: Name='{name}' Icon={icon} Created={created}");
}
Plugin.Logger.Information("[HSUI MacroMenu DBG] ... (showing first 10 of 100)");
}
else
Plugin.Logger.Information("[HSUI MacroMenu DBG] AddonMacro not found - open Macro menu (Character Config > Hotbars > Macros) first");
}
catch (Exception ex)
{
Plugin.Logger.Information($"[HSUI MacroMenu DBG] AddonMacro failed: {ex.Message}");
}
// AgentMacro
try
{
var agentModule = FFXIVClientStructs.FFXIV.Client.UI.Agent.AgentModule.Instance();
if (agentModule != null)
{
var macroAgent = agentModule->GetAgentByInternalId(FFXIVClientStructs.FFXIV.Client.UI.Agent.AgentId.Macro);
if (macroAgent != null)
{
var agent = (FFXIVClientStructs.FFXIV.Client.UI.Agent.AgentMacro*)macroAgent;
Plugin.Logger.Information($"[HSUI MacroMenu DBG] AgentMacro: SelectedMacroSet={agent->SelectedMacroSet} (0=Individual,1=Shared) SelectedMacroIndex={agent->SelectedMacroIndex}");
string raw = agent->RawMacroString.ToString() ?? "(null)";
string parsed = agent->ParsedMacroString.ToString() ?? "(null)";
Plugin.Logger.Information($"[HSUI MacroMenu DBG] RawMacroString(len={raw.Length}): '{(raw.Length > 80 ? raw[..80] + "..." : raw)}'");
Plugin.Logger.Information($"[HSUI MacroMenu DBG] ParsedMacroString(len={parsed.Length}): '{(parsed.Length > 80 ? parsed[..80] + "..." : parsed)}'");
Plugin.Logger.Information($"[HSUI MacroMenu DBG] MacroIconCount={agent->MacroIconCount}");
var clip = &agent->ClipboardMacro;
string clipName = clip->Name.ToString() ?? "(null)";
Plugin.Logger.Information($"[HSUI MacroMenu DBG] ClipboardMacro: IconId={clip->IconId} MacroIconRowId={clip->MacroIconRowId} Name='{clipName}'");
var rm = RaptureMacroModule.Instance();
if (rm != null)
{
var selectedMacro = rm->GetMacro(agent->SelectedMacroSet, agent->SelectedMacroIndex);
if (selectedMacro != null)
{
string rName = selectedMacro->Name.ToString() ?? "(null)";
Plugin.Logger.Information($"[HSUI MacroMenu DBG] RaptureMacroModule.GetMacro({agent->SelectedMacroSet},{agent->SelectedMacroIndex}): IconId={selectedMacro->IconId} MacroIconRowId={selectedMacro->MacroIconRowId} Name='{rName}'");
}
else
Plugin.Logger.Information($"[HSUI MacroMenu DBG] RaptureMacroModule.GetMacro returned null");
}
}
else
Plugin.Logger.Information("[HSUI MacroMenu DBG] AgentMacro agent is null");
}
else
Plugin.Logger.Information("[HSUI MacroMenu DBG] AgentModule.Instance() is null");
}
catch (Exception ex)
{
Plugin.Logger.Information($"[HSUI MacroMenu DBG] AgentMacro failed: {ex.Message}");
}
Plugin.Logger.Information("[HSUI MacroMenu DBG] === end ===");
}
/// <summary>Dump all hotbar slot CommandType/CommandId to the log for SwapSlots diagnosis.</summary>
public unsafe void DumpSlotStateToLog()
{
var module = RaptureHotbarModule.Instance();
if (module == null || !module->ModuleReady) { Plugin.Logger.Information("[HSUI HotbarSlots] Module not ready"); return; }
for (int bar = 1; bar <= 10; bar++)
{
var sb = new System.Text.StringBuilder();
for (int s = 0; s < 12; s++)
{
var slot = module->GetSlotById((uint)(bar - 1), (uint)s);
if (slot == null) { sb.Append("? "); continue; }
if (slot->IsEmpty) { sb.Append("-- "); continue; }
sb.Append($"{slot->CommandType}:{slot->CommandId} ");
}
Plugin.Logger.Information($"[HSUI HotbarSlots] Bar {bar}: {sb}");
}
}
/// <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();
if (module == null || !module->ModuleReady) return false;
int barIdx = Math.Clamp(hotbarIndex, 1, 10) - 1;
int slot = Math.Clamp(slotIndex, 0, 11);
var slotPtr = module->GetSlotById((uint)barIdx, (uint)slot);
if (slotPtr != null)
{
slotPtr->Set(RaptureHotbarModule.HotbarSlotType.Empty, 0);
slotPtr->LoadIconId();
}
SetAndSaveSlotInternal(module, (uint)barIdx, (uint)slot, RaptureHotbarModule.HotbarSlotType.Empty, 0, slotPtr);
return true;
}
/// <summary>Sets a slot and persists to disk. For shared hotbars, explicitly writes to classJobId 0.
/// For job-specific hotbars, explicitly writes to current class job ID to ensure persistence when switching jobs.</summary>
public static unsafe void SetAndSaveSlotInternal(RaptureHotbarModule* module, uint barId, uint slotId, RaptureHotbarModule.HotbarSlotType slotType, uint commandId, RaptureHotbarModule.HotbarSlot* slotPtr = null)
{
var ptr = slotPtr != null ? slotPtr : module->GetSlotById(barId, slotId);
if (ptr == null) return;
if (module->IsHotbarShared(barId))
{
// Shared hotbars: explicitly write to classJobId 0 (shared storage) for persistence across job changes and teleports
module->WriteSavedSlot(0, barId, slotId, ptr, false, false);
}
else
{
// Job-specific hotbars: explicitly write to current class job ID so saves persist when switching jobs
uint classJobId = (uint)(module->ActiveHotbarClassJobId & 0x7F); // strip 0x80 flag if set
if (classJobId == 0)
{
var player = Plugin.ObjectTable?.LocalPlayer;
if (player != null)
classJobId = player.ClassJob.RowId;
}
if (classJobId != 0)
module->WriteSavedSlot(classJobId, barId, slotId, ptr, false, false);
}
// SetAndSaveSlot updates live slot and triggers file save
module->SetAndSaveSlot(barId, slotId, slotType, commandId, ignoreSharedHotbars: false, allowSaveToPvP: true);
}
}
}