Files
HSMappy/Mappy/Controllers/IntegrationsController.cs

646 lines
26 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Hooking;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.Game.Control;
using FFXIVClientStructs.FFXIV.Client.Game.UI;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using KamiLib.Classes;
using KamiLib.Extensions;
using Lumina.Excel.Sheets;
using Mappy.Classes;
using Mappy.Extensions;
using MapType = FFXIVClientStructs.FFXIV.Client.UI.Agent.MapType;
namespace Mappy.Controllers;
public unsafe class IntegrationsController : IDisposable
{
private readonly Hook<AgentMap.Delegates.ShowMap>? showMapHook;
private readonly Hook<AgentMap.Delegates.OpenMap>? openMapHook;
private bool _wasBetweenAreas;
private int _lastQuestCount = -1;
private int _lastTempMarkerCount = -1;
/// <summary>Snapshot of (QuestId, Sequence, variables) for each active quest; when this changes we refresh so markers update (e.g. multi-step objective).</summary>
private string _lastQuestSequenceSnapshot = string.Empty;
/// <summary>Snapshot of temp marker positions; when this changes we refresh so quest area circles update (e.g. marker moved from 1/3 to 2/3 location).</summary>
private string _lastTempMarkerSnapshot = string.Empty;
/// <summary>True on the previous frame if player was in NPC or quest dialogue (Occupied / OccupiedInQuestEvent). Used to refresh minimap when dialogue ends (e.g. after speaking to quest NPC with no step counter).</summary>
private bool _wasInQuestOrNpcDialogue;
private bool _refreshedDuringLoad;
/// <summary>When true, request a silent refresh on the next framework update (e.g. after plugin load).</summary>
private bool _requestRefreshOnLoad = true;
/// <summary>Frames to wait before moving the map off-screen after a silent refresh so the game has time to populate markers.</summary>
private int _silentRefreshHideFramesRemaining;
/// <summary>Frames to skip quest/temp-marker-triggered silent refresh after user opened map via Duty List (quest/gathering/flag/teleport).</summary>
private int _suppressSilentRefreshFramesRemaining;
/// <summary>Frames after user opened map via Duty List; OnShowHook should not Hide() during this window (ProcessingCommand is cleared when MapWindow opens).</summary>
private int _userOpenedMapFramesRemaining;
/// <summary>True while we're doing a silent refresh; OnAreaMapPreShow should not open the MapWindow.</summary>
public static bool SilentRefreshInProgress { get; private set; }
public IntegrationsController()
{
showMapHook ??=
Service.Hooker.HookFromAddress<AgentMap.Delegates.ShowMap>(AgentMap.MemberFunctionPointers.ShowMap,
OnShowHook);
openMapHook ??=
Service.Hooker.HookFromAddress<AgentMap.Delegates.OpenMap>(AgentMap.MemberFunctionPointers.OpenMap,
OnOpenMapHook);
if (Service.ClientState is { IsPvP: false })
{
EnableIntegrations();
}
Service.ClientState.EnterPvP += DisableIntegrations;
Service.ClientState.LeavePvP += EnableIntegrations;
Service.Framework.Update += OnFrameworkUpdate;
}
public void Dispose()
{
Service.Framework.Update -= OnFrameworkUpdate;
DisableIntegrations();
showMapHook?.Dispose();
openMapHook?.Dispose();
Service.ClientState.EnterPvP -= DisableIntegrations;
Service.ClientState.LeavePvP -= EnableIntegrations;
}
private void EnableIntegrations()
{
Service.Log.Debug("Enabling Integrations");
showMapHook?.Enable();
openMapHook?.Enable();
// System.AreaMapController.EnableIntegrations();
System.FlagController.EnableIntegrations();
}
private void DisableIntegrations()
{
Service.Log.Debug("Disabling Integrations");
showMapHook?.Disable();
openMapHook?.Disable();
// System.AreaMapController.DisableIntegrations();
System.FlagController.DisableIntegrations();
}
/// <summary>
/// On load/quest accept/turn-in/objective update we open the map briefly so the game populates markers, then Hide().
/// The map renderer caches static, temp, and event marker positions during that time so the minimap can draw them after the map is closed.
/// </summary>
private void OnFrameworkUpdate(IFramework framework)
{
if (Service.ClientState is not { IsLoggedIn: true } or { IsPvP: true }) return;
// If we're in the middle of a silent refresh, count down then Hide() so the map closes and we don't affect other plugins
if (_silentRefreshHideFramesRemaining > 0) {
_silentRefreshHideFramesRemaining--;
if (_silentRefreshHideFramesRemaining == 0) {
try { AgentMap.Instance()->Hide(); } catch { }
SilentRefreshInProgress = false;
}
// Do NOT update quest/temp baselines here: if the user accepts a quest or advances a step during
// these frames, we would overwrite the baseline and never trigger a refresh for that change.
_wasBetweenAreas = Service.Condition.IsBetweenAreas();
return;
}
var betweenAreas = Service.Condition.IsBetweenAreas();
// First frame after leaving a load screen: refresh so minimap has static POI and markers without user opening map
if (_wasBetweenAreas && !betweenAreas)
RequestSilentRefresh();
// Once per load screen: refresh while the screen is black so the game populates markers
if (betweenAreas) {
if (!_refreshedDuringLoad)
RequestSilentRefresh();
_refreshedDuringLoad = true;
} else {
_refreshedDuringLoad = false;
}
_wasBetweenAreas = betweenAreas;
// On plugin load (first frame we're in a zone), refresh so markers are populated immediately
if (_requestRefreshOnLoad) {
_requestRefreshOnLoad = false;
RequestSilentRefresh();
}
// When player exits NPC or quest dialogue, refresh so minimap updates (e.g. "go to destination and speak to NPC" with no 1/3, 2/3 step counter).
var inDialogue = Service.Condition[ConditionFlag.Occupied] || Service.Condition[ConditionFlag.OccupiedInQuestEvent];
if (_wasInQuestOrNpcDialogue && !inDialogue)
RequestSilentRefresh();
_wasInQuestOrNpcDialogue = inDialogue;
// Quest turned in, quest accepted, or objectives updated: refresh so markers stay in sync
// Skip these triggers when user just opened map via Duty List (quest/gathering/flag/teleport)
// so we don't close the map they intentionally opened.
if (_suppressSilentRefreshFramesRemaining > 0) {
_suppressSilentRefreshFramesRemaining--;
}
if (_userOpenedMapFramesRemaining > 0) {
_userOpenedMapFramesRemaining--;
}
var skipQuestTempRefresh = _suppressSilentRefreshFramesRemaining > 0;
var questCount = GetActiveQuestCount();
var tempCount = -1;
try { tempCount = (int)AgentMap.Instance()->TempMapMarkerCount; } catch { }
var sequenceSnapshot = GetQuestSequenceSnapshot();
var tempMarkerSnapshot = GetTempMarkerSnapshot();
if (!skipQuestTempRefresh) {
if (_lastQuestCount >= 0 && questCount < _lastQuestCount)
RequestSilentRefresh(); // quest turned in
if (_lastQuestCount >= 0 && questCount > _lastQuestCount)
RequestSilentRefresh(); // quest accepted
if (_lastTempMarkerCount >= 0 && tempCount >= 0 && tempCount < _lastTempMarkerCount)
RequestSilentRefresh(); // objectives decreased
if (_lastTempMarkerCount >= 0 && tempCount >= 0 && tempCount > _lastTempMarkerCount)
RequestSilentRefresh(); // objectives added (e.g. new quest)
if (_lastQuestSequenceSnapshot.Length > 0 && sequenceSnapshot != _lastQuestSequenceSnapshot)
RequestSilentRefresh(); // quest step advanced (multi-step objective)
if (_lastTempMarkerSnapshot.Length > 0 && tempMarkerSnapshot.Length > 0 && tempMarkerSnapshot != _lastTempMarkerSnapshot)
RequestSilentRefresh(); // marker positions changed (e.g. 1/3 -> 2/3, circle moved)
_lastQuestCount = questCount;
_lastTempMarkerCount = tempCount;
_lastQuestSequenceSnapshot = sequenceSnapshot;
_lastTempMarkerSnapshot = tempMarkerSnapshot;
} else {
// During suppression: only update temp baseline so we don't false-trigger when suppression
// ends (e.g. Duty List click repopulates markers). Keep _lastQuestCount and _lastQuestSequenceSnapshot
// so quest turn-in and objective progression during suppression still trigger refresh when suppression ends.
_lastTempMarkerCount = tempCount;
}
// Movement trail: record player position when enabled (Carbonite-style)
if (System.SystemConfig.ShowMovementTrail && Service.ObjectTable.LocalPlayer is { } localPlayer) {
try {
var agent = AgentMap.Instance();
System.MovementTrailConfig.TryAddPoint(
agent->CurrentTerritoryId,
agent->CurrentMapId,
localPlayer.Position.X,
localPlayer.Position.Z);
} catch { /* ignore */ }
}
}
/// <summary>Build a string of (QuestId, Sequence, variables) for each active quest so we can detect step advances and multi-step objective progress (1/3, 2/3, etc.).</summary>
private static unsafe string GetQuestSequenceSnapshot()
{
try
{
var parts = new List<string>();
var span = QuestManager.Instance()->NormalQuests;
for (var i = 0; i < span.Length; i++)
{
ref var q = ref span[i];
if (q.QuestId is 0) continue;
// Include variables (objective progress) - changes when 1/3 -> 2/3 even if Sequence does not
var ptr = (byte*)Unsafe.AsPointer(ref q);
var varStr = string.Empty;
for (var j = 0; j < 6; j++) varStr += $"{ptr[0x0C + j]:X2}";
parts.Add($"{q.QuestId}:{q.Sequence}:{varStr}");
}
return string.Join("|", parts);
}
catch
{
return string.Empty;
}
}
/// <summary>Build a string of temp marker positions; when marker moves (e.g. 1/3 to 2/3 location) we detect and refresh.</summary>
private static unsafe string GetTempMarkerSnapshot()
{
try
{
var agent = AgentMap.Instance();
var count = agent->TempMapMarkerCount;
if (count == 0) return string.Empty;
var parts = new List<string>();
var seen = new HashSet<(int, int)>();
for (var i = 0; i < count; i++)
{
ref var m = ref agent->TempMapMarkers[i];
var key = (m.MapMarker.X, m.MapMarker.Y);
if (seen.Add(key))
parts.Add($"{m.MapMarker.X},{m.MapMarker.Y}");
}
return string.Join("|", parts.OrderBy(x => x));
}
catch
{
return string.Empty;
}
}
/// <summary>Call when user opens map via Duty List (quest/gathering/flag/teleport). Cancels any in-progress silent refresh so we never Hide() the map. Suppresses new quest/temp-marker-triggered refresh for ~1s. Must be called BEFORE openMapHook.Original so OnFrameworkUpdate cannot call Hide() first.</summary>
private void SuppressSilentRefreshForUserMapOpen()
{
_silentRefreshHideFramesRemaining = 0; // Cancel in-progress silent refresh so we never Hide() the map the user just opened
SilentRefreshInProgress = false;
_suppressSilentRefreshFramesRemaining = 30; // ~1 second at typical framerate
}
/// <summary>Request a silent map refresh; opens the map, waits a few frames for the game to populate markers (and caches), then Hide().</summary>
private void RequestSilentRefresh()
{
RequestSilentRefreshCore(framesBeforeHide: 5);
}
private void RequestSilentRefreshCore(int framesBeforeHide)
{
if (_silentRefreshHideFramesRemaining > 0) return;
try {
var agent = AgentMap.Instance();
var currentMapId = agent->CurrentMapId;
if (currentMapId == 0) return;
// Clear temp marker cache so old markers (e.g. from turned-in quest) don't persist
MapRenderer.MapRenderer.InvalidateTempMarkerCache(currentMapId);
SilentRefreshInProgress = true;
agent->OpenMapByMapId(currentMapId, 0, true);
agent->ResetMapMarkers();
_silentRefreshHideFramesRemaining = framesBeforeHide;
} catch {
SilentRefreshInProgress = false;
}
}
private static int GetActiveQuestCount()
{
try {
var count = 0;
foreach (var q in QuestManager.Instance()->NormalQuests) {
if (q.QuestId is not 0) count++;
}
return count;
} catch {
return -1;
}
}
/// <summary>
/// Open the current map and hide it after a few frames (see RequestSilentRefresh) so the game
/// populates MapMarkers, EventMarkers, and TempMapMarkers. Used when a refresh is requested
/// from code paths that don't use the frame-delayed flow (e.g. if needed elsewhere).
/// </summary>
private static void SilentRefreshMapMarkers()
{
try {
var agent = AgentMap.Instance();
var currentMapId = agent->CurrentMapId;
if (currentMapId == 0) return;
agent->OpenMapByMapId(currentMapId, 0, true);
agent->ResetMapMarkers();
agent->Hide();
} catch {
// Ignore
}
}
private void OnShowHook(AgentMap* agent, bool a1, bool a2) =>
HookSafety.ExecuteSafe(() =>
{
Service.Log.Verbose("[OnShow] Beginning Show");
// When user just opened via Duty List / gathering / flag / teleport, pass through immediately.
var userRequestedMap = System.MapWindow.ProcessingCommand || _userOpenedMapFramesRemaining > 0;
if (userRequestedMap)
{
showMapHook!.Original(agent, a1, a2);
return;
}
var addonId = AgentMap.Instance()->AddonId;
var currentMapId = AgentMap.Instance()->CurrentMapId;
var selectedMapId = AgentMap.Instance()->SelectedMapId;
if (System.MapWindow.IsOpen && addonId is 0)
{
Service.Log.Debug("[OnShow] MapWindow can not be open now.");
System.MapWindow.Close();
}
if (!ShouldShowMap())
{
Service.Log.Debug("[OnShow] Condition to open map is rejected, aborting.");
return;
}
// CurrentMapId != SelectedMapId = viewing quest map in different zone; pass through, don't Hide()
if (addonId is not 0 && currentMapId != selectedMapId)
{
showMapHook!.Original(agent, a1, a2);
return;
}
if (System.SystemConfig.KeepOpen)
{
Service.Log.Verbose("[OnShow] Keeping Open");
return;
}
showMapHook!.Original(agent, a1, a2);
}, Service.Log, "Exception during OnShowHook");
private static bool IsUserInitiatedMapOpen(MapType type) =>
type is MapType.QuestLog or MapType.GatheringLog or MapType.FlagMarker or MapType.Bozja
or MapType.MobHunt or MapType.SharedFate or MapType.Teleport or MapType.Treasure;
private void OnOpenMapHook(AgentMap* agent, OpenMapInfo* mapInfo) =>
HookSafety.ExecuteSafe(() =>
{
// MUST run before Original: cancel any in-progress silent refresh so OnFrameworkUpdate won't call Hide()
// after the game opens the map. Also set flags for OnShowHook pass-through.
if (IsUserInitiatedMapOpen(mapInfo->Type))
{
SuppressSilentRefreshForUserMapOpen();
System.MapWindow.ProcessingCommand = true;
_userOpenedMapFramesRemaining = 30; // Persists after ProcessingCommand cleared by MapWindow.OnOpen
}
openMapHook!.Original(agent, mapInfo);
switch (mapInfo->Type)
{
case MapType.QuestLog:
ProcessQuestLink(agent, mapInfo);
break;
case MapType.GatheringLog:
ProcessGatheringLink(agent);
break;
case MapType.FlagMarker:
ProcessFlagLink(agent);
break;
case MapType.Bozja:
ProcessForayLink(agent, mapInfo);
break;
case MapType.MobHunt:
case MapType.SharedFate:
case MapType.Teleport:
case MapType.Treasure:
ProcessTeleportLink(agent, mapInfo);
break;
// This appears to get triggered after a Teleport/Shared Fate teleport event.
case MapType.Centered:
case MapType.AetherCurrent:
default:
Service.Log.Debug($"[OpenMap] Ignoring MapType: {mapInfo->Type}");
break;
}
if (System.SystemConfig.AutoZoom)
{
MapRenderer.MapRenderer.Scale =
DrawHelpers.GetMapScaleFactor() * System.SystemConfig.AutoZoomScaleFactor;
}
}, Service.Log, "Exception during OpenMap");
private void ProcessQuestLink(AgentMap* agent, OpenMapInfo* mapInfo)
{
Service.Log.Debug("[OpenMap] Processing QuestLog Event");
var targetMapId = mapInfo->MapId;
if (GetMapIdForQuest(mapInfo) is { } foundMapId)
{
Service.Log.Debug($"[OpenMap] GetMapIdForQuest identified Quest Target Map as MapId: {foundMapId}");
if (targetMapId is 0)
{
Service.Log.Debug($"[OpenMap] targetMapId was {targetMapId} using foundMapId: {foundMapId}");
targetMapId = foundMapId;
}
}
if (agent->SelectedMapId != targetMapId)
{
Service.Log.Debug($"[OpenMap] Opening MapId: {targetMapId}");
OpenMap(targetMapId);
}
else
{
Service.Log.Debug($"[OpenMap] Already in MapId: {targetMapId}, aborting.");
}
if (System.SystemConfig.CenterOnQuest)
{
ref var targetMarker = ref agent->TempMapMarkers[0].MapMarker;
CenterOnMarker(targetMarker);
Service.Log.Debug($"[OpenMap] Centering Map on X = {targetMarker.X}, Y = {targetMarker.Y}");
}
SuppressSilentRefreshForUserMapOpen();
System.MapWindow.ProcessingCommand = true;
}
private void ProcessGatheringLink(AgentMap* agent)
{
Service.Log.Debug("[OpenMap] Processing GatheringLog Event");
if (System.SystemConfig.CenterOnGathering)
{
ref var targetMarker = ref agent->TempMapMarkers[0].MapMarker;
CenterOnMarker(targetMarker);
Service.Log.Debug($"[OpenMap] Centering Map on X = {targetMarker.X}, Y = {targetMarker.Y}");
}
SuppressSilentRefreshForUserMapOpen();
System.MapWindow.ProcessingCommand = true;
}
private void ProcessFlagLink(AgentMap* agent)
{
Service.Log.Debug("[OpenMap] Processing FlagMarker Event");
if (System.SystemConfig.CenterOnFlag)
{
ref var targetMarker = ref agent->FlagMapMarkers[0].MapMarker;
CenterOnMarker(targetMarker);
Service.Log.Debug($"[OpenMap] Centering Map on X = {targetMarker.X}, Y = {targetMarker.Y}");
}
SuppressSilentRefreshForUserMapOpen();
System.MapWindow.ProcessingCommand = true;
}
private void ProcessForayLink(AgentMap* agent, OpenMapInfo* mapInfo)
{
Service.Log.Debug("[OpenMap] Processing Bozja Event");
var eventMarker =
agent->EventMarkers.FirstOrNull(marker => marker.DataId == mapInfo->FateId && marker.Flags == 0x40);
if (eventMarker is not null)
{
CenterOnMarker(eventMarker.Value);
}
SuppressSilentRefreshForUserMapOpen();
System.MapWindow.ProcessingCommand = true;
}
private void ProcessTeleportLink(AgentMap* agent, OpenMapInfo* mapInfo)
{
Service.Log.Debug("[OpenMap] Processing Teleport Event");
var targetMapId = mapInfo->MapId;
if (agent->CurrentMapId != targetMapId)
{
Service.Log.Debug($"[OpenMap] Opening MapId: {targetMapId}");
OpenMap(mapInfo->MapId);
SuppressSilentRefreshForUserMapOpen();
System.MapWindow.ProcessingCommand = true;
return;
}
Service.Log.Debug($"[OpenMap] Already in MapId: {targetMapId}, aborting.");
SuppressSilentRefreshForUserMapOpen();
System.MapWindow.ProcessingCommand = true;
}
public void OpenMap(uint mapId)
{
AgentMap.Instance()->OpenMapByMapId(mapId, 0, true);
// Since this is effecting state, we need to keep an eye on it for potential issues.
AgentMap.Instance()->ResetMapMarkers();
}
/// <summary>
/// Ask the game to refresh its map markers (quests, FATEs, gathering, etc.) for the current map.
/// Uses OpenMapByMapId with show=false so the game repopulates markers without opening the Area Map UI.
/// Call periodically when the minimap is visible so it stays in sync.
/// </summary>
public static void RefreshMapMarkers()
{
try {
var agent = AgentMap.Instance();
var currentMapId = agent->CurrentMapId;
if (currentMapId == 0) return;
// OpenMapByMapId with show=false: refresh map/marker data for current map without showing the map addon.
agent->OpenMapByMapId(currentMapId, 0, false);
} catch {
// Ignore if agent not ready
}
}
public void OpenOccupiedMap() => OpenMap(AgentMap.Instance()->CurrentMapId);
private static void CenterOnMarker(MapMarkerBase marker)
{
var coordinates = new Vector2(marker.X, marker.Y) / 16.0f * DrawHelpers.GetMapScaleFactor() -
DrawHelpers.GetMapOffsetVector();
System.SystemConfig.FollowPlayer = false;
System.MapRenderer.DrawOffset = -coordinates;
}
private static void CenterOnMarker(MapMarkerData marker)
{
var coordinates = marker.Position.AsMapVector() * DrawHelpers.GetMapScaleFactor() -
DrawHelpers.GetMapOffsetVector();
System.SystemConfig.FollowPlayer = false;
System.MapRenderer.DrawOffset = -coordinates;
}
/// <summary>When true, minimap uses same visibility as main map (hides during dialogue/interaction if Hide With Game GUI is on). When false, minimap stays visible during dialogue and object interaction.</summary>
public static bool ShouldShowMinimap()
{
if (Service.ClientState is { IsLoggedIn: false } or { IsPvP: true }) return false;
if (System.SystemConfig.HideInCombat && Service.Condition.IsInCombat()) return false;
if (System.SystemConfig.HideBetweenAreas && Service.Condition.IsBetweenAreas()) return false;
if (!System.SystemConfig.MinimapHideWithGameGui) return true;
// Don't hide during dialogue (Occupied = NPC dialogue, OccupiedInQuestEvent = quest dialogue)
if (Service.Condition[ConditionFlag.Occupied] || Service.Condition[ConditionFlag.OccupiedInQuestEvent])
return true;
// Same as main map for non-dialogue cases
if (System.SystemConfig.HideWithGameGui && !IsNamePlateAddonVisible()) return false;
if (System.SystemConfig.HideWithGameGui && Control.Instance()->TargetSystem.TargetModeIndex is 1) return false;
return true;
}
public static bool ShouldShowMap()
{
if (Service.ClientState is { IsLoggedIn: false } or { IsPvP: true }) return false;
if (System.SystemConfig.HideInCombat && Service.Condition.IsInCombat()) return false;
if (System.SystemConfig.HideBetweenAreas && Service.Condition.IsBetweenAreas()) return false;
if (System.SystemConfig.HideWithGameGui && !IsNamePlateAddonVisible()) return false;
if (System.SystemConfig.HideWithGameGui && Control.Instance()->TargetSystem.TargetModeIndex is 1) return false;
return true;
}
private static bool IsNamePlateAddonVisible() =>
!RaptureAtkUnitManager.Instance()->UiFlags.HasFlag(UIModule.UiFlags.Nameplates);
private uint? GetMapIdForQuest(OpenMapInfo* mapInfo)
{
foreach (var leveQuest in QuestManager.Instance()->LeveQuests)
{
if (leveQuest.LeveId is 0) continue;
var leveData = Service.DataManager.GetExcelSheet<Leve>().GetRow(leveQuest.LeveId);
if (!IsNameMatch(leveData.Name.ExtractText(), mapInfo)) continue;
return leveData.LevelStart.Value.Map.RowId;
}
foreach (var quest in QuestManager.Instance()->NormalQuests)
{
if (quest.QuestId is 0) continue;
// Is this the quest we are looking for?
var questData = Service.DataManager.GetExcelSheet<Quest>().GetRow(quest.QuestId + 65536u);
if (!IsNameMatch(questData.Name.ExtractText(), mapInfo)) continue;
return questData
.TodoParams.FirstOrDefault(param => param.ToDoCompleteSeq == quest.Sequence)
.ToDoLocation.FirstOrDefault(location => location is not { RowId: 0, ValueNullable: null })
.Value.Map.RowId;
}
var possibleQuests = Service.DataManager.GetExcelSheet<Quest>()
.Where(quest => quest is { IssuerLocation: { IsValid: true, RowId: not 0 } })
.FirstOrNull(quest => IsNameMatch(quest.Name.ExtractText(), mapInfo));
return possibleQuests?.IssuerLocation.Value.Map.RowId ?? null;
}
private static bool IsNameMatch(string name, OpenMapInfo* mapInfo) => string.Equals(name,
mapInfo->TitleString.ToString(), StringComparison.OrdinalIgnoreCase);
}