Files
HSUI/Helpers/DutyListScenarioHelper.cs
T

1051 lines
50 KiB
C#

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);
/// <summary>Duty Finder queue status when InDutyQueue.</summary>
public record DutyFinderQueueInfo(
string DutyName,
string StatusText,
byte TanksFound, byte TanksNeeded,
byte HealersFound, byte HealersNeeded,
byte DPSFound, byte DPSNeeded,
TimeSpan TimeElapsed,
string? AverageWaitText);
/// <summary>In-duty information when inside a dungeon/trial/raid (BoundByDuty, not in queue).</summary>
public record DutyInfo(
string DutyName,
string TimerText,
IReadOnlyList<DutyObjectiveEntry> Objectives);
/// <summary>Single duty objective line, e.g. "Bear witness to the first doom: 0/1".</summary>
public record DutyObjectiveEntry(string Text, string Progress);
/// <summary>Resolves the quest icon ID for duty list/journal display, preferring EventIconType over Quest.Icon.</summary>
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;
}
/// <summary>Gets quest level (LevelMax or ClassJobLevel) for "Lv. X ???" display.</summary>
private static ushort GetQuestLevel(Quest questData)
{
if (questData.LevelMax != 0) return questData.LevelMax;
return questData.ClassJobLevel.FirstOrDefault();
}
/// <summary>Returns active duty list entries (quests with objectives).</summary>
public static unsafe List<DutyListEntry> GetDutyListEntries()
{
var result = new List<DutyListEntry>();
try
{
var questSheet = Plugin.DataManager.GetExcelSheet<Quest>();
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<Leve>();
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;
}
/// <summary>Gets the current objective description for a quest step from Lumina TodoParams (field name may vary by version).</summary>
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 "";
}
/// <summary>Tries to get objective text by reflecting on TodoParamsStruct for a property with ExtractText().</summary>
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;
}
/// <summary>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.</summary>
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;
}
/// <summary>Returns true when we should skip reading _ToDoList (Duty Finder open, in queue, or zone transition—addon memory can be invalid).</summary>
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;
}
/// <summary>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 only by quest name (or fuzzy match) so we never assign the wrong objective when array order differs from NormalQuests.</summary>
private static unsafe void TryFillObjectivesFromToDoListStringArray(List<DutyListEntry> 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];
string obj = "";
int titleIdx = -1;
var questNameTrim = entry.QuestName.Trim();
if (string.IsNullOrWhiteSpace(questNameTrim)) continue;
// Exact match first (case-insensitive)
for (int j = 0; j < questCount; j++)
{
var title = ReadSlot(j).Trim();
if (string.Equals(title, questNameTrim, StringComparison.OrdinalIgnoreCase))
{ titleIdx = j; break; }
}
// Fuzzy match for truncated/abbreviated names: game may shorten the title in the array
if (titleIdx < 0)
{
for (int j = 0; j < questCount; j++)
{
var title = ReadSlot(j).Trim();
if (string.IsNullOrWhiteSpace(title)) continue;
if (title.StartsWith(questNameTrim, StringComparison.OrdinalIgnoreCase) ||
questNameTrim.StartsWith(title, 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}");
}
}
/// <summary>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.</summary>
private static unsafe void TryFillObjectivesFromToDoListAddon(List<DutyListEntry> 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<string>();
// 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<List<string>>();
var span = entries.AsSpan();
for (int r = 0; r < span.Length; r++)
{
var row = new List<string>();
if (span[r].Node != null)
CollectTextFromNode(span[r].Node, row);
rowStrings.Add(row);
}
var flat = new List<string>();
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<string>? 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<string> 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);
}
/// <summary>Opens the Quest Journal to the given quest. Uses raw quest ID (16-bit) as expected by the game.</summary>
public static unsafe void OpenJournalForQuest(ushort questId)
{
try
{
AgentQuestJournal.Instance()->OpenForQuest((uint)questId, 1);
}
catch { /* ignore */ }
}
/// <summary>Gets territory ID for a map (for SetFlagMapMarker). Returns 0 if not found.</summary>
private static uint GetTerritoryIdForMap(uint mapId)
{
try
{
var mapSheet = Plugin.DataManager.GetExcelSheet<Lumina.Excel.Sheets.Map>();
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; }
}
/// <summary>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.</summary>
public static unsafe void OpenMapForQuestObjective(ushort questId, byte sequence)
{
try
{
var questSheet = Plugin.DataManager.GetExcelSheet<Quest>();
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 */ }
}
/// <summary>Tries to get map X,Y from a Level/location row (Lumina column names may vary).</summary>
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; }
}
/// <summary>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.</summary>
public static unsafe void OpenMapForQuestIssuer(uint questRowId)
{
try
{
var questSheet = Plugin.DataManager.GetExcelSheet<Quest>();
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 */ }
}
/// <summary>Returns scenario guide entries (MSQ/Job path hints). Uses AgentScenarioTree when populated; otherwise derives next MSQ/Job quest from game data.</summary>
public static unsafe List<ScenarioGuideEntry> GetScenarioGuideEntries()
{
var result = new List<ScenarioGuideEntry>();
try
{
var questSheet = Plugin.DataManager.GetExcelSheet<Quest>();
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<ScenarioGuideEntry> result, ExcelSheet<Quest> questSheet)
{
if (result.Any(e => e.Label == "Main Scenario")) return;
try
{
var scenarioSheet = Plugin.DataManager.GetExcelSheet<ScenarioTree>();
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<ScenarioGuideEntry> result, ExcelSheet<Quest> 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 */ }
}
/// <summary>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.</summary>
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<string>();
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<Lumina.Excel.Sheets.ContentFinderCondition>();
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<Lumina.Excel.Sheets.ContentRoulette>();
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;
}
}
/// <summary>Returns current duty information when inside a dungeon/trial/raid (BoundByDuty and not in queue); otherwise null.</summary>
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<DutyObjectiveEntry>();
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;
}
}
/// <summary>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").</summary>
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;
}
}
/// <summary>True if the value looks like a static estimate ("7m", "2m 30s", "Less than 5m") rather than a timer ("7:01").</summary>
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);
}
/// <summary>Format duty objective progress from game format (e.g. "0/1:0") to "X out of Y step(s)" with " ✓" when completed.</summary>
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;
}
}
}