using System; using System.Collections.Generic; using System.Linq; using System.Numerics; 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? showMapHook; private readonly Hook? openMapHook; private bool _wasBetweenAreas; private int _lastQuestCount = -1; private int _lastTempMarkerCount = -1; /// Snapshot of (QuestId, Sequence) for each active quest; when this changes we refresh so markers update (e.g. multi-step objective). private string _lastQuestSequenceSnapshot = string.Empty; private bool _refreshedDuringLoad; /// When true, request a silent refresh on the next framework update (e.g. after plugin load). private bool _requestRefreshOnLoad = true; /// Frames to wait before moving the map off-screen after a silent refresh so the game has time to populate markers. private int _silentRefreshHideFramesRemaining; /// Frames to skip quest/temp-marker-triggered silent refresh after user opened map via Duty List (quest/gathering/flag/teleport). private int _suppressSilentRefreshFramesRemaining; /// Frames after user opened map via Duty List; OnShowHook should not Hide() during this window (ProcessingCommand is cleared when MapWindow opens). private int _userOpenedMapFramesRemaining; /// True while we're doing a silent refresh; OnAreaMapPreShow should not open the MapWindow. public static bool SilentRefreshInProgress { get; private set; } public IntegrationsController() { showMapHook ??= Service.Hooker.HookFromAddress(AgentMap.MemberFunctionPointers.ShowMap, OnShowHook); openMapHook ??= Service.Hooker.HookFromAddress(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(); } /// /// 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. /// 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; } _wasBetweenAreas = Service.Condition.IsBetweenAreas(); _lastQuestCount = GetActiveQuestCount(); try { _lastTempMarkerCount = (int)AgentMap.Instance()->TempMapMarkerCount; } catch { } 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(); } // 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(); 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) _lastQuestCount = questCount; _lastTempMarkerCount = tempCount; _lastQuestSequenceSnapshot = sequenceSnapshot; } 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 */ } } } /// Build a string of (QuestId, Sequence) for each active quest so we can detect step advances. private static unsafe string GetQuestSequenceSnapshot() { try { var parts = new List(); foreach (var q in QuestManager.Instance()->NormalQuests) { if (q.QuestId is 0) continue; parts.Add($"{q.QuestId}:{q.Sequence}"); } return string.Join("|", parts); } catch { return string.Empty; } } /// 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. 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 } /// Request a silent map refresh; opens the map, waits a few frames for the game to populate markers (and caches), then Hide(). 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; } } /// /// 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). /// 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(); } /// /// 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. /// 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; } /// 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. 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().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().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() .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); }