Files
LightlessClient/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs
2025-12-28 05:24:12 +09:00

2312 lines
91 KiB
C#

using System.Collections.Concurrent;
using System.Diagnostics;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Events;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.PairProcessing;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Services.TextureCompression;
using LightlessSync.Utils;
using LightlessSync.WebAPI.Files;
using LightlessSync.WebAPI.Files.Models;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer;
namespace LightlessSync.PlayerData.Pairs;
/// <summary>
/// handles lifecycle, visibility, queued data, character data for a paired user
/// </summary>
internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPairHandlerAdapter
{
private sealed record CombatData(Guid ApplicationId, CharacterData CharacterData, bool Forced);
private readonly DalamudUtilService _dalamudUtil;
private readonly ActorObjectService _actorObjectService;
private readonly FileDownloadManager _downloadManager;
private readonly FileCacheManager _fileDbManager;
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
private readonly IpcManager _ipcManager;
private readonly IHostApplicationLifetime _lifetime;
private readonly PlayerPerformanceService _playerPerformanceService;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ServerConfigurationManager _serverConfigManager;
private readonly PluginWarningNotificationService _pluginWarningNotificationManager;
private readonly TextureDownscaleService _textureDownscaleService;
private readonly PairStateCache _pairStateCache;
private readonly PairPerformanceMetricsCache _performanceMetricsCache;
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
private readonly PairManager _pairManager;
private CancellationTokenSource? _applicationCancellationTokenSource;
private Guid _applicationId;
private Task? _applicationTask;
private CharacterData? _cachedData = null;
private GameObjectHandler? _charaHandler;
private readonly Dictionary<ObjectKind, Guid?> _customizeIds = [];
private CombatData? _dataReceivedInDowntime;
private CancellationTokenSource? _downloadCancellationTokenSource;
private bool _forceApplyMods = false;
private bool _forceFullReapply;
private Dictionary<(string GamePath, string? Hash), string>? _lastAppliedModdedPaths;
private bool _needsCollectionRebuild;
private bool _pendingModReapply;
private bool _lastModApplyDeferred;
private int _lastMissingCriticalMods;
private int _lastMissingNonCriticalMods;
private int _lastMissingForbiddenMods;
private bool _lastMissingCachedFiles;
private bool _isVisible;
private Guid _penumbraCollection;
private readonly object _collectionGate = new();
private bool _redrawOnNextApplication = false;
private readonly object _initializationGate = new();
private readonly object _pauseLock = new();
private Task _pauseTransitionTask = Task.CompletedTask;
private bool _pauseRequested;
private DateTime? _lastDataReceivedAt;
private DateTime? _lastApplyAttemptAt;
private DateTime? _lastSuccessfulApplyAt;
private string? _lastFailureReason;
private IReadOnlyList<string> _lastBlockingConditions = Array.Empty<string>();
private readonly object _visibilityGraceGate = new();
private CancellationTokenSource? _visibilityGraceCts;
private static readonly TimeSpan VisibilityEvictionGrace = TimeSpan.FromMinutes(1);
private static readonly HashSet<string> NonPriorityModExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".tmb",
".pap",
".atex",
".avfx",
".scd"
};
private DateTime? _invisibleSinceUtc;
private DateTime? _visibilityEvictionDueAtUtc;
private DateTime _nextActorLookupUtc = DateTime.MinValue;
private static readonly TimeSpan ActorLookupInterval = TimeSpan.FromSeconds(1);
private static readonly SemaphoreSlim ActorInitializationLimiter = new(1, 1);
private readonly object _actorInitializationGate = new();
private ActorObjectService.ActorDescriptor? _pendingActorDescriptor;
private bool _actorInitializationInProgress;
private bool _frameworkUpdateSubscribed;
public DateTime? InvisibleSinceUtc => _invisibleSinceUtc;
public DateTime? VisibilityEvictionDueAtUtc => _visibilityEvictionDueAtUtc;
public string Ident { get; }
public bool Initialized { get; private set; }
public bool ScheduledForDeletion { get; set; }
public bool IsVisible
{
get => _isVisible;
private set
{
if (_isVisible == value) return;
_isVisible = value;
if (!_isVisible)
{
DisableSync();
_invisibleSinceUtc = DateTime.UtcNow;
_visibilityEvictionDueAtUtc = _invisibleSinceUtc.Value.Add(VisibilityEvictionGrace);
StartVisibilityGraceTask();
}
else
{
CancelVisibilityGraceTask();
_invisibleSinceUtc = null;
_visibilityEvictionDueAtUtc = null;
ScheduledForDeletion = false;
if (_charaHandler is not null && _charaHandler.Address != nint.Zero)
_ = EnsurePenumbraCollection();
}
var user = GetPrimaryUserData();
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter),
EventSeverity.Informational, "User Visibility Changed, now: " + (_isVisible ? "Is Visible" : "Is not Visible"))));
Mediator.Publish(new RefreshUiMessage());
Mediator.Publish(new VisibilityChange());
}
}
public long LastAppliedDataBytes { get; private set; }
public long LastAppliedDataTris { get; set; } = -1;
public long LastAppliedApproximateVRAMBytes { get; set; } = -1;
public long LastAppliedApproximateEffectiveVRAMBytes { get; set; } = -1;
public CharacterData? LastReceivedCharacterData { get; private set; }
public bool PendingModReapply => _pendingModReapply;
public bool ModApplyDeferred => _lastModApplyDeferred;
public int MissingCriticalMods => _lastMissingCriticalMods;
public int MissingNonCriticalMods => _lastMissingNonCriticalMods;
public int MissingForbiddenMods => _lastMissingForbiddenMods;
public DateTime? LastDataReceivedAt => _lastDataReceivedAt;
public DateTime? LastApplyAttemptAt => _lastApplyAttemptAt;
public DateTime? LastSuccessfulApplyAt => _lastSuccessfulApplyAt;
public string? LastFailureReason => _lastFailureReason;
public IReadOnlyList<string> LastBlockingConditions => _lastBlockingConditions;
public bool IsApplying => _applicationTask is { IsCompleted: false };
public bool IsDownloading => _downloadManager.IsDownloading;
public int PendingDownloadCount => _downloadManager.CurrentDownloads.Count;
public int ForbiddenDownloadCount => _downloadManager.ForbiddenTransfers.Count;
public PairHandlerAdapter(
ILogger<PairHandlerAdapter> logger,
LightlessMediator mediator,
PairManager pairManager,
string ident,
GameObjectHandlerFactory gameObjectHandlerFactory,
IpcManager ipcManager,
FileDownloadManager transferManager,
PluginWarningNotificationService pluginWarningNotificationManager,
DalamudUtilService dalamudUtil,
ActorObjectService actorObjectService,
IHostApplicationLifetime lifetime,
FileCacheManager fileDbManager,
PlayerPerformanceService playerPerformanceService,
PairProcessingLimiter pairProcessingLimiter,
ServerConfigurationManager serverConfigManager,
TextureDownscaleService textureDownscaleService,
PairStateCache pairStateCache,
PairPerformanceMetricsCache performanceMetricsCache,
PenumbraTempCollectionJanitor tempCollectionJanitor) : base(logger, mediator)
{
_pairManager = pairManager;
Ident = ident;
_gameObjectHandlerFactory = gameObjectHandlerFactory;
_ipcManager = ipcManager;
_downloadManager = transferManager;
_pluginWarningNotificationManager = pluginWarningNotificationManager;
_dalamudUtil = dalamudUtil;
_actorObjectService = actorObjectService;
_lifetime = lifetime;
_fileDbManager = fileDbManager;
_playerPerformanceService = playerPerformanceService;
_pairProcessingLimiter = pairProcessingLimiter;
_serverConfigManager = serverConfigManager;
_textureDownscaleService = textureDownscaleService;
_pairStateCache = pairStateCache;
_performanceMetricsCache = performanceMetricsCache;
_tempCollectionJanitor = tempCollectionJanitor;
}
public void Initialize()
{
EnsureInitialized();
}
private void EnsureInitialized()
{
if (Initialized)
{
return;
}
ActorObjectService.ActorDescriptor? trackedDescriptor = null;
lock (_initializationGate)
{
if (Initialized)
{
return;
}
if (LastAppliedDataBytes < 0 || LastAppliedDataTris < 0
|| LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0)
{
_forceApplyMods = true;
}
var useFrameworkUpdate = !_actorObjectService.HooksActive;
if (useFrameworkUpdate)
{
Mediator.Subscribe<FrameworkUpdateMessage>(this, _ => FrameworkUpdate());
_frameworkUpdateSubscribed = true;
}
Mediator.Subscribe<ZoneSwitchStartMessage>(this, _ =>
{
_downloadCancellationTokenSource?.CancelDispose();
_charaHandler?.Invalidate();
IsVisible = false;
});
Mediator.Subscribe<PenumbraInitializedMessage>(this, _ =>
{
ResetPenumbraCollection(releaseFromPenumbra: false, reason: "PenumbraInitialized");
if (!IsVisible && _charaHandler is not null)
{
PlayerName = string.Empty;
_charaHandler.Dispose();
_charaHandler = null;
}
EnableSync();
});
Mediator.Subscribe<PenumbraDisposedMessage>(this, _ => ResetPenumbraCollection(releaseFromPenumbra: false, reason: "PenumbraDisposed"));
Mediator.Subscribe<ClassJobChangedMessage>(this, msg =>
{
if (msg.GameObjectHandler == _charaHandler)
{
_redrawOnNextApplication = true;
}
});
Mediator.Subscribe<CombatEndMessage>(this, _ => EnableSync());
Mediator.Subscribe<CombatStartMessage>(this, _ => DisableSync());
Mediator.Subscribe<PerformanceEndMessage>(this, _ => EnableSync());
Mediator.Subscribe<PerformanceStartMessage>(this, _ => DisableSync());
Mediator.Subscribe<InstanceOrDutyStartMessage>(this, _ => DisableSync());
Mediator.Subscribe<InstanceOrDutyEndMessage>(this, _ => EnableSync());
Mediator.Subscribe<CutsceneStartMessage>(this, _ => DisableSync());
Mediator.Subscribe<CutsceneEndMessage>(this, _ => EnableSync());
Mediator.Subscribe<GposeStartMessage>(this, _ => DisableSync());
Mediator.Subscribe<GposeEndMessage>(this, _ => EnableSync());
Mediator.Subscribe<ActorTrackedMessage>(this, msg => HandleActorTracked(msg.Descriptor));
Mediator.Subscribe<ActorUntrackedMessage>(this, msg => HandleActorUntracked(msg.Descriptor));
Mediator.Subscribe<DownloadFinishedMessage>(this, msg =>
{
if (_charaHandler is null || !ReferenceEquals(msg.DownloadId, _charaHandler))
{
return;
}
if (_pendingModReapply && IsVisible)
{
if (LastReceivedCharacterData is not null)
{
Logger.LogDebug("Downloads finished for {handler}, reapplying pending mod data", GetLogIdentifier());
ApplyLastReceivedData(forced: true);
return;
}
if (_cachedData is not null)
{
Logger.LogDebug("Downloads finished for {handler}, reapplying pending mod data from cache", GetLogIdentifier());
ApplyCharacterData(Guid.NewGuid(), _cachedData, forceApplyCustomization: true);
return;
}
}
TryApplyQueuedData();
});
if (!useFrameworkUpdate
&& _actorObjectService.TryGetActorByHash(Ident, out var descriptor)
&& descriptor.Address != nint.Zero)
{
trackedDescriptor = descriptor;
}
Initialized = true;
}
if (trackedDescriptor.HasValue)
{
HandleActorTracked(trackedDescriptor.Value);
}
}
private IReadOnlyList<PairConnection> GetCurrentPairs()
{
return _pairManager.GetPairsByIdent(Ident);
}
private PairConnection? GetPrimaryPair()
{
var pairs = GetCurrentPairs();
var direct = pairs.FirstOrDefault(p => p.IsDirectlyPaired);
if (direct is not null)
{
return direct;
}
var online = pairs.FirstOrDefault(p => p.IsOnline);
if (online is not null)
{
return online;
}
return pairs.FirstOrDefault();
}
private UserData GetPrimaryUserData()
{
return GetPrimaryPair()?.User ?? new UserData(Ident);
}
private string GetPrimaryAliasOrUid()
{
var pair = GetPrimaryPair();
if (pair?.User is null)
{
return Ident;
}
return string.IsNullOrEmpty(pair.User.AliasOrUID) ? Ident : pair.User.AliasOrUID;
}
private string GetPrimaryAliasOrUidSafe()
{
try
{
return GetPrimaryAliasOrUid();
}
catch
{
return Ident;
}
}
private UserData GetPrimaryUserDataSafe()
{
try
{
return GetPrimaryUserData();
}
catch
{
return new UserData(Ident);
}
}
private string GetLogIdentifier()
{
var alias = GetPrimaryAliasOrUidSafe();
return string.Equals(alias, Ident, StringComparison.Ordinal) ? alias : $"{alias} ({Ident})";
}
private Guid EnsurePenumbraCollection()
{
if (!IsVisible)
{
return Guid.Empty;
}
if (_penumbraCollection != Guid.Empty)
{
return _penumbraCollection;
}
lock (_collectionGate)
{
if (_penumbraCollection != Guid.Empty)
{
return _penumbraCollection;
}
var cached = _pairStateCache.TryGetTemporaryCollection(Ident);
if (cached.HasValue && cached.Value != Guid.Empty)
{
_penumbraCollection = cached.Value;
return _penumbraCollection;
}
if (!_ipcManager.Penumbra.APIAvailable)
{
return Guid.Empty;
}
var user = GetPrimaryUserDataSafe();
var uid = !string.IsNullOrEmpty(user.UID) ? user.UID : Ident;
var created = _ipcManager.Penumbra.CreateTemporaryCollectionAsync(Logger, uid)
.ConfigureAwait(false).GetAwaiter().GetResult();
if (created != Guid.Empty)
{
_penumbraCollection = created;
_pairStateCache.StoreTemporaryCollection(Ident, created);
_tempCollectionJanitor.Register(created);
}
return _penumbraCollection;
}
}
private void ResetPenumbraCollection(bool releaseFromPenumbra = true, string? reason = null)
{
Guid toRelease = Guid.Empty;
bool hadCollection = false;
lock (_collectionGate)
{
if (_penumbraCollection != Guid.Empty)
{
toRelease = _penumbraCollection;
_penumbraCollection = Guid.Empty;
hadCollection = true;
}
}
var cached = _pairStateCache.ClearTemporaryCollection(Ident);
if (cached.HasValue && cached.Value != Guid.Empty)
{
toRelease = cached.Value;
hadCollection = true;
}
if (hadCollection)
{
_needsCollectionRebuild = true;
_forceFullReapply = true;
_forceApplyMods = true;
_tempCollectionJanitor.Unregister(toRelease);
}
if (!releaseFromPenumbra || toRelease == Guid.Empty || !_ipcManager.Penumbra.APIAvailable)
{
return;
}
try
{
var applicationId = Guid.NewGuid();
Logger.LogTrace("[{applicationId}] Removing temp collection {CollectionId} for {handler} ({reason})", applicationId, toRelease, GetLogIdentifier(), reason ?? "Cleanup");
_ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, toRelease).GetAwaiter().GetResult();
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed to remove temporary Penumbra collection for {handler}", GetLogIdentifier());
}
}
private bool AnyPair(Func<PairConnection, bool> predicate)
{
return GetCurrentPairs().Any(predicate);
}
private bool ShouldSkipDownscale()
{
return GetCurrentPairs().Any(p => p.IsDirectlyPaired && p.SelfToOtherPermissions.IsSticky());
}
private bool IsPaused()
{
var pairs = GetCurrentPairs();
return pairs.Count > 0 && pairs.Any(p => p.IsPaused);
}
bool IPairPerformanceSubject.IsPaused => IsPaused();
bool IPairPerformanceSubject.IsDirectlyPaired => AnyPair(p => p.IsDirectlyPaired);
bool IPairPerformanceSubject.HasStickyPermissions => AnyPair(p => p.SelfToOtherPermissions.HasFlag(UserPermissions.Sticky));
UserData IPairPerformanceSubject.UserData => GetPrimaryUserData();
string IPairPerformanceSubject.PlayerName => PlayerName ?? GetPrimaryAliasOrUidSafe();
private UserPermissions GetCombinedPermissions()
{
var pairs = GetCurrentPairs();
if (pairs.Count == 0)
{
return UserPermissions.NoneSet;
}
var combined = pairs[0].SelfToOtherPermissions | pairs[0].OtherToSelfPermissions;
for (int i = 1; i < pairs.Count; i++)
{
var perms = pairs[i].SelfToOtherPermissions | pairs[i].OtherToSelfPermissions;
combined &= perms;
}
return combined;
}
public nint PlayerCharacter => _charaHandler?.Address ?? nint.Zero;
public uint PlayerCharacterId => _charaHandler?.EntityId ?? uint.MaxValue;
public string? PlayerName { get; private set; }
public string PlayerNameHash => Ident;
public void ApplyData(CharacterData data)
{
EnsureInitialized();
LastReceivedCharacterData = data;
_lastDataReceivedAt = DateTime.UtcNow;
ApplyLastReceivedData();
}
public void LoadCachedCharacterData(CharacterData data)
{
if (data is null)
{
return;
}
LastReceivedCharacterData = data;
_cachedData = null;
_forceApplyMods = true;
LastAppliedDataBytes = -1;
LastAppliedDataTris = -1;
LastAppliedApproximateVRAMBytes = -1;
LastAppliedApproximateEffectiveVRAMBytes = -1;
}
public void ApplyLastReceivedData(bool forced = false)
{
EnsureInitialized();
if (LastReceivedCharacterData is null)
{
Logger.LogTrace("No cached data to apply for {Ident}", Ident);
return;
}
var hasMissingCachedFiles = HasMissingCachedFiles(LastReceivedCharacterData);
var missingResolved = _lastMissingCachedFiles && !hasMissingCachedFiles;
_lastMissingCachedFiles = hasMissingCachedFiles;
var shouldForce = forced || missingResolved;
if (IsPaused())
{
Logger.LogTrace("Permissions paused for {Ident}, skipping reapply", Ident);
return;
}
if (shouldForce)
{
_forceApplyMods = true;
_forceFullReapply = true;
LastAppliedDataBytes = -1;
LastAppliedDataTris = -1;
LastAppliedApproximateVRAMBytes = -1;
LastAppliedApproximateEffectiveVRAMBytes = -1;
}
var sanitized = CloneAndSanitizeLastReceived(out _);
if (sanitized is null)
{
Logger.LogTrace("Sanitized data null for {Ident}", Ident);
return;
}
_pairStateCache.Store(Ident, sanitized);
if (!IsVisible)
{
Logger.LogTrace("Handler for {Ident} not visible, caching sanitized data for later", Ident);
_cachedData = sanitized;
_forceFullReapply = true;
return;
}
ApplyCharacterData(Guid.NewGuid(), sanitized, shouldForce);
}
public bool FetchPerformanceMetricsFromCache()
{
EnsureInitialized();
var sanitized = CloneAndSanitizeLastReceived(out var dataHash);
if (sanitized is null || string.IsNullOrEmpty(dataHash))
{
return false;
}
if (!TryApplyCachedMetrics(dataHash))
{
return false;
}
_cachedData = sanitized;
_pairStateCache.Store(Ident, sanitized);
return true;
}
private CharacterData? CloneAndSanitizeLastReceived(out string? dataHash)
{
dataHash = null;
if (LastReceivedCharacterData is null)
{
return null;
}
var sanitized = RemoveNotSyncedFiles(LastReceivedCharacterData.DeepClone());
if (sanitized is null)
{
return null;
}
dataHash = GetDataHashSafe(sanitized);
return sanitized;
}
private string? GetDataHashSafe(CharacterData data)
{
try
{
return data.DataHash.Value;
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed to compute character data hash for {Ident}", Ident);
return null;
}
}
private bool TryApplyCachedMetrics(string? dataHash)
{
if (string.IsNullOrEmpty(dataHash))
{
return false;
}
if (!_performanceMetricsCache.TryGetMetrics(Ident, dataHash, out var metrics))
{
return false;
}
ApplyCachedMetrics(metrics);
return true;
}
private void ApplyCachedMetrics(PairPerformanceMetrics metrics)
{
LastAppliedDataTris = metrics.TriangleCount;
LastAppliedApproximateVRAMBytes = metrics.ApproximateVramBytes;
LastAppliedApproximateEffectiveVRAMBytes = metrics.ApproximateEffectiveVramBytes;
}
private void StorePerformanceMetrics(CharacterData charaData)
{
if (LastAppliedDataTris < 0
|| LastAppliedApproximateVRAMBytes < 0
|| LastAppliedApproximateEffectiveVRAMBytes < 0)
{
return;
}
var dataHash = GetDataHashSafe(charaData);
if (string.IsNullOrEmpty(dataHash))
{
return;
}
_performanceMetricsCache.StoreMetrics(
Ident,
dataHash,
new PairPerformanceMetrics(LastAppliedDataTris, LastAppliedApproximateVRAMBytes, LastAppliedApproximateEffectiveVRAMBytes));
}
private bool HasMissingCachedFiles(CharacterData characterData)
{
try
{
HashSet<string> inspectedHashes = new(StringComparer.OrdinalIgnoreCase);
foreach (var replacements in characterData.FileReplacements.Values)
{
foreach (var replacement in replacements)
{
if (!string.IsNullOrEmpty(replacement.FileSwapPath))
{
if (Path.IsPathRooted(replacement.FileSwapPath) && !File.Exists(replacement.FileSwapPath))
{
Logger.LogTrace("Missing file swap path {Path} detected for {Handler}", replacement.FileSwapPath, GetLogIdentifier());
return true;
}
continue;
}
if (string.IsNullOrEmpty(replacement.Hash) || !inspectedHashes.Add(replacement.Hash))
{
continue;
}
var cacheEntry = _fileDbManager.GetFileCacheByHash(replacement.Hash);
if (cacheEntry is null)
{
Logger.LogTrace("Missing cached file {Hash} detected for {Handler}", replacement.Hash, GetLogIdentifier());
return true;
}
if (!File.Exists(cacheEntry.ResolvedFilepath))
{
Logger.LogTrace("Cached file {Hash} missing on disk for {Handler}, removing cache entry", replacement.Hash, GetLogIdentifier());
_fileDbManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath);
return true;
}
}
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed to determine cache availability for {Handler}", GetLogIdentifier());
}
return false;
}
private CharacterData? RemoveNotSyncedFiles(CharacterData? data)
{
Logger.LogTrace("Removing not synced files for {Ident}", Ident);
if (data is null)
{
return null;
}
var permissions = GetCombinedPermissions();
bool disableAnimations = permissions.IsDisableAnimations();
bool disableVfx = permissions.IsDisableVFX();
bool disableSounds = permissions.IsDisableSounds();
if (!(disableAnimations || disableVfx || disableSounds))
{
return data;
}
foreach (var objectKind in data.FileReplacements.Keys.ToList())
{
var replacements = data.FileReplacements[objectKind];
if (disableSounds)
{
replacements = replacements
.Where(f => !f.GamePaths.Any(p => p.EndsWith("scd", StringComparison.OrdinalIgnoreCase)))
.ToList();
}
if (disableAnimations)
{
replacements = replacements
.Where(f => !f.GamePaths.Any(p =>
p.EndsWith("tmb", StringComparison.OrdinalIgnoreCase) ||
p.EndsWith("pap", StringComparison.OrdinalIgnoreCase)))
.ToList();
}
if (disableVfx)
{
replacements = replacements
.Where(f => !f.GamePaths.Any(p =>
p.EndsWith("atex", StringComparison.OrdinalIgnoreCase) ||
p.EndsWith("avfx", StringComparison.OrdinalIgnoreCase)))
.ToList();
}
data.FileReplacements[objectKind] = replacements;
}
return data;
}
private bool HasValidCachedModdedPaths()
{
if (_lastAppliedModdedPaths is null || _lastAppliedModdedPaths.Count == 0)
{
return false;
}
foreach (var entry in _lastAppliedModdedPaths)
{
if (string.IsNullOrEmpty(entry.Value) || !File.Exists(entry.Value))
{
Logger.LogDebug("Cached file path {path} missing for {handler}, forcing recalculation", entry.Value ?? "empty", GetLogIdentifier());
return false;
}
}
return true;
}
private bool IsForbiddenHash(string hash)
=> _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, hash, StringComparison.Ordinal));
private static bool IsNonPriorityModPath(string? gamePath)
{
if (string.IsNullOrEmpty(gamePath))
{
return false;
}
var extension = Path.GetExtension(gamePath);
return !string.IsNullOrEmpty(extension) && NonPriorityModExtensions.Contains(extension);
}
private static bool IsCriticalModReplacement(FileReplacementData replacement)
{
foreach (var gamePath in replacement.GamePaths)
{
if (!IsNonPriorityModPath(gamePath))
{
return true;
}
}
return false;
}
private void CountMissingReplacements(IEnumerable<FileReplacementData> missing, out int critical, out int nonCritical, out int forbidden)
{
critical = 0;
nonCritical = 0;
forbidden = 0;
foreach (var replacement in missing)
{
if (IsForbiddenHash(replacement.Hash))
{
forbidden++;
}
if (IsCriticalModReplacement(replacement))
{
critical++;
}
else
{
nonCritical++;
}
}
}
private static void RemoveModApplyChanges(Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData)
{
foreach (var changes in updatedData.Values)
{
changes.Remove(PlayerChanges.ModFiles);
changes.Remove(PlayerChanges.ModManip);
changes.Remove(PlayerChanges.ForcedRedraw);
}
}
private bool CanApplyNow()
{
return !_dalamudUtil.IsInCombat
&& !_dalamudUtil.IsPerforming
&& !_dalamudUtil.IsInInstance
&& !_dalamudUtil.IsInCutscene
&& !_dalamudUtil.IsInGpose
&& _ipcManager.Penumbra.APIAvailable
&& _ipcManager.Glamourer.APIAvailable;
}
private void RecordFailure(string reason, params string[] conditions)
{
_lastFailureReason = reason;
_lastBlockingConditions = conditions.Length == 0 ? Array.Empty<string>() : conditions.ToArray();
}
private void ClearFailureState()
{
_lastFailureReason = null;
_lastBlockingConditions = Array.Empty<string>();
}
private void DeferApplication(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization, UserData user, string reason,
string failureKey, LogLevel logLevel, string logMessage, params object?[] logArgs)
{
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, reason)));
Logger.Log(logLevel, logMessage, logArgs);
RecordFailure(reason, failureKey);
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
SetUploading(false);
}
public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = false)
{
_lastApplyAttemptAt = DateTime.UtcNow;
ClearFailureState();
if (characterData is null)
{
RecordFailure("Received null character data", "InvalidData");
Logger.LogWarning("[BASE-{appBase}] Received null character data, skipping application for {handler}", applicationBase, GetLogIdentifier());
SetUploading(false);
return;
}
var user = GetPrimaryUserData();
if (_dalamudUtil.IsInCombat)
{
const string reason = "Cannot apply character data: you are in combat, deferring application";
DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "Combat", LogLevel.Debug,
"[BASE-{appBase}] Received data but player is in combat", applicationBase);
return;
}
if (_dalamudUtil.IsPerforming)
{
const string reason = "Cannot apply character data: you are performing music, deferring application";
DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "Performance", LogLevel.Debug,
"[BASE-{appBase}] Received data but player is performing", applicationBase);
return;
}
if (_dalamudUtil.IsInInstance)
{
const string reason = "Cannot apply character data: you are in an instance, deferring application";
DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "Instance", LogLevel.Debug,
"[BASE-{appBase}] Received data but player is in instance", applicationBase);
return;
}
if (_dalamudUtil.IsInCutscene)
{
const string reason = "Cannot apply character data: you are in a cutscene, deferring application";
DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "Cutscene", LogLevel.Debug,
"[BASE-{appBase}] Received data but player is in a cutscene", applicationBase);
return;
}
if (_dalamudUtil.IsInGpose)
{
const string reason = "Cannot apply character data: you are in GPose, deferring application";
DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "GPose", LogLevel.Debug,
"[BASE-{appBase}] Received data but player is in GPose", applicationBase);
return;
}
if (!_ipcManager.Penumbra.APIAvailable || !_ipcManager.Glamourer.APIAvailable)
{
const string reason = "Cannot apply character data: Penumbra or Glamourer is not available, deferring application";
DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "PluginUnavailable", LogLevel.Information,
"[BASE-{appbase}] Application of data for {player} while Penumbra/Glamourer unavailable, returning", applicationBase, GetLogIdentifier());
return;
}
var handlerReady = _charaHandler is not null && PlayerCharacter != IntPtr.Zero;
if (!handlerReady)
{
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), 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 || _cachedData == null;
_cachedData = characterData;
_forceFullReapply = true;
Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods);
}
SetUploading(false);
Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, GetLogIdentifier(), 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 (handlerReady
&& string.Equals(characterData.DataHash.Value, _cachedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal)
&& !forceApplyCustomization && !_forceApplyMods)
{
return;
}
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational,
"Applying Character Data")));
var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, forceApplyCustomization, _forceApplyMods);
if (handlerReady && _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(user, PlayerName!, playerChanges);
}
Logger.LogDebug("[BASE-{appbase}] Downloading and applying character for {name}", applicationBase, GetPrimaryAliasOrUidSafe());
var forceFullReapply = _forceFullReapply
|| LastAppliedApproximateVRAMBytes < 0 || LastAppliedDataTris < 0;
DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate, forceFullReapply);
}
public override string ToString()
{
var alias = GetPrimaryAliasOrUidSafe();
return $"{alias}:{PlayerName ?? string.Empty}:{(PlayerCharacter != nint.Zero ? "HasChar" : "NoChar")}";
}
public void SetUploading(bool uploading)
{
Logger.LogTrace("Setting {name} uploading {uploading}", GetPrimaryAliasOrUidSafe(), uploading);
if (_charaHandler != null)
{
Mediator.Publish(new PlayerUploadingMessage(_charaHandler, uploading));
}
}
public void SetPaused(bool paused)
{
lock (_pauseLock)
{
if (_pauseRequested == paused)
{
return;
}
_pauseRequested = paused;
_pauseTransitionTask = _pauseTransitionTask
.ContinueWith(_ => paused ? PauseInternalAsync() : ResumeInternalAsync(), TaskScheduler.Default)
.Unwrap();
}
}
private void CancelVisibilityGraceTask()
{
lock (_visibilityGraceGate)
{
_visibilityGraceCts?.CancelDispose();
_visibilityGraceCts = null;
}
}
private void StartVisibilityGraceTask()
{
CancellationToken token;
lock (_visibilityGraceGate)
{
_visibilityGraceCts = _visibilityGraceCts?.CancelRecreate() ?? new CancellationTokenSource();
token = _visibilityGraceCts.Token;
}
_visibilityGraceTask = Task.Run(async () =>
{
try
{
await Task.Delay(VisibilityEvictionGrace, token).ConfigureAwait(false);
token.ThrowIfCancellationRequested();
if (IsVisible) return;
ScheduledForDeletion = true;
ResetPenumbraCollection(reason: "VisibilityLostTimeout");
}
catch (OperationCanceledException)
{
// operation cancelled, do nothing
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Visibility grace task failed for {handler}", GetLogIdentifier());
}
}, CancellationToken.None);
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
SetUploading(false);
var name = PlayerName;
var user = GetPrimaryUserDataSafe();
var alias = GetPrimaryAliasOrUidSafe();
Logger.LogDebug("Disposing {name} ({user})", name, alias);
try
{
Guid applicationId = Guid.NewGuid();
_applicationCancellationTokenSource?.CancelDispose();
_applicationCancellationTokenSource = null;
_downloadCancellationTokenSource?.CancelDispose();
_downloadCancellationTokenSource = null;
_downloadManager.Dispose();
_charaHandler?.Dispose();
CancelVisibilityGraceTask();
_charaHandler = null;
_invisibleSinceUtc = null;
_visibilityEvictionDueAtUtc = null;
if (!string.IsNullOrEmpty(name))
{
Mediator.Publish(new EventMessage(new Event(name, user, nameof(PairHandlerAdapter), EventSeverity.Informational, "Disposing User")));
}
if (_lifetime.ApplicationStopping.IsCancellationRequested) return;
if (_dalamudUtil is { IsZoning: false, IsInCutscene: false } && !string.IsNullOrEmpty(name))
{
Logger.LogTrace("[{applicationId}] Restoring state for {name} ({user})", applicationId, name, alias);
Logger.LogDebug("[{applicationId}] Removing Temp Collection for {name} ({user})", applicationId, name, alias);
ResetPenumbraCollection(reason: nameof(Dispose));
if (!IsVisible)
{
Logger.LogDebug("[{applicationId}] Restoring Glamourer for {name} ({user})", applicationId, name, alias);
_ipcManager.Glamourer.RevertByNameAsync(Logger, name, applicationId).GetAwaiter().GetResult();
}
else
{
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(60));
var effectiveCachedData = _cachedData ?? _pairStateCache.TryLoad(Ident);
if (effectiveCachedData is not null)
{
_cachedData = effectiveCachedData;
}
Logger.LogInformation("[{applicationId}] CachedData is null {isNull}, contains things: {contains}",
applicationId, _cachedData == null, _cachedData?.FileReplacements.Any() ?? false);
foreach (KeyValuePair<ObjectKind, List<FileReplacementData>> item in _cachedData?.FileReplacements ?? [])
{
try
{
RevertCustomizationDataAsync(item.Key, name, applicationId, cts.Token).GetAwaiter().GetResult();
}
catch (InvalidOperationException ex)
{
Logger.LogWarning(ex, "Failed disposing player (not present anymore?)");
break;
}
}
}
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Error on disposal of {name}", name);
}
finally
{
PlayerName = null;
_cachedData = null;
_lastAppliedModdedPaths = null;
_needsCollectionRebuild = false;
_performanceMetricsCache.Clear(Ident);
Logger.LogDebug("Disposing {name} complete", name);
}
}
private async Task ApplyCustomizationDataAsync(Guid applicationId, KeyValuePair<ObjectKind, HashSet<PlayerChanges>> 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.GetCompanionPtr(ptr), isWatched: false).ConfigureAwait(false),
ObjectKind.MinionOrMount => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetMinionOrMountPtr(ptr), isWatched: false).ConfigureAwait(false),
ObjectKind.Pet => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetPetPtr(ptr), isWatched: false).ConfigureAwait(false),
_ => throw new NotSupportedException("ObjectKind not supported: " + changes.Key)
};
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);
if (handler.Address != nint.Zero)
{
await _actorObjectService.WaitForFullyLoadedAsync(handler.Address, token).ConfigureAwait(false);
}
token.ThrowIfCancellationRequested();
var tasks = new List<Task>();
bool needsRedraw = false;
foreach (var change in changes.Value.OrderBy(p => (int)p))
{
Logger.LogDebug("[{applicationId}] Processing {change} for {handler}", applicationId, change, handler);
switch (change)
{
case PlayerChanges.Customize:
if (charaData.CustomizePlusData.TryGetValue(changes.Key, out var customizePlusData))
{
tasks.Add(ApplyCustomizeAsync(handler.Address, customizePlusData, changes.Key));
}
else if (_customizeIds.TryGetValue(changes.Key, out var customizeId))
{
tasks.Add(RevertCustomizeAsync(customizeId, changes.Key));
}
break;
case PlayerChanges.Heels:
tasks.Add(_ipcManager.Heels.SetOffsetForPlayerAsync(handler.Address, charaData.HeelsData));
break;
case PlayerChanges.Honorific:
tasks.Add(_ipcManager.Honorific.SetTitleAsync(handler.Address, charaData.HonorificData));
break;
case PlayerChanges.Glamourer:
if (charaData.GlamourerData.TryGetValue(changes.Key, out var glamourerData))
{
tasks.Add(_ipcManager.Glamourer.ApplyAllAsync(Logger, handler, glamourerData, applicationId, token));
}
break;
case PlayerChanges.Moodles:
tasks.Add(_ipcManager.Moodles.SetStatusAsync(handler.Address, charaData.MoodlesData));
break;
case PlayerChanges.PetNames:
tasks.Add(_ipcManager.PetNames.SetPlayerData(handler.Address, charaData.PetNamesData));
break;
case PlayerChanges.ForcedRedraw:
needsRedraw = true;
break;
default:
break;
}
token.ThrowIfCancellationRequested();
}
if (tasks.Count > 0)
{
await Task.WhenAll(tasks).ConfigureAwait(false);
}
if (needsRedraw)
{
await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false);
}
}
finally
{
if (handler != _charaHandler) handler.Dispose();
}
}
private static Dictionary<ObjectKind, HashSet<PlayerChanges>> BuildFullChangeSet(CharacterData characterData)
{
var result = new Dictionary<ObjectKind, HashSet<PlayerChanges>>();
foreach (var objectKind in Enum.GetValues<ObjectKind>())
{
var changes = new HashSet<PlayerChanges>();
if (characterData.FileReplacements.TryGetValue(objectKind, out var replacements) && replacements.Count > 0)
{
changes.Add(PlayerChanges.ModFiles);
if (objectKind == ObjectKind.Player)
{
changes.Add(PlayerChanges.ForcedRedraw);
}
}
if (characterData.GlamourerData.TryGetValue(objectKind, out var glamourer) && !string.IsNullOrEmpty(glamourer))
{
changes.Add(PlayerChanges.Glamourer);
}
if (characterData.CustomizePlusData.TryGetValue(objectKind, out var customize) && !string.IsNullOrEmpty(customize))
{
changes.Add(PlayerChanges.Customize);
}
if (objectKind == ObjectKind.Player)
{
if (!string.IsNullOrEmpty(characterData.ManipulationData))
{
changes.Add(PlayerChanges.ModManip);
changes.Add(PlayerChanges.ForcedRedraw);
}
if (!string.IsNullOrEmpty(characterData.HeelsData))
{
changes.Add(PlayerChanges.Heels);
}
if (!string.IsNullOrEmpty(characterData.HonorificData))
{
changes.Add(PlayerChanges.Honorific);
}
if (!string.IsNullOrEmpty(characterData.MoodlesData))
{
changes.Add(PlayerChanges.Moodles);
}
if (!string.IsNullOrEmpty(characterData.PetNamesData))
{
changes.Add(PlayerChanges.PetNames);
}
}
if (changes.Count > 0)
{
result[objectKind] = changes;
}
}
return result;
}
private static bool PlayerModFilesChanged(CharacterData newData, CharacterData? previousData)
{
return !FileReplacementListsEqual(
TryGetFileReplacementList(newData, ObjectKind.Player),
TryGetFileReplacementList(previousData, ObjectKind.Player));
}
private static IReadOnlyCollection<FileReplacementData>? TryGetFileReplacementList(CharacterData? data, ObjectKind objectKind)
{
if (data is null)
{
return null;
}
return data.FileReplacements.TryGetValue(objectKind, out var list) ? list : null;
}
private static bool FileReplacementListsEqual(IReadOnlyCollection<FileReplacementData>? left, IReadOnlyCollection<FileReplacementData>? right)
{
if (left is null || left.Count == 0)
{
return right is null || right.Count == 0;
}
if (right is null || right.Count == 0)
{
return false;
}
var comparer = FileReplacementDataComparer.Instance;
return !left.Except(right, comparer).Any() && !right.Except(left, comparer).Any();
}
private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData, bool forceFullReapply)
{
if (!updatedData.Any())
{
if (forceFullReapply)
{
updatedData = BuildFullChangeSet(charaData);
}
if (!updatedData.Any())
{
Logger.LogDebug("[BASE-{appBase}] Nothing to update for {obj}", applicationBase, GetLogIdentifier());
_forceFullReapply = false;
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));
var needsCollectionRebuild = _needsCollectionRebuild;
var reuseCachedModdedPaths = !updateModdedPaths && needsCollectionRebuild && _lastAppliedModdedPaths is not null;
updateModdedPaths = updateModdedPaths || needsCollectionRebuild;
updateManip = updateManip || needsCollectionRebuild;
Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths = null;
if (reuseCachedModdedPaths)
{
if (HasValidCachedModdedPaths())
{
cachedModdedPaths = _lastAppliedModdedPaths;
}
else
{
Logger.LogDebug("{handler}: Cached files missing, recalculating mappings", GetLogIdentifier());
_lastAppliedModdedPaths = null;
}
}
_downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource();
var downloadToken = _downloadCancellationTokenSource.Token;
_ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, cachedModdedPaths, downloadToken)
.ConfigureAwait(false);
}
private Task? _pairDownloadTask;
private Task _visibilityGraceTask;
private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData,
bool updateModdedPaths, bool updateManip, Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths, CancellationToken downloadToken)
{
var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false);
try
{
bool skipDownscaleForPair = ShouldSkipDownscale();
var user = GetPrimaryUserData();
Dictionary<(string GamePath, string? Hash), string> moddedPaths;
List<FileReplacementData> missingReplacements = [];
if (updateModdedPaths)
{
if (cachedModdedPaths is not null)
{
moddedPaths = new Dictionary<(string GamePath, string? Hash), string>(cachedModdedPaths, cachedModdedPaths.Comparer);
}
else
{
int attempts = 0;
List<FileReplacementData> toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken);
missingReplacements = toDownloadReplacements;
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, user, nameof(PairHandlerAdapter), EventSeverity.Informational,
$"Starting download for {toDownloadReplacements.Count} files")));
var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false);
if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles))
{
RecordFailure("Auto pause triggered by VRAM usage thresholds", "VRAMThreshold");
_downloadManager.ClearDownload();
return;
}
var handlerForDownload = _charaHandler;
_pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, downloadToken, skipDownscaleForPair).ConfigureAwait(false));
await _pairDownloadTask.ConfigureAwait(false);
if (downloadToken.IsCancellationRequested)
{
Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase);
RecordFailure("Download cancelled", "Cancellation");
return;
}
if (!skipDownscaleForPair)
{
var downloadedTextureHashes = toDownloadReplacements
.Where(static replacement => replacement.GamePaths.Any(static path => path.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)))
.Select(static replacement => replacement.Hash)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (downloadedTextureHashes.Count > 0)
{
await _textureDownscaleService.WaitForPendingJobsAsync(downloadedTextureHashes, downloadToken).ConfigureAwait(false);
}
}
toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken);
missingReplacements = toDownloadReplacements;
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);
}
if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false))
{
RecordFailure("Auto pause triggered by performance thresholds", "PerformanceThreshold");
return;
}
}
}
else
{
moddedPaths = cachedModdedPaths is not null
? new Dictionary<(string GamePath, string? Hash), string>(cachedModdedPaths, cachedModdedPaths.Comparer)
: [];
}
var wantsModApply = updateModdedPaths || updateManip;
var pendingModReapply = false;
var deferModApply = false;
if (wantsModApply && missingReplacements.Count > 0)
{
CountMissingReplacements(missingReplacements, out var missingCritical, out var missingNonCritical, out var missingForbidden);
_lastMissingCriticalMods = missingCritical;
_lastMissingNonCriticalMods = missingNonCritical;
_lastMissingForbiddenMods = missingForbidden;
var hasCriticalMissing = missingCritical > 0;
var hasNonCriticalMissing = missingNonCritical > 0;
var hasDownloadableMissing = missingReplacements.Any(replacement => !IsForbiddenHash(replacement.Hash));
var hasDownloadableCriticalMissing = hasCriticalMissing
&& missingReplacements.Any(replacement => !IsForbiddenHash(replacement.Hash) && IsCriticalModReplacement(replacement));
pendingModReapply = hasDownloadableMissing;
_lastModApplyDeferred = false;
if (hasDownloadableCriticalMissing)
{
deferModApply = true;
_lastModApplyDeferred = true;
Logger.LogDebug("[BASE-{appBase}] Critical mod files missing for {handler}, deferring mod apply ({count} missing)",
applicationBase, GetLogIdentifier(), missingReplacements.Count);
}
else if (hasNonCriticalMissing && hasDownloadableMissing)
{
Logger.LogDebug("[BASE-{appBase}] Non-critical mod files missing for {handler}, applying partial mods and reapplying after downloads ({count} missing)",
applicationBase, GetLogIdentifier(), missingReplacements.Count);
}
}
else
{
_lastMissingCriticalMods = 0;
_lastMissingNonCriticalMods = 0;
_lastMissingForbiddenMods = 0;
_lastModApplyDeferred = false;
}
if (deferModApply)
{
updateModdedPaths = false;
updateManip = false;
RemoveModApplyChanges(updatedData);
}
downloadToken.ThrowIfCancellationRequested();
var handlerForApply = _charaHandler;
if (handlerForApply is null || handlerForApply.Address == nint.Zero)
{
Logger.LogDebug("[BASE-{appBase}] Handler not available for {player}, cached data for later application", applicationBase, GetLogIdentifier());
_cachedData = charaData;
_pairStateCache.Store(Ident, charaData);
_forceFullReapply = true;
RecordFailure("Handler not available for application", "HandlerUnavailable");
return;
}
var appToken = _applicationCancellationTokenSource?.Token;
while ((!_applicationTask?.IsCompleted ?? false)
&& !downloadToken.IsCancellationRequested
&& (!appToken?.IsCancellationRequested ?? false))
{
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))
{
_forceFullReapply = true;
RecordFailure("Application cancelled", "Cancellation");
return;
}
_applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource();
var token = _applicationCancellationTokenSource.Token;
_applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, wantsModApply, pendingModReapply, token);
}
finally
{
await concurrencyLease.DisposeAsync().ConfigureAwait(false);
}
}
private async Task ApplyCharacterDataAsync(Guid applicationBase, GameObjectHandler handlerForApply, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData, bool updateModdedPaths, bool updateManip,
Dictionary<(string GamePath, string? Hash), string> moddedPaths, bool wantsModApply, bool pendingModReapply, CancellationToken token)
{
try
{
_applicationId = Guid.NewGuid();
Logger.LogDebug("[BASE-{applicationId}] Starting application task for {handler}: {appId}", applicationBase, GetLogIdentifier(), _applicationId);
Logger.LogDebug("[{applicationId}] Waiting for initial draw for for {handler}", _applicationId, handlerForApply);
await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handlerForApply, _applicationId, 30000, token).ConfigureAwait(false);
if (handlerForApply.Address != nint.Zero)
{
await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false);
}
token.ThrowIfCancellationRequested();
Guid penumbraCollection = Guid.Empty;
if (updateModdedPaths || updateManip)
{
penumbraCollection = EnsurePenumbraCollection();
if (penumbraCollection == Guid.Empty)
{
Logger.LogTrace("[BASE-{applicationId}] Penumbra collection unavailable for {handler}, caching data for later application", applicationBase, GetLogIdentifier());
_cachedData = charaData;
_pairStateCache.Store(Ident, charaData);
_forceFullReapply = true;
RecordFailure("Penumbra collection unavailable", "PenumbraUnavailable");
return;
}
}
if (updateModdedPaths)
{
// ensure collection is set
var objIndex = await _dalamudUtil.RunOnFrameworkThread(() =>
{
var gameObject = handlerForApply.GetGameObject();
return gameObject?.ObjectIndex;
}).ConfigureAwait(false);
if (!objIndex.HasValue)
{
Logger.LogDebug("[BASE-{applicationId}] GameObject not available for {handler}, caching data for later application", applicationBase, GetLogIdentifier());
_cachedData = charaData;
_pairStateCache.Store(Ident, charaData);
_forceFullReapply = true;
RecordFailure("Game object not available for application", "GameObjectUnavailable");
return;
}
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false);
await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, _applicationId, penumbraCollection,
moddedPaths.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)).ConfigureAwait(false);
_lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(moddedPaths, moddedPaths.Comparer);
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;
_pairStateCache.Store(Ident, charaData);
if (wantsModApply)
{
_pendingModReapply = pendingModReapply;
}
_forceFullReapply = _pendingModReapply;
_needsCollectionRebuild = false;
if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0)
{
_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List<DownloadFileTransfer>());
}
if (LastAppliedDataTris < 0)
{
await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(false);
}
StorePerformanceMetrics(charaData);
_lastSuccessfulApplyAt = DateTime.UtcNow;
ClearFailureState();
Logger.LogDebug("[{applicationId}] Application finished", _applicationId);
}
catch (OperationCanceledException)
{
Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier());
_cachedData = charaData;
_pairStateCache.Store(Ident, charaData);
_forceFullReapply = true;
RecordFailure("Application cancelled", "Cancellation");
}
catch (Exception ex)
{
if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException))
{
IsVisible = false;
_forceApplyMods = true;
_cachedData = charaData;
_pairStateCache.Store(Ident, charaData);
_forceFullReapply = true;
Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId);
}
else
{
Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId);
_forceFullReapply = true;
}
RecordFailure($"Application failed: {ex.Message}", "Exception");
}
}
private void FrameworkUpdate()
{
if (string.IsNullOrEmpty(PlayerName) && _charaHandler is null)
{
var now = DateTime.UtcNow;
if (now < _nextActorLookupUtc)
{
return;
}
_nextActorLookupUtc = now + ActorLookupInterval;
var pc = _dalamudUtil.FindPlayerByNameHash(Ident);
if (pc == default((string, nint))) return;
Logger.LogDebug("One-Time Initializing {handler}", GetLogIdentifier());
Initialize(pc.Name);
Logger.LogDebug("One-Time Initialized {handler}", GetLogIdentifier());
Mediator.Publish(new EventMessage(new Event(PlayerName, GetPrimaryUserData(), nameof(PairHandlerAdapter), EventSeverity.Informational,
$"Initializing User For Character {pc.Name}")));
}
TryHandleVisibilityUpdate();
}
private void TryHandleVisibilityUpdate()
{
if (_charaHandler?.Address != nint.Zero && !IsVisible && !_pauseRequested)
{
Guid appData = Guid.NewGuid();
IsVisible = true;
if (_cachedData is not null)
{
var cachedData = _cachedData;
Logger.LogTrace("[BASE-{appBase}] {handler} visibility changed, now: {visi}, cached data exists", appData, GetLogIdentifier(), IsVisible);
_ = Task.Run(() =>
{
try
{
_forceFullReapply = true;
ApplyCharacterData(appData, cachedData!, forceApplyCustomization: true);
}
catch (Exception ex)
{
Logger.LogError(ex, "[BASE-{appBase}] Failed to apply cached character data for {handler}", appData, GetLogIdentifier());
}
});
}
else if (LastReceivedCharacterData is not null)
{
Logger.LogTrace("[BASE-{appBase}] {handler} visibility changed, now: {visi}, last received data exists", appData, GetLogIdentifier(), IsVisible);
_ = Task.Run(() =>
{
try
{
_forceFullReapply = true;
ApplyLastReceivedData(forced: true);
}
catch (Exception ex)
{
Logger.LogError(ex, "[BASE-{appBase}] Failed to reapply last received data for {handler}", appData, GetLogIdentifier());
}
});
}
else
{
Logger.LogTrace("{handler} visibility changed, now: {visi}, no cached or received data exists", GetLogIdentifier(), IsVisible);
}
}
else if (_charaHandler?.Address == nint.Zero && IsVisible)
{
HandleVisibilityLoss(logChange: true);
}
TryApplyQueuedData();
}
private void HandleVisibilityLoss(bool logChange)
{
IsVisible = false;
_charaHandler?.Invalidate();
_downloadCancellationTokenSource?.CancelDispose();
_downloadCancellationTokenSource = null;
if (logChange)
{
Logger.LogTrace("{handler} visibility changed, now: {visi}", GetLogIdentifier(), IsVisible);
}
}
private void Initialize(string name)
{
PlayerName = name;
_charaHandler = _gameObjectHandlerFactory.Create(ObjectKind.Player, () => _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Ident), isWatched: false).GetAwaiter().GetResult();
var user = GetPrimaryUserData();
if (!string.IsNullOrEmpty(user.UID))
{
_serverConfigManager.AutoPopulateNoteForUid(user.UID, name);
}
Mediator.Subscribe<HonorificReadyMessage>(this, _message =>
{
var honorificData = _cachedData?.HonorificData;
if (string.IsNullOrEmpty(honorificData))
return;
_ = ReapplyHonorificAsync(honorificData!);
});
Mediator.Subscribe<PetNamesReadyMessage>(this, _message =>
{
var petNamesData = _cachedData?.PetNamesData;
if (string.IsNullOrEmpty(petNamesData))
return;
_ = ReapplyPetNamesAsync(petNamesData!);
});
}
private async Task ReapplyHonorificAsync(string honorificData)
{
Logger.LogTrace("Reapplying Honorific data for {handler}", GetLogIdentifier());
await _ipcManager.Honorific.SetTitleAsync(PlayerCharacter, honorificData).ConfigureAwait(false);
}
private async Task ReapplyPetNamesAsync(string petNamesData)
{
Logger.LogTrace("Reapplying Pet Names data for {handler}", GetLogIdentifier());
await _ipcManager.PetNames.SetPlayerData(PlayerCharacter, petNamesData).ConfigureAwait(false);
}
private async Task RevertCustomizationDataAsync(ObjectKind objectKind, string name, Guid applicationId, CancellationToken cancelToken)
{
nint address = _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Ident);
if (address == nint.Zero) return;
var alias = GetPrimaryAliasOrUid();
Logger.LogDebug("[{applicationId}] Reverting all Customization for {alias}/{name} {objectKind}", applicationId, alias, 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, alias, name);
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
tempHandler.CompareNameAndThrow(name);
Logger.LogDebug("[{applicationId}] Restoring Heels for {alias}/{name}", applicationId, alias, name);
await _ipcManager.Heels.RestoreOffsetForPlayerAsync(address).ConfigureAwait(false);
tempHandler.CompareNameAndThrow(name);
Logger.LogDebug("[{applicationId}] Restoring C+ for {alias}/{name}", applicationId, alias, name);
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
tempHandler.CompareNameAndThrow(name);
Logger.LogDebug("[{applicationId}] Restoring Honorific for {alias}/{name}", applicationId, alias, name);
await _ipcManager.Honorific.ClearTitleAsync(address).ConfigureAwait(false);
Logger.LogDebug("[{applicationId}] Restoring Moodles for {alias}/{name}", applicationId, alias, name);
await _ipcManager.Moodles.RevertStatusAsync(address).ConfigureAwait(false);
Logger.LogDebug("[{applicationId}] Restoring Pet Nicknames for {alias}/{name}", applicationId, alias, name);
await _ipcManager.PetNames.ClearPlayerData(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.Companion, () => 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<FileReplacementData> TryCalculateModdedDictionary(Guid applicationBase, CharacterData charaData, out Dictionary<(string GamePath, string? Hash), string> moddedDictionary, CancellationToken token)
{
Stopwatch st = Stopwatch.StartNew();
ConcurrentBag<FileReplacementData> missingFiles = [];
moddedDictionary = [];
ConcurrentDictionary<(string GamePath, string? Hash), string> outputDict = new();
bool hasMigrationChanges = false;
bool skipDownscaleForPair = ShouldSkipDownscale();
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);
if (fileCache is not null && !File.Exists(fileCache.ResolvedFilepath))
{
Logger.LogTrace("[BASE-{appBase}] Cached path {Path} missing on disk for hash {Hash}, removing cache entry", applicationBase, fileCache.ResolvedFilepath, item.Hash);
_fileDbManager.RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
fileCache = null;
}
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)
{
var preferredPath = skipDownscaleForPair
? fileCache.ResolvedFilepath
: _textureDownscaleService.GetPreferredPath(item.Hash, fileCache.ResolvedFilepath);
outputDict[(gamePath, item.Hash)] = preferredPath;
}
}
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)
{
Logger.LogTrace("[BASE-{appBase}] Modded path calculation cancelled", applicationBase);
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];
}
private async Task PauseInternalAsync()
{
try
{
Logger.LogDebug("Pausing handler {handler}", GetLogIdentifier());
DisableSync();
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
{
IsVisible = false;
return;
}
var applicationId = Guid.NewGuid();
await RevertToRestoredAsync(applicationId).ConfigureAwait(false);
IsVisible = false;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to pause handler {handler}", GetLogIdentifier());
}
}
private async Task ResumeInternalAsync()
{
try
{
Logger.LogDebug("Resuming handler {handler}", GetLogIdentifier());
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
{
return;
}
if (!IsVisible)
{
IsVisible = true;
}
if (LastReceivedCharacterData is not null)
{
ApplyLastReceivedData(forced: true);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to resume handler {handler}", GetLogIdentifier());
}
}
private async Task RevertToRestoredAsync(Guid applicationId)
{
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
{
return;
}
try
{
var gameObject = await _dalamudUtil.RunOnFrameworkThread(() => _charaHandler.GetGameObject()).ConfigureAwait(false);
if (gameObject is not Dalamud.Game.ClientState.Objects.Types.ICharacter character)
{
return;
}
if (_ipcManager.Penumbra.APIAvailable)
{
var penumbraCollection = EnsurePenumbraCollection();
if (penumbraCollection != Guid.Empty)
{
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, character.ObjectIndex).ConfigureAwait(false);
await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, applicationId, penumbraCollection, new Dictionary<string, string>(StringComparer.Ordinal)).ConfigureAwait(false);
await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, applicationId, penumbraCollection, string.Empty).ConfigureAwait(false);
}
}
var kinds = new HashSet<ObjectKind>(_customizeIds.Keys);
if (_cachedData is not null)
{
foreach (var kind in _cachedData.FileReplacements.Keys)
{
kinds.Add(kind);
}
}
kinds.Add(ObjectKind.Player);
var characterName = character.Name.TextValue;
if (string.IsNullOrEmpty(characterName))
{
characterName = character.Name.ToString();
}
if (string.IsNullOrEmpty(characterName))
{
Logger.LogWarning("[{applicationId}] Failed to determine character name for {handler} while reverting", applicationId, GetLogIdentifier());
return;
}
foreach (var kind in kinds)
{
await RevertCustomizationDataAsync(kind, characterName, applicationId, CancellationToken.None).ConfigureAwait(false);
}
_cachedData = null;
LastAppliedDataBytes = -1;
LastAppliedDataTris = -1;
LastAppliedApproximateVRAMBytes = -1;
LastAppliedApproximateEffectiveVRAMBytes = -1;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to revert handler {handler} during pause", GetLogIdentifier());
}
}
private void DisableSync()
{
_downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate();
_applicationCancellationTokenSource = _applicationCancellationTokenSource?.CancelRecreate();
}
private void EnableSync()
{
TryApplyQueuedData();
}
private void TryApplyQueuedData()
{
var pending = _dataReceivedInDowntime;
if (pending is null || !IsVisible)
{
return;
}
if (!CanApplyNow())
{
return;
}
_dataReceivedInDowntime = null;
_ = Task.Run(() =>
{
try
{
ApplyCharacterData(pending.ApplicationId, pending.CharacterData, pending.Forced);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed applying queued data for {handler}", GetLogIdentifier());
}
});
}
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
{
if (!TryResolveDescriptorHash(descriptor, out var hashedCid))
return;
if (!string.Equals(hashedCid, Ident, StringComparison.Ordinal))
return;
if (descriptor.Address == nint.Zero)
return;
RefreshTrackedHandler(descriptor);
QueueActorInitialization(descriptor);
}
private void QueueActorInitialization(ActorObjectService.ActorDescriptor descriptor)
{
lock (_actorInitializationGate)
{
_pendingActorDescriptor = descriptor;
if (_actorInitializationInProgress)
{
return;
}
_actorInitializationInProgress = true;
}
_ = Task.Run(InitializeFromTrackedAsync);
}
private async Task InitializeFromTrackedAsync()
{
try
{
await ActorInitializationLimiter.WaitAsync().ConfigureAwait(false);
while (true)
{
ActorObjectService.ActorDescriptor? descriptor;
lock (_actorInitializationGate)
{
descriptor = _pendingActorDescriptor;
_pendingActorDescriptor = null;
}
if (!descriptor.HasValue)
{
break;
}
if (_frameworkUpdateSubscribed && _actorObjectService.HooksActive)
{
Mediator.Unsubscribe<FrameworkUpdateMessage>(this);
_frameworkUpdateSubscribed = false;
}
if (string.IsNullOrEmpty(PlayerName) || _charaHandler is null)
{
Logger.LogDebug("Actor tracked for {handler}, initializing from hook", GetLogIdentifier());
Initialize(descriptor.Value.Name);
Mediator.Publish(new EventMessage(new Event(PlayerName, GetPrimaryUserData(), nameof(PairHandlerAdapter), EventSeverity.Informational,
$"Initializing User For Character {descriptor.Value.Name}")));
}
RefreshTrackedHandler(descriptor.Value);
TryHandleVisibilityUpdate();
}
}
finally
{
ActorInitializationLimiter.Release();
lock (_actorInitializationGate)
{
_actorInitializationInProgress = false;
if (_pendingActorDescriptor.HasValue)
{
_actorInitializationInProgress = true;
_ = Task.Run(InitializeFromTrackedAsync);
}
}
}
}
private void RefreshTrackedHandler(ActorObjectService.ActorDescriptor descriptor)
{
if (_charaHandler is null)
return;
if (descriptor.Address == nint.Zero)
return;
if (_charaHandler.Address == descriptor.Address)
return;
_charaHandler.Refresh();
}
private void HandleActorUntracked(ActorObjectService.ActorDescriptor descriptor)
{
if (!TryResolveDescriptorHash(descriptor, out var hashedCid))
{
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
return;
if (descriptor.Address != _charaHandler.Address)
return;
}
else if (!string.Equals(hashedCid, Ident, StringComparison.Ordinal))
{
return;
}
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
return;
if (descriptor.Address != _charaHandler.Address)
return;
HandleVisibilityLoss(logChange: false);
}
private bool TryResolveDescriptorHash(ActorObjectService.ActorDescriptor descriptor, out string hashedCid)
{
hashedCid = descriptor.HashedContentId ?? string.Empty;
if (!string.IsNullOrEmpty(hashedCid))
return true;
if (descriptor.ObjectKind != DalamudObjectKind.Player || descriptor.Address == nint.Zero)
return false;
hashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(descriptor.Address);
return !string.IsNullOrEmpty(hashedCid);
}
private async Task ApplyCustomizeAsync(nint address, string customizeData, ObjectKind kind)
{
_customizeIds[kind] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false);
}
private async Task RevertCustomizeAsync(Guid? customizeId, ObjectKind kind)
{
if (!customizeId.HasValue)
return;
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId.Value).ConfigureAwait(false);
_customizeIds.Remove(kind);
}
}