using System; using System.Collections.Generic; using System.IO; using System.Numerics; using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Hooking; using Dalamud.Plugin; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game.Event; using FFXIVClientStructs.FFXIV.Client.Game.Object; using Lumina.Excel.Sheets; using Microsoft.Extensions.Logging; using Questionable.Functions; using Questionable.Model.Questing; using SmartNav.Data; namespace Questionable.Navigation; internal sealed class WarpRecordingService : IDisposable { private unsafe delegate void ProcessEventPlayDelegate(EventFramework* self, GameObject* gameObject, FFXIVClientStructs.FFXIV.Client.Game.Event.EventId eventId, short scene, ulong sceneFlags, uint* sceneData, byte sceneDataCount); private enum EWarpPhase { None, Armed, DialogOpen, AwaitingTransit, InTransit, JumpSettling } public sealed record WarpCapture(uint WarpRowId, string WarpName, uint? NpcDataId, ushort SourceTerritoryId, Vector3 SourcePosition, ushort DestTerritoryId, Vector3 DestPosition, uint? SeenDuringMsqId); public sealed record TicketCapture(uint ItemId, string ItemName, ushort DestTerritoryId, Vector3 DestPosition); private const float JumpDistance = 40f; private static readonly TimeSpan CancelGrace = TimeSpan.FromSeconds(3L); private static readonly TimeSpan JumpSettle = TimeSpan.FromSeconds(1L); private static readonly TimeSpan ArmedTimeout = TimeSpan.FromSeconds(120L); private static readonly TimeSpan TicketTimeout = TimeSpan.FromSeconds(30L); private readonly IClientState _clientState; private readonly IObjectTable _objectTable; private readonly ITargetManager _targetManager; private readonly ICondition _condition; private readonly IFramework _framework; private readonly IDataManager _dataManager; private readonly Configuration _configuration; private readonly QuestFunctions _questFunctions; private readonly TeleportTicketService _teleportTicketService; private readonly ILogger _logger; private readonly Hook? _processEventPlayHook; private EWarpPhase _warpPhase; private uint _warpRowId; private string _warpName = string.Empty; private uint? _npcDataId; private ushort _sourceTerritory; private Vector3 _sourcePosition; private uint? _seenMsqId; private DateTime _armedAt; private DateTime _awaitTransitSince; private DateTime _jumpDetectedAt; private uint _lastPlayerTerritory; private Vector3? _lastPlayerPosition; private readonly bool _devEnvironmentGate; private uint? _ticketItemId; private string _ticketItemName = string.Empty; private bool _ticketSeenLoading; private DateTime _ticketArmedAt; private readonly Dictionary _ticketInventoryCounts = new Dictionary(); public bool IsWarpPending => _warpPhase != EWarpPhase.None; public uint? PendingWarpRowId { get { if (!IsWarpPending) { return null; } return _warpRowId; } } public string? PendingWarpName { get { if (!IsWarpPending) { return null; } return _warpName; } } public bool IsTicketPending => _ticketItemId.HasValue; public uint? PendingTicketItemId => _ticketItemId; public string? PendingTicketItemName { get { if (!_ticketItemId.HasValue) { return null; } return _ticketItemName; } } public bool CaptureAvailable => _devEnvironmentGate; private bool AutoCaptureEnabled { get { if (_devEnvironmentGate) { return _configuration.Advanced.EnableNavDataCapture; } return false; } } public event Action? WarpCaptured; public event Action? TicketCaptured; public unsafe WarpRecordingService(IDalamudPluginInterface pluginInterface, IClientState clientState, IObjectTable objectTable, ITargetManager targetManager, ICondition condition, IFramework framework, IDataManager dataManager, IGameInteropProvider gameInteropProvider, Configuration configuration, QuestFunctions questFunctions, TeleportTicketService teleportTicketService, ILogger logger) { _clientState = clientState; _objectTable = objectTable; _targetManager = targetManager; _condition = condition; _framework = framework; _dataManager = dataManager; _configuration = configuration; _questFunctions = questFunctions; _teleportTicketService = teleportTicketService; _logger = logger; _devEnvironmentGate = pluginInterface.IsDev || File.Exists(Path.Combine(pluginInterface.ConfigDirectory.FullName, ".nav-authoring")); if (_devEnvironmentGate) { _logger.LogDebug("Warp recorder: dev environment gate open (IsDev={IsDev})", pluginInterface.IsDev); } try { _processEventPlayHook = gameInteropProvider.HookFromAddress((nint)EventFramework.MemberFunctionPointers.ProcessEventPlay, OnProcessEventPlay); _processEventPlayHook.Enable(); } catch (Exception exception) { _logger.LogError(exception, "Failed to hook ProcessEventPlay for warp recorder"); } _framework.Update += OnFrameworkTick; } public void ArmManual(uint warpRowId, string? warpName, uint npcDataId, ushort sourceTerritory, Vector3 sourcePosition) { if (_devEnvironmentGate) { Arm(warpRowId, warpName ?? $"Warp#{warpRowId}", (npcDataId != 0) ? new uint?(npcDataId) : ((uint?)null), sourceTerritory, sourcePosition); } } public bool CaptureWarpDestNow() { if (_warpPhase == EWarpPhase.None) { return false; } IGameObject gameObject = _objectTable[0]; if (gameObject == null || gameObject.Position == Vector3.Zero) { return false; } CompleteWarp(gameObject.Position); return true; } public void DismissWarp() { ResetWarp("dismissed"); } public bool CaptureTicketDestNow() { if (!_ticketItemId.HasValue) { return false; } IGameObject gameObject = _objectTable[0]; if (gameObject == null || gameObject.Position == Vector3.Zero) { return false; } TicketCapture ticketCapture = new TicketCapture(_ticketItemId.Value, _ticketItemName, (ushort)_clientState.TerritoryType, gameObject.Position); _logger.LogInformation("Ticket recorder: manually captured {Name} (#{Id}) -> {Territory}", ticketCapture.ItemName, ticketCapture.ItemId, ticketCapture.DestTerritoryId); ClearTicket(); this.TicketCaptured?.Invoke(ticketCapture); return true; } public void DismissTicket() { ClearTicket(); } public void Dispose() { _framework.Update -= OnFrameworkTick; _processEventPlayHook?.Disable(); _processEventPlayHook?.Dispose(); } private unsafe void OnProcessEventPlay(EventFramework* self, GameObject* gameObject, FFXIVClientStructs.FFXIV.Client.Game.Event.EventId eventId, short scene, ulong sceneFlags, uint* sceneData, byte sceneDataCount) { _processEventPlayHook.Original(self, gameObject, eventId, scene, sceneFlags, sceneData, sceneDataCount); try { if (AutoCaptureEnabled && eventId.ContentId == EventHandlerContent.Warp) { uint id = eventId.Id; string text = null; Warp? rowOrDefault = _dataManager.GetExcelSheet().GetRowOrDefault(id); if (rowOrDefault.HasValue) { string text2 = rowOrDefault.Value.Name.ExtractText(); string text3 = rowOrDefault.Value.Question.ExtractText(); text = ((!string.IsNullOrEmpty(text2)) ? text2 : text3); } uint? npcDataId; Vector3 sourcePosition; if (gameObject != null) { npcDataId = ((gameObject->BaseId != 0) ? new uint?(gameObject->BaseId) : ((uint?)null)); sourcePosition = gameObject->Position; } else { IGameObject? target = _targetManager.Target; npcDataId = target?.BaseId; sourcePosition = target?.Position ?? _objectTable[0]?.Position ?? Vector3.Zero; } Arm(id, text ?? $"Warp#{id}", npcDataId, (ushort)_clientState.TerritoryType, sourcePosition); } } catch (Exception exception) { _logger.LogError(exception, "Warp recorder: ProcessEventPlay handler failed"); } } private void Arm(uint warpRowId, string warpName, uint? npcDataId, ushort sourceTerritory, Vector3 sourcePosition) { _warpPhase = EWarpPhase.Armed; _warpRowId = warpRowId; _warpName = warpName; _npcDataId = npcDataId; _sourceTerritory = sourceTerritory; _sourcePosition = sourcePosition; _seenMsqId = CurrentMsqQuestId(); _armedAt = DateTime.UtcNow; _logger.LogDebug("Warp recorder: armed warp {Id} ({Name}) at territory {Territory} ({X:F1}, {Y:F1}, {Z:F1}), MSQ {Msq}", warpRowId, warpName, sourceTerritory, sourcePosition.X, sourcePosition.Y, sourcePosition.Z, _seenMsqId); } private void OnFrameworkTick(IFramework _) { IGameObject gameObject = _objectTable[0]; bool flag = _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51]; DateTime utcNow = DateTime.UtcNow; TickWarp(gameObject, flag, utcNow); TickTicket(gameObject, flag, utcNow); if (gameObject != null && !flag) { _lastPlayerTerritory = _clientState.TerritoryType; _lastPlayerPosition = gameObject.Position; } else { _lastPlayerPosition = null; } } private void TickWarp(IGameObject? player, bool loading, DateTime now) { if (_warpPhase == EWarpPhase.None) { return; } bool flag = _condition[ConditionFlag.OccupiedInQuestEvent] || _condition[ConditionFlag.OccupiedInEvent]; switch (_warpPhase) { case EWarpPhase.Armed: if (loading) { EnterTransit(); } else if (flag) { _warpPhase = EWarpPhase.DialogOpen; } else if (!TryDetectJump(player, now) && now - _armedAt > ArmedTimeout) { ResetWarp("no transit within timeout"); } break; case EWarpPhase.DialogOpen: if (loading) { EnterTransit(); } else if (!flag) { _warpPhase = EWarpPhase.AwaitingTransit; _awaitTransitSince = now; } break; case EWarpPhase.AwaitingTransit: if (loading) { EnterTransit(); } else if (!TryDetectJump(player, now) && now - _awaitTransitSince > CancelGrace) { ResetWarp("dialog closed without transit (cancelled)"); } break; case EWarpPhase.InTransit: if (!loading && player != null && player.Position != Vector3.Zero) { CompleteWarp(player.Position); } break; case EWarpPhase.JumpSettling: if (loading) { EnterTransit(); } else if (now - _jumpDetectedAt > JumpSettle && player != null && player.Position != Vector3.Zero) { CompleteWarp(player.Position); } break; } } private void EnterTransit() { _warpPhase = EWarpPhase.InTransit; _logger.LogDebug("Warp recorder: loading transition detected for pending warp {Id}", _warpRowId); } private bool TryDetectJump(IGameObject? player, DateTime now) { if (player == null || !_lastPlayerPosition.HasValue || _lastPlayerTerritory != _clientState.TerritoryType) { return false; } if (Vector3.DistanceSquared(player.Position, _lastPlayerPosition.Value) < 1600f) { return false; } _warpPhase = EWarpPhase.JumpSettling; _jumpDetectedAt = now; _logger.LogDebug("Warp recorder: position jump detected for pending warp {Id}", _warpRowId); return true; } private void CompleteWarp(Vector3 destPosition) { WarpCapture warpCapture = new WarpCapture(_warpRowId, _warpName, _npcDataId, _sourceTerritory, _sourcePosition, (ushort)_clientState.TerritoryType, destPosition, _seenMsqId); _logger.LogInformation("Warp recorder: captured warp {Id} ({Name}): {SrcTerritory} -> {DstTerritory} at ({X:F1}, {Y:F1}, {Z:F1}), MSQ {Msq}", warpCapture.WarpRowId, warpCapture.WarpName, warpCapture.SourceTerritoryId, warpCapture.DestTerritoryId, destPosition.X, destPosition.Y, destPosition.Z, warpCapture.SeenDuringMsqId); ResetWarp(null); this.WarpCaptured?.Invoke(warpCapture); } private void ResetWarp(string? reason) { if (reason != null && _warpPhase != EWarpPhase.None) { _logger.LogDebug("Warp recorder: clearing pending warp {Id}: {Reason}", _warpRowId, reason); } _warpPhase = EWarpPhase.None; _npcDataId = null; _seenMsqId = null; _warpName = string.Empty; } private unsafe void TickTicket(IGameObject? player, bool loading, DateTime now) { if (!_ticketItemId.HasValue) { if (!AutoCaptureEnabled) { if (_ticketInventoryCounts.Count > 0) { _ticketInventoryCounts.Clear(); } } else { if (loading) { return; } InventoryManager* ptr = InventoryManager.Instance(); if (ptr == null) { return; } foreach (TeleportTicketService.ScannedTicket scannedTicket in _teleportTicketService.ScannedTickets) { int inventoryItemCount = ptr->GetInventoryItemCount(scannedTicket.ItemId, isHq: false, checkEquipped: true, checkArmory: true, 0); if (_ticketInventoryCounts.TryGetValue(scannedTicket.ItemId, out var value) && inventoryItemCount < value) { _logger.LogInformation("Ticket recorder: {Name} (#{Id}) count decreased {Old} -> {New}, arming capture", scannedTicket.Name, scannedTicket.ItemId, value, inventoryItemCount); _ticketItemId = scannedTicket.ItemId; _ticketItemName = scannedTicket.Name; _ticketSeenLoading = false; _ticketArmedAt = now; } _ticketInventoryCounts[scannedTicket.ItemId] = inventoryItemCount; } } } else if (loading) { _ticketSeenLoading = true; } else if (_ticketSeenLoading) { if (player != null && !(player.Position == Vector3.Zero)) { TicketCapture ticketCapture = new TicketCapture(_ticketItemId.Value, _ticketItemName, (ushort)_clientState.TerritoryType, player.Position); _logger.LogInformation("Ticket recorder: captured {Name} (#{Id}) -> {Territory} ({X:F1}, {Y:F1}, {Z:F1})", ticketCapture.ItemName, ticketCapture.ItemId, ticketCapture.DestTerritoryId, player.Position.X, player.Position.Y, player.Position.Z); ClearTicket(); this.TicketCaptured?.Invoke(ticketCapture); } } else if (now - _ticketArmedAt > TicketTimeout) { _logger.LogDebug("Ticket recorder: no transit after ticket use, clearing pending {Id}", _ticketItemId); ClearTicket(); } } private void ClearTicket() { _ticketItemId = null; _ticketItemName = string.Empty; _ticketSeenLoading = false; } private uint? CurrentMsqQuestId() { if (!(_questFunctions.GetMainScenarioQuest().Item1.CurrentQuest is QuestId questId)) { return null; } return questId.Value; } }