1
0
Fork 0
forked from aly/qstbak
qstbak/Questionable/Questionable.Navigation/WarpRecordingService.cs
2026-08-17 20:29:32 +10:00

501 lines
14 KiB
C#

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<WarpRecordingService> _logger;
private readonly Hook<ProcessEventPlayDelegate>? _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<uint, int> _ticketInventoryCounts = new Dictionary<uint, int>();
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<WarpCapture>? WarpCaptured;
public event Action<TicketCapture>? 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<WarpRecordingService> 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<ProcessEventPlayDelegate>((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<Warp>().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;
}
}