using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Plugin.Services; using LightlessSync.Services; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; namespace LightlessSync.PlayerData.Handlers; /// /// Game object handler for managing game object state and updates /// public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighPriorityMediatorSubscriber { private readonly DalamudUtilService _dalamudUtil; private readonly IObjectTable _objectTable; private readonly Func _getAddress; private readonly bool _isOwnedObject; private readonly PerformanceCollectorService _performanceCollector; private readonly Lock _frameworkUpdateGate = new(); private bool _frameworkUpdateSubscribed; private byte _classJob = 0; private Task? _delayedZoningTask; private bool _haltProcessing = false; private CancellationTokenSource _zoningCts = new(); /// /// Constructor for GameObjectHandler /// /// Logger /// Performance Collector /// Lightless Mediator /// Dalamud Utilties Service /// Object kind of Object /// Get Adress /// Object table of Dalamud /// Object is owned by user public GameObjectHandler( ILogger logger, PerformanceCollectorService performanceCollector, LightlessMediator mediator, DalamudUtilService dalamudUtil, ObjectKind objectKind, Func getAddress, IObjectTable objectTable, bool ownedObject = true) : base(logger, mediator) { _performanceCollector = performanceCollector; ObjectKind = objectKind; _dalamudUtil = dalamudUtil; _objectTable = objectTable; _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)); } }); } 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.EnsureIsOnFramework(); CheckAndUpdateObject(allowPublish: true); } /// /// Draw Condition Enum /// public enum DrawCondition { None, ObjectZero, DrawObjectZero, RenderFlags, ModelInSlotLoaded, ModelFilesInSlotLoaded } // Properties 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; } /// /// Act on framework thread after ensuring no draw condition /// /// Action of Character /// Cancellation Token /// Task Completion 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 ICharacter chara) { act.Invoke(chara); } return false; }).ConfigureAwait(false)) { await Task.Delay(250, token).ConfigureAwait(false); } } /// /// Compare Name And Throw if not equal /// /// Name that will be compared to Object Handler. /// Not equal if thrown 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"); } } /// /// Gets the game object from the address /// /// Gane object public IGameObject? GetGameObject() { return _dalamudUtil.CreateGameObject(Address); } /// /// Invalidate the object handler /// public void Invalidate() { Address = IntPtr.Zero; DrawObjectAddress = IntPtr.Zero; EntityId = uint.MaxValue; _haltProcessing = false; } /// /// Refresh the object handler state /// public void Refresh() { _dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult(); } /// /// Is Being Drawn Run On Framework Asyncronously /// /// Object is being run in framework public async Task IsBeingDrawnRunOnFrameworkAsync() { return await _dalamudUtil.RunOnFrameworkThread(IsBeingDrawn).ConfigureAwait(false); } /// /// Override ToString method for GameObjectHandler /// /// String public override string ToString() { var owned = _isOwnedObject ? "Self" : "Other"; return $"{owned}/{ObjectKind}:{Name} ({Address:X},{DrawObjectAddress:X})"; } /// /// Try Get Object By Address from Object Table /// /// Object address /// Game Object of adress private IGameObject? TryGetObjectByAddress(nint address) { if (address == nint.Zero) return null; // Search object table foreach (var obj in _objectTable) { if (obj is null) continue; if (obj.Address == address) return obj; } return null; } /// /// Checks and updates the object state /// private void CheckAndUpdateObject() => CheckAndUpdateObject(allowPublish: true); /// /// Checks and updates the object state with option to allow publish /// /// Allows to publish the object private void CheckAndUpdateObject(bool allowPublish) { var prevAddr = Address; var prevDrawObj = DrawObjectAddress; string? nameString = null; Address = _getAddress(); IGameObject? obj = null; ICharacter? chara = null; if (Address != nint.Zero) { // Try get object obj = TryGetObjectByAddress(Address); if (obj is not null) { EntityId = obj.EntityId; DrawObjectAddress = Address; // Name update nameString = obj.Name.TextValue ?? string.Empty; if (!string.IsNullOrEmpty(nameString) && !string.Equals(nameString, Name, StringComparison.Ordinal)) Name = nameString; chara = obj as ICharacter; } else { DrawObjectAddress = nint.Zero; EntityId = uint.MaxValue; } } else { DrawObjectAddress = nint.Zero; EntityId = uint.MaxValue; } // Update draw condition CurrentDrawCondition = IsBeingDrawnSafe(obj, chara); if (_haltProcessing || !allowPublish) return; // Determine differences bool drawObjDiff = DrawObjectAddress != prevDrawObj; bool addrDiff = Address != prevAddr; // Name change check bool nameChange = false; if (nameString is not null) { nameChange = !string.Equals(nameString, Name, StringComparison.Ordinal); if (nameChange) Name = nameString; } // Customize data change check bool customizeDiff = false; if (chara is not null) { // Class job change check var classJob = chara.ClassJob.RowId; if (classJob != _classJob) { Logger.LogTrace("[{this}] classjob changed from {old} to {new}", this, _classJob, classJob); _classJob = (byte)classJob; Mediator.Publish(new ClassJobChangedMessage(this)); } // Customize data comparison customizeDiff = CompareAndUpdateCustomizeData(chara.Customize); // Census update publish if (_isOwnedObject && ObjectKind == ObjectKind.Player && chara.Customize.Length > (int)CustomizeIndex.Tribe) { var gender = chara.Customize[(int)CustomizeIndex.Gender]; var raceId = chara.Customize[(int)CustomizeIndex.Race]; var tribeId = chara.Customize[(int)CustomizeIndex.Tribe]; if (gender != Gender || raceId != RaceId || tribeId != TribeId) { Mediator.Publish(new CensusUpdateMessage(gender, raceId, tribeId)); Gender = gender; RaceId = raceId; TribeId = tribeId; } } } if ((addrDiff || drawObjDiff || customizeDiff || nameChange) && _isOwnedObject) { Logger.LogDebug("[{this}] Changed, Sending CreateCacheObjectMessage", this); Mediator.Publish(new CreateCacheForObjectMessage(this)); } else if (addrDiff || drawObjDiff) { if (Address == nint.Zero) CurrentDrawCondition = DrawCondition.ObjectZero; else if (DrawObjectAddress == nint.Zero) CurrentDrawCondition = DrawCondition.DrawObjectZero; Logger.LogTrace("[{this}] Changed", this); if (_isOwnedObject && ObjectKind != ObjectKind.Player) Mediator.Publish(new ClearCacheForObjectMessage(this)); } } /// /// Is object being drawn safe check /// /// Object thats being checked /// Character of the object /// Draw Condition of character private DrawCondition IsBeingDrawnSafe(IGameObject? obj, ICharacter? chara) { // Object zero check if (Address == nint.Zero) return DrawCondition.ObjectZero; if (obj is null) return DrawCondition.DrawObjectZero; // Draw Object check if (chara is not null && (chara.Customize is null || chara.Customize.Length == 0)) return DrawCondition.DrawObjectZero; return DrawCondition.None; } /// /// Compare and update customize data of character /// /// Customize+ data of object /// Successfully applied or not private bool CompareAndUpdateCustomizeData(ReadOnlySpan customizeData) { bool hasChanges = false; // Resize if needed var len = Math.Min(customizeData.Length, CustomizeData.Length); for (int i = 0; i < len; i++) { var data = customizeData[i]; if (CustomizeData[i] != data) { CustomizeData[i] = data; hasChanges = true; } } return hasChanges; } /// /// Framework update method /// 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); } } /// /// Is object being drawn check /// /// Is being drawn 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; } /// /// Ensures the latest object state /// private void EnsureLatestObjectState() { if (_haltProcessing || !_frameworkUpdateSubscribed) { CheckAndUpdateObject(); } } /// /// Enables framework updates for the object handler /// private void EnableFrameworkUpdates() { lock (_frameworkUpdateGate) { if (_frameworkUpdateSubscribed) { return; } Mediator.Subscribe(this, _ => FrameworkUpdate()); _frameworkUpdateSubscribed = true; } } /// /// Zone switch end handling /// private void ZoneSwitchEnd() { if (!_isOwnedObject) return; try { _zoningCts?.CancelAfter(2500); } catch (ObjectDisposedException) { // ignore canelled after disposed } catch (Exception ex) { Logger.LogWarning(ex, "Zoning CTS cancel issue"); } } /// /// Zone switch start handling /// 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); } }