using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.Game.Object; using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; using MareSynchronos.Services; using MareSynchronos.Services.Mediator; using Microsoft.Extensions.Logging; using Penumbra.String; using System.Runtime.InteropServices; using ObjectKind = MareSynchronos.API.Data.Enum.ObjectKind; namespace MareSynchronos.PlayerData.Handlers; public sealed class GameObjectHandler : DisposableMediatorSubscriberBase { private readonly DalamudUtilService _dalamudUtil; private readonly Func _getAddress; private readonly bool _isOwnedObject; private readonly PerformanceCollectorService _performanceCollector; private CancellationTokenSource? _clearCts = new(); private Task? _delayedZoningTask; private bool _haltProcessing = false; private bool _ignoreSendAfterRedraw = false; private CancellationTokenSource _zoningCts = new(); public GameObjectHandler(ILogger logger, PerformanceCollectorService performanceCollector, MareMediator mediator, DalamudUtilService dalamudUtil, ObjectKind objectKind, Func getAddress, bool watchedObject = true) : base(logger, mediator) { _performanceCollector = performanceCollector; ObjectKind = objectKind; _dalamudUtil = dalamudUtil; _getAddress = () => { _dalamudUtil.EnsureIsOnFramework(); return getAddress.Invoke(); }; _isOwnedObject = watchedObject; Name = string.Empty; if (watchedObject) { Mediator.Subscribe(this, (msg) => { if (_delayedZoningTask?.IsCompleted ?? true) { if (msg.Address != Address) return; Mediator.Publish(new CreateCacheForObjectMessage(this)); } }); Mediator.Publish(new AddWatchedGameObjectHandler(this)); } Mediator.Subscribe(this, (_) => FrameworkUpdate()); Mediator.Subscribe(this, (_) => ZoneSwitchEnd()); Mediator.Subscribe(this, (_) => ZoneSwitchStart()); Mediator.Subscribe(this, (_) => { _haltProcessing = true; }); Mediator.Subscribe(this, (_) => { _haltProcessing = false; }); Mediator.Subscribe(this, (msg) => { if (msg.Address == Address) { _haltProcessing = true; } }); Mediator.Subscribe(this, (msg) => { if (msg.Address == Address) { _haltProcessing = false; Task.Run(async () => { _ignoreSendAfterRedraw = true; await Task.Delay(500).ConfigureAwait(false); _ignoreSendAfterRedraw = false; }); } }); _dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult(); } public IntPtr Address { get; private set; } public string Name { get; private set; } public ObjectKind ObjectKind { get; } private byte[] CustomizeData { get; set; } = new byte[26]; private IntPtr DrawObjectAddress { get; set; } private byte[] EquipSlotData { get; set; } = new byte[40]; public async Task ActOnFrameworkAfterEnsureNoDrawAsync(Action act, CancellationToken token) { while (await _dalamudUtil.RunOnFrameworkThread(() => { if (IsBeingDrawn()) return true; act(); return false; }).ConfigureAwait(false)) { await Task.Delay(250, token).ConfigureAwait(false); } } public IntPtr CurrentAddress() { _dalamudUtil.EnsureIsOnFramework(); return _getAddress.Invoke(); } public Dalamud.Game.ClientState.Objects.Types.GameObject? GetGameObject() { return _dalamudUtil.CreateGameObject(Address); } public async Task IsBeingDrawnRunOnFrameworkAsync() { return await _dalamudUtil.RunOnFrameworkThread(IsBeingDrawn).ConfigureAwait(false); } public override string ToString() { var owned = _isOwnedObject ? "Self" : "Other"; return $"{owned}/{ObjectKind}:{Name} ({Address:X},{DrawObjectAddress:X})"; } protected override void Dispose(bool disposing) { base.Dispose(disposing); if (_isOwnedObject) Mediator.Publish(new RemoveWatchedGameObjectHandler(this)); } private unsafe void CheckAndUpdateObject() { if (_haltProcessing) return; var prevAddr = Address; var prevDrawObj = DrawObjectAddress; Address = _getAddress(); if (Address != IntPtr.Zero) { var drawObjAddr = (IntPtr)((GameObject*)Address)->DrawObject; DrawObjectAddress = drawObjAddr; } else { DrawObjectAddress = IntPtr.Zero; } bool drawObjDiff = DrawObjectAddress != prevDrawObj; bool addrDiff = Address != prevAddr; if (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero) { if (_clearCts != null) { Logger.LogDebug("[{this}] Cancelling Clear Task", this); _clearCts?.Cancel(); _clearCts = null; } var chara = (Character*)Address; var name = new ByteString(chara->GameObject.Name).ToString(); bool nameChange = !string.Equals(name, Name, StringComparison.Ordinal); Name = name; bool equipDiff = CompareAndUpdateEquipByteData(chara->EquipSlotData); if (equipDiff && !_isOwnedObject && !_ignoreSendAfterRedraw) // send the message out immediately and cancel out, no reason to continue if not self { Logger.LogTrace("[{this}] Changed", this); Mediator.Publish(new CharacterChangedMessage(this)); return; } var customizeDiff = CompareAndUpdateCustomizeData(chara->CustomizeData); if ((addrDiff || drawObjDiff || equipDiff || customizeDiff || nameChange) && _isOwnedObject) { Logger.LogDebug("[{this}] Changed, Sending CreateCacheObjectMessage", this); Mediator.Publish(new CreateCacheForObjectMessage(this)); } } else if (addrDiff || drawObjDiff) { Logger.LogTrace("[{this}] Changed", this); if (_isOwnedObject && ObjectKind != ObjectKind.Player) { _clearCts?.Cancel(); _clearCts?.Dispose(); _clearCts = new(); var token = _clearCts.Token; _ = Task.Run(() => ClearAsync(token), token); } } } private async Task ClearAsync(CancellationToken token) { Logger.LogDebug("[{this}] Running Clear Task", this); await Task.Delay(TimeSpan.FromSeconds(1), token).ConfigureAwait(false); Logger.LogDebug("[{this}] Sending ClearCachedForObjectMessage", this); Mediator.Publish(new ClearCacheForObjectMessage(this)); _clearCts = null; } private unsafe bool CompareAndUpdateCustomizeData(byte* customizeData) { bool hasChanges = false; for (int i = 0; i < CustomizeData.Length; i++) { var data = Marshal.ReadByte((IntPtr)customizeData, i); if (CustomizeData[i] != data) { CustomizeData[i] = data; hasChanges = true; } } return hasChanges; } private unsafe bool CompareAndUpdateEquipByteData(byte* equipSlotData) { bool hasChanges = false; for (int i = 0; i < EquipSlotData.Length; i++) { var data = Marshal.ReadByte((IntPtr)equipSlotData, i); if (EquipSlotData[i] != data) { EquipSlotData[i] = data; hasChanges = true; } } return hasChanges; } private void FrameworkUpdate() { if (!_delayedZoningTask?.IsCompleted ?? false) return; try { _performanceCollector.LogPerformance(this, "CheckAndUpdateObject>" + (_isOwnedObject ? "Self+" : "Other+") + ObjectKind + "/" + (string.IsNullOrEmpty(Name) ? "Unk" : Name) + "+" + Address.ToString("X"), CheckAndUpdateObject); } catch (Exception ex) { Logger.LogWarning(ex, "Error during FrameworkUpdate of {this}", this); } } private unsafe IntPtr GetDrawObj(nint curPtr) { Logger.LogTrace("[{this}] IsBeingDrawnRunOnFramework, Getting new DrawObject", this); return (IntPtr)((GameObject*)curPtr)->DrawObject; } private bool IsBeingDrawn() { var curPtr = _getAddress(); Logger.LogTrace("[{this}] IsBeingDrawnRunOnFramework, CurPtr: {ptr}", this, curPtr.ToString("X")); if (curPtr == IntPtr.Zero) { Logger.LogTrace("[{this}] IsBeingDrawnRunOnFramework, CurPtr is ZERO, returning", this); Address = IntPtr.Zero; DrawObjectAddress = IntPtr.Zero; return false; } var drawObj = GetDrawObj(curPtr); Logger.LogTrace("[{this}] IsBeingDrawnRunOnFramework, DrawObjPtr: {ptr}", this, drawObj.ToString("X")); return IsBeingDrawn(drawObj, curPtr); } private unsafe bool IsBeingDrawn(IntPtr drawObj, IntPtr curPtr) { Logger.LogTrace("[{this}] IsBeingDrawnRunOnFramework, Checking IsBeingDrawn for Ptr {curPtr} : DrawObj {drawObj}", this, curPtr.ToString("X"), drawObj.ToString("X")); if (ObjectKind == ObjectKind.Player) { var drawObjZero = drawObj == IntPtr.Zero; Logger.LogTrace("[{this}] IsBeingDrawnRunOnFramework, Condition IsDrawObjZero: {cond}", this, drawObjZero); if (drawObjZero) return true; var renderFlags = (((GameObject*)curPtr)->RenderFlags) != 0x0; Logger.LogTrace("[{this}] IsBeingDrawnRunOnFramework, Condition RenderFlags: {cond}", this, renderFlags); if (renderFlags) return true; var modelInSlotLoaded = (((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0); Logger.LogTrace("[{this}] IsBeingDrawnRunOnFramework, Condition ModelInSlotLoaded: {cond}", this, modelInSlotLoaded); if (modelInSlotLoaded) return true; var modelFilesInSlotLoaded = (((CharacterBase*)drawObj)->HasModelFilesInSlotLoaded != 0); Logger.LogTrace("[{this}] IsBeingDrawnRunOnFramework, Condition ModelFilesInSlotLoaded: {cond}", this, modelFilesInSlotLoaded); if (modelFilesInSlotLoaded) return true; Logger.LogTrace("[{this}] IsBeingDrawnRunOnFramework, Is not being drawn", this); return false; } return drawObj == IntPtr.Zero || ((GameObject*)curPtr)->RenderFlags != 0x0; } private void ZoneSwitchEnd() { if (!_isOwnedObject || _haltProcessing) return; _clearCts?.Cancel(); _clearCts?.Dispose(); _clearCts = null; _zoningCts.CancelAfter(2500); } private void ZoneSwitchStart() { if (!_isOwnedObject || _haltProcessing) return; _zoningCts = new(); Logger.LogDebug("[{obj}] Starting Delay After Zoning", this); _delayedZoningTask = Task.Run(async () => { try { await Task.Delay(TimeSpan.FromSeconds(120), _zoningCts.Token).ConfigureAwait(false); } catch { // ignore cancelled } finally { Logger.LogDebug("[{this}] Delay after zoning complete", this); _zoningCts.Dispose(); } }); } }