Initial release: HSUI v1.0.0.0 - HUD replacement with configurable hotbars

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-01-30 23:52:46 -05:00
commit f37369cdda
202 changed files with 40137 additions and 0 deletions
+805
View File
@@ -0,0 +1,805 @@
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Memory;
using HSUI.Config;
using HSUI.Helpers;
using FFXIVClientStructs.FFXIV.Client.Game.Group;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Client.UI.Info;
using Lumina.Excel.Sheets;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using FFXIVClientStructs.FFXIV.Client.UI.Arrays;
using FFXIVClientStructs.FFXIV.Component.GUI;
using static FFXIVClientStructs.FFXIV.Client.Game.Group.GroupManager;
using DalamudPartyMember = Dalamud.Game.ClientState.Party.IPartyMember;
using StructsPartyMember = FFXIVClientStructs.FFXIV.Client.Game.Group.PartyMember;
namespace HSUI.Interface.Party
{
public delegate void PartyMembersChangedEventHandler(PartyManager sender);
public unsafe class PartyManager : IDisposable
{
#region Singleton
public static PartyManager Instance { get; private set; } = null!;
private PartyFramesConfig _config = null!;
private PartyFramesIconsConfig _iconsConfig = null!;
private PartyManager()
{
_readyCheckHelper = new PartyReadyCheckHelper();
_raiseTracker = new PartyFramesRaiseTracker();
_invulnTracker = new PartyFramesInvulnTracker();
_cleanseTracker = new PartyFramesCleanseTracker();
ConfigurationManager.Instance.ResetEvent += OnConfigReset;
OnConfigReset(ConfigurationManager.Instance);
UpdatePreview();
// find offline string for active language
if (Plugin.DataManager.GetExcelSheet<Addon>().TryGetRow(9836, out Addon row))
{
_offlineString = row.Text.ToString();
}
}
public static void Initialize()
{
Instance = new PartyManager();
}
~PartyManager()
{
Dispose(false);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected void Dispose(bool disposing)
{
if (!disposing)
{
return;
}
_readyCheckHelper.Dispose();
_raiseTracker.Dispose();
_invulnTracker.Dispose();
_cleanseTracker.Dispose();
_config.ValueChangeEvent -= OnConfigPropertyChanged;
Instance = null!;
}
private void OnConfigReset(ConfigurationManager sender)
{
if (_config != null)
{
_config.ValueChangeEvent -= OnConfigPropertyChanged;
}
_config = sender.GetConfigObject<PartyFramesConfig>();
_config.ValueChangeEvent += OnConfigPropertyChanged;
_iconsConfig = ConfigurationManager.Instance.GetConfigObject<PartyFramesIconsConfig>();
}
#endregion Singleton
public AddonPartyList* PartyListAddon { get; private set; } = null;
public IntPtr HudAgent { get; private set; } = IntPtr.Zero;
private RaptureAtkModule* _raptureAtkModule = null;
private const int PartyListInfoOffset = 0x0D40;
private const int PartyListMemberRawInfoSize = 0x28;
private const int PartyMembersInfoIndex = 12; // TODO: Should be reworked to use PartyMemberListStringArray.Instance()
private List<IPartyFramesMember> _groupMembers = new List<IPartyFramesMember>();
public IReadOnlyCollection<IPartyFramesMember> GroupMembers => _groupMembers.AsReadOnly();
private List<IPartyFramesMember> _sortedGroupMembers = new List<IPartyFramesMember>();
public IReadOnlyCollection<IPartyFramesMember> SortedGroupMembers => _sortedGroupMembers.AsReadOnly();
public uint MemberCount => (uint)_groupMembers.Count;
private string? _partyTitle = null;
public string PartyTitle => _partyTitle ?? "";
private int _groupMemberCount => GroupManager.Instance()->MainGroup.MemberCount;
private int _realMemberCount => PartyListAddon != null ? PartyListAddon->MemberCount : Plugin.PartyList.Length;
private int _realMemberAndChocoboCount => PartyListAddon != null ? PartyListAddon->MemberCount + Math.Max(1, (int)PartyListAddon->ChocoboCount) : Plugin.PartyList.Length;
private Dictionary<string, InternalMemberData> _prevDataMap = new();
private bool _wasRealGroup = false;
private bool _wasCrossWorld = false;
private InfoProxyCrossRealm* _crossRealmInfo => InfoProxyCrossRealm.Instance();
private Group _mainGroup => GroupManager.Instance()->MainGroup;
private string _offlineString = "offline";
private PartyReadyCheckHelper _readyCheckHelper;
private PartyFramesRaiseTracker _raiseTracker;
private PartyFramesInvulnTracker _invulnTracker;
private PartyFramesCleanseTracker _cleanseTracker;
public event PartyMembersChangedEventHandler? MembersChangedEvent;
public bool Previewing => _config.Preview;
public bool IsSoloParty()
{
if (!_config.ShowWhenSolo) { return false; }
return _groupMembers.Count <= 1 ||
(_groupMembers.Count == 2 && _config.ShowChocobo &&
_groupMembers[1].Character is IBattleNpc npc && npc.BattleNpcKind == BattleNpcSubKind.Chocobo);
}
public void Update()
{
// find party list hud agent
PartyListAddon = (AddonPartyList*)Plugin.GameGui.GetAddonByName("_PartyList", 1).Address;
HudAgent = Plugin.GameGui.FindAgentInterface(PartyListAddon);
if (PartyListAddon == null || HudAgent == IntPtr.Zero)
{
if (_groupMembers.Count > 0)
{
_groupMembers.Clear();
MembersChangedEvent?.Invoke(this);
}
return;
}
_raptureAtkModule = RaptureAtkModule.Instance();
// no need to update on preview mode
if (_config.Preview)
{
return;
}
InternalUpdate();
}
private void InternalUpdate()
{
IPlayerCharacter? player = Plugin.ObjectTable.LocalPlayer;
if (player is null || player is not IPlayerCharacter)
{
return;
}
bool isCrossWorld = IsCrossWorldParty();
// ready check update
if (_iconsConfig.ReadyCheckStatus.Enabled)
{
_readyCheckHelper.Update(_iconsConfig.ReadyCheckStatus.Duration);
}
try
{
// title
_partyTitle = GetPartyListTitle();
// solo
if (_realMemberCount <= 1 && PartyListAddon->TrustCount == 0)
{
if (_config.ShowWhenSolo)
{
UpdateSoloParty(player);
}
else if (_groupMembers.Count > 0)
{
_groupMembers.Clear();
MembersChangedEvent?.Invoke(this);
}
_wasRealGroup = false;
}
else
{
// player maps
Dictionary<string, InternalMemberData> dataMap = GetMembersDataMap(player, isCrossWorld);
bool partyChanged = _prevDataMap.Count != dataMap.Count;
if (!partyChanged)
{
foreach (string key in dataMap.Keys)
{
InternalMemberData newData = dataMap[key];
if (!_prevDataMap.TryGetValue(key, out InternalMemberData? oldData) ||
oldData == null ||
newData.Order != oldData.Order)
{
partyChanged = true;
break;
}
}
}
_prevDataMap = dataMap;
// trust
if (PartyListAddon->TrustCount > 0)
{
UpdateTrustParty(player, dataMap, partyChanged);
}
// cross world party/alliance
else if (isCrossWorld)
{
UpdateCrossWorldParty(player, dataMap, partyChanged);
}
// regular party
else
{
UpdateRegularParty(player, dataMap, partyChanged);
}
_wasRealGroup = true;
}
UpdateTrackers();
}
catch (Exception e)
{
Plugin.Logger.Warning(e.Message);
}
_wasCrossWorld = isCrossWorld;
}
private Dictionary<string, InternalMemberData> GetMembersDataMap(IPlayerCharacter player, bool isCrossWorld)
{
Dictionary<string, InternalMemberData> dataMap = new Dictionary<string, InternalMemberData>();
if (_raptureAtkModule == null || _raptureAtkModule->AtkModule.AtkArrayDataHolder.StringArrayCount <= PartyMembersInfoIndex)
{
return dataMap;
}
// raw info
int allianceNum = FindAlliance(player);
int count = isCrossWorld ? _crossRealmInfo->CrossRealmGroups[allianceNum].GroupMemberCount : _realMemberCount + PartyListAddon->TrustCount;
for (int i = 0; i < count; i++)
{
InternalMemberData data = new InternalMemberData();
data.Index = i;
if (!isCrossWorld)
{
PartyListMemberRawInfo* info = (PartyListMemberRawInfo*)(HudAgent + (PartyListInfoOffset + PartyListMemberRawInfoSize * i));
data.ObjectId = info->ObjectId;
data.ContentId = info->ContentId;
data.Name = info->Name;
data.Order = info->Order;
}
else
{
CrossRealmMember member = _crossRealmInfo->CrossRealmGroups[allianceNum].GroupMembers[i];
data.ObjectId = member.EntityId;
data.ContentId = (long)member.ContentId;
data.Name = member.NameString;
data.Order = i;
}
if (!dataMap.ContainsKey(data.Name))
{
dataMap.Add(data.Name, data);
}
}
// status string
var stringArrayData = _raptureAtkModule->AtkModule.AtkArrayDataHolder.StringArrays[PartyMembersInfoIndex];
for (int i = 0; i < count; i++)
{
int index = i * 5;
if (stringArrayData->AtkArrayData.Size <= index + 3 ||
stringArrayData->StringArray[index] == null ||
stringArrayData->StringArray[index + 3] == null) { break; }
IntPtr ptr = new IntPtr(stringArrayData->StringArray[index]);
string name = MemoryHelper.ReadSeStringNullTerminated(ptr).ToString();
ptr = new IntPtr(stringArrayData->StringArray[index + 3]);
string a = MemoryHelper.ReadSeStringNullTerminated(ptr).ToString();
if (dataMap.TryGetValue(name, out InternalMemberData? data) && data != null)
{
data.Status = MemoryHelper.ReadSeStringNullTerminated(ptr).ToString();
}
}
return dataMap;
}
private bool IsCrossWorldParty()
{
return _crossRealmInfo->IsCrossRealm && _crossRealmInfo->GroupCount > 0 && _mainGroup.MemberCount == 0;
}
private ReadyCheckStatus GetReadyCheckStatus(ulong contentId)
{
return _readyCheckHelper.GetStatusForContentId(contentId);
}
private void UpdateTrustParty(IPlayerCharacter player, Dictionary<string, InternalMemberData> dataMap, bool forced)
{
bool softUpdate = true;
if (_groupMembers.Count != dataMap.Count || forced)
{
_groupMembers.Clear();
softUpdate = false;
}
if (softUpdate)
{
foreach (IPartyFramesMember member in _groupMembers)
{
if (member.ObjectId == player.GameObjectId)
{
member.Update(EnmityForIndex(member.Index), PartyMemberStatus.None, ReadyCheckStatus.None, true, player.ClassJob.RowId);
}
else
{
member.Update(EnmityForTrustMemberIndex(member.Index - 1), PartyMemberStatus.None, ReadyCheckStatus.None, false, 0);
}
}
}
else
{
string[] keys = dataMap.Keys.ToArray();
for (int i = 0; i < keys.Length; i++)
{
InternalMemberData data = dataMap[keys[i]];
if (keys[i] == player.Name.ToString())
{
PartyFramesMember playerMember = new PartyFramesMember(player, data.Index, data.Order, EnmityForIndex(i), PartyMemberStatus.None, ReadyCheckStatus.None, true);
_groupMembers.Add(playerMember);
}
else
{
ICharacter? trustChara = Utils.GetGameObjectByName(keys[i]) as ICharacter;
if (trustChara != null)
{
_groupMembers.Add(new PartyFramesMember(trustChara, data.Index, data.Order, EnmityForTrustMemberIndex(i), PartyMemberStatus.None, ReadyCheckStatus.None, false));
}
}
}
}
if (!softUpdate)
{
SortGroupMembers(player);
MembersChangedEvent?.Invoke(this);
}
}
private void UpdateSoloParty(IPlayerCharacter player)
{
ICharacter? chocobo = null;
if (_config.ShowChocobo)
{
var gameObject = Utils.GetBattleChocobo(player);
if (gameObject != null && gameObject is ICharacter)
{
chocobo = (ICharacter)gameObject;
}
}
bool needsUpdate =
_groupMembers.Count == 0 ||
(_groupMembers.Count != 2 && _config.ShowChocobo && chocobo != null) ||
(_groupMembers.Count > 1 && !_config.ShowChocobo) ||
(_groupMembers.Count > 1 && chocobo == null) ||
(_groupMembers.Count == 2 && _config.ShowChocobo && _groupMembers[1].ObjectId != chocobo?.EntityId);
EnmityLevel playerEnmity = PartyListAddon->EnmityLeaderIndex == 0 ? EnmityLevel.Leader : EnmityLevel.Last;
// for some reason chocobos never get a proper enmity value even though they have aggro
// if the player enmity is set to first, but the "leader index" is invalid
// we can pretty much deduce that the chocobo is the one with aggro
// this might fail on some cases when there are other players not in party hitting the same thing
// but the edge case is so minor we should be fine
EnmityLevel chocoboEnmity = PartyListAddon->EnmityLeaderIndex == -1 && PartyListAddon->PartyMembers[0].EmnityByte == 1 ? EnmityLevel.Leader : EnmityLevel.Last;
if (needsUpdate)
{
_groupMembers.Clear();
_groupMembers.Add(new PartyFramesMember(player, 0, 0, playerEnmity, PartyMemberStatus.None, ReadyCheckStatus.None, true));
if (chocobo != null)
{
_groupMembers.Add(new PartyFramesMember(chocobo, 1, 1, chocoboEnmity, PartyMemberStatus.None, ReadyCheckStatus.None, false));
}
SortGroupMembers(player);
MembersChangedEvent?.Invoke(this);
}
else
{
for (int i = 0; i < _groupMembers.Count; i++)
{
_groupMembers[i].Update(i == 0 ? playerEnmity : chocoboEnmity, PartyMemberStatus.None, ReadyCheckStatus.None, i == 0, i == 0 ? player.ClassJob.RowId : 0);
}
}
}
private void UpdateCrossWorldParty(IPlayerCharacter player, Dictionary<string, InternalMemberData> dataMap, bool forced)
{
bool softUpdate = true;
int allianceNum = FindAlliance(player);
int count = _crossRealmInfo->CrossRealmGroups[allianceNum].GroupMemberCount;
if (!_wasCrossWorld || count != _groupMembers.Count || forced)
{
_groupMembers.Clear();
softUpdate = false;
}
// create new members array with cross world data
for (int i = 0; i < count; i++)
{
CrossRealmMember member = _crossRealmInfo->CrossRealmGroups[allianceNum].GroupMembers[i];
string memberName = member.NameString;
if (!dataMap.TryGetValue(memberName, out InternalMemberData? data) || data == null)
{
continue;
}
bool isPlayer = member.EntityId == player.EntityId;
bool isLeader = member.IsPartyLeader;
PartyMemberStatus status = data.Status != null ? StatusForCrossWorldMember(data.Status) : PartyMemberStatus.None;
ReadyCheckStatus readyCheckStatus = GetReadyCheckStatus(member.ContentId);
if (softUpdate)
{
IPartyFramesMember groupMember = _groupMembers.ElementAt(i);
groupMember.Update(EnmityLevel.Last, status, readyCheckStatus, isLeader, member.ClassJobId);
}
else
{
PartyFramesMember partyMember = isPlayer ?
new PartyFramesMember(player, i, data.Order, EnmityLevel.Last, status, readyCheckStatus, isLeader) :
new PartyFramesMember(member, i, data.Order, status, readyCheckStatus, isLeader);
_groupMembers.Add(partyMember);
}
}
if (!softUpdate)
{
SortGroupMembers(player);
MembersChangedEvent?.Invoke(this);
}
}
private int FindAlliance(IPlayerCharacter player)
{
for (int i = 0; i < _crossRealmInfo->CrossRealmGroups.Length; i++)
{
for (int j = 0; j < _crossRealmInfo->CrossRealmGroups[i].GroupMemberCount; j++)
{
CrossRealmMember member = _crossRealmInfo->CrossRealmGroups[i].GroupMembers[j];
if (member.EntityId == player.EntityId)
{
return i;
}
}
}
return 0;
}
private void UpdateRegularParty(IPlayerCharacter player, Dictionary<string, InternalMemberData> dataMap, bool forced)
{
bool softUpdate = true;
if (!_wasRealGroup || _realMemberCount != _groupMembers.Count || forced)
{
_groupMembers.Clear();
softUpdate = false;
}
string[] keys = dataMap.Keys.ToArray();
for (int i = 0; i < keys.Length; i++)
{
if (!dataMap.TryGetValue(keys[i], out InternalMemberData? data) || data == null)
{
continue;
}
bool isPlayer = data.ObjectId == player.GameObjectId;
bool isLeader = IsPartyLeader(data.Order);
EnmityLevel enmity = EnmityForIndex(data.Index);
PartyMemberStatus status = data.Status != null ? StatusForMember(data.Status, data.Name) : PartyMemberStatus.None;
ReadyCheckStatus readyCheckStatus = GetReadyCheckStatus((ulong)data.ContentId);
if (softUpdate)
{
IPartyFramesMember groupMember = _groupMembers.ElementAt(i);
groupMember.Update(enmity, status, readyCheckStatus, isLeader);
}
else
{
PartyFramesMember partyMember;
var member = GetDalamudPartyMember(data.Name);
if (member.HasValue && member.Value.Item1 is DalamudPartyMember dalamudPartyMember)
{
partyMember = new PartyFramesMember(dalamudPartyMember, i, data.Order, enmity, status, readyCheckStatus, isLeader);
}
else
{
partyMember = new PartyFramesMember(data.ObjectId, i, data.Order, enmity, status, readyCheckStatus, isLeader);
}
_groupMembers.Add(partyMember);
}
}
// player's chocobo (always last)
if (_config.ShowChocobo)
{
IGameObject? companion = Utils.GetBattleChocobo(player);
if (softUpdate && _groupMembers.FirstOrDefault(o => o.IsChocobo) is PartyFramesMember chocoboMember)
{
if (companion is ICharacter)
{
chocoboMember.Update(EnmityLevel.Last, PartyMemberStatus.None, ReadyCheckStatus.None, false);
}
else
{
_groupMembers.Remove(chocoboMember);
}
}
else if (companion is ICharacter companionCharacter)
{
_groupMembers.Add(new PartyFramesMember(companionCharacter, _groupMemberCount, 10, EnmityLevel.Last, PartyMemberStatus.None, ReadyCheckStatus.None, false, true));
}
}
if (!softUpdate)
{
SortGroupMembers(player);
MembersChangedEvent?.Invoke(this);
}
}
private void SortGroupMembers(IPlayerCharacter? player = null)
{
_sortedGroupMembers.Clear();
_sortedGroupMembers.AddRange(_groupMembers);
_sortedGroupMembers.Sort((a, b) =>
{
if (a.Order == b.Order)
{
if (a.ObjectId == player?.GameObjectId)
{
return 1;
}
else if (b.ObjectId == player?.GameObjectId)
{
return -1;
}
return a.Name.CompareTo(b.Name);
}
if (a.Order < b.Order)
{
return -1;
}
return 1;
});
}
private (DalamudPartyMember?, int)? GetDalamudPartyMember(string name)
{
for (int i = 0; i < Plugin.PartyList.Length; i++)
{
DalamudPartyMember? member = Plugin.PartyList[i];
if (member != null && member.Name.ToString() == name)
{
return (member, i);
}
}
return null;
}
private void UpdateTrackers()
{
_raiseTracker.Update(_groupMembers);
_invulnTracker.Update(_groupMembers);
_cleanseTracker.Update(_groupMembers);
}
#region utils
private bool IsPartyLeader(int index)
{
if (PartyListAddon == null)
{
return false;
}
// we use the icon Y coordinate in the party list to know the index (lmao)
uint partyLeadIndex = (uint)PartyListAddon->LeaderMarkResNode->ChildNode->Y / 40;
return index == partyLeadIndex;
}
private PartyMemberStatus StatusForCrossWorldMember(string statusStr)
{
// offline status
if (statusStr.Contains(_offlineString, StringComparison.InvariantCultureIgnoreCase))
{
return PartyMemberStatus.Offline;
}
return PartyMemberStatus.None;
}
private PartyMemberStatus StatusForMember(string statusStr, string name)
{
// offline status
if (statusStr.Contains(_offlineString, StringComparison.InvariantCultureIgnoreCase))
{
return PartyMemberStatus.Offline;
}
// viewing cutscene status
for (int i = 0; i < _mainGroup.MemberCount; i++)
{
if (_mainGroup.PartyMembers[i].NameString == name)
{
if ((_mainGroup.PartyMembers[i].Flags & 0x10) != 0)
{
return PartyMemberStatus.ViewingCutscene;
}
break;
}
}
return PartyMemberStatus.None;
}
private EnmityLevel EnmityForIndex(int index)
{
if (PartyListAddon == null || index < 0 || index > 7)
{
return EnmityLevel.Last;
}
EnmityLevel enmityLevel = (EnmityLevel)PartyListAddon->PartyMembers[index].EmnityByte;
if (enmityLevel == EnmityLevel.Leader && PartyListAddon->EnmityLeaderIndex != index)
{
enmityLevel = EnmityLevel.Last;
}
return enmityLevel;
}
private EnmityLevel EnmityForTrustMemberIndex(int index)
{
if (PartyListAddon == null || index < 0 || index > 6)
{
return EnmityLevel.Last;
}
return (EnmityLevel)PartyListAddon->TrustMembers[index].EmnityByte;
}
private static unsafe string? GetPartyListTitle()
{
AgentModule* agentModule = AgentModule.Instance();
if (agentModule == null) { return ""; }
AgentHUD* agentHUD = agentModule->GetAgentHUD();
if (agentHUD == null) { return ""; }
Lumina.Excel.ExcelSheet<Addon> sheet = Plugin.DataManager.GetExcelSheet<Addon>();
if (sheet.TryGetRow(agentHUD->PartyTitleAddonId, out Addon row))
{
return row.Text.ToString();
}
return null;
}
#endregion
#region events
private void OnConfigPropertyChanged(object sender, OnChangeBaseArgs args)
{
if (args.PropertyName == "Preview")
{
UpdatePreview();
}
}
public void UpdatePreview()
{
_iconsConfig.Sign.Preview = _config.Preview;
if (!_config.Preview)
{
_groupMembers.Clear();
MembersChangedEvent?.Invoke(this);
return;
}
// fill list with fake members for UI testing
_groupMembers.Clear();
if (_config.Preview)
{
int count = FakePartyFramesMember.RNG.Next(4, 9);
for (int i = 0; i < count; i++)
{
_groupMembers.Add(new FakePartyFramesMember(i));
}
}
SortGroupMembers();
MembersChangedEvent?.Invoke(this);
}
#endregion
}
internal class InternalMemberData
{
internal uint ObjectId = 0;
internal long ContentId = 0;
internal string Name = "";
internal uint JobId = 0;
internal int Order = 0;
internal int Index = 0;
internal string? Status = null;
public InternalMemberData()
{
}
}
[StructLayout(LayoutKind.Explicit, Size = 28)]
public unsafe struct PartyListMemberRawInfo
{
[FieldOffset(0x00)] public byte* NamePtr;
[FieldOffset(0x08)] public long ContentId;
[FieldOffset(0x10)] public uint ObjectId;
// some kind of type
// 1 = player
// 2 = party member?
// 3 = unknown
// 4 = chocobo
// 5 = summon?
[FieldOffset(0x14)] public byte Type;
[FieldOffset(0x18)] public byte Order;
public string Name => Marshal.PtrToStringUTF8(new IntPtr(NamePtr)) ?? "";
}
}