using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Utils; using Microsoft.Extensions.Logging; using System.Runtime.CompilerServices; using static FFXIVClientStructs.FFXIV.Client.Game.Character.DrawDataContainer; using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags; namespace LightlessSync.PlayerData.Handlers; public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighPriorityMediatorSubscriber { private readonly DalamudUtilService _dalamudUtil; private readonly Func _getAddress; private readonly bool _isOwnedObject; private readonly PerformanceCollectorService _performanceCollector; private readonly object _frameworkUpdateGate = new(); private bool _frameworkUpdateSubscribed; private byte _classJob = 0; private Task? _delayedZoningTask; private bool _haltProcessing = false; private CancellationTokenSource _zoningCts = new(); public GameObjectHandler(ILogger logger, PerformanceCollectorService performanceCollector, LightlessMediator mediator, DalamudUtilService dalamudUtil, ObjectKind objectKind, Func getAddress, bool ownedObject = true) : base(logger, mediator) { _performanceCollector = performanceCollector; ObjectKind = objectKind; _dalamudUtil = dalamudUtil; _getAddress = () => { _dalamudUtil.EnsureIsOnFramework(); return getAddress.Invoke(); }; _isOwnedObject = ownedObject; Name = string.Empty; if (ownedObject) { Mediator.Subscribe(this, (msg) => { if (_delayedZoningTask?.IsCompleted ?? true) { if (msg.Address != Address) return; Mediator.Publish(new CreateCacheForObjectMessage(this)); } }); } if (_isOwnedObject) { EnableFrameworkUpdates(); } Mediator.Subscribe(this, (_) => ZoneSwitchEnd()); Mediator.Subscribe(this, (_) => ZoneSwitchStart()); Mediator.Subscribe(this, (_) => { _haltProcessing = true; }); Mediator.Subscribe(this, (_) => { _haltProcessing = false; ZoneSwitchEnd(); }); Mediator.Subscribe(this, (msg) => { if (msg.Address == Address) { _haltProcessing = true; } }); Mediator.Subscribe(this, (msg) => { if (msg.Address == Address) { _haltProcessing = false; } }); Mediator.Publish(new GameObjectHandlerCreatedMessage(this, _isOwnedObject)); _dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult(); } public enum DrawCondition { None, ObjectZero, DrawObjectZero, RenderFlags, ModelInSlotLoaded, ModelFilesInSlotLoaded } public IntPtr Address { get; private set; } public DrawCondition CurrentDrawCondition { get; set; } = DrawCondition.None; public byte Gender { get; private set; } public string Name { get; private set; } public uint EntityId { get; private set; } = uint.MaxValue; public ObjectKind ObjectKind { get; } public byte RaceId { get; private set; } public byte TribeId { get; private set; } private byte[] CustomizeData { get; set; } = new byte[26]; private IntPtr DrawObjectAddress { get; set; } private byte[] EquipSlotData { get; set; } = new byte[40]; private ushort[] MainHandData { get; set; } = new ushort[3]; private ushort[] OffHandData { get; set; } = new ushort[3]; public async Task ActOnFrameworkAfterEnsureNoDrawAsync(Action act, CancellationToken token) { while (await _dalamudUtil.RunOnFrameworkThread(() => { EnsureLatestObjectState(); if (CurrentDrawCondition != DrawCondition.None) return true; var gameObj = _dalamudUtil.CreateGameObject(Address); if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter chara) { act.Invoke(chara); } return false; }).ConfigureAwait(false)) { await Task.Delay(250, token).ConfigureAwait(false); } } public void CompareNameAndThrow(string name) { if (!string.Equals(Name, name, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException("Player name not equal to requested name, pointer invalid"); } if (Address == IntPtr.Zero) { throw new InvalidOperationException("Player pointer is zero, pointer invalid"); } } public Dalamud.Game.ClientState.Objects.Types.IGameObject? GetGameObject() { return _dalamudUtil.CreateGameObject(Address); } public void Invalidate() { Address = IntPtr.Zero; DrawObjectAddress = IntPtr.Zero; EntityId = uint.MaxValue; _haltProcessing = false; } public void Refresh() { _dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult(); } 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})"; } private void CheckAndUpdateObject() => CheckAndUpdateObject(allowPublish: true); private unsafe void CheckAndUpdateObject(bool allowPublish) { var prevAddr = Address; var prevDrawObj = DrawObjectAddress; string? nameString = null; var nextAddr = _getAddress(); if (nextAddr != IntPtr.Zero && !PtrGuard.LooksLikePtr(nextAddr)) { nextAddr = IntPtr.Zero; } if (nextAddr != IntPtr.Zero && !PtrGuard.IsReadable(nextAddr, (nuint)sizeof(FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject))) { nextAddr = IntPtr.Zero; } Address = nextAddr; if (Address != IntPtr.Zero) { var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address; var draw = (nint)gameObject->DrawObject; if (!PtrGuard.LooksLikePtr(draw) || !PtrGuard.IsReadable(draw, (nuint)sizeof(DrawObject))) draw = 0; DrawObjectAddress = draw; EntityId = gameObject->EntityId; if (PtrGuard.IsReadable(Address, (nuint)sizeof(Character))) { var chara = (Character*)Address; nameString = chara->GameObject.NameString; if (!string.IsNullOrEmpty(nameString) && !string.Equals(nameString, Name, StringComparison.Ordinal)) Name = nameString; } } else { DrawObjectAddress = IntPtr.Zero; EntityId = uint.MaxValue; } CurrentDrawCondition = (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero) ? IsBeingDrawnUnsafe() : DrawCondition.DrawObjectZero; if (_haltProcessing || !allowPublish) return; bool drawObjDiff = DrawObjectAddress != prevDrawObj; bool addrDiff = Address != prevAddr; if (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero && PtrGuard.IsReadable(Address, (nuint)sizeof(Character)) && PtrGuard.IsReadable(DrawObjectAddress, (nuint)sizeof(DrawObject))) { var chara = (Character*)Address; var drawObj = (DrawObject*)DrawObjectAddress; var objType = drawObj->Object.GetObjectType(); var isHuman = objType == ObjectType.CharacterBase && ((CharacterBase*)drawObj)->GetModelType() == CharacterBase.ModelType.Human; nameString ??= chara->GameObject.NameString; var nameChange = !string.Equals(nameString, Name, StringComparison.Ordinal); if (nameChange) Name = nameString; bool equipDiff = false; if (isHuman) { if (PtrGuard.IsReadable(DrawObjectAddress, (nuint)sizeof(Human))) { var classJob = chara->CharacterData.ClassJob; if (classJob != _classJob) { Logger.LogTrace("[{this}] classjob changed from {old} to {new}", this, _classJob, classJob); _classJob = classJob; Mediator.Publish(new ClassJobChangedMessage(this)); } equipDiff = CompareAndUpdateEquipByteData((byte*)&((Human*)drawObj)->Head); ref var mh = ref chara->DrawData.Weapon(WeaponSlot.MainHand); ref var oh = ref chara->DrawData.Weapon(WeaponSlot.OffHand); equipDiff |= CompareAndUpdateMainHand((Weapon*)mh.DrawObject); equipDiff |= CompareAndUpdateOffHand((Weapon*)oh.DrawObject); } else { isHuman = false; } } if (!isHuman) { equipDiff = CompareAndUpdateEquipByteData((byte*)Unsafe.AsPointer(ref chara->DrawData.EquipmentModelIds[0])); } if (equipDiff && !_isOwnedObject) { Logger.LogTrace("[{this}] Changed", this); return; } bool customizeDiff = false; if (isHuman && PtrGuard.IsReadable(DrawObjectAddress, (nuint)sizeof(Human))) { var human = (Human*)drawObj; var gender = human->Customize.Sex; var raceId = human->Customize.Race; var tribeId = human->Customize.Tribe; if (_isOwnedObject && ObjectKind == ObjectKind.Player && (gender != Gender || raceId != RaceId || tribeId != TribeId)) { Mediator.Publish(new CensusUpdateMessage(gender, raceId, tribeId)); Gender = gender; RaceId = raceId; TribeId = tribeId; } customizeDiff = CompareAndUpdateCustomizeData(human->Customize.Data); } else { customizeDiff = CompareAndUpdateCustomizeData(chara->DrawData.CustomizeData.Data); } if ((addrDiff || drawObjDiff || equipDiff || customizeDiff || nameChange) && _isOwnedObject) { Logger.LogDebug("[{this}] Changed, Sending CreateCacheObjectMessage", this); Mediator.Publish(new CreateCacheForObjectMessage(this)); } } else if (addrDiff || drawObjDiff) { CurrentDrawCondition = DrawCondition.DrawObjectZero; Logger.LogTrace("[{this}] Changed", this); if (_isOwnedObject && ObjectKind != ObjectKind.Player) Mediator.Publish(new ClearCacheForObjectMessage(this)); } } private unsafe bool CompareAndUpdateCustomizeData(Span customizeData) { bool hasChanges = false; for (int i = 0; i < customizeData.Length; i++) { var data = 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 = equipSlotData[i]; if (EquipSlotData[i] != data) { EquipSlotData[i] = data; hasChanges = true; } } return hasChanges; } private unsafe bool CompareAndUpdateMainHand(Weapon* weapon) { var p = (nint)weapon; if (!PtrGuard.LooksLikePtr(p) || !PtrGuard.IsReadable(p, (nuint)sizeof(Weapon))) return false; bool hasChanges = false; hasChanges |= weapon->ModelSetId != MainHandData[0]; MainHandData[0] = weapon->ModelSetId; hasChanges |= weapon->Variant != MainHandData[1]; MainHandData[1] = weapon->Variant; hasChanges |= weapon->SecondaryId != MainHandData[2]; MainHandData[2] = weapon->SecondaryId; return hasChanges; } private unsafe bool CompareAndUpdateOffHand(Weapon* weapon) { var p = (nint)weapon; if (!PtrGuard.LooksLikePtr(p) || !PtrGuard.IsReadable(p, (nuint)sizeof(Weapon))) return false; bool hasChanges = false; hasChanges |= weapon->ModelSetId != OffHandData[0]; OffHandData[0] = weapon->ModelSetId; hasChanges |= weapon->Variant != OffHandData[1]; OffHandData[1] = weapon->Variant; hasChanges |= weapon->SecondaryId != OffHandData[2]; OffHandData[2] = weapon->SecondaryId; return hasChanges; } private void FrameworkUpdate() { try { var zoningDelayActive = !(_delayedZoningTask?.IsCompleted ?? true); _performanceCollector.LogPerformance(this, $"CheckAndUpdateObject>{(_isOwnedObject ? "Self" : "Other")}+{ObjectKind}/{(string.IsNullOrEmpty(Name) ? "Unk" : Name)}", () => CheckAndUpdateObject(allowPublish: !zoningDelayActive)); } catch (Exception ex) { Logger.LogWarning(ex, "Error during FrameworkUpdate of {this}", this); } } private bool IsBeingDrawn() { EnsureLatestObjectState(); if (_dalamudUtil.IsAnythingDrawing) { Logger.LogTrace("[{this}] IsBeingDrawn, Global draw block", this); return true; } Logger.LogTrace("[{this}] IsBeingDrawn, Condition: {cond}", this, CurrentDrawCondition); return CurrentDrawCondition != DrawCondition.None; } private void EnsureLatestObjectState() { if (_haltProcessing || !_frameworkUpdateSubscribed) { CheckAndUpdateObject(); } } private void EnableFrameworkUpdates() { lock (_frameworkUpdateGate) { if (_frameworkUpdateSubscribed) { return; } Mediator.Subscribe(this, _ => FrameworkUpdate()); _frameworkUpdateSubscribed = true; } } private unsafe DrawCondition IsBeingDrawnUnsafe() { if (Address == IntPtr.Zero) return DrawCondition.ObjectZero; if (DrawObjectAddress == IntPtr.Zero) return DrawCondition.DrawObjectZero; var visibilityFlags = ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address)->RenderFlags; if (visibilityFlags != VisibilityFlags.None) return DrawCondition.RenderFlags; if (ObjectKind == ObjectKind.Player) { var modelInSlotLoaded = (((CharacterBase*)DrawObjectAddress)->HasModelInSlotLoaded != 0); if (modelInSlotLoaded) return DrawCondition.ModelInSlotLoaded; var modelFilesInSlotLoaded = (((CharacterBase*)DrawObjectAddress)->HasModelFilesInSlotLoaded != 0); if (modelFilesInSlotLoaded) return DrawCondition.ModelFilesInSlotLoaded; } return DrawCondition.None; } private void ZoneSwitchEnd() { if (!_isOwnedObject) return; try { _zoningCts?.CancelAfter(2500); } catch (ObjectDisposedException) { // ignore } catch (Exception ex) { Logger.LogWarning(ex, "Zoning CTS cancel issue"); } } private void ZoneSwitchStart() { if (!_isOwnedObject) 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(); } }, _zoningCts.Token); } }