PvP hotbar fix: load saved PvE on zone leave then use live bars; Show Action ID in tooltips

- On leaving PvP, LoadSavedHotbar for all 10 bars (via TryRestorePvEHotbarsAfterLeavePvP in Framework update) and re-apply for ~2s so live Hotbars show PvE
- GetSlotData always reads from live StandardHotbars so combo updates (e.g. Pictomancer) and icons work normally
- Misc: Show Action ID option in Misc -> Tooltips; hotbar/party cooldown tooltips pass TooltipIdKind for Action vs Status IDs

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-08 16:26:26 -05:00
parent 5ffbdd0b51
commit ccee580789
9 changed files with 186 additions and 102 deletions
+41 -8
View File
@@ -89,9 +89,48 @@ namespace HSUI.Helpers
return p; 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> /// <summary>
/// Reads hotbar slot data from RaptureHotbarModule. Returns up to slotCount slots. /// Reads hotbar slot data from RaptureHotbarModule. Returns up to slotCount slots.
/// hotbarIndex 1-10 maps to StandardHotbars 0-9. /// 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> /// </summary>
public unsafe List<SlotInfo> GetSlotData(int hotbarIndex, int slotCount) public unsafe List<SlotInfo> GetSlotData(int hotbarIndex, int slotCount)
{ {
@@ -100,11 +139,11 @@ namespace HSUI.Helpers
if (module == null || !module->ModuleReady) if (module == null || !module->ModuleReady)
return list; return list;
var hotbars = module->StandardHotbars;
int barIdx = Math.Clamp(hotbarIndex, 1, 10) - 1; int barIdx = Math.Clamp(hotbarIndex, 1, 10) - 1;
int count = Math.Clamp(slotCount, 1, 12); int count = Math.Clamp(slotCount, 1, 12);
var hotbars = module->StandardHotbars;
ref var bar = ref hotbars[barIdx]; ref var bar = ref hotbars[barIdx];
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
var slot = bar.GetHotbarSlot((uint)i); var slot = bar.GetHotbarSlot((uint)i);
@@ -116,7 +155,6 @@ namespace HSUI.Helpers
continue; 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; bool isEmpty = slot->IsEmpty && slot->CommandType != RaptureHotbarModule.HotbarSlotType.GearSet;
if (isEmpty) if (isEmpty)
{ {
@@ -124,7 +162,6 @@ namespace HSUI.Helpers
continue; 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 var slotType = slot->CommandType == RaptureHotbarModule.HotbarSlotType.GearSet
? RaptureHotbarModule.HotbarSlotType.GearSet ? RaptureHotbarModule.HotbarSlotType.GearSet
: slot->ApparentSlotType; : slot->ApparentSlotType;
@@ -132,13 +169,11 @@ namespace HSUI.Helpers
? slot->CommandId ? slot->CommandId
: slot->ApparentActionId; : slot->ApparentActionId;
// For GearSet slots, refresh IconId from the gearset (e.g. job icon from first equipment slot).
if (slotType == RaptureHotbarModule.HotbarSlotType.GearSet) if (slotType == RaptureHotbarModule.HotbarSlotType.GearSet)
slot->LoadIconId(); slot->LoadIconId();
bool usable = slot->IsSlotUsable(slotType, actionId); bool usable = slot->IsSlotUsable(slotType, actionId);
uint iconId = slot->IconId; 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) if (slotType == RaptureHotbarModule.HotbarSlotType.GearSet && iconId == 0)
{ {
int resolved = slot->GetIconIdForSlot(slotType, actionId); int resolved = slot->GetIconIdForSlot(slotType, actionId);
@@ -148,8 +183,6 @@ namespace HSUI.Helpers
(int pct, int secsLeft) = GetSlotCooldown(slot); (int pct, int secsLeft) = GetSlotCooldown(slot);
(int currentCharges, int maxCharges) = GetSlotCharges(slotType, actionId); (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; uint apparentCharges = slotType == RaptureHotbarModule.HotbarSlotType.Action ? slot->GetApparentIconRecastCharges() : 0;
if (maxCharges > 1 && (apparentCharges > 0 || currentCharges > 0)) if (maxCharges > 1 && (apparentCharges > 0 || currentCharges > 0))
usable = true; usable = true;
+23
View File
@@ -51,6 +51,8 @@ namespace HSUI.Helpers
_config = ConfigurationManager.Instance.GetConfigObject<WorldObjectTooltipConfig>(); _config = ConfigurationManager.Instance.GetConfigObject<WorldObjectTooltipConfig>();
private void OnFrameworkUpdate(IFramework framework) private void OnFrameworkUpdate(IFramework framework)
{
try
{ {
if (!_config.ActionChatLinkEnabled) return; if (!_config.ActionChatLinkEnabled) return;
@@ -97,6 +99,12 @@ namespace HSUI.Helpers
InsertOrCopyToChat(text); InsertOrCopyToChat(text);
_pendingActionId = 0; _pendingActionId = 0;
} }
catch (Exception ex)
{
_pendingActionId = 0;
Plugin.Logger.Warning($"[ActionChatLink] OnFrameworkUpdate: {ex.Message}");
}
}
/// <summary>Read action ID from AddonActionMenu. Tries ActionList and TraitList, HoveredItemIndex and HeldItemIndex.</summary> /// <summary>Read action ID from AddonActionMenu. Tries ActionList and TraitList, HoveredItemIndex and HeldItemIndex.</summary>
private static unsafe bool TryGetHoveredActionFromAddon(IntPtr addonAddress, out uint actionId) private static unsafe bool TryGetHoveredActionFromAddon(IntPtr addonAddress, out uint actionId)
@@ -104,6 +112,8 @@ namespace HSUI.Helpers
actionId = 0; actionId = 0;
if (addonAddress == IntPtr.Zero) return false; if (addonAddress == IntPtr.Zero) return false;
try
{
var addon = (AddonActionMenu*)addonAddress; var addon = (AddonActionMenu*)addonAddress;
byte* basePtr = (byte*)addonAddress; byte* basePtr = (byte*)addonAddress;
@@ -117,6 +127,11 @@ namespace HSUI.Helpers
if (actionId != 0) return true; if (actionId != 0) return true;
} }
} }
}
catch (Exception ex)
{
Plugin.Logger.Verbose($"[ActionChatLink] TryGetHoveredActionFromAddon: {ex.Message}");
}
return false; return false;
} }
@@ -124,6 +139,8 @@ namespace HSUI.Helpers
private static unsafe bool IsMouseOverActionMenu(IntPtr addonAddress) private static unsafe bool IsMouseOverActionMenu(IntPtr addonAddress)
{ {
if (addonAddress == IntPtr.Zero) return false; if (addonAddress == IntPtr.Zero) return false;
try
{
var addon = (AtkUnitBase*)addonAddress; var addon = (AtkUnitBase*)addonAddress;
var root = addon->RootNode; var root = addon->RootNode;
if (root == null || !addon->IsVisible) return false; if (root == null || !addon->IsVisible) return false;
@@ -132,6 +149,12 @@ namespace HSUI.Helpers
float x = root->ScreenX, y = root->ScreenY, w = root->Width, h = root->Height; float x = root->ScreenX, y = root->ScreenY, w = root->Width, h = root->Height;
return mp.X >= x && mp.X < x + w && mp.Y >= y && mp.Y < y + h; return mp.X >= x && mp.X < x + w && mp.Y >= y && mp.Y < y + h;
} }
catch (Exception ex)
{
Plugin.Logger.Verbose($"[ActionChatLink] IsMouseOverActionMenu: {ex.Message}");
return false;
}
}
private static string? GetActionName(uint actionId) private static string? GetActionName(uint actionId)
{ {
+10
View File
@@ -470,6 +470,11 @@ namespace HSUI.Helpers
public void OnFrameworkUpdate(IFramework framework) public void OnFrameworkUpdate(IFramework framework)
{ {
try
{
// When leaving PvP, force load PvE hotbars so HSUI bars don't keep showing PvP actions.
ActionBarsManager.TryRestorePvEHotbarsAfterLeavePvP();
// Keep WndProc hooked when: proxy mode (for mouseover) OR we need to block game drag // 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). // release (so dropping on HSUI action bar doesn't execute the ability).
bool needHook = IsProxyEnabled || ShouldBlockGameDragRelease(); bool needHook = IsProxyEnabled || ShouldBlockGameDragRelease();
@@ -483,6 +488,11 @@ namespace HSUI.Helpers
else if (!needHook && _wndProcPtr != IntPtr.Zero) else if (!needHook && _wndProcPtr != IntPtr.Zero)
RestoreWndProc(); RestoreWndProc();
} }
catch (Exception ex)
{
Plugin.Logger.Warning($"[HSUI InputsHelper] OnFrameworkUpdate: {ex.Message}");
}
}
private static bool ShouldBlockGameDragRelease() private static bool ShouldBlockGameDragRelease()
{ {
+7 -8
View File
@@ -42,18 +42,16 @@ namespace HSUI.Helpers
public double LastTick => LastTickTime; public double LastTick => LastTickTime;
private void FrameworkOnOnUpdateEvent(IFramework framework) private void FrameworkOnOnUpdateEvent(IFramework framework)
{
try
{ {
var player = Plugin.ObjectTable.LocalPlayer; var player = Plugin.ObjectTable.LocalPlayer;
if (player is null) if (player is null)
{
return; return;
}
var now = ImGui.GetTime(); var now = ImGui.GetTime();
if (now - LastUpdate < PollingRate) if (now - LastUpdate < PollingRate)
{
return; return;
}
LastUpdate = now; LastUpdate = now;
@@ -63,16 +61,17 @@ namespace HSUI.Helpers
var lucidDreamingActive = Utils.StatusListForBattleChara(player).Any(e => e.StatusId == 1204); var lucidDreamingActive = Utils.StatusListForBattleChara(player).Any(e => e.StatusId == 1204);
if (!lucidDreamingActive && _lastMpValue < mp) if (!lucidDreamingActive && _lastMpValue < mp)
{
LastTickTime = now; LastTickTime = now;
}
else if (LastTickTime + ServerTickRate <= now) else if (LastTickTime + ServerTickRate <= now)
{
LastTickTime += ServerTickRate; LastTickTime += ServerTickRate;
}
_lastMpValue = (int)mp; _lastMpValue = (int)mp;
} }
catch (Exception ex)
{
Plugin.Logger.Verbose($"[HSUI MpTickHelper] FrameworkOnOnUpdateEvent: {ex.Message}");
}
}
protected virtual void Dispose(bool disposing) protected virtual void Dispose(bool disposing)
{ {
+20 -4
View File
@@ -13,6 +13,14 @@ using System.Text;
namespace HSUI.Helpers namespace HSUI.Helpers
{ {
/// <summary>When showing an ID in the tooltip title, use Action for action IDs or Status for status effect IDs.</summary>
public enum TooltipIdKind
{
None = 0,
Action = 1,
Status = 2,
}
public class TooltipsHelper : IDisposable public class TooltipsHelper : IDisposable
{ {
#region Singleton #region Singleton
@@ -65,12 +73,12 @@ namespace HSUI.Helpers
private const float IconSize = 24f; private const float IconSize = 24f;
public void ShowTooltipOnCursor(string text, string? title = null, uint id = 0, string name = "", uint? iconId = null) public void ShowTooltipOnCursor(string text, string? title = null, uint id = 0, string name = "", uint? iconId = null, TooltipIdKind idKind = TooltipIdKind.None)
{ {
ShowTooltip(text, ImGui.GetMousePos(), title, id, name, iconId); ShowTooltip(text, ImGui.GetMousePos(), title, id, name, iconId, idKind);
} }
public void ShowTooltip(string text, Vector2 position, string? title = null, uint id = 0, string name = "", uint? iconId = null) public void ShowTooltip(string text, Vector2 position, string? title = null, uint id = 0, string name = "", uint? iconId = null, TooltipIdKind idKind = TooltipIdKind.None)
{ {
if (text == null) if (text == null)
{ {
@@ -99,7 +107,11 @@ namespace HSUI.Helpers
_currentTooltipTitle += $" ({name})"; _currentTooltipTitle += $" ({name})";
} }
if (_config.ShowStatusIDs) bool showId = id != 0 && (
(idKind == TooltipIdKind.Action && _config.ShowActionIDs) ||
(idKind == TooltipIdKind.Status && _config.ShowStatusIDs) ||
(idKind == TooltipIdKind.None && _config.ShowStatusIDs));
if (showId)
{ {
_currentTooltipTitle += " (ID: " + id + ")"; _currentTooltipTitle += " (ID: " + id + ")";
} }
@@ -407,6 +419,10 @@ namespace HSUI.Helpers
[Order(7)] [Order(7)]
public bool ShowStatusIDs = false; public bool ShowStatusIDs = false;
[Checkbox("Show Action ID", help = "Show action ID in hotbar and action tooltips.")]
[Order(8)]
public bool ShowActionIDs = false;
[Checkbox("Show Source Name")] [Checkbox("Show Source Name")]
[Order(10)] [Order(10)]
public bool ShowSourceName = false; public bool ShowSourceName = false;
+2 -2
View File
@@ -370,7 +370,7 @@ namespace HSUI.Interface.GeneralElements
if (!string.IsNullOrEmpty(title) || !string.IsNullOrEmpty(text)) if (!string.IsNullOrEmpty(title) || !string.IsNullOrEmpty(text))
{ {
string body = string.IsNullOrEmpty(text) ? title : text; string body = string.IsNullOrEmpty(text) ? title : text;
TooltipsHelper.Instance.ShowTooltipOnCursor(body, title, slot.ActionId, "", slot.IconId > 0 ? slot.IconId : null); TooltipsHelper.Instance.ShowTooltipOnCursor(body, title, slot.ActionId, "", slot.IconId > 0 ? slot.IconId : null, Helpers.TooltipIdKind.Action);
if (IsTooltipDebugEnabled()) if (IsTooltipDebugEnabled())
Plugin.Logger.Information($"[HSUI Tooltip DBG] ActionBar tooltip (main overlay): slot={i} title='{title}'"); Plugin.Logger.Information($"[HSUI Tooltip DBG] ActionBar tooltip (main overlay): slot={i} title='{title}'");
} }
@@ -429,7 +429,7 @@ namespace HSUI.Interface.GeneralElements
if (!string.IsNullOrEmpty(title) || !string.IsNullOrEmpty(text)) if (!string.IsNullOrEmpty(title) || !string.IsNullOrEmpty(text))
{ {
string body = string.IsNullOrEmpty(text) ? title : text; string body = string.IsNullOrEmpty(text) ? title : text;
TooltipsHelper.Instance.ShowTooltipOnCursor(body, title, slot.ActionId, "", slot.IconId > 0 ? slot.IconId : null); TooltipsHelper.Instance.ShowTooltipOnCursor(body, title, slot.ActionId, "", slot.IconId > 0 ? slot.IconId : null, Helpers.TooltipIdKind.Action);
if (IsTooltipDebugEnabled()) if (IsTooltipDebugEnabled())
Plugin.Logger.Information($"[HSUI Tooltip DBG] ActionBar tooltip (overlay): slot={i} title='{title}'"); Plugin.Logger.Information($"[HSUI Tooltip DBG] ActionBar tooltip (overlay): slot={i} title='{title}'");
} }
@@ -289,7 +289,8 @@ namespace HSUI.Interface.Party
hoveringCooldown.Data.Name, hoveringCooldown.Data.Name,
hoveringCooldown.Data.ActionId, hoveringCooldown.Data.ActionId,
"", "",
iconId iconId,
HSUI.Helpers.TooltipIdKind.Action
); );
} }
} }
@@ -310,7 +310,8 @@ namespace HSUI.Interface.PartyCooldowns
cooldown.Data.Name, cooldown.Data.Name,
cooldown.Data.ActionId, cooldown.Data.ActionId,
"", "",
iconId iconId,
HSUI.Helpers.TooltipIdKind.Action
); );
} }
@@ -474,7 +474,8 @@ namespace HSUI.Interface.StatusEffects
EncryptedStringsHelper.GetString(data.Data.Name.ToString()), EncryptedStringsHelper.GetString(data.Data.Name.ToString()),
data.Status.StatusId, data.Status.StatusId,
GetStatusActorName(data.Status), GetStatusActorName(data.Status),
iconId iconId,
HSUI.Helpers.TooltipIdKind.Status
); );
} }