using MareSynchronos.API.Data; using MareSynchronos.FileCache; using MareSynchronos.Interop.Ipc; using MareSynchronos.MareConfiguration; using MareSynchronos.PlayerData.Factories; using MareSynchronos.PlayerData.Pairs; using MareSynchronos.Services; using MareSynchronos.Services.Events; using MareSynchronos.Services.Mediator; using MareSynchronos.Services.ServerConfiguration; using MareSynchronos.Utils; using MareSynchronos.WebAPI.Files; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Diagnostics; using ObjectKind = MareSynchronos.API.Data.Enum.ObjectKind; namespace MareSynchronos.PlayerData.Handlers; public sealed class PairHandler : DisposableMediatorSubscriberBase { private sealed record CombatData(Guid ApplicationId, CharacterData CharacterData, bool Forced); private readonly MareConfigService _configService; private readonly DalamudUtilService _dalamudUtil; private readonly FileDownloadManager _downloadManager; private readonly FileCacheManager _fileDbManager; private readonly GameObjectHandlerFactory _gameObjectHandlerFactory; private readonly IpcManager _ipcManager; private readonly PlayerPerformanceService _playerPerformanceService; private readonly ServerConfigurationManager _serverConfigManager; private readonly PluginWarningNotificationService _pluginWarningNotificationManager; private readonly VisibilityService _visibilityService; private CancellationTokenSource? _applicationCancellationTokenSource = new(); private Guid _applicationId; private Task? _applicationTask; private CharacterData? _cachedData = null; private GameObjectHandler? _charaHandler; private readonly Dictionary _customizeIds = []; private CombatData? _dataReceivedInDowntime; private CancellationTokenSource? _downloadCancellationTokenSource = new(); private bool _forceApplyMods = false; private bool _isVisible; private Guid _deferred = Guid.Empty; private Guid _penumbraCollection = Guid.Empty; private bool _redrawOnNextApplication = false; public PairHandler(ILogger logger, Pair pair, PairAnalyzer pairAnalyzer, GameObjectHandlerFactory gameObjectHandlerFactory, IpcManager ipcManager, FileDownloadManager transferManager, PluginWarningNotificationService pluginWarningNotificationManager, DalamudUtilService dalamudUtil, IHostApplicationLifetime lifetime, FileCacheManager fileDbManager, MareMediator mediator, PlayerPerformanceService playerPerformanceService, ServerConfigurationManager serverConfigManager, MareConfigService configService, VisibilityService visibilityService) : base(logger, mediator) { Pair = pair; PairAnalyzer = pairAnalyzer; _gameObjectHandlerFactory = gameObjectHandlerFactory; _ipcManager = ipcManager; _downloadManager = transferManager; _pluginWarningNotificationManager = pluginWarningNotificationManager; _dalamudUtil = dalamudUtil; _fileDbManager = fileDbManager; _playerPerformanceService = playerPerformanceService; _serverConfigManager = serverConfigManager; _configService = configService; _visibilityService = visibilityService; _visibilityService.StartTracking(Pair.Ident); Mediator.SubscribeKeyed(this, Pair.Ident, (msg) => UpdateVisibility(msg.IsVisible, msg.Invalidate)); Mediator.Subscribe(this, (_) => { _downloadCancellationTokenSource?.CancelDispose(); _charaHandler?.Invalidate(); IsVisible = false; }); Mediator.Subscribe(this, (_) => { _penumbraCollection = Guid.Empty; if (!IsVisible && _charaHandler != null) { PlayerName = string.Empty; _charaHandler.Dispose(); _charaHandler = null; } }); Mediator.Subscribe(this, (msg) => { if (msg.GameObjectHandler == _charaHandler) { _redrawOnNextApplication = true; } }); Mediator.Subscribe(this, (msg) => { if (IsVisible && _dataReceivedInDowntime != null) { ApplyCharacterData(_dataReceivedInDowntime.ApplicationId, _dataReceivedInDowntime.CharacterData, _dataReceivedInDowntime.Forced); _dataReceivedInDowntime = null; } }); Mediator.Subscribe(this, _ => { if (_configService.Current.HoldCombatApplication) { _dataReceivedInDowntime = null; _downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate(); _applicationCancellationTokenSource = _applicationCancellationTokenSource?.CancelRecreate(); } }); Mediator.Subscribe(this, (msg) => { if (msg.UID != null && !msg.UID.Equals(Pair.UserData.UID, StringComparison.Ordinal)) return; Logger.LogDebug("Recalculating performance for {uid}", Pair.UserData.UID); pair.ApplyLastReceivedData(forced: true); }); LastAppliedDataBytes = -1; } public bool IsVisible { get => _isVisible; private set { if (_isVisible != value) { _isVisible = value; string text = "User Visibility Changed, now: " + (_isVisible ? "Is Visible" : "Is not Visible"); Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Informational, text))); } } } public long LastAppliedDataBytes { get; private set; } public Pair Pair { get; private init; } public PairAnalyzer PairAnalyzer { get; private init; } public nint PlayerCharacter => _charaHandler?.Address ?? nint.Zero; public unsafe uint PlayerCharacterId => (_charaHandler?.Address ?? nint.Zero) == nint.Zero ? uint.MaxValue : ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)_charaHandler!.Address)->EntityId; public string? PlayerName { get; private set; } public string PlayerNameHash => Pair.Ident; public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = false) { if (_configService.Current.HoldCombatApplication && _dalamudUtil.IsInCombatOrPerforming) { Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning, "Cannot apply character data: you are in combat or performing music, deferring application"))); Logger.LogDebug("[BASE-{appBase}] Received data but player is in combat or performing", applicationBase); _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); SetUploading(isUploading: false); return; } if (_charaHandler == null || (PlayerCharacter == IntPtr.Zero)) { if (_deferred != Guid.Empty) { _isVisible = false; _visibilityService.StopTracking(Pair.Ident); _visibilityService.StartTracking(Pair.Ident); } Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning, "Cannot apply character data: Receiving Player is in an invalid state, deferring application"))); Logger.LogDebug("[BASE-{appBase}] Received data but player was in invalid state, charaHandlerIsNull: {charaIsNull}, playerPointerIsNull: {ptrIsNull}", applicationBase, _charaHandler == null, PlayerCharacter == IntPtr.Zero); var hasDiffMods = characterData.CheckUpdatedData(applicationBase, _cachedData, Logger, this, forceApplyCustomization, forceApplyMods: false) .Any(p => p.Value.Contains(PlayerChanges.ModManip) || p.Value.Contains(PlayerChanges.ModFiles)); _forceApplyMods = hasDiffMods || _forceApplyMods || (PlayerCharacter == IntPtr.Zero && _cachedData == null); _cachedData = characterData; Mediator.Publish(new PairDataAppliedMessage(Pair.UserData.UID, characterData)); Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods); // Ensure that this deferred application actually occurs by forcing visibiltiy to re-proc // Set _deferred as a silencing flag to avoid spamming logs once per frame with failed applications _isVisible = false; _deferred = applicationBase; _visibilityService.StopTracking(Pair.Ident); _visibilityService.StartTracking(Pair.Ident); return; } _deferred = Guid.Empty; SetUploading(isUploading: false); if (Pair.IsDownloadBlocked) { var reasons = string.Join(", ", Pair.HoldDownloadReasons); Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning, $"Not applying character data: {reasons}"))); Logger.LogDebug("[BASE-{appBase}] Not applying due to hold: {reasons}", applicationBase, reasons); var hasDiffMods = characterData.CheckUpdatedData(applicationBase, _cachedData, Logger, this, forceApplyCustomization, forceApplyMods: false) .Any(p => p.Value.Contains(PlayerChanges.ModManip) || p.Value.Contains(PlayerChanges.ModFiles)); _forceApplyMods = hasDiffMods || _forceApplyMods || (PlayerCharacter == IntPtr.Zero && _cachedData == null); _cachedData = characterData; Mediator.Publish(new PairDataAppliedMessage(Pair.UserData.UID, characterData)); Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods); return; } Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, this, forceApplyCustomization, _forceApplyMods); Logger.LogDebug("[BASE-{appbase}] Hash for data is {newHash}, current cache hash is {oldHash}", applicationBase, characterData.DataHash.Value, _cachedData?.DataHash.Value ?? "NODATA"); if (string.Equals(characterData.DataHash.Value, _cachedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal) && !forceApplyCustomization) return; if (_dalamudUtil.IsInCutscene || _dalamudUtil.IsInGpose || !_ipcManager.Penumbra.APIAvailable || !_ipcManager.Glamourer.APIAvailable) { Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning, "Cannot apply character data: you are in GPose, a Cutscene or Penumbra/Glamourer is not available"))); Logger.LogInformation("[BASE-{appbase}] Application of data for {player} while in cutscene/gpose or Penumbra/Glamourer unavailable, returning", applicationBase, this); return; } Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Informational, "Applying Character Data"))); _forceApplyMods |= forceApplyCustomization; var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, forceApplyCustomization, _forceApplyMods); if (_charaHandler != null && _forceApplyMods) { _forceApplyMods = false; } if (_redrawOnNextApplication && charaDataToUpdate.TryGetValue(ObjectKind.Player, out var player)) { player.Add(PlayerChanges.ForcedRedraw); _redrawOnNextApplication = false; } if (charaDataToUpdate.TryGetValue(ObjectKind.Player, out var playerChanges)) { _pluginWarningNotificationManager.NotifyForMissingPlugins(Pair.UserData, PlayerName!, playerChanges); } Logger.LogDebug("[BASE-{appbase}] Downloading and applying character for {name}", applicationBase, this); DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate); } public override string ToString() { return Pair == null ? base.ToString() ?? string.Empty : Pair.UserData.AliasOrUID + ":" + PlayerName + ":" + (PlayerCharacter != nint.Zero ? "HasChar" : "NoChar"); } internal void SetUploading(bool isUploading = true) { Logger.LogTrace("Setting {this} uploading {uploading}", this, isUploading); if (_charaHandler != null) { Mediator.Publish(new PlayerUploadingMessage(_charaHandler, isUploading)); } } protected override void Dispose(bool disposing) { base.Dispose(disposing); if (!disposing) return; _visibilityService.StopTracking(Pair.Ident); SetUploading(isUploading: false); var name = PlayerName; Logger.LogDebug("Disposing {name} ({user})", name, Pair); try { Guid applicationId = Guid.NewGuid(); if (!string.IsNullOrEmpty(name)) { Mediator.Publish(new EventMessage(new Event(name, Pair.UserData, nameof(PairHandler), EventSeverity.Informational, "Disposing User"))); } UndoApplicationAsync(applicationId).GetAwaiter().GetResult(); _applicationCancellationTokenSource?.Dispose(); _applicationCancellationTokenSource = null; _downloadCancellationTokenSource?.Dispose(); _downloadCancellationTokenSource = null; _charaHandler?.Dispose(); _charaHandler = null; } catch (Exception ex) { Logger.LogWarning(ex, "Error on disposal of {name}", name); } finally { PlayerName = null; _cachedData = null; Mediator.Publish(new PairDataAppliedMessage(Pair.UserData.UID, null)); Logger.LogDebug("Disposing {name} complete", name); } } public void UndoApplication(Guid applicationId = default) { _ = Task.Run(async () => { await UndoApplicationAsync(applicationId).ConfigureAwait(false); }); } private async Task UndoApplicationAsync(Guid applicationId = default) { Logger.LogDebug($"Undoing application of {Pair.UserPair}"); var name = PlayerName; try { if (applicationId == default) applicationId = Guid.NewGuid(); _applicationCancellationTokenSource = _applicationCancellationTokenSource?.CancelRecreate(); _downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate(); Logger.LogDebug("[{applicationId}] Removing Temp Collection for {name} ({user})", applicationId, name, Pair.UserPair); if (_penumbraCollection != Guid.Empty) { await _ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, _penumbraCollection).ConfigureAwait(false); _penumbraCollection = Guid.Empty; } if (_dalamudUtil is { IsZoning: false, IsInCutscene: false } && !string.IsNullOrEmpty(name)) { Logger.LogTrace("[{applicationId}] Restoring state for {name} ({OnlineUser})", applicationId, name, Pair.UserPair); if (!IsVisible) { Logger.LogDebug("[{applicationId}] Restoring Glamourer for {name} ({user})", applicationId, name, Pair.UserPair); await _ipcManager.Glamourer.RevertByNameAsync(Logger, name, applicationId).ConfigureAwait(false); } else { using var cts = new CancellationTokenSource(); cts.CancelAfter(TimeSpan.FromSeconds(60)); Logger.LogInformation("[{applicationId}] CachedData is null {isNull}, contains things: {contains}", applicationId, _cachedData == null, _cachedData?.FileReplacements.Any() ?? false); foreach (KeyValuePair> item in _cachedData?.FileReplacements ?? []) { try { await RevertCustomizationDataAsync(item.Key, name, applicationId, cts.Token).ConfigureAwait(false); } catch (InvalidOperationException ex) { Logger.LogWarning(ex, "Failed disposing player (not present anymore?)"); break; } } } } } catch (Exception ex) { Logger.LogWarning(ex, "Error on undoing application of {name}", name); } } private async Task ApplyCustomizationDataAsync(Guid applicationId, KeyValuePair> changes, CharacterData charaData, CancellationToken token) { if (PlayerCharacter == nint.Zero) return; var ptr = PlayerCharacter; var handler = changes.Key switch { ObjectKind.Player => _charaHandler!, ObjectKind.Companion => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetCompanion(ptr), isWatched: false).ConfigureAwait(false), ObjectKind.MinionOrMount => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetMinionOrMount(ptr), isWatched: false).ConfigureAwait(false), ObjectKind.Pet => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetPet(ptr), isWatched: false).ConfigureAwait(false), _ => throw new NotSupportedException("ObjectKind not supported: " + changes.Key) }; async Task processApplication(IEnumerable changeList) { foreach (var change in changeList) { Logger.LogDebug("[{applicationId}{ft}] Processing {change} for {handler}", applicationId, _dalamudUtil.IsOnFrameworkThread ? "*" : "", change, handler); switch (change) { case PlayerChanges.Customize: if (charaData.CustomizePlusData.TryGetValue(changes.Key, out var customizePlusData)) { _customizeIds[changes.Key] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(handler.Address, customizePlusData).ConfigureAwait(false); } else if (_customizeIds.TryGetValue(changes.Key, out var customizeId)) { await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); _customizeIds.Remove(changes.Key); } break; case PlayerChanges.Heels: await _ipcManager.Heels.SetOffsetForPlayerAsync(handler.Address, charaData.HeelsData).ConfigureAwait(false); break; case PlayerChanges.Honorific: await _ipcManager.Honorific.SetTitleAsync(handler.Address, charaData.HonorificData).ConfigureAwait(false); break; case PlayerChanges.Glamourer: if (charaData.GlamourerData.TryGetValue(changes.Key, out var glamourerData)) { await _ipcManager.Glamourer.ApplyAllAsync(Logger, handler, glamourerData, applicationId, token, allowImmediate: true).ConfigureAwait(false); } break; case PlayerChanges.PetNames: await _ipcManager.PetNames.SetPlayerData(handler.Address, charaData.PetNamesData).ConfigureAwait(false); break; case PlayerChanges.Moodles: await _ipcManager.Moodles.SetStatusAsync(handler.Address, charaData.MoodlesData).ConfigureAwait(false); break; case PlayerChanges.ForcedRedraw: await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false); break; default: break; } token.ThrowIfCancellationRequested(); } } try { if (handler.Address == nint.Zero) { return; } Logger.LogDebug("[{applicationId}] Applying Customization Data for {handler}", applicationId, handler); await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handler, applicationId, 30000, token).ConfigureAwait(false); token.ThrowIfCancellationRequested(); if (_configService.Current.SerialApplication) { var serialChangeList = changes.Value.Where(p => p <= PlayerChanges.ForcedRedraw).OrderBy(p => (int)p); var asyncChangeList = changes.Value.Where(p => p > PlayerChanges.ForcedRedraw).OrderBy(p => (int)p); await _dalamudUtil.RunOnFrameworkThread(async () => await processApplication(serialChangeList).ConfigureAwait(false)).ConfigureAwait(false); await Task.Run(async () => await processApplication(asyncChangeList).ConfigureAwait(false), CancellationToken.None).ConfigureAwait(false); } else { _ = processApplication(changes.Value.OrderBy(p => (int)p)); } } finally { if (handler != _charaHandler) handler.Dispose(); } } private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary> updatedData) { if (!updatedData.Any()) { Logger.LogDebug("[BASE-{appBase}] Nothing to update for {obj}", applicationBase, this); return; } var updateModdedPaths = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModFiles)); var updateManip = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModManip)); _downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource(); var downloadToken = _downloadCancellationTokenSource.Token; _ = Task.Run(async () => { await DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, downloadToken).ConfigureAwait(false); }); } private Task? _pairDownloadTask; private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, bool updateModdedPaths, bool updateManip, CancellationToken downloadToken) { Logger.LogTrace("[BASE-{appBase}] DownloadAndApplyCharacterAsync", applicationBase); Dictionary<(string GamePath, string? Hash), string> moddedPaths = []; if (updateModdedPaths) { Logger.LogTrace("[BASE-{appBase}] DownloadAndApplyCharacterAsync > updateModdedPaths", applicationBase); int attempts = 0; List toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested) { if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted) { Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData); await _pairDownloadTask.ConfigureAwait(false); } Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData); Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Informational, $"Starting download for {toDownloadReplacements.Count} files"))); var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false); if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles)) { Pair.HoldApplication("IndividualPerformanceThreshold", maxValue: 1); _downloadManager.ClearDownload(); return; } _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false), downloadToken); await _pairDownloadTask.ConfigureAwait(false); if (downloadToken.IsCancellationRequested) { Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase); return; } toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal)))) { break; } await Task.Delay(TimeSpan.FromSeconds(2), downloadToken).ConfigureAwait(false); } try { Mediator.Publish(new HaltScanMessage(nameof(PlayerPerformanceService.ShrinkTextures))); if (await _playerPerformanceService.ShrinkTextures(this, charaData, downloadToken).ConfigureAwait(false)) _ = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); } finally { Mediator.Publish(new ResumeScanMessage(nameof(PlayerPerformanceService.ShrinkTextures))); } bool exceedsThreshold = !await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false); if (exceedsThreshold) Pair.HoldApplication("IndividualPerformanceThreshold", maxValue: 1); else Pair.UnholdApplication("IndividualPerformanceThreshold"); if (exceedsThreshold) { Logger.LogTrace("[BASE-{appBase}] Not applying due to performance thresholds", applicationBase); return; } } if (Pair.IsApplicationBlocked) { var reasons = string.Join(", ", Pair.HoldApplicationReasons); Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning, $"Not applying character data: {reasons}"))); Logger.LogTrace("[BASE-{appBase}] Not applying due to hold: {reasons}", applicationBase, reasons); return; } downloadToken.ThrowIfCancellationRequested(); var appToken = _applicationCancellationTokenSource?.Token; while ((!_applicationTask?.IsCompleted ?? false) && !downloadToken.IsCancellationRequested && (!appToken?.IsCancellationRequested ?? false)) { // block until current application is done Logger.LogDebug("[BASE-{appBase}] Waiting for current data application (Id: {id}) for player ({handler}) to finish", applicationBase, _applicationId, PlayerName); await Task.Delay(250).ConfigureAwait(false); } if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false)) return; _applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource(); var token = _applicationCancellationTokenSource.Token; _applicationTask = ApplyCharacterDataAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, token); } private async Task ApplyCharacterDataAsync(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, bool updateModdedPaths, bool updateManip, Dictionary<(string GamePath, string? Hash), string> moddedPaths, CancellationToken token) { ushort objIndex = ushort.MaxValue; try { _applicationId = Guid.NewGuid(); Logger.LogDebug("[BASE-{applicationId}] Starting application task for {this}: {appId}", applicationBase, this, _applicationId); if (_penumbraCollection == Guid.Empty) { if (objIndex == ushort.MaxValue) objIndex = await _dalamudUtil.RunOnFrameworkThread(() => _charaHandler!.GetGameObject()!.ObjectIndex).ConfigureAwait(false); _penumbraCollection = await _ipcManager.Penumbra.CreateTemporaryCollectionAsync(Logger, Pair.UserData.UID).ConfigureAwait(false); await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, _penumbraCollection, objIndex).ConfigureAwait(false); } Logger.LogDebug("[{applicationId}] Waiting for initial draw for for {handler}", _applicationId, _charaHandler); await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, _charaHandler!, _applicationId, 30000, token).ConfigureAwait(false); token.ThrowIfCancellationRequested(); if (updateModdedPaths) { // ensure collection is set if (objIndex == ushort.MaxValue) objIndex = await _dalamudUtil.RunOnFrameworkThread(() => _charaHandler!.GetGameObject()!.ObjectIndex).ConfigureAwait(false); await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, _penumbraCollection, objIndex).ConfigureAwait(false); await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, _applicationId, _penumbraCollection, moddedPaths.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)).ConfigureAwait(false); LastAppliedDataBytes = -1; foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists)) { if (LastAppliedDataBytes == -1) LastAppliedDataBytes = 0; LastAppliedDataBytes += path.Length; } } if (updateManip) { await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, _applicationId, _penumbraCollection, charaData.ManipulationData).ConfigureAwait(false); } token.ThrowIfCancellationRequested(); foreach (var kind in updatedData) { await ApplyCustomizationDataAsync(_applicationId, kind, charaData, token).ConfigureAwait(false); token.ThrowIfCancellationRequested(); } _cachedData = charaData; Mediator.Publish(new PairDataAppliedMessage(Pair.UserData.UID, charaData)); Logger.LogDebug("[{applicationId}] Application finished", _applicationId); } catch (Exception ex) { if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException)) { IsVisible = false; _forceApplyMods = true; _cachedData = charaData; Mediator.Publish(new PairDataAppliedMessage(Pair.UserData.UID, charaData)); Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId); } else { Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId); } } } private void UpdateVisibility(bool nowVisible, bool invalidate = false) { if (string.IsNullOrEmpty(PlayerName)) { var pc = _dalamudUtil.FindPlayerByNameHash(Pair.Ident); if (pc.ObjectId == 0) return; Logger.LogDebug("One-Time Initializing {this}", this); Initialize(pc.Name); Logger.LogDebug("One-Time Initialized {this}", this); Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Informational, $"Initializing User For Character {pc.Name}"))); } // This was triggered by the character becoming handled by Mare, so unapply everything // There seems to be a good chance that this races Mare and then crashes if (!nowVisible && invalidate) { bool wasVisible = IsVisible; IsVisible = false; _charaHandler?.Invalidate(); _downloadCancellationTokenSource?.CancelDispose(); _downloadCancellationTokenSource = null; if (wasVisible) Logger.LogTrace("{this} visibility changed, now: {visi}", this, IsVisible); Logger.LogDebug("Invalidating {this}", this); UndoApplication(); return; } if (!IsVisible && nowVisible) { // This is deferred application attempt, avoid any log output if (_deferred != Guid.Empty) { _isVisible = true; _ = Task.Run(() => { ApplyCharacterData(_deferred, _cachedData!, forceApplyCustomization: true); }); } IsVisible = true; Mediator.Publish(new PairHandlerVisibleMessage(this)); if (_cachedData != null) { Guid appData = Guid.NewGuid(); Logger.LogTrace("[BASE-{appBase}] {this} visibility changed, now: {visi}, cached data exists", appData, this, IsVisible); _ = Task.Run(() => { ApplyCharacterData(appData, _cachedData!, forceApplyCustomization: true); }); } else { Logger.LogTrace("{this} visibility changed, now: {visi}, no cached data exists", this, IsVisible); } } else if (IsVisible && !nowVisible) { IsVisible = false; _charaHandler?.Invalidate(); _downloadCancellationTokenSource?.CancelDispose(); _downloadCancellationTokenSource = null; Logger.LogTrace("{this} visibility changed, now: {visi}", this, IsVisible); } } private void Initialize(string name) { PlayerName = name; _charaHandler = _gameObjectHandlerFactory.Create(ObjectKind.Player, () => _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Pair.Ident), isWatched: false).GetAwaiter().GetResult(); Mediator.Subscribe(this, msg => { if (string.IsNullOrEmpty(_cachedData?.HonorificData)) return; Logger.LogTrace("Reapplying Honorific data for {this}", this); _ = Task.Run(async () => await _ipcManager.Honorific.SetTitleAsync(PlayerCharacter, _cachedData.HonorificData).ConfigureAwait(false), CancellationToken.None); }); Mediator.Subscribe(this, msg => { if (string.IsNullOrEmpty(_cachedData?.PetNamesData)) return; Logger.LogTrace("Reapplying Pet Names data for {this}", this); _ = Task.Run(async () => await _ipcManager.PetNames.SetPlayerData(PlayerCharacter, _cachedData.PetNamesData).ConfigureAwait(false), CancellationToken.None); }); } private async Task RevertCustomizationDataAsync(ObjectKind objectKind, string name, Guid applicationId, CancellationToken cancelToken) { nint address = _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Pair.Ident); if (address == nint.Zero) return; Logger.LogDebug("[{applicationId}] Reverting all Customization for {alias}/{name} {objectKind}", applicationId, Pair.UserData.AliasOrUID, name, objectKind); if (_customizeIds.TryGetValue(objectKind, out var customizeId)) { _customizeIds.Remove(objectKind); } if (objectKind == ObjectKind.Player) { using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Player, () => address, isWatched: false).ConfigureAwait(false); tempHandler.CompareNameAndThrow(name); Logger.LogDebug("[{applicationId}] Restoring Customization and Equipment for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name); await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); tempHandler.CompareNameAndThrow(name); Logger.LogDebug("[{applicationId}] Restoring Heels for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name); await _ipcManager.Heels.RestoreOffsetForPlayerAsync(address).ConfigureAwait(false); tempHandler.CompareNameAndThrow(name); Logger.LogDebug("[{applicationId}] Restoring C+ for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name); await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); tempHandler.CompareNameAndThrow(name); Logger.LogDebug("[{applicationId}] Restoring Honorific for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name); await _ipcManager.Honorific.ClearTitleAsync(address).ConfigureAwait(false); Logger.LogDebug("[{applicationId}] Restoring Pet Nicknames for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name); await _ipcManager.PetNames.ClearPlayerData(address).ConfigureAwait(false); Logger.LogDebug("[{applicationId}] Restoring Moodles for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name); await _ipcManager.Moodles.RevertStatusAsync(address).ConfigureAwait(false); } else if (objectKind == ObjectKind.MinionOrMount) { var minionOrMount = await _dalamudUtil.GetMinionOrMountAsync(address).ConfigureAwait(false); if (minionOrMount != nint.Zero) { await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.MinionOrMount, () => minionOrMount, isWatched: false).ConfigureAwait(false); await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); } } else if (objectKind == ObjectKind.Pet) { var pet = await _dalamudUtil.GetPetAsync(address).ConfigureAwait(false); if (pet != nint.Zero) { await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Pet, () => pet, isWatched: false).ConfigureAwait(false); await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); } } else if (objectKind == ObjectKind.Companion) { var companion = await _dalamudUtil.GetCompanionAsync(address).ConfigureAwait(false); if (companion != nint.Zero) { await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Pet, () => companion, isWatched: false).ConfigureAwait(false); await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); } } } private List TryCalculateModdedDictionary(Guid applicationBase, CharacterData charaData, out Dictionary<(string GamePath, string? Hash), string> moddedDictionary, CancellationToken token) { Stopwatch st = Stopwatch.StartNew(); ConcurrentBag missingFiles = []; moddedDictionary = []; ConcurrentDictionary<(string GamePath, string? Hash), string> outputDict = new(); bool hasMigrationChanges = false; try { var replacementList = charaData.FileReplacements.SelectMany(k => k.Value.Where(v => string.IsNullOrEmpty(v.FileSwapPath))).ToList(); Parallel.ForEach(replacementList, new ParallelOptions() { CancellationToken = token, MaxDegreeOfParallelism = 4 }, (item) => { token.ThrowIfCancellationRequested(); var fileCache = _fileDbManager.GetFileCacheByHash(item.Hash, preferSubst: true); if (fileCache != null) { if (string.IsNullOrEmpty(new FileInfo(fileCache.ResolvedFilepath).Extension)) { hasMigrationChanges = true; fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, item.GamePaths[0].Split(".")[^1]); } foreach (var gamePath in item.GamePaths) { outputDict[(gamePath, item.Hash)] = fileCache.ResolvedFilepath; } } else { Logger.LogTrace("Missing file: {hash}", item.Hash); missingFiles.Add(item); } }); moddedDictionary = outputDict.ToDictionary(k => k.Key, k => k.Value); foreach (var item in charaData.FileReplacements.SelectMany(k => k.Value.Where(v => !string.IsNullOrEmpty(v.FileSwapPath))).ToList()) { foreach (var gamePath in item.GamePaths) { Logger.LogTrace("[BASE-{appBase}] Adding file swap for {path}: {fileSwap}", applicationBase, gamePath, item.FileSwapPath); moddedDictionary[(gamePath, null)] = item.FileSwapPath; } } } catch (OperationCanceledException) { throw; } catch (Exception ex) { Logger.LogError(ex, "[BASE-{appBase}] Something went wrong during calculation replacements", applicationBase); } if (hasMigrationChanges) _fileDbManager.WriteOutFullCsv(); st.Stop(); Logger.LogDebug("[BASE-{appBase}] ModdedPaths calculated in {time}ms, missing files: {count}, total files: {total}", applicationBase, st.ElapsedMilliseconds, missingFiles.Count, moddedDictionary.Keys.Count); return [.. missingFiles]; } }