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().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(); _config.ValueChangeEvent += OnConfigPropertyChanged; _iconsConfig = ConfigurationManager.Instance.GetConfigObject(); } #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 _groupMembers = new List(); public IReadOnlyCollection GroupMembers => _groupMembers.AsReadOnly(); private List _sortedGroupMembers = new List(); public IReadOnlyCollection 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 _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 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 GetMembersDataMap(IPlayerCharacter player, bool isCrossWorld) { Dictionary dataMap = new Dictionary(); 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 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 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 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 sheet = Plugin.DataManager.GetExcelSheet(); 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)) ?? ""; } }