From 27e743c80fb287fa3a3648c6107fee5a578924aa Mon Sep 17 00:00:00 2001 From: Knack117 Date: Mon, 2 Mar 2026 04:06:13 -0500 Subject: [PATCH] v1.0.8.21: Duty Information in duty, objective progress as X out of Y steps + checkmark, average wait from queue Made-with: Cursor --- Config/ConfigurationManager.cs | 1 + Enums/ElementKind.cs | 2 + HSUI.csproj | 6 +- HSUI.json | 2 +- Helpers/DrawHelper.cs | 7 +- Helpers/DutyListScenarioHelper.cs | 1037 +++++++++++++++++ .../GeneralElements/DutyListScenarioConfig.cs | 72 ++ .../GeneralElements/DutyListScenarioHud.cs | 272 +++++ Interface/HudHelper.cs | 101 ++ Interface/HudManager.cs | 4 + changelog.md | 4 + 11 files changed, 1502 insertions(+), 6 deletions(-) create mode 100644 Helpers/DutyListScenarioHelper.cs create mode 100644 Interface/GeneralElements/DutyListScenarioConfig.cs create mode 100644 Interface/GeneralElements/DutyListScenarioHud.cs diff --git a/Config/ConfigurationManager.cs b/Config/ConfigurationManager.cs index 5a7f526..f385aec 100644 --- a/Config/ConfigurationManager.cs +++ b/Config/ConfigurationManager.cs @@ -708,6 +708,7 @@ namespace HSUI.Config typeof(PullTimerConfig), typeof(LimitBreakConfig), typeof(MPTickerConfig), + typeof(DutyListScenarioConfig), // Colors typeof(TanksColorConfig), diff --git a/Enums/ElementKind.cs b/Enums/ElementKind.cs index 996c52d..d2efbd0 100644 --- a/Enums/ElementKind.cs +++ b/Enums/ElementKind.cs @@ -40,6 +40,8 @@ public enum ElementKind : uint EnemyList = 0xB8BD6685, // _EnemyList_a // Misc HUD + ToDoList = 0xA29100D2, // _ToDoList_a (Duty List) + ScenarioTree = 0x88EE6357, // ScenarioTree_a (Scenario Guide) ExperienceBar = 0x21E53CCE, // _Exp_a LimitGauge = 0xC79F450A, // _LimitBreak_a StatusEffects = 0x4A569616, // _Status_a diff --git a/HSUI.csproj b/HSUI.csproj index 4efd1d1..d11a76f 100644 --- a/HSUI.csproj +++ b/HSUI.csproj @@ -9,9 +9,9 @@ HSUI - 1.0.8.20 - 1.0.8.20 - 1.0.8.20 + 1.0.8.21 + 1.0.8.21 + 1.0.8.21 diff --git a/HSUI.json b/HSUI.json index 8249bbe..3c40d2c 100644 --- a/HSUI.json +++ b/HSUI.json @@ -2,7 +2,7 @@ "Author": "Knack117", "Name": "HSUI", "InternalName": "HSUI", - "AssemblyVersion": "1.0.8.20", + "AssemblyVersion": "1.0.8.21", "Description": "HSUI provides a highly configurable HUD replacement for FFXIV, recreated from DelvUI using KamiToolKit, FFXIVClientStructs, and Dalamud. Features unit frames, castbars, job gauges, nameplates, party frames, status effects, enemy list, configurable hotbars with drag-and-drop, and profiles.", "ApplicableVersion": "any", "RepoUrl": "https://github.com/Knack117/HSUI", diff --git a/Helpers/DrawHelper.cs b/Helpers/DrawHelper.cs index dfc5ac0..b4da90b 100644 --- a/Helpers/DrawHelper.cs +++ b/Helpers/DrawHelper.cs @@ -359,8 +359,11 @@ namespace HSUI.Helpers if (!ClipRectsHelper.Instance.Enabled || ClipRectsHelper.Instance.Mode == WindowClippingMode.Performance) { - drawAction(ImGui.GetWindowDrawList()); - return; + if (!needsInput && !needsWindow) + { + drawAction(ImGui.GetWindowDrawList()); + return; + } } windowFlags |= ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoBringToFrontOnFocus; diff --git a/Helpers/DutyListScenarioHelper.cs b/Helpers/DutyListScenarioHelper.cs new file mode 100644 index 0000000..bef9634 --- /dev/null +++ b/Helpers/DutyListScenarioHelper.cs @@ -0,0 +1,1037 @@ +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.Game.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Arrays; +using FFXIVClientStructs.FFXIV.Component.GUI; +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Utility; +using Lumina.Excel; +using Lumina.Excel.Sheets; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; + +namespace HSUI.Helpers +{ + public static class DutyListScenarioHelper + { + private const uint DefaultQuestIconId = 61801; + + public record DutyListEntry(ushort QuestId, byte Sequence, string QuestName, string ObjectiveText, uint IconId); + public record ScenarioGuideEntry(string Label, string QuestName, uint IconId, ushort Level, uint QuestRowId); + /// Duty Finder queue status when InDutyQueue. + public record DutyFinderQueueInfo( + string DutyName, + string StatusText, + byte TanksFound, byte TanksNeeded, + byte HealersFound, byte HealersNeeded, + byte DPSFound, byte DPSNeeded, + TimeSpan TimeElapsed, + string? AverageWaitText); + + /// In-duty information when inside a dungeon/trial/raid (BoundByDuty, not in queue). + public record DutyInfo( + string DutyName, + string TimerText, + IReadOnlyList Objectives); + + /// Single duty objective line, e.g. "Bear witness to the first doom: 0/1". + public record DutyObjectiveEntry(string Text, string Progress); + + /// Resolves the quest icon ID for duty list/journal display, preferring EventIconType over Quest.Icon. + private static uint GetQuestIconId(Quest questData) + { + try + { + if (questData.EventIconType.RowId != 0) + { + var eventIcon = questData.EventIconType.Value; + if (eventIcon.MapIconAvailable != 0) + { + uint offset = questData.IsRepeatable ? 2u : 1u; + return eventIcon.MapIconAvailable + offset; + } + } + if (questData.Icon != 0) + return (uint)questData.Icon; + } + catch { /* ignore */ } + return DefaultQuestIconId; + } + + /// Gets quest level (LevelMax or ClassJobLevel) for "Lv. X ???" display. + private static ushort GetQuestLevel(Quest questData) + { + if (questData.LevelMax != 0) return questData.LevelMax; + return questData.ClassJobLevel.FirstOrDefault(); + } + + /// Returns active duty list entries (quests with objectives). + public static unsafe List GetDutyListEntries() + { + var result = new List(); + try + { + var questSheet = Plugin.DataManager.GetExcelSheet(); + if (questSheet == null) return result; + + var span = QuestManager.Instance()->NormalQuests; + for (int i = 0; i < span.Length; i++) + { + ref var q = ref span[i]; + if (q.QuestId == 0) continue; + + uint rowId = q.QuestId + 65536u; + if (!questSheet.HasRow(rowId)) continue; + + var questData = questSheet.GetRow(rowId); + + string questName = questData.Name.ExtractText(); + if (string.IsNullOrWhiteSpace(questName)) questName = "???"; + uint iconId = GetQuestIconId(questData); + + // Current objective text from Sequence + string objectiveText = GetObjectiveText(questData, q.Sequence); + + result.Add(new DutyListEntry(q.QuestId, q.Sequence, questName, objectiveText, iconId)); + } + + // If Lumina didn't give objective text, try the game's ToDoList string array (same source as the default Duty List UI). + TryFillObjectivesFromToDoListStringArray(result); + // Do not read from _ToDoList addon text nodes: addon memory can be invalid when Duty Finder is open or when queuing, causing AccessViolationException in Utf8String.ToString(). + + // Levequests + var leveSpan = QuestManager.Instance()->LeveQuests; + var leveSheet = Plugin.DataManager.GetExcelSheet(); + if (leveSheet != null) + { + for (int i = 0; i < leveSpan.Length; i++) + { + ref var lq = ref leveSpan[i]; + if (lq.LeveId == 0) continue; + + var leveData = leveSheet.GetRow(lq.LeveId); + + string leveName = leveData.Name.ExtractText(); + if (string.IsNullOrWhiteSpace(leveName)) leveName = "Levequest"; + uint leveIcon = 61801; // default leve/quest icon + + result.Add(new DutyListEntry(0, 0, leveName, "", leveIcon)); + } + } + } + catch (Exception ex) + { + Plugin.Logger.Verbose($"[DutyListScenario] GetDutyListEntries: {ex.Message}"); + } + return result; + } + + /// Gets the current objective description for a quest step from Lumina TodoParams (field name may vary by version). + private static string GetObjectiveText(Quest questData, byte sequence) + { + try + { + foreach (var todo in questData.TodoParams) + { + if (todo.ToDoCompleteSeq != sequence) continue; + string? text = TryGetTodoTextViaReflection(todo); + if (!string.IsNullOrWhiteSpace(text)) return text; + break; + } + } + catch { /* ignore */ } + return ""; + } + + /// Tries to get objective text by reflecting on TodoParamsStruct for a property with ExtractText(). + private static string? TryGetTodoTextViaReflection(Quest.TodoParamsStruct todo) + { + var type = todo.GetType(); + foreach (var prop in type.GetProperties()) + { + try + { + var val = prop.GetValue(todo); + if (val == null) continue; + var vType = val.GetType(); + var extractMethod = vType.GetMethod("ExtractText", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance, null, Type.EmptyTypes, null); + if (extractMethod != null) + { + var result = extractMethod.Invoke(val, null) as string; + if (!string.IsNullOrWhiteSpace(result)) return result; + } + } + catch { /* skip this property */ } + } + return null; + } + + /// Returns true when Duty Finder (ContentsFinder) is open—avoid reading _ToDoList then (addon memory can be invalid). Party Finder is left allowed so duty list can still be read. + private static bool IsDutyOrPartyFinderOpen() + { + try + { + if (Plugin.GameGui.GetAddonByName("ContentsFinder", 0).Address != nint.Zero) return true; + if (Plugin.GameGui.GetAddonByName("ContentsFinder", 1).Address != nint.Zero) return true; + if (Plugin.GameGui.GetAddonByName("ContentsFinderConfirm", 0).Address != nint.Zero) return true; + } + catch { /* ignore */ } + return false; + } + + /// Returns true when we should skip reading _ToDoList (Duty Finder open, in queue, or zone transition—addon memory can be invalid). + private static unsafe bool ShouldSkipReadingDutyListAddon() + { + if (IsDutyOrPartyFinderOpen()) return true; + try + { + if (Conditions.Instance() != null && Conditions.Instance()->InDutyQueue) return true; + if (Plugin.Condition[ConditionFlag.BetweenAreas]) return true; + if (Plugin.Condition[ConditionFlag.BetweenAreas51]) return true; + } + catch { /* ignore */ } + return false; + } + + /// Read objective text from the game's ToDoList string array (same data the default Duty List UI uses). Layout: titles at 0..QuestCount-1, details at QuestCount..2*QuestCount-1. + private static unsafe void TryFillObjectivesFromToDoListStringArray(List result) + { + if (result.Count == 0) return; + try + { + var stringArray = ToDoListStringArray.Instance(); + var numberArray = ToDoListNumberArray.Instance(); + if (stringArray == null || numberArray == null) return; + + int questCount = numberArray->QuestCount; + if (questCount <= 0 || questCount > 30) return; + + // _questTexts at offset 9*8, 60 CStringPointer (8 bytes each). Titles 0..questCount-1, details questCount..2*questCount-1. + const int questTextsOffset = 9 * 8; + byte* baseAddr = (byte*)stringArray; + + string ReadSlot(int index) + { + if (index < 0 || index >= 60) return ""; + byte* ptr = *(byte**)(baseAddr + questTextsOffset + index * 8); + if (ptr == null) return ""; + try { return Marshal.PtrToStringUTF8((IntPtr)ptr) ?? ""; } + catch { return ""; } + } + + for (int i = 0; i < result.Count; i++) + { + var entry = result[i]; + if (!string.IsNullOrWhiteSpace(entry.ObjectiveText)) continue; + + string obj = ""; + if (i < questCount) + obj = ReadSlot(questCount + i).Trim(); + if (string.IsNullOrWhiteSpace(obj)) + { + int titleIdx = -1; + for (int j = 0; j < questCount; j++) + { + if (string.Equals(ReadSlot(j).Trim(), entry.QuestName, StringComparison.OrdinalIgnoreCase)) + { titleIdx = j; break; } + } + if (titleIdx >= 0) + obj = ReadSlot(questCount + titleIdx).Trim(); + } + if (string.IsNullOrWhiteSpace(obj)) continue; + result[i] = entry with { ObjectiveText = obj }; + } + } + catch (Exception ex) + { + Plugin.Logger.Verbose($"[DutyListScenario] TryFillObjectivesFromToDoListStringArray: {ex.Message}"); + } + } + + /// When the game's Duty List addon (_ToDoList) is visible, read its text nodes to get objective strings. Tries ObjectiveTimerTextNodes first (objective lines), then tree-walk, DutyFinderTextNodes, and flat UldManager. + private static unsafe void TryFillObjectivesFromToDoListAddon(List result) + { + if (result.Count == 0) return; + try + { + // Try multiple addon instances (HUD can create different indices) + var addonHandle = Plugin.GameGui.GetAddonByName("_ToDoList", 1); + if (addonHandle.Address == nint.Zero) addonHandle = Plugin.GameGui.GetAddonByName("_ToDoList", 0); + if (addonHandle.Address == nint.Zero) addonHandle = Plugin.GameGui.GetAddonByName("_ToDoList", 2); + if (addonHandle.Address == nint.Zero) + { + LogDutyListAddonDebug(result.Count, found: false, ready: false, 0, 0, 0, null); + return; + } + var addon = (AddonToDoList*)addonHandle.Address; + if (addon == null || !addon->IsReady) + { + LogDutyListAddonDebug(result.Count, found: true, ready: addon != null && addon->IsReady, 0, 0, 0, null); + return; + } + + int nodeListCount = addon->UldManager.NodeListCount; + int objTimerCount = addon->ObjectiveTimerTextNodes.Count; + int textNodeCount = 0; + int textNodeNonEmptyCount = 0; + + var strings = new List(); + + // 0) ObjectiveTimerTextNodes: game stores one text node per objective line; order matches duty list + try + { + var objVec = addon->ObjectiveTimerTextNodes; + if (objVec.Count > 0) + { + var span = objVec.AsSpan(); + for (int i = 0; i < span.Length; i++) + { + ref readonly var data = ref span[i]; + if (data.Node == null) continue; + try + { + string s = data.Node->NodeText.ToString(); + if (!string.IsNullOrWhiteSpace(s)) strings.Add(s.Trim()); + } + catch { /* ignore */ } + } + if (strings.Count >= result.Count) + { + for (int i = 0; i < result.Count && i < strings.Count; i++) + { + if (string.IsNullOrWhiteSpace(result[i].ObjectiveText)) + result[i] = result[i] with { ObjectiveText = strings[i] }; + } + LogDutyListAddonDebug(result.Count, true, true, nodeListCount, objTimerCount, strings.Count, strings); + return; + } + strings.Clear(); + } + } + catch { /* ignore */ } + + // 0b) ListPanel: duty list rows; each entry's node tree has text (typically name then objective per row) + if (strings.Count < result.Count * 2) + { + try + { + var listPanel = addon->ListPanel; + var entries = listPanel.Entries; + if (entries.Count > 0) + { + var rowStrings = new List>(); + var span = entries.AsSpan(); + for (int r = 0; r < span.Length; r++) + { + var row = new List(); + if (span[r].Node != null) + CollectTextFromNode(span[r].Node, row); + rowStrings.Add(row); + } + var flat = new List(); + foreach (var row in rowStrings) + { + foreach (var s in row) + if (!string.IsNullOrWhiteSpace(s)) flat.Add(s.Trim()); + } + if (flat.Count >= result.Count * 2) + { + for (int i = 0; i < result.Count; i++) + { + if (!string.IsNullOrWhiteSpace(result[i].ObjectiveText)) continue; + if ((i * 2 + 1) < flat.Count) + result[i] = result[i] with { ObjectiveText = flat[i * 2 + 1] }; + } + LogDutyListAddonDebug(result.Count, true, true, nodeListCount, objTimerCount, flat.Count, flat); + return; + } + if (flat.Count >= result.Count) + { + for (int i = 0; i < result.Count && i < flat.Count; i++) + { + if (string.IsNullOrWhiteSpace(result[i].ObjectiveText)) + result[i] = result[i] with { ObjectiveText = flat[i] }; + } + LogDutyListAddonDebug(result.Count, true, true, nodeListCount, objTimerCount, flat.Count, flat); + return; + } + strings = flat; + } + } + catch { /* ignore */ } + } + + // 1) Tree-walk (display order: typically name then objective per quest) + if (addon->RootNode != null) + CollectTextFromNode(addon->RootNode, strings); + + // 2) Try DutyFinderTextNodes if tree-walk gave too few + if (strings.Count < 2) + { + try + { + var vec = addon->DutyFinderTextNodes; + if (vec.First != null && vec.Count > 0) + { + var ptrs = (AtkTextNode**)vec.First; + for (int i = 0; i < vec.Count; i++) + { + var textNode = ptrs[i]; + if (textNode == null) continue; + try + { + string s = textNode->NodeText.ToString(); + if (!string.IsNullOrWhiteSpace(s)) strings.Add(s.Trim()); + } + catch { /* ignore */ } + } + } + } + catch { /* ignore */ } + } + + // 3) Fallback: flat UldManager node list + if (strings.Count < 2) + { + strings.Clear(); + var uld = addon->UldManager; + if (uld.NodeListCount > 0) + { + for (uint i = 0; i < uld.NodeListCount; i++) + { + AtkResNode* node = uld.NodeList[i]; + if (node == null || node->Type != NodeType.Text) continue; + textNodeCount++; + var textNode = node->GetAsAtkTextNode(); + if (textNode == null) continue; + try + { + string s = textNode->NodeText.ToString(); + if (!string.IsNullOrWhiteSpace(s)) + { + textNodeNonEmptyCount++; + strings.Add(s.Trim()); + } + } + catch { /* ignore */ } + } + } + if (strings.Count < 2 && addon->RootNode != null) + CollectTextFromNode(addon->RootNode, strings); + } + // Pair by index when layout is exactly [name0, obj0, name1, obj1, ...] + bool pairedByIndex = strings.Count >= result.Count * 2; + for (int i = 0; i < result.Count; i++) + { + var entry = result[i]; + if (!string.IsNullOrWhiteSpace(entry.ObjectiveText)) continue; + string? obj = null; + if (pairedByIndex && (i * 2 + 1) < strings.Count) + obj = strings[i * 2 + 1]; + if (string.IsNullOrWhiteSpace(obj)) + { + int idx = strings.IndexOf(entry.QuestName); + if (idx < 0) + idx = strings.FindIndex(s => s.Contains(entry.QuestName, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0 && idx + 1 < strings.Count) + obj = strings[idx + 1]; + } + if (string.IsNullOrWhiteSpace(obj)) continue; + result[i] = entry with { ObjectiveText = obj }; + } + + LogDutyListAddonDebug(result.Count, true, true, nodeListCount, objTimerCount, strings.Count, strings, textNodeCount, textNodeNonEmptyCount); + } + catch (Exception ex) + { + Plugin.Logger.Verbose($"[DutyListScenario] TryFillObjectivesFromToDoListAddon: {ex.GetType().Name}: {ex.Message}"); + } + } + + private static DateTime _lastDutyListDebugLog = DateTime.MinValue; + private static void LogDutyListAddonDebug(int entryCount, bool found, bool ready, int nodeListCount, int objTimerCount, int stringCount, List? stringsForSample, int textNodeCount = -1, int textNodeNonEmptyCount = -1) + { + if (entryCount == 0) return; + var now = DateTime.UtcNow; + if ((now - _lastDutyListDebugLog).TotalSeconds < 15) return; + _lastDutyListDebugLog = now; + string sample = stringsForSample != null && stringsForSample.Count > 0 + ? string.Join(" | ", stringsForSample.Take(2).Select(s => s.Length > 40 ? s.Substring(0, 40) + "…" : s)) + : ""; + string extra = (textNodeCount >= 0 || textNodeNonEmptyCount >= 0) ? $" textNodes={textNodeCount} nonEmpty={textNodeNonEmptyCount}" : ""; + Plugin.Logger.Verbose($"[DutyList] addon found={found} ready={ready} nodeListCount={nodeListCount} objTimerCount={objTimerCount} stringCount={stringCount}{extra} sample=[{sample}]"); + } + + private static unsafe void CollectTextFromNode(AtkResNode* node, List outStrings) + { + if (node == null) return; + if (node->Type == NodeType.Text) + { + var textNode = node->GetAsAtkTextNode(); + if (textNode != null) + { + try + { + string s = textNode->NodeText.ToString(); + if (!string.IsNullOrWhiteSpace(s)) outStrings.Add(s.Trim()); + } + catch { /* ignore */ } + } + } + else if (node->Type == NodeType.Component) + { + var compNode = node->GetAsAtkComponentNode(); + if (compNode != null && compNode->Component != null) + { + var comp = compNode->Component; + var uld = &comp->UldManager; + if (uld->RootNode != null) + CollectTextFromNode(uld->RootNode, outStrings); + // Also walk flat NodeList in case text nodes aren't under RootNode + if (uld->NodeListCount > 0 && uld->NodeList != null) + { + for (ushort i = 0; i < uld->NodeListCount; i++) + { + AtkResNode* n = uld->NodeList[i]; + if (n != null && n->Type == NodeType.Text) + { + var tn = n->GetAsAtkTextNode(); + if (tn != null) + { + try + { + string s = tn->NodeText.ToString(); + if (!string.IsNullOrWhiteSpace(s)) outStrings.Add(s.Trim()); + } + catch { /* ignore */ } + } + } + } + } + } + } + for (var child = node->ChildNode; child != null; child = child->NextSiblingNode) + CollectTextFromNode(child, outStrings); + } + + /// Opens the Quest Journal to the given quest. Uses raw quest ID (16-bit) as expected by the game. + public static unsafe void OpenJournalForQuest(ushort questId) + { + try + { + AgentQuestJournal.Instance()->OpenForQuest((uint)questId, 1); + } + catch { /* ignore */ } + } + + /// Gets territory ID for a map (for SetFlagMapMarker). Returns 0 if not found. + private static uint GetTerritoryIdForMap(uint mapId) + { + try + { + var mapSheet = Plugin.DataManager.GetExcelSheet(); + if (mapSheet == null || !mapSheet.HasRow(mapId)) return 0; + var mapRow = mapSheet.GetRow(mapId); + if (mapRow.TerritoryType.RowId == 0) return 0; + return mapRow.TerritoryType.RowId; + } + catch { return 0; } + } + + /// Opens the Area Map centered on the quest's current objective location. Sets a flag marker when coordinates are available so the map centers on the objective. + public static unsafe void OpenMapForQuestObjective(ushort questId, byte sequence) + { + try + { + var questSheet = Plugin.DataManager.GetExcelSheet(); + if (questSheet == null) return; + uint rowId = questId + 65536u; + if (!questSheet.HasRow(rowId)) return; + var questData = questSheet.GetRow(rowId); + + var todoParam = questData.TodoParams.FirstOrDefault(p => p.ToDoCompleteSeq == sequence); + var location = todoParam.ToDoLocation.FirstOrDefault(loc => loc is not { RowId: 0, ValueNullable: null }); + if (location.ValueNullable == null) return; + + uint mapId = location.Value.Map.RowId; + if (mapId == 0) return; + uint territoryId = GetTerritoryIdForMap(mapId); + if (territoryId == 0) return; + + var agent = AgentMap.Instance(); + (float x, float y)? coords = TryGetMapCoordinates(location.Value); + if (coords.HasValue) + { + agent->SetFlagMapMarker(territoryId, mapId, coords.Value.x, coords.Value.y); + agent->OpenMap(mapId, territoryId, null, FFXIVClientStructs.FFXIV.Client.UI.Agent.MapType.FlagMarker); + } + else + { + agent->OpenMap(mapId, territoryId, null, FFXIVClientStructs.FFXIV.Client.UI.Agent.MapType.Centered); + } + } + catch { /* ignore */ } + } + + /// Tries to get map X,Y from a Level/location row (Lumina column names may vary). + private static (float x, float y)? TryGetMapCoordinates(object? locationRow) + { + if (locationRow == null) return null; + try + { + var type = locationRow.GetType(); + var xProp = type.GetProperty("X") ?? type.GetProperty("MapX"); + var yProp = type.GetProperty("Y") ?? type.GetProperty("MapY"); + if (xProp == null || yProp == null) return null; + var xVal = xProp.GetValue(locationRow); + var yVal = yProp.GetValue(locationRow); + if (xVal == null || yVal == null) return null; + float x = Convert.ToSingle(xVal); + float y = Convert.ToSingle(yVal); + return (x, y); + } + catch { return null; } + } + + /// Opens the Area Map to the quest's current objective (for scenario guide hints). Prefers current objective so MSQ and Job hints each open the correct map on first click; falls back to issuer location if quest is not in the duty list. + public static unsafe void OpenMapForQuestIssuer(uint questRowId) + { + try + { + var questSheet = Plugin.DataManager.GetExcelSheet(); + if (questSheet == null || !questSheet.HasRow(questRowId)) return; + ushort questId = (ushort)(questRowId & 0xFFFF); + + // Prefer current objective so clicking Job hint opens Job's map (not MSQ's) on first click + var span = QuestManager.Instance()->NormalQuests; + for (int i = 0; i < span.Length; i++) + { + if (span[i].QuestId != questId) continue; + OpenMapForQuestObjective(questId, span[i].Sequence); + return; + } + + // Fallback: open to issuer location when quest is not in the duty list yet + var questData = questSheet.GetRow(questRowId); + if (questData.IssuerLocation.IsValid && questData.IssuerLocation.RowId != 0) + { + var loc = questData.IssuerLocation.Value; + uint mapId = loc.Map.RowId; + if (mapId != 0) + { + uint territoryId = GetTerritoryIdForMap(mapId); + if (territoryId != 0) + { + var agent = AgentMap.Instance(); + (float x, float y)? coords = TryGetMapCoordinates(loc); + if (coords.HasValue) + { + agent->SetFlagMapMarker(territoryId, mapId, coords.Value.x, coords.Value.y); + agent->OpenMap(mapId, territoryId, null, FFXIVClientStructs.FFXIV.Client.UI.Agent.MapType.FlagMarker); + } + else + { + agent->OpenMap(mapId, territoryId, null, FFXIVClientStructs.FFXIV.Client.UI.Agent.MapType.Centered); + } + } + } + } + } + catch { /* ignore */ } + } + + /// Returns scenario guide entries (MSQ/Job path hints). Uses AgentScenarioTree when populated; otherwise derives next MSQ/Job quest from game data. + public static unsafe List GetScenarioGuideEntries() + { + var result = new List(); + try + { + var questSheet = Plugin.DataManager.GetExcelSheet(); + if (questSheet == null) return result; + + // Try agent first + var agent = AgentScenarioTree.Instance(); + if (agent != null && agent->Data != null) + { + var data = agent->Data; + bool hasMsqFromAgent = false; + bool hasJobFromAgent = false; + + // Main Scenario Quest - use MainScenarioQuestIds[MSQPathIndex], fallback to [0], then [3] (last completed) + byte msqPathIndex = data->MSQPathIndex; + ushort msqQuestId = msqPathIndex < 3 ? data->MainScenarioQuestIds[msqPathIndex] : data->MainScenarioQuestIds[0]; + if (msqQuestId == 0) msqQuestId = data->MainScenarioQuestIds[0]; + if (msqQuestId == 0) msqQuestId = data->MainScenarioQuestIds[3]; + if (msqQuestId != 0) + { + uint rowId = msqQuestId + 65536u; + if (questSheet.HasRow(rowId)) + { + var questData = questSheet.GetRow(rowId); + string questName = questData.Name.ExtractText(); + if (string.IsNullOrWhiteSpace(questName)) questName = "???"; + uint iconId = GetQuestIconId(questData); + ushort level = GetQuestLevel(questData); + result.Add(new ScenarioGuideEntry("Main Scenario", questName, iconId, level, rowId)); + hasMsqFromAgent = true; + } + } + + // Job quest(s) - use JobQuestIndex so we show the one the game considers selected (avoids wrong map when both slots exist) + byte jobIdx = data->JobQuestIndex; + if (jobIdx <= 1) + { + ushort questId = data->JobQuestIds[jobIdx]; + if (questId != 0) + { + uint rowId = questId + 65536u; + if (questSheet.HasRow(rowId)) + { + var questData = questSheet.GetRow(rowId); + string questName = questData.Name.ExtractText(); + if (string.IsNullOrWhiteSpace(questName)) questName = "???"; + uint iconId = GetQuestIconId(questData); + ushort level = GetQuestLevel(questData); + result.Add(new ScenarioGuideEntry("Job Quest", questName, iconId, level, rowId)); + hasJobFromAgent = true; + } + } + } + if (!hasJobFromAgent) + { + for (int i = 0; i < 2; i++) + { + ushort questId = data->JobQuestIds[i]; + if (questId == 0) continue; + uint rowId = questId + 65536u; + if (!questSheet.HasRow(rowId)) continue; + var questData = questSheet.GetRow(rowId); + string questName = questData.Name.ExtractText(); + if (string.IsNullOrWhiteSpace(questName)) questName = "???"; + uint iconId = GetQuestIconId(questData); + ushort level = GetQuestLevel(questData); + result.Add(new ScenarioGuideEntry("Job Quest", questName, iconId, level, rowId)); + hasJobFromAgent = true; + break; + } + } + + if (hasMsqFromAgent && hasJobFromAgent) + return result; + } + + // Fallback: derive next MSQ and Job quest from game data when agent has no hint + TryAddNextMsqFromScenarioTree(result, questSheet); + TryAddNextJobQuestFromQuests(result, questSheet); + } + catch (Exception ex) + { + Plugin.Logger.Verbose($"[DutyListScenario] GetScenarioGuideEntries: {ex.Message}"); + } + return result; + } + + private static unsafe void TryAddNextMsqFromScenarioTree(List result, ExcelSheet questSheet) + { + if (result.Any(e => e.Label == "Main Scenario")) return; + try + { + var scenarioSheet = Plugin.DataManager.GetExcelSheet(); + if (scenarioSheet == null) return; + + var ordered = new List<(ScenarioTree St, Quest Q)>(); + foreach (var st in scenarioSheet) + { + if (!questSheet.TryGetRow(st.RowId, out var quest) || !quest.Expansion.IsValid) + continue; + ordered.Add((st, quest)); + } + ordered = ordered.OrderBy(x => x.St.Unknown2).ToList(); + + foreach (var (st, quest) in ordered) + { + if (QuestManager.IsQuestComplete(st.RowId)) continue; + string questName = quest.Name.ExtractText(); + if (string.IsNullOrWhiteSpace(questName)) questName = "???"; + uint iconId = GetQuestIconId(quest); + ushort level = GetQuestLevel(quest); + result.Insert(0, new ScenarioGuideEntry("Main Scenario", questName, iconId, level, quest.RowId)); + return; + } + } + catch { /* ignore */ } + } + + private static unsafe void TryAddNextJobQuestFromQuests(List result, ExcelSheet questSheet) + { + if (result.Any(e => e.Label == "Job Quest")) return; + var player = Plugin.ObjectTable?.LocalPlayer; + if (player == null) return; + uint classJobId = player.ClassJob.RowId; + try + { + foreach (var quest in questSheet) + { + if (quest.ClassJobCategory0.RowId == 0) continue; + if (quest.ClassJobCategory0.RowId != classJobId) continue; + if (!quest.JournalGenre.IsValid) continue; + if (QuestManager.IsQuestComplete(quest.RowId)) continue; + ushort reqLevel = quest.ClassJobLevel.FirstOrDefault(); + if (reqLevel == 0) continue; + if (player.Level < reqLevel) continue; + + string questName = quest.Name.ExtractText(); + if (string.IsNullOrWhiteSpace(questName)) questName = "???"; + uint iconId = GetQuestIconId(quest); + ushort level = GetQuestLevel(quest); + result.Add(new ScenarioGuideEntry("Job Quest", questName, iconId, level, quest.RowId)); + return; + } + } + catch { /* ignore */ } + } + + /// Returns current Duty Finder queue info when the player is in the duty queue; otherwise null. Uses the actual queued duties (from QueueInfo) and supports multiple selected duties. + public static unsafe DutyFinderQueueInfo? GetDutyFinderQueueInfo() + { + try + { + if (Conditions.Instance() == null || !Conditions.Instance()->InDutyQueue) + return null; + var finder = ContentsFinder.Instance(); + if (finder == null) return null; + ref var queueInfo = ref finder->QueueInfo; + + // Build duty name(s) from all queued entries (up to 5). Each entry is 8 bytes: ContentType at 0, Id at 4. + var dutyNames = new List(); + var queueInfoPtr = (byte*)Unsafe.AsPointer(ref queueInfo); + for (int i = 0; i < 5; i++) + { + byte contentType = queueInfoPtr[i * 8 + 0]; + uint id = *(uint*)(queueInfoPtr + i * 8 + 4); + if (contentType == 0) continue; // None / empty slot + if (contentType == (byte)ContentsId.ContentsType.Regular && id != 0) + { + var cfcSheet = Plugin.DataManager.GetExcelSheet(); + if (cfcSheet != null && cfcSheet.HasRow(id)) + { + var row = cfcSheet.GetRow(id); + string name = row.Name.ExtractText(); + if (!string.IsNullOrWhiteSpace(name)) dutyNames.Add(name); + } + } + else if (contentType == (byte)ContentsId.ContentsType.Roulette) + { + byte rouletteId = (byte)(id & 0xFF); + var rouletteSheet = Plugin.DataManager.GetExcelSheet(); + if (rouletteSheet != null && rouletteSheet.HasRow(rouletteId)) + { + var row = rouletteSheet.GetRow(rouletteId); + string name = row.Name.ExtractText(); + dutyNames.Add(string.IsNullOrWhiteSpace(name) ? "Duty Roulette" : name); + } + else + dutyNames.Add("Duty Roulette"); + } + } + string dutyName = dutyNames.Count == 0 ? "Duty Finder" : (dutyNames.Count == 1 ? dutyNames[0] : string.Join(", ", dutyNames)); + + string statusText = queueInfo.QueueState switch + { + ContentsFinderQueueInfo.QueueStates.Pending => "Pending", + ContentsFinderQueueInfo.QueueStates.Queued => "Forming Party", + ContentsFinderQueueInfo.QueueStates.Ready => "Duty Ready", + ContentsFinderQueueInfo.QueueStates.Accepted => "Accepted", + _ => "In Queue" + }; + var infoState = queueInfo.InfoState; + var elapsed = queueInfo.EnteredQueueTimestamp != 0 + ? TimeSpan.FromSeconds(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - queueInfo.EnteredQueueTimestamp) + : TimeSpan.Zero; + string? avgWait = null; + if (infoState.AverageWaitTime) + { + avgWait = TryGetAverageWaitFromQueueStatusMessages(); + if (string.IsNullOrEmpty(avgWait)) avgWait = "—"; + } + return new DutyFinderQueueInfo( + dutyName, + statusText, + infoState.TanksFound, infoState.TanksNeeded, + infoState.HealersFound, infoState.HealersNeeded, + infoState.DPSFound, infoState.DPSNeeded, + elapsed, + avgWait); + } + catch (Exception ex) + { + Plugin.Logger.Verbose($"[DutyListScenario] GetDutyFinderQueueInfo: {ex.Message}"); + return null; + } + } + + /// Returns current duty information when inside a dungeon/trial/raid (BoundByDuty and not in queue); otherwise null. + public static unsafe DutyInfo? GetDutyInfo() + { + try + { + if (!Plugin.Condition[ConditionFlag.BoundByDuty]) return null; + if (Conditions.Instance() != null && Conditions.Instance()->InDutyQueue) return null; + + var stringArray = ToDoListStringArray.Instance(); + var numberArray = ToDoListNumberArray.Instance(); + if (stringArray == null || numberArray == null) return null; + + int dutyState = numberArray->DutyState; + // DutyState 2 = in duty, 3 = in duty with single text line (per ToDoListNumberArray). + if (dutyState != 2 && dutyState != 3) return null; + + byte* strBase = (byte*)stringArray; + string ReadStr(int offset) + { + byte* ptr = *(byte**)(strBase + offset); + if (ptr == null) return ""; + try { return Marshal.PtrToStringUTF8((IntPtr)ptr) ?? ""; } + catch { return ""; } + } + + string dutyName = ReadStr(159 * 8).Trim(); // ActiveDutyTitle + string timerText = ReadStr(162 * 8).Trim(); // DutyTimer + + int objectiveCount = numberArray->DutyObjectiveCount; + if (objectiveCount <= 0 || objectiveCount > 10) objectiveCount = 0; + + var objectives = new List(); + const int dutyObjectivesOffset = 165 * 8; + const int dutyTimersOffset = 175 * 8; + byte* numBase = (byte*)numberArray; + for (int i = 0; i < objectiveCount; i++) + { + string text = ReadStr(dutyObjectivesOffset + i * 8).Trim(); + string progress = ReadStr(dutyTimersOffset + i * 8).Trim(); + if (string.IsNullOrWhiteSpace(text) && string.IsNullOrWhiteSpace(progress)) continue; + int value = *(int*)(numBase + (147 + i) * 4); // _dutyObjectiveValue + if (string.IsNullOrWhiteSpace(progress) && value >= 0) + progress = value.ToString(); + else if (value < 0 && string.IsNullOrWhiteSpace(progress)) + progress = "?"; + string displayProgress = FormatDutyObjectiveProgress(progress); + objectives.Add(new DutyObjectiveEntry(text, displayProgress)); + } + + if (string.IsNullOrWhiteSpace(dutyName) && objectives.Count == 0 && string.IsNullOrWhiteSpace(timerText)) + return null; + + return new DutyInfo( + string.IsNullOrWhiteSpace(dutyName) ? "Duty" : dutyName, + timerText, + objectives); + } + catch (Exception ex) + { + Plugin.Logger.Verbose($"[DutyListScenario] GetDutyInfo: {ex.Message}"); + return null; + } + } + + /// Read average wait time from ToDoList string array queue status messages (slots 5-8). Prefers static format (e.g. "7m") over timer format (e.g. "7:01"). + private static unsafe string? TryGetAverageWaitFromQueueStatusMessages() + { + try + { + var stringArray = ToDoListStringArray.Instance(); + if (stringArray == null) return null; + byte* baseAddr = (byte*)stringArray; + const int queueStatusOffset = 5 * 8; // _queueStatusMessages + string? fallback = null; // use if we only find timer-style values + for (int i = 0; i < 4; i++) + { + byte* ptr = *(byte**)(baseAddr + queueStatusOffset + i * 8); + if (ptr == null) continue; + string s; + try { s = Marshal.PtrToStringUTF8((IntPtr)ptr) ?? ""; } + catch { continue; } + if (string.IsNullOrWhiteSpace(s)) continue; + s = s.Trim(); + // Game can show "Average Wait Time: 7:01/Average Wait Time: 7m" (timer then static) or vice versa. + if (s.IndexOf("Average", StringComparison.OrdinalIgnoreCase) < 0 || s.IndexOf("Wait", StringComparison.OrdinalIgnoreCase) < 0) + { + if ((s.Contains("m") || s.Contains("s")) && s.Length > 0 && (char.IsDigit(s[0]) || s.StartsWith("Less", StringComparison.OrdinalIgnoreCase))) + return s; + continue; + } + const string label = "Average Wait Time:"; + int start = 0; + while (start < s.Length) + { + int idx = s.IndexOf(label, start, StringComparison.OrdinalIgnoreCase); + if (idx < 0) break; + int valueStart = idx + label.Length; + if (valueStart >= s.Length) break; + int valueEnd = s.Length; + int nextLabel = s.IndexOf(label, valueStart, StringComparison.OrdinalIgnoreCase); + if (nextLabel >= 0) valueEnd = nextLabel; + string value = s.Substring(valueStart, valueEnd - valueStart).Trim().TrimEnd('/', ' '); + if (!string.IsNullOrWhiteSpace(value)) + { + if (IsStaticAverageWaitFormat(value)) + return value; + fallback ??= value; + } + start = valueEnd; + } + } + return fallback; + } + catch (Exception ex) + { + Plugin.Logger.Verbose($"[DutyListScenario] TryGetAverageWaitFromQueueStatusMessages: {ex.Message}"); + return null; + } + } + + /// True if the value looks like a static estimate ("7m", "2m 30s", "Less than 5m") rather than a timer ("7:01"). + private static bool IsStaticAverageWaitFormat(string value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + // Timer format is minutes:seconds (e.g. "7:01"); static is "7m", "7m 30s", "Less than 5m". + if (value.Contains(':')) return false; + return value.Contains("m") || value.Contains("s") || value.StartsWith("Less", StringComparison.OrdinalIgnoreCase); + } + + /// Format duty objective progress from game format (e.g. "0/1:0") to "X out of Y step(s)" with " ✓" when completed. + private static string FormatDutyObjectiveProgress(string progress) + { + if (string.IsNullOrWhiteSpace(progress)) return ""; + string main = progress; + bool completed = false; + int colonIdx = progress.IndexOf(':'); + if (colonIdx >= 0) + { + main = progress.Substring(0, colonIdx).Trim(); + if (colonIdx + 1 < progress.Length) + { + string flag = progress.Substring(colonIdx + 1).Trim(); + completed = flag == "1"; + } + } + if (string.IsNullOrWhiteSpace(main)) return progress; + int slashIdx = main.IndexOf('/'); + if (slashIdx < 0) + return completed ? progress + " ✓" : progress; + string currentStr = main.Substring(0, slashIdx).Trim(); + string totalStr = slashIdx + 1 < main.Length ? main.Substring(slashIdx + 1).Trim() : ""; + if (!int.TryParse(currentStr, out int current) || !int.TryParse(totalStr, out int total) || total <= 0) + return completed ? progress + " ✓" : progress; + string stepWord = total == 1 ? "step" : "steps"; + string result = $"{current} out of {total} {stepWord}"; + if (completed) result += " ✓"; + return result; + } + } +} diff --git a/Interface/GeneralElements/DutyListScenarioConfig.cs b/Interface/GeneralElements/DutyListScenarioConfig.cs new file mode 100644 index 0000000..64bda30 --- /dev/null +++ b/Interface/GeneralElements/DutyListScenarioConfig.cs @@ -0,0 +1,72 @@ +using Dalamud.Bindings.ImGui; +using HSUI.Config; +using HSUI.Config.Attributes; +using HSUI.Enums; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + [Section("Other Elements")] + [SubSection("Duty List & Scenario Guide", 0)] + public class DutyListScenarioConfig : AnchorablePluginConfigObject + { + [Checkbox("Show Quest Icons")] + [Order(20)] + public bool ShowQuestIcons = true; + + [DragInt("Icon Size", min = 12, max = 48)] + [Order(21, collapseWith = nameof(ShowQuestIcons))] + public int IconSize = 24; + + [DragFloat("Font Scale", min = 0.5f, max = 2f)] + [Order(23)] + public float FontScale = 1f; + + [Font("Font")] + [Order(24)] + public string FontID = "Default"; + + [ColorEdit4("Text Color")] + [Order(30)] + public PluginConfigColor TextColor = new(new Vector4(1f, 1f, 1f, 1f)); + + [ColorEdit4("Background Color")] + [Order(31)] + public PluginConfigColor BackgroundColor = new(new Vector4(0f, 0f, 0f, 0.5f)); + + [ColorEdit4("Divider Color")] + [Order(32)] + public PluginConfigColor DividerColor = new(new Vector4(1f, 1f, 1f, 0.6f)); + + [Checkbox("Show Duty Finder section when in queue")] + [Order(35)] + public bool ShowDutyFinderSection = true; + + [ColorEdit4("Duty Finder title color")] + [Order(36, collapseWith = nameof(ShowDutyFinderSection))] + public PluginConfigColor DutyFinderTitleColor = new(new Vector4(1f, 0.85f, 0.2f, 1f)); + + [ColorEdit4("Duty Finder detail color")] + [Order(37, collapseWith = nameof(ShowDutyFinderSection))] + public PluginConfigColor DutyFinderDetailColor = new(new Vector4(0.4f, 0.75f, 1f, 1f)); + + [NestedConfig("Visibility", 70)] + public VisibilityConfig VisibilityConfig = new(); + + public DutyListScenarioConfig() + { + Size = new Vector2(320, 200); + } + + public new static DutyListScenarioConfig DefaultConfig() + { + var config = new DutyListScenarioConfig + { + Position = new Vector2(ImGui.GetMainViewport().Size.X * 0.38f, -ImGui.GetMainViewport().Size.Y * 0.35f), + Size = new Vector2(320, 200), + Anchor = DrawAnchor.TopRight + }; + return config; + } + } +} diff --git a/Interface/GeneralElements/DutyListScenarioHud.cs b/Interface/GeneralElements/DutyListScenarioHud.cs new file mode 100644 index 0000000..abd8831 --- /dev/null +++ b/Interface/GeneralElements/DutyListScenarioHud.cs @@ -0,0 +1,272 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using HSUI.Config; +using HSUI.Helpers; +using HSUI.Interface; +using System.Collections.Generic; +using System.Numerics; + +namespace HSUI.Interface.GeneralElements +{ + public class DutyListScenarioHud : DraggableHudElement, IHudElementWithVisibilityConfig + { + private DutyListScenarioConfig Config => (DutyListScenarioConfig)_config; + public VisibilityConfig VisibilityConfig => Config.VisibilityConfig; + + public DutyListScenarioHud(DutyListScenarioConfig config, string displayName) + : base(config, displayName) { } + + protected override (List, List) ChildrenPositionsAndSizes() + { + return (new List { Config.Position }, new List { Config.Size }); + } + + public override void DrawChildren(Vector2 origin) + { + if (!Config.Enabled) + return; + + var dutyListEntries = DutyListScenarioHelper.GetDutyListEntries(); + var scenarioEntries = DutyListScenarioHelper.GetScenarioGuideEntries(); + var dutyFinderInfo = DutyListScenarioHelper.GetDutyFinderQueueInfo(); + var dutyInfo = DutyListScenarioHelper.GetDutyInfo(); + + bool hasDutyList = dutyListEntries.Count > 0; + bool hasScenario = scenarioEntries.Count > 0; + // Show queue info when in queue; show duty info when in duty (not in queue). Same section, different content. + bool hasDutyFinder = Config.ShowDutyFinderSection && dutyFinderInfo != null; + bool hasDutyInfo = Config.ShowDutyFinderSection && dutyInfo != null; + + if (!hasDutyList && !hasScenario && !hasDutyFinder && !hasDutyInfo) + return; + + Vector2 basePos = Utils.GetAnchoredPosition(origin + Config.Position, Config.Size, Config.Anchor); + Vector2 iconSize = new Vector2(Config.IconSize, Config.IconSize); + float lineHeight = Config.IconSize + 4; + float padding = 6; + float contentWidth = Config.Size.X - padding * 2; + + AddDrawAction(Config.StrataLevel, () => + { + DrawHelper.DrawInWindow(ID, basePos, Config.Size, true, (drawList) => + { + drawList.AddRectFilled(basePos, basePos + Config.Size, Config.BackgroundColor.Base, 4); + drawList.PushClipRect(basePos, basePos + Config.Size, true); + + float y = basePos.Y + padding; + uint textColor = Config.TextColor.Base; + + if (Config.FontID != "Default" && FontsManager.Instance != null) + { + using (FontsManager.Instance.PushFont(Config.FontID)) + { + ImGui.SetWindowFontScale(Config.FontScale); + DrawContent(drawList, ref y, basePos.X + padding, contentWidth, lineHeight, iconSize, textColor, hasDutyList, hasScenario, hasDutyFinder, hasDutyInfo, dutyListEntries, scenarioEntries, dutyFinderInfo, dutyInfo); + ImGui.SetWindowFontScale(1); + } + } + else + { + ImGui.PushFont(UiBuilder.DefaultFont); + ImGui.SetWindowFontScale(Config.FontScale); + DrawContent(drawList, ref y, basePos.X + padding, contentWidth, lineHeight, iconSize, textColor, hasDutyList, hasScenario, hasDutyFinder, hasDutyInfo, dutyListEntries, scenarioEntries, dutyFinderInfo, dutyInfo); + ImGui.SetWindowFontScale(1); + ImGui.PopFont(); + } + drawList.PopClipRect(); + }); + }); + } + + private static string TruncateText(string text, float maxWidth) + { + if (ImGui.CalcTextSize(text).X <= maxWidth) + return text; + string suffix = "..."; + while (text.Length > 1 && ImGui.CalcTextSize(text + suffix).X > maxWidth) + text = text[..^1]; + return text + suffix; + } + + private void DrawContent( + ImDrawListPtr drawList, + ref float y, + float x, + float contentWidth, + float lineHeight, + Vector2 iconSize, + uint textColor, + bool hasDutyList, + bool hasScenario, + bool hasDutyFinder, + bool hasDutyInfo, + List dutyListEntries, + List scenarioEntries, + DutyListScenarioHelper.DutyFinderQueueInfo? dutyFinderInfo, + DutyListScenarioHelper.DutyInfo? dutyInfo) + { + const uint TankIconId = 62581; + const uint HealerIconId = 62582; + const uint DPSIconId = 62583; + + if (hasDutyInfo && dutyInfo != null) + { + uint titleColor = Config.DutyFinderTitleColor.Base; + uint detailColor = Config.DutyFinderDetailColor.Base; + + drawList.AddText(new Vector2(x, y), titleColor, "Duty Information"); + y += lineHeight; + + string dutyText = TruncateText(dutyInfo.DutyName, contentWidth); + drawList.AddText(new Vector2(x, y), detailColor, dutyText); + y += lineHeight; + + if (!string.IsNullOrEmpty(dutyInfo.TimerText)) + { + drawList.AddText(new Vector2(x, y), detailColor, TruncateText(dutyInfo.TimerText, contentWidth)); + y += lineHeight; + } + + foreach (var obj in dutyInfo.Objectives) + { + string line = string.IsNullOrWhiteSpace(obj.Text) ? "???" : obj.Text; + if (!string.IsNullOrWhiteSpace(obj.Progress)) + line += ": " + obj.Progress; + drawList.AddText(new Vector2(x, y), detailColor, TruncateText(line, contentWidth)); + y += lineHeight; + } + + y += 4; + if (hasScenario || hasDutyList) + { + float divY = y + 2; + drawList.AddRectFilled(new Vector2(x, divY), new Vector2(x + contentWidth, divY + 2f), Config.DividerColor.Base); + y += 8; + } + } + else if (hasDutyFinder && dutyFinderInfo != null) + { + uint titleColor = Config.DutyFinderTitleColor.Base; + uint detailColor = Config.DutyFinderDetailColor.Base; + + drawList.AddText(new Vector2(x, y), titleColor, "Duty Finder"); + y += lineHeight; + + string dutyText = TruncateText(dutyFinderInfo.DutyName, contentWidth); + drawList.AddText(new Vector2(x, y), detailColor, dutyText); + y += lineHeight; + + string statusText = TruncateText(dutyFinderInfo.StatusText, contentWidth); + drawList.AddText(new Vector2(x, y), detailColor, statusText); + y += lineHeight; + + float roleX = x; + roleX += 4; + DrawHelper.DrawIcon(TankIconId, new Vector2(roleX, y), new Vector2(iconSize.X * 0.8f, iconSize.Y * 0.8f), false, detailColor, drawList); + roleX += iconSize.X * 0.8f + 2; + drawList.AddText(new Vector2(roleX, y), detailColor, $":{dutyFinderInfo.TanksFound}/{dutyFinderInfo.TanksNeeded}"); + roleX += ImGui.CalcTextSize($":{dutyFinderInfo.TanksFound}/{dutyFinderInfo.TanksNeeded}").X + 6; + DrawHelper.DrawIcon(HealerIconId, new Vector2(roleX, y), new Vector2(iconSize.X * 0.8f, iconSize.Y * 0.8f), false, detailColor, drawList); + roleX += iconSize.X * 0.8f + 2; + drawList.AddText(new Vector2(roleX, y), detailColor, $":{dutyFinderInfo.HealersFound}/{dutyFinderInfo.HealersNeeded}"); + roleX += ImGui.CalcTextSize($":{dutyFinderInfo.HealersFound}/{dutyFinderInfo.HealersNeeded}").X + 6; + DrawHelper.DrawIcon(DPSIconId, new Vector2(roleX, y), new Vector2(iconSize.X * 0.8f, iconSize.Y * 0.8f), false, detailColor, drawList); + roleX += iconSize.X * 0.8f + 2; + drawList.AddText(new Vector2(roleX, y), detailColor, $":{dutyFinderInfo.DPSFound}/{dutyFinderInfo.DPSNeeded}"); + y += lineHeight; + + string elapsedStr = $"{(int)dutyFinderInfo.TimeElapsed.TotalMinutes}:{dutyFinderInfo.TimeElapsed.Seconds:D2}"; + string timeLine = "Time Elapsed: " + elapsedStr; + if (!string.IsNullOrEmpty(dutyFinderInfo.AverageWaitText)) + timeLine += " / Average Wait Time: " + dutyFinderInfo.AverageWaitText; + timeLine = TruncateText(timeLine, contentWidth); + drawList.AddText(new Vector2(x, y), detailColor, timeLine); + y += lineHeight + 4; + + if (hasScenario || hasDutyList) + { + float divY = y + 2; + drawList.AddRectFilled(new Vector2(x, divY), new Vector2(x + contentWidth, divY + 2f), Config.DividerColor.Base); + y += 8; + } + } + + if (hasScenario) + { + foreach (var entry in scenarioEntries) + { + float lineX = x; + if (Config.ShowQuestIcons && entry.IconId != 0) + { + DrawHelper.DrawIcon(entry.IconId, new Vector2(lineX, y), iconSize, false, textColor, drawList); + lineX += iconSize.X + 4; + } + + string text = TruncateText($"{entry.Label}: Lv. {entry.Level} {entry.QuestName}", contentWidth - (lineX - x)); + var textSize = ImGui.CalcTextSize(text); + drawList.AddText(new Vector2(lineX, y), textColor, text); + + // Use full line height for hit area so the second line (Job) doesn't get covered by the first (MSQ) + if (ImGui.IsMouseHoveringRect(new Vector2(lineX, y), new Vector2(x + contentWidth, y + lineHeight)) + && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + DutyListScenarioHelper.OpenMapForQuestIssuer(entry.QuestRowId); + } + + y += lineHeight; + } + if (hasDutyList) + { + float divY = y + 2; + drawList.AddRectFilled(new Vector2(x, divY), new Vector2(x + contentWidth, divY + 2f), Config.DividerColor.Base); + y += 8; + } + } + + if (hasDutyList) + { + uint objectiveColor = ImGui.ColorConvertFloat4ToU32(new Vector4(0.78f, 0.78f, 0.78f, 1f)); + + foreach (var entry in dutyListEntries) + { + float lineX = x; + if (Config.ShowQuestIcons && entry.IconId != 0) + { + DrawHelper.DrawIcon(entry.IconId, new Vector2(lineX, y), iconSize, false, textColor, drawList); + lineX += iconSize.X + 4; + } + + string nameText = TruncateText(entry.QuestName, contentWidth - (lineX - x)); + var nameSize = ImGui.CalcTextSize(nameText); + drawList.AddText(new Vector2(lineX, y), textColor, nameText); + + if (entry.QuestId != 0 + && ImGui.IsMouseHoveringRect(new Vector2(lineX, y), new Vector2(lineX + nameSize.X, y + nameSize.Y)) + && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + DutyListScenarioHelper.OpenJournalForQuest(entry.QuestId); + } + + y += lineHeight; + + if (!string.IsNullOrWhiteSpace(entry.ObjectiveText)) + { + float objX = lineX + 8; + string objText = TruncateText("• " + entry.ObjectiveText, contentWidth - (objX - x)); + var objSize = ImGui.CalcTextSize(objText); + drawList.AddText(new Vector2(objX, y), objectiveColor, objText); + + if (entry.QuestId != 0 + && ImGui.IsMouseHoveringRect(new Vector2(objX, y), new Vector2(objX + objSize.X, y + objSize.Y)) + && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + DutyListScenarioHelper.OpenMapForQuestObjective(entry.QuestId, entry.Sequence); + } + + y += lineHeight; + } + } + } + } + } +} diff --git a/Interface/HudHelper.cs b/Interface/HudHelper.cs index 90714a5..14184d8 100644 --- a/Interface/HudHelper.cs +++ b/Interface/HudHelper.cs @@ -30,6 +30,8 @@ namespace HSUI.Interface private bool _hidingCastBar = false; private Vector2 _pullTimerPos = Vector2.Zero; private bool _hidingPullTimer = false; + private bool _hidingDutyListOffScreen = false; + private bool _toDoListVisibleBeforeHide = true; public HudHelper() { @@ -63,6 +65,7 @@ namespace HSUI.Interface SetGameHudElementsHidden(Array.Empty(), true); UpdateDefaultCastBar(true); UpdateDefaultPulltimer(true); + UpdateDefaultDutyList(true); UpdateDefaultNameplates(true); UpdateJobGauges(true); } @@ -107,6 +110,7 @@ namespace HSUI.Interface UpdateJobGauges(); UpdateDefaultHudElementsHidden(); UpdateDefaultCastBar(); + UpdateDefaultDutyList(); UpdateDefaultNameplates(); } catch (Exception ex) @@ -253,6 +257,13 @@ namespace HSUI.Interface if (enemyList?.Enabled == true) AddElements(ElementKind.EnemyList); + var dutyListScenario = ConfigurationManager.Instance?.GetConfigObject(); + if (dutyListScenario?.Enabled == true) + { + // Only hide Scenario Tree via layout. Keep ToDoList in layout so it stays created/updated; we move it off-screen in UpdateDefaultDutyList so we can read objective text. + AddElements(ElementKind.ScenarioTree); + } + // NamePlate is not in HUD Layout config; use UpdateDefaultNameplates (IsVisible) for that if (IsCurrentJobHudEnabled()) { @@ -293,6 +304,8 @@ namespace HSUI.Interface /// Visibility (ByteValue2) of game HUD elements before we hid them. Restored on disable/unload. private readonly Dictionary _gameHudVisibilityBeforeHide = new(); + /// When we force-show ToDoList for off-screen text reading, save its ByteValue2 here to restore on disable. + private readonly Dictionary _gameHudForceShowBeforeRestore = new(); private unsafe void SetGameHudElementsHidden(uint[] hashesToHide, bool forceRestore) { @@ -316,6 +329,17 @@ namespace HSUI.Interface hasChanges = true; } _gameHudVisibilityBeforeHide.Clear(); + + foreach (ref var entry in entries) + { + if (!_gameHudForceShowBeforeRestore.TryGetValue(entry.AddonNameHash, out byte saved)) + continue; + if (entry.ByteValue2 == saved) + continue; + entry.ByteValue2 = saved; + hasChanges = true; + } + _gameHudForceShowBeforeRestore.Clear(); } else { @@ -330,6 +354,44 @@ namespace HSUI.Interface entry.ByteValue2 = 0x0; hasChanges = true; } + + // When Duty List/Scenario is enabled, force-show the Duty List (ToDoList) so the addon is created and we can move it off-screen for text reading + var dutyListScenario = ConfigurationManager.Instance?.GetConfigObject(); + if (dutyListScenario?.Enabled == true) + { + uint toDoListHash = (uint)ElementKind.ToDoList; + foreach (ref var entry in entries) + { + if (entry.AddonNameHash != toDoListHash) + continue; + if (!_gameHudForceShowBeforeRestore.ContainsKey(entry.AddonNameHash)) + _gameHudForceShowBeforeRestore[entry.AddonNameHash] = entry.ByteValue2; + if (entry.ByteValue2 != 0x0) + continue; + entry.ByteValue2 = 0x1; + hasChanges = true; + break; + } + } + else + { + uint toDoListHash = (uint)ElementKind.ToDoList; + if (_gameHudForceShowBeforeRestore.TryGetValue(toDoListHash, out byte saved)) + { + foreach (ref var entry in entries) + { + if (entry.AddonNameHash != toDoListHash) + continue; + if (entry.ByteValue2 != saved) + { + entry.ByteValue2 = saved; + hasChanges = true; + } + _gameHudForceShowBeforeRestore.Remove(toDoListHash); + break; + } + } + } } if (hasChanges) @@ -418,6 +480,45 @@ namespace HSUI.Interface return; } + /// When Duty List/Scenario is enabled, hide the game's Duty List addon with IsVisible=false so it stays at layout position and remains populated for objective text; restore on disable. + private unsafe void UpdateDefaultDutyList(bool forceRestore = false) + { + var dutyListScenario = ConfigurationManager.Instance?.GetConfigObject(); + bool shouldHide = !forceRestore && (dutyListScenario?.Enabled ?? false); + + if (shouldHide && !_hidingDutyListOffScreen) + { + Plugin.AddonLifecycle.RegisterListener(AddonEvent.PreDraw, "_ToDoList", (addonEvent, args) => + { + AtkUnitBase* addon = (AtkUnitBase*)args.Addon.Address; + if (addon == null) return; + addon->IsVisible = false; + }); + + var addon = (AtkUnitBase*)Plugin.GameGui.GetAddonByName("_ToDoList", 1).Address; + if (addon == null) addon = (AtkUnitBase*)Plugin.GameGui.GetAddonByName("_ToDoList", 0).Address; + if (addon != null) + { + _toDoListVisibleBeforeHide = addon->IsVisible; + addon->IsVisible = false; + } + + _hidingDutyListOffScreen = true; + } + else if ((forceRestore || !shouldHide) && _hidingDutyListOffScreen) + { + Plugin.AddonLifecycle.UnregisterListener(AddonEvent.PreDraw, "_ToDoList"); + + var addon = (AtkUnitBase*)Plugin.GameGui.GetAddonByName("_ToDoList", 1).Address; + if (addon == null) addon = (AtkUnitBase*)Plugin.GameGui.GetAddonByName("_ToDoList", 0).Address; + if (addon != null) + addon->IsVisible = _toDoListVisibleBeforeHide; + + _toDoListVisibleBeforeHide = true; + _hidingDutyListOffScreen = false; + } + } + private static readonly Dictionary _jobIdToConfigType = new() { [JobIDs.PLD] = typeof(PaladinConfig), [JobIDs.WAR] = typeof(WarriorConfig), diff --git a/Interface/HudManager.cs b/Interface/HudManager.cs index 94e157e..0bd6b83 100644 --- a/Interface/HudManager.cs +++ b/Interface/HudManager.cs @@ -448,6 +448,10 @@ namespace HSUI.Interface var partyCooldownsHud = new PartyCooldownsHud(partyCooldownsConfig, "Party Cooldowns"); _hudElements.Add(partyCooldownsConfig, partyCooldownsHud); _hudElementsWithPreview.Add(partyCooldownsHud); + + var dutyListScenarioConfig = ConfigurationManager.Instance.GetConfigObject(); + var dutyListScenarioHud = new DutyListScenarioHud(dutyListScenarioConfig, "Duty List & Scenario Guide"); + _hudElements.Add(dutyListScenarioConfig, dutyListScenarioHud); } public void Draw(uint jobId) diff --git a/changelog.md b/changelog.md index d31c145..7c1f4b1 100644 --- a/changelog.md +++ b/changelog.md @@ -1,4 +1,8 @@ +# 1.0.8.21 +- **Duty List & Scenario Guide**: Duty Information when inside a duty (dungeon/trial/raid) — shows duty name, elapsed timer, and objectives. Replaces queue info with in-duty info when BoundByDuty. Objective progress shown as "X out of Y step(s)" with checkmark when completed (replaces raw 0/1:0). Average wait time from queue status messages (static format preferred); multiple queued duties supported; redundant "Average Wait Time" label fixed. + # 1.0.8.20 +- **Duty List & Scenario Guide**: New combined HUD element replacing the game's Duty List and Scenario Guide. Shows active quests and levequests with quest icons, plus Main Scenario and Job Quest hints. Configurable position, size, font, colors, and visibility. - **Hotbars**: Fixed cooldown overlays showing through game UI (e.g. dialogue box) when "Show HUD during dialogue and interaction" is enabled. Hotbar cooldown timers and numbers are now skipped for slots that overlap dialogue, select, or journal addons. # 1.0.8.19