Files
HSUI/Helpers/ActionBarsManager.cs
T
KnackAtNite 5ffbdd0b51 Release v1.0.8.9: Gearset persists on slot
- Do not treat slot as empty when CommandType is GearSet (CommandId 0 valid)
- Use CommandType/CommandId for GearSet display; resolve icon when IconId 0
- Bump version, changelog, pluginmaster

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 15:29:37 -05:00

598 lines
30 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 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>
/// Reads hotbar slot data from RaptureHotbarModule. Returns up to slotCount slots.
/// hotbarIndex 1-10 maps to StandardHotbars 0-9.
/// </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;
var hotbars = module->StandardHotbars;
int barIdx = Math.Clamp(hotbarIndex, 1, 10) - 1;
int count = Math.Clamp(slotCount, 1, 12);
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;
}
// GearSet with id 0 is valid (first gearset); the game's IsEmpty (CommandId == 0) would wrongly treat it as empty.
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;
}
// Use CommandType/CommandId for GearSet so we handle gearset 0 and slots not yet synced to Apparent*.
var slotType = slot->CommandType == RaptureHotbarModule.HotbarSlotType.GearSet
? RaptureHotbarModule.HotbarSlotType.GearSet
: slot->ApparentSlotType;
uint actionId = slot->CommandType == RaptureHotbarModule.HotbarSlotType.GearSet
? slot->CommandId
: slot->ApparentActionId;
// For GearSet slots, refresh IconId from the gearset (e.g. job icon from first equipment slot).
if (slotType == RaptureHotbarModule.HotbarSlotType.GearSet)
slot->LoadIconId();
bool usable = slot->IsSlotUsable(slotType, actionId);
uint iconId = slot->IconId;
// GearSet 0 or just-dropped: game may not have synced Apparent* so IconId can be 0; resolve for display.
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);
// For charge-based actions, don't grey out the icon until all charges are spent.
// Use both the slot's recast-charge count and ActionManager so we catch all cases.
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>
/// 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>Clear a hotbar slot. hotbarIndex 1-10, slotIndex 0-based.</summary>
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);
}
}
}