using System; using Dalamud.Game.Addon.Lifecycle; using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Component.GUI; using Microsoft.Extensions.Logging; namespace Questionable.Controller.GameUi; internal sealed class CreditsController : IDisposable { private static CreditsController? _instance; private static IAddonLifecycle? _registeredLifecycle; private static readonly IAddonLifecycle.AddonEventDelegate _creditScrollHandler = delegate(AddonEvent t, AddonArgs a) { _instance?.CreditScrollPostSetup(t, a); }; private static readonly IAddonLifecycle.AddonEventDelegate _creditHandler = delegate(AddonEvent t, AddonArgs a) { _instance?.CreditPostSetup(t, a); }; private static readonly IAddonLifecycle.AddonEventDelegate _creditPlayerHandler = delegate(AddonEvent t, AddonArgs a) { _instance?.CreditPlayerPostSetup(t, a); }; private static readonly string[] CreditScrollArray = new string[1] { "CreditScroll" }; private static readonly string[] CreditArray = new string[1] { "Credit" }; private static readonly string[] CreditPlayerArray = new string[1] { "CreditPlayer" }; private static bool _deferDisposeUntilCutsceneEnds; private static bool _pendingDispose; private static int _handledConsecutiveCutscenes; private static int _maxConsecutiveSkips = 5; private static DateTime _lastHandledAt = DateTime.MinValue; private static readonly TimeSpan _reentrancySuppress = TimeSpan.FromMilliseconds(500L, 0L); private static long _lastHandledAddr; private static readonly TimeSpan _consecutiveResetWindow = TimeSpan.FromSeconds(10L); private readonly IAddonLifecycle _addonLifecycle; private readonly ILogger _logger; private static readonly object _lock = new object(); public CreditsController(IAddonLifecycle addonLifecycle, ILogger logger) { _addonLifecycle = addonLifecycle; _logger = logger; lock (_lock) { if (_instance == null) { _instance = this; _registeredLifecycle = _addonLifecycle; _addonLifecycle.RegisterListener(AddonEvent.PostSetup, CreditScrollArray, _creditScrollHandler); _addonLifecycle.RegisterListener(AddonEvent.PostSetup, CreditArray, _creditHandler); _addonLifecycle.RegisterListener(AddonEvent.PostSetup, CreditPlayerArray, _creditPlayerHandler); _logger.LogDebug("CreditsController: registered listeners and ready to skip up to {MaxSkips} successive cutscenes.", _maxConsecutiveSkips); } } } private unsafe void CreditScrollPostSetup(AddonEvent type, AddonArgs args) { HandleCutscene(delegate { _logger.LogInformation("CreditScrollPostSetup: attempting to close credits sequence (scroll)."); AtkUnitBase* address = (AtkUnitBase*)args.Addon.Address; address->FireCallbackInt(-2); }, args); } private unsafe void CreditPostSetup(AddonEvent type, AddonArgs args) { HandleCutscene(delegate { _logger.LogInformation("CreditPostSetup: attempting to close credits sequence."); AtkUnitBase* address = (AtkUnitBase*)args.Addon.Address; address->FireCallbackInt(-2); }, args); } private unsafe void CreditPlayerPostSetup(AddonEvent type, AddonArgs args) { HandleCutscene(delegate { _logger.LogInformation("CreditPlayerPostSetup: attempting to close CreditPlayer."); AtkUnitBase* address = (AtkUnitBase*)args.Addon.Address; address->Close(fireCallback: true); }, args); } private void HandleCutscene(Action nativeAction, AddonArgs args) { long num = ((args.Addon.Address == IntPtr.Zero) ? 0 : ((IntPtr)args.Addon.Address).ToInt64()); if (num == 0L) { _logger.LogInformation("HandleCutscene: Addon address is zero, skipping."); return; } lock (_lock) { if (_lastHandledAt != DateTime.MinValue && DateTime.Now - _lastHandledAt > _consecutiveResetWindow) { if (_handledConsecutiveCutscenes != 0) { _logger.LogDebug("HandleCutscene: long pause detected ({Elapsed}), resetting consecutive counter (was {Was}).", DateTime.Now - _lastHandledAt, _handledConsecutiveCutscenes); } _handledConsecutiveCutscenes = 0; } if (DateTime.Now - _lastHandledAt < _reentrancySuppress && num == _lastHandledAddr) { _logger.LogDebug("HandleCutscene: suppressed re-entrant invocation for same addon (addr=0x{Address:X}).", num); return; } if (_handledConsecutiveCutscenes >= _maxConsecutiveSkips) { _logger.LogInformation("HandleCutscene: reached max consecutive skips ({MaxSkips}), unregistering listeners.", _maxConsecutiveSkips); TryUnregisterAndClear(); return; } _lastHandledAt = DateTime.Now; _lastHandledAddr = num; _handledConsecutiveCutscenes++; _logger.LogDebug("HandleCutscene: handling cutscene #{Count} (addr=0x{Address:X}).", _handledConsecutiveCutscenes, num); } try { nativeAction(); _logger.LogInformation("HandleCutscene: native action executed for addon at 0x{Address:X}.", num); } catch (Exception exception) { _logger.LogError(exception, "HandleCutscene: exception while executing native action for addon at 0x{Address:X}.", num); } lock (_lock) { if (_handledConsecutiveCutscenes >= _maxConsecutiveSkips && !_deferDisposeUntilCutsceneEnds) { _logger.LogDebug("HandleCutscene: max handled reached and no defer active, unregistering now."); TryUnregisterAndClear(); } else { _logger.LogDebug("HandleCutscene: leaving listeners registered (handled {Count}/{Max}).", _handledConsecutiveCutscenes, _maxConsecutiveSkips); } } } private void TryUnregisterAndClear() { if (_registeredLifecycle != null) { try { _registeredLifecycle.UnregisterListener(AddonEvent.PostSetup, CreditPlayerArray, _creditPlayerHandler); _registeredLifecycle.UnregisterListener(AddonEvent.PostSetup, CreditArray, _creditHandler); _registeredLifecycle.UnregisterListener(AddonEvent.PostSetup, CreditScrollArray, _creditScrollHandler); _logger.LogDebug("TryUnregisterAndClear: listeners unregistered successfully."); } catch (Exception exception) { _logger.LogError(exception, "TryUnregisterAndClear: exception while unregistering listeners."); } } _registeredLifecycle = null; _instance = null; _handledConsecutiveCutscenes = 0; _lastHandledAddr = 0L; _lastHandledAt = DateTime.MinValue; } public static void DeferDisposeUntilCutsceneEnds() { lock (_lock) { _deferDisposeUntilCutsceneEnds = true; if (_instance != null) { _instance._logger.LogDebug("CreditsController: deferring dispose until cutscene ends."); } } } public static void NotifyCutsceneEnded() { lock (_lock) { _deferDisposeUntilCutsceneEnds = false; if (_instance != null) { _instance._logger.LogDebug("CreditsController: cutscene ended, processing pending dispose state."); } if (_pendingDispose && _instance != null) { _instance._logger.LogDebug("CreditsController: pending dispose detected, performing unregister now."); _instance.Dispose(); } } } public void Dispose() { lock (_lock) { if (_instance != this) { return; } if (_deferDisposeUntilCutsceneEnds) { _pendingDispose = true; _logger.LogInformation("CreditsController.Dispose deferred until cutscene end."); return; } if (_registeredLifecycle != null) { _registeredLifecycle.UnregisterListener(AddonEvent.PostSetup, CreditPlayerArray, _creditPlayerHandler); _registeredLifecycle.UnregisterListener(AddonEvent.PostSetup, CreditArray, _creditHandler); _registeredLifecycle.UnregisterListener(AddonEvent.PostSetup, CreditScrollArray, _creditScrollHandler); } _registeredLifecycle = null; _instance = null; _pendingDispose = false; _handledConsecutiveCutscenes = 0; _lastHandledAddr = 0L; _lastHandledAt = DateTime.MinValue; _logger.LogDebug("CreditsController listeners unregistered and disposed (primary instance)."); } } }