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!;
}
///
/// Slot data for ImGui drawing. Updated each call from game state.
///
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; }
/// Keybind hint from game (user's keybind settings). Empty if unavailable.
public string KeybindHint { get; }
/// Current charges for charge-based actions; 0 when not applicable.
public int CurrentCharges { get; }
/// Max charges for charge-based actions; >1 only for actions with charges.
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;
}
}
///
/// Reads keybind hint from HotbarSlot. Uses _keybindHint (slot display) then _popUpKeybindHint (trimmed) as fallback.
/// Offsets match RaptureHotbarModule.HotbarSlot: _keybindHint 0xA8, _popUpKeybindHint 0x88.
///
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;
}
///
/// Reads hotbar slot data from RaptureHotbarModule. Returns up to slotCount slots.
/// hotbarIndex 1-10 maps to StandardHotbars 0-9.
///
public unsafe List GetSlotData(int hotbarIndex, int slotCount)
{
var list = new List(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;
}
if (slot->IsEmpty)
{
list.Add(new SlotInfo(0, true, false, 0, 0, 0, 0, keybind, 0, 0));
continue;
}
// For GearSet slots, refresh IconId from the gearset (e.g. job icon from first equipment slot).
// Only call for already-synced GearSet slots: calling LoadIconId() on other types or before the
// game has synced a just-dropped slot can prevent the drop from sticking.
if (slot->ApparentSlotType == RaptureHotbarModule.HotbarSlotType.GearSet)
slot->LoadIconId();
bool usable = slot->IsSlotUsable(slot->ApparentSlotType, slot->ApparentActionId);
uint iconId = slot->IconId;
uint actionId = slot->ApparentActionId;
var slotType = slot->ApparentSlotType;
(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;
}
///
/// 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.
///
///
/// Gets current and max charges for a slot. Only applies to Action type; returns (0,0) otherwise.
///
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);
}
///
/// Returns the default game keybind label for a hotbar slot (Hotbar 1: 1,2,...,0,-,=; Bar 2: Ctrl+1..12; etc.).
/// hotbarIndex 1–10, slotIndex 0–11. Used to mirror the default hotbar keybind display.
///
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}"
};
}
///
/// Execute a hotbar slot. hotbarIndex 1-10, slotIndex 0-based.
///
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;
}
///
/// 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.
///
/// When non-null, receives diagnostic info for logging.
public unsafe bool SwapSlots(int hotbarA, int slotA, int hotbarB, int slotB, Action? 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;
}
/// Dump HotbarSlot and RaptureMacroModule.Macro memory for a slot (for macro persistence debugging).
/// 1-10
/// 0-11
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");
}
}
/// Dump AddonMacro and AgentMacro state (open Macro menu first for best data).
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 ===");
}
/// Dump all hotbar slot CommandType/CommandId to the log for SwapSlots diagnosis.
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}");
}
}
/// Clear a hotbar slot. hotbarIndex 1-10, slotIndex 0-based.
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;
}
/// 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.
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);
}
}
}