using System.Diagnostics; using System.Globalization; using System.Text; using Dalamud.Game.Addon.Lifecycle; using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.Gui.ContextMenu; using Dalamud.Game.Text; using Dalamud.IoC; using Dalamud.Memory; using Dalamud.Plugin; using Dalamud.Plugin.Services; using Dalamud.Utility.Signatures; using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.Game.Group; using FFXIVClientStructs.FFXIV.Client.Game.Object; using FFXIVClientStructs.FFXIV.Client.Game.UI; using FFXIVClientStructs.FFXIV.Client.Graphics; using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Client.UI.Agent; using FFXIVClientStructs.FFXIV.Client.UI.Info; using FFXIVClientStructs.FFXIV.Component.GUI; using FFXIVClientStructs.Interop; using Lumina.Excel.GeneratedSheets; namespace PartyDamage; public class Plugin : IDalamudPlugin { [PluginService] internal static IPluginLog Log { get; private set; } [PluginService] private IAddonLifecycle AddonLifecycle { get; init; } [PluginService] private IClientState ClientState { get; init; } [PluginService] private IFramework Framework { get; init; } [PluginService] private IPartyList PartyList { get; init; } [PluginService] internal IDalamudPluginInterface Interface { get; init; } [PluginService] internal ICommandManager CommandManager { get; init; } [PluginService] internal IContextMenu ContextMenu { get; init; } [PluginService] internal IDataManager DataManager { get; init; } [PluginService] internal ICondition Condition { get; init; } // [PluginService] // internal ITextureProvider TextureProvider { get; init; } internal Configuration Config { get; } private Client Client { get; } internal PluginUi Ui { get; } private Commands Commands { get; } private Stopwatch AlternateWatch { get; } = Stopwatch.StartNew(); private Stopwatch DelayWatch { get; } = new(); private bool _ranLastTick; private bool _showDps = true; private bool _reset; private bool _wasActive; private bool _manuallyReset = true; [Signature("E8 ?? ?? ?? ?? E9 ?? ?? ?? ?? FE C2")] internal unsafe delegate* unmanaged PlaySound; internal readonly byte[] ManaUsers = [ 6, // cnj 7, // thm 19, // pld 24, // whm 25, // blm 26, // acn 27, // smn 28, // sch 32, // drk 33, // ast 35, // rdm 36, // blu 40, // sge 42, // pct ]; [PluginService] private IGameInteropProvider GameInteropProvider { get; init; } public Plugin() { this.Config = this.Interface!.GetPluginConfig() as Configuration ?? new Configuration(); this.Client = new Client(); this.Ui = new PluginUi(this); this.Commands = new Commands(this); if (this.Config.ManaModeAlternateOnlyManaUsers) { this.Config.ManaModeAlternateOnlyManaUsers = false; this.Config.AlternateJobs.AddRange(this.ManaUsers.Select(cj => (uint) cj)); this.SaveConfig(); } this.AddonLifecycle!.RegisterListener(AddonEvent.PostRequestedUpdate, "_PartyList", this.UpdateList); this.AddonLifecycle!.RegisterListener(AddonEvent.PreDraw, "_PartyList", this.UpdateList); this.ContextMenu!.OnMenuOpened += this.MenuOpened; this.GameInteropProvider!.InitializeFromAttributes(this); } public void Dispose() { this.ContextMenu.OnMenuOpened -= this.MenuOpened; this.AddonLifecycle.UnregisterListener(AddonEvent.PreDraw, "_PartyList", this.UpdateList); this.AddonLifecycle.UnregisterListener(AddonEvent.PostRequestedUpdate, "_PartyList", this.UpdateList); this.Commands.Dispose(); this.Ui.Dispose(); this.Client.Dispose(); } internal void SaveConfig() { this.Interface.SavePluginConfig(this.Config); } private unsafe void MenuOpened(IMenuOpenedArgs args) { var add = args.AddonName == "_PartyList"; if (!add) { var ctx = AgentContext.Instance(); if (args.AgentPtr != (nint) ctx) { return; } var targetId = ctx->TargetObjectId.ObjectId; if (targetId == 0xE000_0000) { return; } if (this.ClientState.LocalPlayer is { } player && player.GameObjectId == targetId) { add = true; } else { var group = GroupManager.Instance()->GetGroup(); if (group != null && group->GetPartyMemberByEntityId(targetId) != null) { add = true; } } } if (!add) { return; } args.AddMenuItem(new MenuItem { Name = "Clear parse results", Prefix = SeIconChar.BoxedLetterP, PrefixColor = 37, IsEnabled = this.Client.Data?.IsActive == false, OnClicked = _ => { this._reset = true; this._manuallyReset = true; }, }); } private unsafe void UpdateList(AddonEvent type, AddonArgs args) { var ranLast = this._ranLastTick; this._ranLastTick = false; var list = (AddonPartyList*) AtkStage.Instance()->RaptureAtkUnitManager->GetAddonByName("_PartyList"); if (list == null) { return; } var shouldUpdate = false; var wasActive = this._wasActive; var becameInactive = false; if (this.Client.Data is { } data) { if (wasActive && !data.IsActive) { becameInactive = true; } this._wasActive = data.IsActive; } if (this.Client.Data is { IsActive: true }) { shouldUpdate = true; this._manuallyReset = false; } else if (becameInactive) { if (this.Config.ClearResultsOnInactive) { this.DelayWatch.Restart(); } this.HandleEvaluation(); } if (this.Config.ClearResultsOnInactive && this.DelayWatch.IsRunning && this.DelayWatch.Elapsed >= TimeSpan.FromSeconds(this.Config.ClearDelaySeconds)) { this.DelayWatch.Reset(); this._reset = true; } if (!this._reset && !this._manuallyReset) { // only do these checks if we shouldn't reset and if we're waiting for active data // keep running if we're in the delay period if (this.DelayWatch.IsRunning) { shouldUpdate = true; } // keep running if the user wants to manually clear results if (!this.Config.ClearResultsOnInactive) { shouldUpdate = true; } } if (this._reset) { this.DelayWatch.Reset(); this._reset = false; this._manuallyReset = true; this.ResetMembers(list); } if (shouldUpdate && this.UpdateListInner(list) && ranLast) { this.ResetMembers(list); } } private unsafe void HandleEvaluation() { if ( !this.Config.UseEvaluatorNpc || this.ClientState.LocalPlayer is not { } player || this.Client.Data == null ) { return; } var evaluator = this.GetEvaluator(); if (evaluator == null) { return; } if (this.Client.Data.Combatants.Count < this.Config.EvaluationMinCombatants) { return; } var combatants = this.Client.Data.Combatants.Values .Where(combatant => { var job = this.DataManager.GetExcelSheet()! .FirstOrDefault(j => j.Abbreviation.RawString.Equals(combatant.JobAbbr, StringComparison.InvariantCultureIgnoreCase)); return job?.Role == player.ClassJob.GameData?.Role; }) .ToList(); if (combatants.Count < this.Config.EvaluationMinSameRole) { return; } combatants.Sort((a, b) => a.EncDps.CompareTo(b.EncDps)); var youIndex = combatants.FindIndex(combatant => combatant.Name == "YOU"); if (youIndex == -1) { return; } var rank = (float) (youIndex + 1) / combatants.Count; var thresholds = this.Config.EvaluationThresholds.ToList(); thresholds.Sort((a, b) => b.Value.CompareTo(a.Value)); foreach (var threshold in thresholds) { if (rank < threshold.Value) { continue; } var msg = evaluator.GetLineFor(threshold.Key, this.Config.BlendEvaluations, youIndex + 1); if (msg != null) { var bytes = msg.EncodeWithNullTerminator(); UIModule.Instance()->ShowBattleTalkImage( Encoding.UTF8.GetBytes(evaluator.Name), bytes, this.Config.EvaluationLength, evaluator.BattleTalkImage, 6 ); } break; } } internal Evaluator? GetEvaluator() { if (this.Config.EvaluatorId != Evaluator.RandomId) { return Evaluator.Evaluators.FirstOrDefault(e => e.Id == this.Config.EvaluatorId); } var pool = Evaluator.Evaluators .Where(e => this.Config.EvaluatorsRandomEnabled.GetValueOrDefault(e.Id)) .ToArray(); return pool.Length == 0 ? null : Random.Shared.GetItems(pool, 1).GetIndexOrDefault(0); } /// /// Update the party list. /// /// true if the list should be reset immediately private unsafe bool UpdateListInner(AddonPartyList* list) { if (this.Client.Data is not { } data) { return false; } if (this.ClientState.LocalPlayer is not { } player) { return false; } var chara = (Character*) player.Address; var playerName = player.Name.TextValue; if (list->HoveredIndex >= 0 || list->TargetedIndex >= 0) { return true; } var names = new List(); var members = AgentHUD.Instance()->PartyMembers; for (var i = 0; i < members.Length && i < list->PartyMembers.Length; i++) { var member = members[i]; if (member.Name == null) { continue; } // controller soft target not handled by hoveredindex above if (chara->GetSoftTargetId() == member.EntityId) { return true; } var name = MemoryHelper.ReadStringNullTerminated((nint) member.Name); var isChocobo = member.Object != null && member.Object->GetObjectKind() == ObjectKind.BattleNpc && member.Object->CompanionOwnerId == player.GameObjectId; if (isChocobo) { name += " (YOU)"; } var info = new ListInfo { Name = name, IsChocobo = isChocobo, Mana = member.Object != null ? member.Object->Mana : null, PartyListIndex = i, ClassJob = member.Object != null ? member.Object->ClassJob : null, }; names.Add(info); } // check cross realm party for any members we may have missed var crossInfo = InfoProxyCrossRealm.Instance(); if (crossInfo->IsInCrossRealmParty != 0) { var crossGroup = crossInfo->CrossRealmGroups[crossInfo->LocalPlayerGroupIndex]; for (var i = 0; i < crossGroup.GroupMemberCount && i < list->PartyMembers.Length; i++) { var member = crossGroup.GroupMembers[i]; var name = member.NameString; if (string.IsNullOrWhiteSpace(name)) { continue; } if (chara->GetSoftTargetId() == member.EntityId) { return true; } if (names.FirstOrDefault(info => info.Name == name) is { } info) { info.PartyListIndex = member.MemberIndex; continue; } info = new ListInfo { Name = name, IsChocobo = false, Mana = null, PartyListIndex = member.MemberIndex, ClassJob = member.ClassJobId, }; names.Add(info); } } this._ranLastTick = true; if (this.AlternateWatch.Elapsed.TotalMilliseconds > this.Config.AlternateSeconds * 1_000) { this.AlternateWatch.Restart(); this._showDps ^= true; } names.Sort((a, b) => a.PartyListIndex.CompareTo(b.PartyListIndex)); for (var i = 0; i < names.Count; i++) { var info = names[i]; var lookupName = info.Name == playerName ? "YOU" : info.Name; if (!data.Combatants.TryGetValue(lookupName, out var combatant)) { continue; } this.UpdateMember(list, info, data.Encounter, combatant, data.Combatants.Values); } return false; } private unsafe (Pointer, Pointer) GetTextNodes(AddonPartyList.PartyListMemberStruct unit) { if (unit.MPGaugeBar == null) { return (null, null); } var left = (AtkTextNode*) unit.MPGaugeBar->GetTextNodeById(2); var right = (AtkTextNode*) unit.MPGaugeBar->GetTextNodeById(3); var hasLeft = left != null && left->IsVisible(); var hasRight = right != null && right->IsVisible(); return ( hasLeft ? (Pointer) left : null, hasRight ? (Pointer) right : null ); } private unsafe void ResetTextColour(AddonPartyList.PartyListMemberStruct unit) { var (left, right) = this.GetTextNodes(unit); if (left.Value == null) { return; } left.Value->TextColor = new ByteColor { RGBA = 0xFFFFFFFF, }; left.Value->AddRed = 0; left.Value->AddGreen = 0; left.Value->AddBlue = 0; left.Value->MultiplyRed = 100; left.Value->MultiplyGreen = 100; left.Value->MultiplyBlue = 100; if (right.Value == null) { return; } right.Value->TextColor = left.Value->TextColor; right.Value->AddRed = left.Value->AddRed; right.Value->AddGreen = left.Value->AddGreen; right.Value->AddBlue = left.Value->AddBlue; right.Value->MultiplyRed = left.Value->MultiplyRed; right.Value->MultiplyGreen = left.Value->MultiplyGreen; right.Value->MultiplyBlue = left.Value->MultiplyBlue; } private unsafe void ResetManaText(AddonPartyList.PartyListMemberStruct unit, uint? mana) { var (left, right) = this.GetTextNodes(unit); if (left.Value == null) { return; } var manaString = mana == null ? "???" : mana.Value.ToString(CultureInfo.InvariantCulture); if (right.Value != null) { if (manaString.Length <= 1) { left.Value->SetText(""); right.Value->SetText(manaString); } else { left.Value->SetText(manaString[..^2]); right.Value->SetText(manaString[^2..]); } } else { left.Value->SetText(manaString); } } private unsafe void ResetMember( AddonPartyList* list, ListInfo info, bool includeBar, bool includeTargeted ) { var unit = info.IsChocobo ? list->Chocobo : list->PartyMembers[info.PartyListIndex]; if (includeBar && unit.TargetGlow != null && (includeTargeted || list->TargetedIndex != info.PartyListIndex)) { unit.TargetGlow->SetAlpha(255); unit.TargetGlow->SetScaleX(0); unit.TargetGlow->AddRed = 0; unit.TargetGlow->AddGreen = 0; unit.TargetGlow->AddBlue = 0; unit.TargetGlow->MultiplyRed = 100; unit.TargetGlow->MultiplyGreen = 100; unit.TargetGlow->MultiplyBlue = 100; unit.TargetGlow->ToggleVisibility(true); unit.TargetGlow->SetWidth(320); } if (info.IsChocobo) { var chocoboTimer = list->GetTextNodeById(5); var chocoboTimerIcon = list->GetTextNodeById(4); var hasTimer = chocoboTimer != null && chocoboTimer->IsVisible(); var hasTimerIcon = chocoboTimerIcon != null && chocoboTimerIcon->IsVisible(); if (hasTimer) { var companion = UIState.Instance()->Buddy.CompanionInfo; var timeLeft = TimeSpan.FromSeconds(Math.Ceiling(companion.TimeLeft)); chocoboTimer->SetText($"{timeLeft.Minutes:00}:{timeLeft.Seconds:00}"); } if (hasTimerIcon) { chocoboTimerIcon->SetText(SeIconChar.Clock.ToIconString()); } } this.ResetTextColour(unit); this.ResetManaText(unit, info.Mana); } private unsafe void ResetMembers(AddonPartyList* list) { foreach (var member in list->PartyMembers) { if (member.TargetGlow != null) { member.TargetGlow->SetWidth(320); } this.ResetTextColour(member); } if (list->Chocobo.TargetGlow != null) { list->Chocobo.TargetGlow->SetWidth(220); } list->OnRequestedUpdate(AtkStage.Instance()->GetNumberArrayData(), AtkStage.Instance()->GetStringArrayData()); // var members = AgentHUD.Instance()->PartyMembers; // for (var i = 0; i < members.Length && i < list->PartyMembers.Length; i++) { // var member = members[i]; // var name = MemoryHelper.ReadStringNullTerminated((nint) member.Name); // var isChocobo = member.Object != null // && this.ClientState.LocalPlayer is { } player // && member.Object->GetObjectKind() == ObjectKind.BattleNpc // && member.Object->CompanionOwnerId == player.GameObjectId; // if (isChocobo) { // name += " (YOU)"; // } // // var info = new ListInfo { // Name = name, // IsChocobo = isChocobo, // Mana = member.Object != null ? member.Object->Mana : null, // PartyListIndex = i, // ClassJob = member.Object != null ? member.Object->ClassJob : null, // }; // // this.ResetMember(list, info, true, false); // } } private unsafe string[] GetPartyNames(bool alliance) { var names = new HashSet(); var cross = InfoProxyCrossRealm.Instance(); if (cross->IsInCrossRealmParty != 0) { var crossGroup = cross->CrossRealmGroups[cross->LocalPlayerGroupIndex]; foreach (var member in crossGroup.GroupMembers) { names.Add(member.NameString); } } var group = GroupManager.Instance()->GetGroup(); if (group == null) { return names.ToArray(); } foreach (var member in group->PartyMembers) { names.Add(member.NameString); } if (alliance) { foreach (var member in group->AllianceMembers) { names.Add(member.NameString); } } return names.ToArray(); } private unsafe void UpdateMember( AddonPartyList* list, ListInfo info, Encounter encounter, Combatant combatant, IEnumerable combatants ) { var member = info.IsChocobo ? list->Chocobo : list->PartyMembers[info.PartyListIndex]; if (this.Config.UseDpsBar && member.TargetGlow != null) { float denominator; switch (this.Config.DpsBarMode) { case DpsBarMode.Encounter: { denominator = combatants .Select(c => c.EncDps) .Aggregate(0f, (acc, val) => val > acc ? val : acc); break; } case DpsBarMode.Party: { var party = this.GetPartyNames(false); denominator = combatants .Where(c => c.Name == "YOU" || party.Contains(c.Name) || c.Name.EndsWith(')')) .Select(c => c.EncDps) .Aggregate(0f, (acc, val) => val > acc ? val : acc); break; } case DpsBarMode.Alliance: { var alliance = this.GetPartyNames(true); denominator = combatants .Where(c => c.Name == "YOU" || alliance.Contains(c.Name) || c.Name.EndsWith(')')) .Select(c => c.EncDps) .Aggregate(0f, (acc, val) => val > acc ? val : acc); break; } default: { denominator = encounter.EncDps; break; } } member.TargetGlow->ToggleVisibility(true); member.TargetGlow->SetAlpha((byte) Math.Round(this.Config.BarAlpha * 255)); // member.TargetGlow->SetScaleX( // denominator == 0 // ? 0 // : combatant.EncDps / denominator // ); member.TargetGlow->SetScaleX(1); member.TargetGlow->AddRed = (byte) Math.Clamp(this.Config.BarAddRed, 0, 255); member.TargetGlow->AddGreen = (byte) Math.Clamp(this.Config.BarAddGreen, 0, 255); member.TargetGlow->AddBlue = (byte) Math.Clamp(this.Config.BarAddBlue, 0, 255); member.TargetGlow->MultiplyRed = (byte) Math.Clamp(this.Config.BarMulRed, 0, 100); member.TargetGlow->MultiplyGreen = (byte) Math.Clamp(this.Config.BarMulGreen, 0, 100); member.TargetGlow->MultiplyBlue = (byte) Math.Clamp(this.Config.BarMulBlue, 0, 100); member.TargetGlow->PartId = 3; // member.TargetGlow->SetWidth(320); member.TargetGlow->SetWidth( (ushort) Math.Clamp( Math.Round(320 * (denominator == 0 ? 1 : combatant.EncDps / denominator)), 10, 320 ) ); member.TargetGlow->SetPositionShort(47, 21); } var inCombat = this.Condition[ConditionFlag.InCombat]; if (this.Config.Mode == MeterMode.Mana && info.IsChocobo) { var chocoboTimer = list->GetTextNodeById(5); var chocoboTimerIcon = list->GetTextNodeById(4); var hasTimer = chocoboTimer != null && chocoboTimer->IsVisible(); var hasTimerIcon = chocoboTimerIcon != null && chocoboTimerIcon->IsVisible(); if (this.Config.Alternate && this.Config.AlternateChocobo && (!this.Config.OnlyAlternateInCombat || inCombat) && !this._showDps) { this.ResetMember(list, info, false, false); } else { if (hasTimer) { var (leftText, rightText) = GetDpsText(combatant); chocoboTimer->SetText($"{leftText}{rightText}"); } if (hasTimerIcon) { chocoboTimerIcon->SetText("\ue05e"); } } } if (this.Config.Mode == MeterMode.Mana && member.MPGaugeBar != null) { var left = (AtkTextNode*) member.MPGaugeBar->GetTextNodeById(2); var right = (AtkTextNode*) member.MPGaugeBar->GetTextNodeById(3); var hasLeft = left != null && left->IsVisible(); var hasRight = right != null && right->IsVisible(); if (!hasLeft) { return; } if (!left->ParentNode->IsVisible() && !info.IsChocobo && InfoProxyCrossRealm.Instance()->IsInCrossRealmParty != 0) { var gauge = left->ParentNode; gauge->ToggleVisibility(true); for (var sibling = left->NextSiblingNode; sibling != null; sibling = sibling->NextSiblingNode) { if (sibling->NodeId is not (6 or 7)) { continue; } sibling->ToggleVisibility(false); } } if (this.Config.Alternate && (!this.Config.OnlyAlternateInCombat || inCombat) && !this._showDps && info.ClassJob is { } classJob) { var shouldAlternate = this.Config.AlternateJobs.Contains(classJob); if (shouldAlternate) { this.ResetMember(list, info, false, false); return; } } SetTextNodeColor(left); if (hasRight) { SetTextNodeColor(right); } var (leftText, rightText) = GetDpsText(combatant); if (hasRight) { left->SetText(leftText); right->SetText(rightText); } else { left->SetText($"{leftText}{rightText}"); } } return; (string, string) GetDpsText(Combatant combatant) { string leftText, rightText; if (combatant.EncDps == 0 || float.IsInfinity(combatant.EncDps) || float.IsNaN(combatant.EncDps)) { leftText = "0."; rightText = "00"; } else if (combatant.EncDps < 1_000) { var dps = Math.Round(combatant.EncDps * 100).ToString(CultureInfo.InvariantCulture); leftText = $"{dps[..^2]}."; rightText = dps[^2..]; } else if (combatant.EncDps < 1_000_000) { var dps = Math.Round(combatant.EncDps / 100).ToString(CultureInfo.InvariantCulture); leftText = $"{dps[..^1]}."; rightText = $"{dps[^1..]}K"; } else if (combatant.EncDps < 1_000_000_000) { var dps = Math.Round(combatant.EncDps / 100_000).ToString(CultureInfo.InvariantCulture); leftText = $"{dps[..^1]}."; rightText = $"{dps[^1..]}M"; } else { var dps = Math.Round(combatant.EncDps / 100_000_000).ToString(CultureInfo.InvariantCulture); leftText = $"{dps[..^1]}."; rightText = $"{dps[^1..]}B"; } return (leftText, rightText); } void SetTextNodeColor(AtkTextNode* node) { node->TextColor = new ByteColor { RGBA = this.Config.TextColour, }; node->AddRed = (byte) Math.Clamp(this.Config.TextAddRed, 0, 255); node->AddGreen = (byte) Math.Clamp(this.Config.TextAddGreen, 0, 255); node->AddBlue = (byte) Math.Clamp(this.Config.TextAddBlue, 0, 255); node->MultiplyRed = (byte) Math.Clamp(this.Config.TextMulRed, 0, 100); node->MultiplyGreen = (byte) Math.Clamp(this.Config.TextMulGreen, 0, 100); node->MultiplyBlue = (byte) Math.Clamp(this.Config.TextMulBlue, 0, 100); } } } public class ListInfo { public required string Name { get; init; } public required bool IsChocobo { get; init; } public required int PartyListIndex { get; set; } public required uint? Mana { get; init; } public required byte? ClassJob { get; init; } }