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. Pairs by quest name to avoid wrong objectives when array order differs from NormalQuests order. 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; // Prefer matching by quest name: game's string array order can differ from NormalQuests order (e.g. display order), so index-based pairing can assign wrong objectives to the top entries. string 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(); // Fallback: use index when name match failed (e.g. trimmed name difference or empty slot) if (string.IsNullOrWhiteSpace(obj) && i < questCount) obj = ReadSlot(questCount + i).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; } } }