using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration;
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.ModelDecimation;
using LightlessSync.Services.PairProcessing;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Services.TextureCompression;
using LightlessSync.Utils;
using LightlessSync.WebAPI.Files;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Diagnostics;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.PlayerData.Pairs;
///
/// handles lifecycle, visibility, queued data, character data for a paired user
///
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 PlayerPerformanceConfigService _playerPerformanceConfigService;
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 ModelDecimationService _modelDecimationService;
private readonly PairStateCache _pairStateCache;
private readonly PairPerformanceMetricsCache _performanceMetricsCache;
private readonly XivDataAnalyzer _modelAnalyzer;
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
private readonly LightlessConfigService _configService;
private readonly PairManager _pairManager;
private readonly OwnedObjectHandler _ownedObjectHandler;
private readonly IFramework _framework;
private CancellationTokenSource? _applicationCancellationTokenSource;
private Guid _applicationId;
private Task? _applicationTask;
private CharacterData? _cachedData = null;
private GameObjectHandler? _charaHandler;
private readonly Dictionary _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 string? _lastSuccessfulDataHash;
private bool _isVisible;
private Guid _penumbraCollection;
private Guid _penumbraOwnedCollection;
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 _lastBlockingConditions = Array.Empty();
private readonly object _visibilityGraceGate = new();
private CancellationTokenSource? _visibilityGraceCts;
private static readonly TimeSpan VisibilityEvictionGrace = TimeSpan.FromMinutes(1);
private readonly object _ownedRetryGate = new();
private readonly Dictionary> _pendingOwnedChanges = new();
private CancellationTokenSource? _ownedRetryCts;
private Task _ownedRetryTask = Task.CompletedTask;
private string OwnedCollectionCacheKey => $"{Ident}:owned";
private static readonly TimeSpan OwnedRetryInitialDelay = TimeSpan.FromSeconds(1);
private static readonly TimeSpan OwnedRetryMaxDelay = TimeSpan.FromSeconds(10);
private static readonly TimeSpan OwnedRetryStaleDataGrace = TimeSpan.FromMinutes(5);
private static readonly HashSet NonPriorityModExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".tmb",
".pap",
".atex",
".avfx",
".scd"
};
private readonly ConcurrentDictionary _blockedPapHashes = new(StringComparer.OrdinalIgnoreCase);
private AnimationValidationMode _lastAnimMode = (AnimationValidationMode)(-1);
private bool _lastAllowOneBasedShift;
private bool _lastAllowNeighborTolerance;
private readonly ConcurrentDictionary _dumpedRemoteSkeletonForHash = new(StringComparer.OrdinalIgnoreCase);
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 static readonly SemaphoreSlim _papParseLimiter = new(1, 1);
private const int FullyLoadedTimeoutMsPlayer = 30000;
private const int FullyLoadedTimeoutMsOther = 5000;
private readonly object _actorInitializationGate = new();
private ActorObjectService.ActorDescriptor? _pendingActorDescriptor;
private bool _actorInitializationInProgress;
private bool _frameworkUpdateSubscribed;
private nint _lastKnownAddress = nint.Zero;
private ushort _lastKnownObjectIndex = ushort.MaxValue;
private string? _lastKnownName;
private readonly object _ownedReapplyGate = new();
private DateTime _nextOwnedReapplyUtc = DateTime.MinValue;
private static readonly TimeSpan OwnedReapplyThrottle = TimeSpan.FromSeconds(1);
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 LastAppliedApproximateEffectiveTris { 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 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 string? MinionAddressHex => _ownedObjectHandler.MinionResolveDebug.AddressHex;
public ushort? MinionObjectIndex => _ownedObjectHandler.MinionResolveDebug.ObjectIndex;
public DateTime? MinionResolvedAtUtc => _ownedObjectHandler.MinionResolveDebug.ResolvedAtUtc;
public string? MinionResolveStage => string.IsNullOrEmpty(_ownedObjectHandler.MinionResolveDebug.Stage) ? null : _ownedObjectHandler.MinionResolveDebug.Stage;
public string? MinionResolveFailureReason => _ownedObjectHandler.MinionResolveDebug.FailureReason;
public bool MinionPendingRetry
{
get
{
lock (_ownedRetryGate)
return _pendingOwnedChanges.ContainsKey(ObjectKind.MinionOrMount);
}
}
public IReadOnlyList MinionPendingRetryChanges
{
get
{
lock (_ownedRetryGate)
{
if (_pendingOwnedChanges.TryGetValue(ObjectKind.MinionOrMount, out var set))
return set.Select(s => s.ToString()).ToArray();
return Array.Empty();
}
}
}
public bool MinionHasAppearanceData
{
get
{
var data = _cachedData ?? LastReceivedCharacterData ?? _pairStateCache.TryLoad(Ident);
return data is not null && HasAppearanceDataForKind(data, ObjectKind.MinionOrMount);
}
}
public Guid OwnedPenumbraCollectionId
{
get
{
lock (_collectionGate)
return _penumbraOwnedCollection;
}
}
public bool NeedsCollectionRebuildDebug => _needsCollectionRebuild;
public PairHandlerAdapter(
ILogger logger,
LightlessMediator mediator,
PairManager pairManager,
string ident,
GameObjectHandlerFactory gameObjectHandlerFactory,
IpcManager ipcManager,
FileDownloadManager transferManager,
PluginWarningNotificationService pluginWarningNotificationManager,
DalamudUtilService dalamudUtil,
IFramework framework,
IObjectTable objectTable,
ActorObjectService actorObjectService,
IHostApplicationLifetime lifetime,
FileCacheManager fileDbManager,
PlayerPerformanceConfigService playerPerformanceConfigService,
PlayerPerformanceService playerPerformanceService,
PairProcessingLimiter pairProcessingLimiter,
ServerConfigurationManager serverConfigManager,
TextureDownscaleService textureDownscaleService,
ModelDecimationService modelDecimationService,
PairStateCache pairStateCache,
PairPerformanceMetricsCache performanceMetricsCache,
PenumbraTempCollectionJanitor tempCollectionJanitor,
XivDataAnalyzer modelAnalyzer,
LightlessConfigService configService) : base(logger, mediator)
{
_pairManager = pairManager;
Ident = ident;
_gameObjectHandlerFactory = gameObjectHandlerFactory;
_ipcManager = ipcManager;
_downloadManager = transferManager;
_pluginWarningNotificationManager = pluginWarningNotificationManager;
_dalamudUtil = dalamudUtil;
_framework = framework;
_actorObjectService = actorObjectService;
_lifetime = lifetime;
_fileDbManager = fileDbManager;
_playerPerformanceConfigService = playerPerformanceConfigService;
_playerPerformanceService = playerPerformanceService;
_pairProcessingLimiter = pairProcessingLimiter;
_serverConfigManager = serverConfigManager;
_textureDownscaleService = textureDownscaleService;
_modelDecimationService = modelDecimationService;
_pairStateCache = pairStateCache;
_performanceMetricsCache = performanceMetricsCache;
_tempCollectionJanitor = tempCollectionJanitor;
_modelAnalyzer = modelAnalyzer;
_configService = configService;
_ownedObjectHandler = new OwnedObjectHandler(Logger, _dalamudUtil, _gameObjectHandlerFactory, _ipcManager, _actorObjectService, objectTable);
}
public void Initialize()
{
EnsureInitialized();
}
private void EnsureInitialized()
{
if (Initialized)
{
return;
}
ActorObjectService.ActorDescriptor? trackedDescriptor = null;
lock (_initializationGate)
{
if (Initialized)
{
return;
}
if (LastAppliedDataBytes < 0 || LastAppliedDataTris < 0 || LastAppliedApproximateEffectiveTris < 0
|| LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0)
{
_forceApplyMods = true;
}
var useFrameworkUpdate = !_actorObjectService.HooksActive;
if (useFrameworkUpdate)
{
Mediator.Subscribe(this, _ => FrameworkUpdate());
_frameworkUpdateSubscribed = true;
}
Mediator.Subscribe(this, _ =>
{
_downloadCancellationTokenSource?.CancelDispose();
_charaHandler?.Invalidate();
IsVisible = false;
});
Mediator.Subscribe(this, _ =>
{
ResetPenumbraCollection(releaseFromPenumbra: false, reason: "PenumbraInitialized");
if (!IsVisible && _charaHandler is not null)
{
PlayerName = string.Empty;
_charaHandler.Dispose();
_charaHandler = null;
}
EnableSync();
});
Mediator.Subscribe(this, _ => ResetPenumbraCollection(releaseFromPenumbra: false, reason: "PenumbraDisposed"));
Mediator.Subscribe(this, msg =>
{
if (msg.GameObjectHandler == _charaHandler)
{
_redrawOnNextApplication = true;
}
});
Mediator.Subscribe(this, _ => EnableSync());
Mediator.Subscribe(this, _ => DisableSync());
Mediator.Subscribe(this, _ => EnableSync());
Mediator.Subscribe(this, _ => DisableSync());
Mediator.Subscribe(this, _ => DisableSync());
Mediator.Subscribe(this, _ => EnableSync());
Mediator.Subscribe(this, _ => DisableSync());
Mediator.Subscribe(this, _ => EnableSync());
Mediator.Subscribe(this, _ => DisableSync());
Mediator.Subscribe(this, _ => EnableSync());
Mediator.Subscribe(this, msg => HandleActorTracked(msg.Descriptor));
Mediator.Subscribe(this, msg => HandleActorUntracked(msg.Descriptor));
Mediator.Subscribe(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 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 Guid EnsureOwnedPenumbraCollection()
{
if (!IsVisible)
return Guid.Empty;
if (_penumbraOwnedCollection != Guid.Empty)
return _penumbraOwnedCollection;
lock (_collectionGate)
{
if (_penumbraOwnedCollection != Guid.Empty)
return _penumbraOwnedCollection;
var cached = _pairStateCache.TryGetTemporaryCollection(OwnedCollectionCacheKey);
if (cached.HasValue && cached.Value != Guid.Empty)
{
_penumbraOwnedCollection = cached.Value;
return _penumbraOwnedCollection;
}
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 + "_Owned")
.ConfigureAwait(false).GetAwaiter().GetResult();
if (created != Guid.Empty)
{
_penumbraOwnedCollection = created;
_pairStateCache.StoreTemporaryCollection(OwnedCollectionCacheKey, created);
_tempCollectionJanitor.Register(created);
}
return _penumbraOwnedCollection;
}
}
private void ResetPenumbraCollection(bool releaseFromPenumbra = true, string? reason = null, bool awaitIpc = true)
{
Guid toReleasePlayer = Guid.Empty;
Guid toReleaseOwned = Guid.Empty;
lock (_collectionGate)
{
if (_penumbraCollection != Guid.Empty)
{
toReleasePlayer = _penumbraCollection;
_penumbraCollection = Guid.Empty;
}
if (_penumbraOwnedCollection != Guid.Empty)
{
toReleaseOwned = _penumbraOwnedCollection;
_penumbraOwnedCollection = Guid.Empty;
}
}
var cachedPlayer = _pairStateCache.ClearTemporaryCollection(Ident);
if (cachedPlayer is { } cp && cp != Guid.Empty)
toReleasePlayer = cp;
var cachedOwned = _pairStateCache.ClearTemporaryCollection(OwnedCollectionCacheKey);
if (cachedOwned is { } co && co != Guid.Empty)
toReleaseOwned = co;
if (toReleasePlayer != Guid.Empty)
_tempCollectionJanitor.Unregister(toReleasePlayer);
if (toReleaseOwned != Guid.Empty)
_tempCollectionJanitor.Unregister(toReleaseOwned);
if (!releaseFromPenumbra || !_ipcManager.Penumbra.APIAvailable)
return;
async Task RemoveAsync(Guid id)
{
if (id == Guid.Empty) return;
try
{
var appId = Guid.NewGuid();
Logger.LogTrace("[{applicationId}] Removing temp collection {CollectionId} for {handler} ({reason})",
appId, id, GetLogIdentifier(), reason ?? "Cleanup");
await _ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, appId, id).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed to remove temporary Penumbra collection for {handler}", GetLogIdentifier());
}
}
if (awaitIpc)
{
RemoveAsync(toReleasePlayer).GetAwaiter().GetResult();
RemoveAsync(toReleaseOwned).GetAwaiter().GetResult();
}
else
{
_ = Task.Run(() => RemoveAsync(toReleasePlayer));
_ = Task.Run(() => RemoveAsync(toReleaseOwned));
}
}
private bool AnyPair(Func predicate)
{
return GetCurrentPairs().Any(predicate);
}
private bool IsPreferredDirectPair()
{
return GetCurrentPairs().Any(p => p.IsDirectlyPaired && p.SelfToOtherPermissions.IsSticky());
}
private bool ShouldSkipDownscale()
{
if (!_playerPerformanceConfigService.Current.SkipTextureDownscaleForPreferredPairs)
{
return false;
}
return IsPreferredDirectPair();
}
private bool ShouldSkipDecimation()
{
if (!_playerPerformanceConfigService.Current.SkipModelDecimationForPreferredPairs)
{
return false;
}
return IsPreferredDirectPair();
}
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;
LastAppliedApproximateEffectiveTris = -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 missingStarted = !_lastMissingCachedFiles && hasMissingCachedFiles;
var missingResolved = _lastMissingCachedFiles && !hasMissingCachedFiles;
_lastMissingCachedFiles = hasMissingCachedFiles;
var shouldForce = forced || missingStarted || missingResolved;
if (IsPaused())
{
Logger.LogTrace("Permissions paused for {Ident}, skipping reapply", Ident);
return;
}
var sanitized = CloneAndSanitizeLastReceived(out var dataHash);
if (sanitized is null)
{
Logger.LogTrace("Sanitized data null for {Ident}", Ident);
return;
}
var dataApplied = !string.IsNullOrEmpty(dataHash)
&& string.Equals(dataHash, _lastSuccessfulDataHash ?? string.Empty, StringComparison.Ordinal);
var needsApply = !dataApplied;
var modFilesChanged = PlayerModFilesChanged(sanitized, _cachedData);
var shouldForceMods = shouldForce || modFilesChanged;
bool forceApplyCustomization = forced || _needsCollectionRebuild || _forceFullReapply;
var suppressForcedModRedraw = !forced && hasMissingCachedFiles && dataApplied;
if (shouldForceMods)
{
_forceApplyMods = true;
_forceFullReapply = true;
LastAppliedDataBytes = -1;
LastAppliedDataTris = -1;
LastAppliedApproximateEffectiveTris = -1;
LastAppliedApproximateVRAMBytes = -1;
LastAppliedApproximateEffectiveVRAMBytes = -1;
}
_pairStateCache.Store(Ident, sanitized);
if (!IsVisible && !_pauseRequested)
{
if (_charaHandler is not null && _charaHandler.Address == nint.Zero)
{
_charaHandler.Refresh();
}
if (PlayerCharacter != nint.Zero)
{
IsVisible = true;
}
}
if (!IsVisible)
{
Logger.LogTrace("Handler for {Ident} not visible, caching sanitized data for later", Ident);
_cachedData = sanitized;
_forceFullReapply = true;
return;
}
ApplyCharacterData(Guid.NewGuid(), sanitized, forceApplyCustomization, suppressForcedModRedraw);
}
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;
LastAppliedApproximateEffectiveTris = metrics.ApproximateEffectiveTris;
LastAppliedApproximateVRAMBytes = metrics.ApproximateVramBytes;
LastAppliedApproximateEffectiveVRAMBytes = metrics.ApproximateEffectiveVramBytes;
}
private void StorePerformanceMetrics(CharacterData charaData)
{
if (LastAppliedDataTris < 0
|| LastAppliedApproximateEffectiveTris < 0
|| LastAppliedApproximateVRAMBytes < 0
|| LastAppliedApproximateEffectiveVRAMBytes < 0)
{
return;
}
var dataHash = GetDataHashSafe(charaData);
if (string.IsNullOrEmpty(dataHash))
{
return;
}
_performanceMetricsCache.StoreMetrics(
Ident,
dataHash,
new PairPerformanceMetrics(LastAppliedDataTris, LastAppliedApproximateVRAMBytes, LastAppliedApproximateEffectiveVRAMBytes, LastAppliedApproximateEffectiveTris));
}
private bool HasMissingCachedFiles(CharacterData characterData)
{
try
{
HashSet 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 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> 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() : conditions.ToArray();
}
private void ClearFailureState()
{
_lastFailureReason = null;
_lastBlockingConditions = Array.Empty();
}
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, bool suppressForcedModRedraw = 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 diffs = characterData.CheckUpdatedData(applicationBase, _cachedData, Logger,
this, forceApplyCustomization, forceApplyMods: false);
var hasDiffPlayerMods =
diffs.TryGetValue(ObjectKind.Player, out var set)
&& (set.Contains(PlayerChanges.ModManip) || set.Contains(PlayerChanges.ModFiles));
_forceApplyMods = hasDiffPlayerMods || _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, suppressForcedModRedraw);
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 || LastAppliedApproximateEffectiveTris < 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);
}
private void ScheduleOwnedObjectRetry(ObjectKind kind, HashSet changes)
{
if (kind == ObjectKind.Player || changes.Count == 0)
{
return;
}
lock (_ownedRetryGate)
{
_pendingOwnedChanges[kind] = [.. changes];
if (!_ownedRetryTask.IsCompleted)
{
return;
}
_ownedRetryCts = _ownedRetryCts?.CancelRecreate() ?? new CancellationTokenSource();
var token = _ownedRetryCts.Token;
_ownedRetryTask = Task.Run(() => OwnedObjectRetryLoopAsync(token), CancellationToken.None);
}
}
private void ClearOwnedObjectRetry(ObjectKind kind)
{
lock (_ownedRetryGate)
{
if (!_pendingOwnedChanges.Remove(kind))
{
// nothing to remove
}
}
}
private void ClearAllOwnedObjectRetries()
{
lock (_ownedRetryGate)
{
_pendingOwnedChanges.Clear();
}
}
private bool IsOwnedRetryDataStale()
{
if (!_lastDataReceivedAt.HasValue)
{
return true;
}
return DateTime.UtcNow - _lastDataReceivedAt.Value > OwnedRetryStaleDataGrace;
}
private async Task OwnedObjectRetryLoopAsync(CancellationToken token)
{
var delay = OwnedRetryInitialDelay;
try
{
while (!token.IsCancellationRequested)
{
if (IsOwnedRetryDataStale())
{
ClearAllOwnedObjectRetries();
return;
}
Dictionary> pending;
lock (_ownedRetryGate)
{
if (_pendingOwnedChanges.Count == 0)
return;
pending = _pendingOwnedChanges.ToDictionary(
kvp => kvp.Key,
kvp => new HashSet(kvp.Value));
}
if (!IsVisible || IsPaused() || !CanApplyNow() || PlayerCharacter == nint.Zero || _charaHandler is null)
{
await Task.Delay(delay, token).ConfigureAwait(false);
delay = IncreaseRetryDelay(delay);
continue;
}
if (!(_applicationTask?.IsCompleted ?? true) || !(_pairDownloadTask?.IsCompleted ?? true))
{
await Task.Delay(delay, token).ConfigureAwait(false);
delay = IncreaseRetryDelay(delay);
continue;
}
var sanitized = CloneAndSanitizeLastReceived(out _);
if (sanitized is null)
{
await Task.Delay(delay, token).ConfigureAwait(false);
delay = IncreaseRetryDelay(delay);
continue;
}
token.ThrowIfCancellationRequested();
var ownedPending = pending
.Where(k => k.Key != ObjectKind.Player)
.ToList();
if (ownedPending.Count == 0)
{
return;
}
var needsOwnedCollection =
_ipcManager.Penumbra.APIAvailable
&& ownedPending.Any(e =>
e.Value.Contains(PlayerChanges.ModFiles)
&& sanitized.FileReplacements.TryGetValue(e.Key, out var repls)
&& repls is { Count: > 0 });
Guid ownedCollection = Guid.Empty;
if (needsOwnedCollection)
{
ownedCollection = EnsureOwnedPenumbraCollection();
if (ownedCollection == Guid.Empty)
{
await Task.Delay(delay, token).ConfigureAwait(false);
delay = IncreaseRetryDelay(delay);
continue;
}
await TryRefreshOwnedCollectionModsAsync(ownedCollection, sanitized, token).ConfigureAwait(false);
}
bool anyApplied = false;
foreach (var entry in ownedPending)
{
token.ThrowIfCancellationRequested();
if (!HasAppearanceDataForKind(sanitized, entry.Key))
{
ClearOwnedObjectRetry(entry.Key);
continue;
}
var applied = await _ownedObjectHandler.ApplyAsync(
Guid.NewGuid(),
entry.Key,
entry.Value,
sanitized,
_charaHandler,
ownedCollection,
_customizeIds,
token)
.ConfigureAwait(false);
if (applied)
{
ClearOwnedObjectRetry(entry.Key);
anyApplied = true;
}
}
if (!anyApplied)
{
await Task.Delay(delay, token).ConfigureAwait(false);
delay = IncreaseRetryDelay(delay);
}
else
{
delay = OwnedRetryInitialDelay;
}
}
}
catch (OperationCanceledException)
{
// ignore
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Owned object retry task failed for {handler}", GetLogIdentifier());
}
}
private async Task TryRefreshOwnedCollectionModsAsync(
Guid ownedCollection,
CharacterData sanitized,
CancellationToken token)
{
if (ownedCollection == Guid.Empty)
return;
if (!_ipcManager.Penumbra.APIAvailable)
return;
static bool IsOwnedKind(ObjectKind k) =>
k is ObjectKind.MinionOrMount or ObjectKind.Pet or ObjectKind.Companion;
var ownedGamePaths = new HashSet(StringComparer.OrdinalIgnoreCase);
var ownedFileSwaps = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in sanitized.FileReplacements)
{
if (!IsOwnedKind(kvp.Key))
continue;
foreach (var repl in kvp.Value)
{
if (!string.IsNullOrEmpty(repl.FileSwapPath))
{
foreach (var gp in repl.GamePaths)
{
if (!string.IsNullOrEmpty(gp))
ownedFileSwaps[gp] = repl.FileSwapPath!;
}
continue;
}
foreach (var gp in repl.GamePaths)
{
if (!string.IsNullOrEmpty(gp))
ownedGamePaths.Add(gp);
}
}
}
if (ownedGamePaths.Count == 0 && ownedFileSwaps.Count == 0)
return;
token.ThrowIfCancellationRequested();
Dictionary<(string GamePath, string? Hash), string>? resolved = null;
if (_lastAppliedModdedPaths is not null && _lastAppliedModdedPaths.Count > 0 && HasValidCachedModdedPaths())
{
resolved = _lastAppliedModdedPaths;
}
else
{
_ = TryCalculateModdedDictionary(Guid.NewGuid(), sanitized, out var recomputed, token);
resolved = recomputed;
_lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(resolved, resolved.Comparer);
}
token.ThrowIfCancellationRequested();
var ownedMods = new Dictionary(StringComparer.Ordinal);
foreach (var kv in resolved)
{
var gp = kv.Key.GamePath;
if (ownedGamePaths.Contains(gp))
ownedMods[gp] = kv.Value;
}
foreach (var kv in ownedFileSwaps)
ownedMods[kv.Key] = kv.Value;
if (ownedMods.Count == 0)
return;
var refreshId = Guid.NewGuid();
Logger.LogDebug("[{appId}] Refreshing OWNED temp collection mods ({count} paths) for {handler}",
refreshId, ownedMods.Count, GetLogIdentifier());
await _ipcManager.Penumbra
.SetTemporaryModsAsync(Logger, refreshId, ownedCollection, ownedMods, scope: "OwnedRetryRefresh")
.ConfigureAwait(false);
}
private static TimeSpan IncreaseRetryDelay(TimeSpan delay)
{
var nextMs = Math.Min(delay.TotalMilliseconds * 2, OwnedRetryMaxDelay.TotalMilliseconds);
return TimeSpan.FromMilliseconds(nextMs);
}
private static bool HasAppearanceDataForKind(CharacterData data, ObjectKind kind)
{
if (data.FileReplacements.TryGetValue(kind, out var replacements) && replacements.Count > 0)
{
return true;
}
if (data.GlamourerData.TryGetValue(kind, out var glamourer) && !string.IsNullOrEmpty(glamourer))
{
return true;
}
if (data.CustomizePlusData.TryGetValue(kind, out var customize) && !string.IsNullOrEmpty(customize))
{
return true;
}
return false;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
SetUploading(false);
var name = PlayerName;
if (!string.IsNullOrEmpty(name))
{
_lastKnownName = name;
}
var currentAddress = PlayerCharacter;
if (currentAddress != nint.Zero)
{
_lastKnownAddress = currentAddress;
}
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;
ClearAllOwnedObjectRetries();
_ownedRetryCts?.CancelDispose();
_ownedRetryCts = 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 (IsFrameworkUnloading())
{
Logger.LogWarning("Framework is unloading, skipping disposal for {name} ({user})", name, alias);
return;
}
var isStopping = _lifetime.ApplicationStopping.IsCancellationRequested;
if (isStopping)
{
ResetPenumbraCollection(reason: "DisposeStopping", awaitIpc: false);
ScheduleSafeRevertOnDisposal(applicationId, name, alias);
return;
}
var canCleanup = !string.IsNullOrEmpty(name)
&& _dalamudUtil.IsLoggedIn
&& !_dalamudUtil.IsZoning
&& !_dalamudUtil.IsInCutscene;
if (!canCleanup)
{
return;
}
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> 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;
_lastSuccessfulDataHash = null;
_lastAppliedModdedPaths = null;
_needsCollectionRebuild = false;
_performanceMetricsCache.Clear(Ident);
Logger.LogDebug("Disposing {name} complete", name);
}
}
private bool IsFrameworkUnloading()
{
try
{
var prop = _framework.GetType().GetProperty("IsFrameworkUnloading");
if (prop?.PropertyType == typeof(bool))
{
return (bool)prop.GetValue(_framework)!;
}
}
catch
{
// ignore
}
return false;
}
private void ScheduleSafeRevertOnDisposal(Guid applicationId, string? name, string alias)
{
var cleanupName = !string.IsNullOrEmpty(name) ? name : _lastKnownName;
var cleanupAddress = _lastKnownAddress != nint.Zero
? _lastKnownAddress
: _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Ident);
var cleanupObjectIndex = _lastKnownObjectIndex;
var cleanupIdent = Ident;
var customizeIds = _customizeIds.Values.Where(id => id.HasValue)
.Select(id => id!.Value)
.Distinct()
.ToList();
if (string.IsNullOrEmpty(cleanupName)
&& cleanupAddress == nint.Zero
&& cleanupObjectIndex == ushort.MaxValue
&& customizeIds.Count == 0)
{
return;
}
_ = Task.Run(() => SafeRevertOnDisposalAsync(
applicationId,
cleanupName,
cleanupAddress,
cleanupObjectIndex,
cleanupIdent,
customizeIds,
alias));
}
private async Task SafeRevertOnDisposalAsync(
Guid applicationId,
string? cleanupName,
nint cleanupAddress,
ushort cleanupObjectIndex,
string cleanupIdent,
IReadOnlyList customizeIds,
string alias)
{
try
{
if (IsFrameworkUnloading())
{
return;
}
if (!string.IsNullOrEmpty(cleanupName) && _ipcManager.Glamourer.APIAvailable)
{
Logger.LogDebug("[{applicationId}] Restoring Glamourer for {name} ({user})", applicationId, cleanupName, alias);
await _ipcManager.Glamourer.RevertByNameAsync(Logger, cleanupName, applicationId).ConfigureAwait(false);
}
if (_ipcManager.CustomizePlus.APIAvailable && customizeIds.Count > 0)
{
foreach (var customizeId in customizeIds)
{
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
}
}
var address = cleanupAddress;
if (address == nint.Zero && cleanupObjectIndex != ushort.MaxValue)
{
address = await _dalamudUtil.RunOnFrameworkThread(() =>
{
var obj = _dalamudUtil.GetCharacterFromObjectTableByIndex(cleanupObjectIndex);
if (obj is not Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter player)
{
return nint.Zero;
}
if (!DalamudUtilService.TryGetHashedCID(player, out var hash)
|| !string.Equals(hash, cleanupIdent, StringComparison.Ordinal))
{
return nint.Zero;
}
return player.Address;
}).ConfigureAwait(false);
}
if (address == nint.Zero)
{
return;
}
if (_ipcManager.CustomizePlus.APIAvailable)
{
await _ipcManager.CustomizePlus.RevertAsync(address).ConfigureAwait(false);
}
if (_ipcManager.Heels.APIAvailable)
{
await _ipcManager.Heels.RestoreOffsetForPlayerAsync(address).ConfigureAwait(false);
}
if (_ipcManager.Honorific.APIAvailable)
{
await _ipcManager.Honorific.ClearTitleAsync(address).ConfigureAwait(false);
}
if (_ipcManager.Moodles.APIAvailable)
{
await _ipcManager.Moodles.RevertStatusAsync(address).ConfigureAwait(false);
}
if (_ipcManager.PetNames.APIAvailable)
{
await _ipcManager.PetNames.ClearPlayerData(address).ConfigureAwait(false);
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed shutdown cleanup for {name}", cleanupName ?? cleanupIdent);
}
}
private async Task ApplyCustomizationDataAsync(Guid applicationId, KeyValuePair> changes, CharacterData charaData, CancellationToken token)
{
if (PlayerCharacter == nint.Zero) return false;
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 CreateMinionOrMountHandlerAsync(token).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 false;
}
Logger.LogDebug("[{applicationId}] Applying Customization Data for {handler}", applicationId, handler);
await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false);
if (handler.ObjectKind != ObjectKind.Player
&& handler.CurrentDrawCondition == GameObjectHandler.DrawCondition.DrawObjectZero)
{
Logger.LogDebug("[{applicationId}] Skipping customization apply for {handler}, draw object not available", applicationId, handler);
return false;
}
var drawTimeoutMs = handler.ObjectKind == ObjectKind.Player ? 30000 : 5000;
var fullyLoadedTimeoutMs = handler.ObjectKind == ObjectKind.Player ? FullyLoadedTimeoutMsPlayer : FullyLoadedTimeoutMsOther;
await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handler, applicationId, drawTimeoutMs, token).ConfigureAwait(false);
if (handler.Address != nint.Zero)
{
var fullyLoaded = await _actorObjectService.WaitForFullyLoadedAsync(handler.Address, token, fullyLoadedTimeoutMs).ConfigureAwait(false);
if (!fullyLoaded)
{
Logger.LogDebug("[{applicationId}] Timed out waiting for {handler} to fully load, skipping customization apply", applicationId, handler);
return false;
}
}
token.ThrowIfCancellationRequested();
var kind = changes.Key;
var changeSet = changes.Value;
var tasks = new List();
bool needsRedraw =
changeSet.Contains(PlayerChanges.ForcedRedraw)
|| changeSet.Contains(PlayerChanges.ModFiles);
bool isIpcOnly =
!needsRedraw
&& changeSet.All(c => c is PlayerChanges.Honorific
or PlayerChanges.Moodles
or PlayerChanges.PetNames
or PlayerChanges.Heels);
foreach (var change in changeSet.OrderBy(p => (int)p))
{
Logger.LogDebug("[{applicationId}] Processing {change} for {handler}", applicationId, change, handler);
switch (change)
{
case PlayerChanges.Customize:
if (charaData.CustomizePlusData.TryGetValue(kind, out var customizePlusData) && !string.IsNullOrEmpty(customizePlusData))
tasks.Add(ApplyCustomizeAsync(handler.Address, customizePlusData, kind));
else if (_customizeIds.TryGetValue(kind, out var customizeId))
tasks.Add(RevertCustomizeAsync(customizeId, kind));
break;
case PlayerChanges.Heels:
if (!string.IsNullOrEmpty(charaData.HeelsData))
tasks.Add(_ipcManager.Heels.SetOffsetForPlayerAsync(handler.Address, charaData.HeelsData));
break;
case PlayerChanges.Honorific:
if (!string.IsNullOrEmpty(charaData.HonorificData))
tasks.Add(_ipcManager.Honorific.SetTitleAsync(handler.Address, charaData.HonorificData));
break;
case PlayerChanges.Glamourer:
if (charaData.GlamourerData.TryGetValue(kind, out var glamourerData) && !string.IsNullOrEmpty(glamourerData))
tasks.Add(_ipcManager.Glamourer.ApplyAllAsync(Logger, handler, glamourerData, applicationId, token));
needsRedraw = true;
break;
case PlayerChanges.Moodles:
if (!string.IsNullOrEmpty(charaData.MoodlesData))
tasks.Add(_ipcManager.Moodles.SetStatusAsync(handler.Address, charaData.MoodlesData));
break;
case PlayerChanges.PetNames:
if (!string.IsNullOrEmpty(charaData.PetNamesData))
tasks.Add(_ipcManager.PetNames.SetPlayerData(handler.Address, charaData.PetNamesData));
break;
case PlayerChanges.ModFiles:
case PlayerChanges.ForcedRedraw:
break;
}
token.ThrowIfCancellationRequested();
}
if (tasks.Count > 0)
await Task.WhenAll(tasks).ConfigureAwait(false);
if (!isIpcOnly && needsRedraw && _ipcManager.Penumbra.APIAvailable)
{
await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false);
if (handler.ObjectKind == ObjectKind.Player)
{
var fullyLoaded = await _actorObjectService
.WaitForFullyLoadedAsync(handler.Address, token, FullyLoadedTimeoutMsPlayer)
.ConfigureAwait(false);
if (!fullyLoaded)
{
Logger.LogDebug("[{applicationId}] Timed out waiting for PLAYER {handler} to fully load, skipping customization apply",
applicationId, handler);
return false;
}
}
else
{
var ready = await WaitForNonPlayerDrawableAsync(handler.Address, token, timeoutMs: FullyLoadedTimeoutMsOther)
.ConfigureAwait(false);
if (!ready)
{
Logger.LogDebug("[{applicationId}] Timed out waiting for OWNED {handler} to become drawable, skipping (will retry)",
applicationId, handler);
return false;
}
}
}
return true;
}
finally
{
if (handler != _charaHandler) handler.Dispose();
}
}
private async Task CreateMinionOrMountHandlerAsync(CancellationToken token)
{
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
return await _gameObjectHandlerFactory.Create(ObjectKind.MinionOrMount, () => nint.Zero, isWatched: false).ConfigureAwait(false);
var ownedPtr = await ResolveMinionOrMountAddressAsync(_charaHandler, token).ConfigureAwait(false);
return await _gameObjectHandlerFactory.Create(ObjectKind.MinionOrMount, () => ownedPtr, isWatched: false).ConfigureAwait(false);
}
private static async Task WaitForNonPlayerDrawableAsync(nint address, CancellationToken token, int timeoutMs)
{
var until = Environment.TickCount64 + timeoutMs;
while (Environment.TickCount64 < until)
{
token.ThrowIfCancellationRequested();
if (IsNonPlayerDrawable(address))
return true;
await Task.Delay(100, token).ConfigureAwait(false);
}
return false;
}
private static unsafe bool IsNonPlayerDrawable(nint address)
{
if (address == nint.Zero)
return false;
var go = (GameObject*)address;
if (go == null)
return false;
if (go->DrawObject == null)
return false;
if ((ulong)go->RenderFlags == 2048)
return false;
return true;
}
private async Task> ResolveMinionOrMountCandidatesAsync(GameObjectHandler playerHandler, CancellationToken token)
{
if (playerHandler is null || playerHandler.Address == nint.Zero)
return [];
return await _dalamudUtil.RunOnFrameworkThread(() =>
{
var ownerEntityId = playerHandler.EntityId;
if (ownerEntityId == 0 || ownerEntityId == uint.MaxValue)
return Array.Empty();
var objIndex = playerHandler.GetGameObject()?.ObjectIndex ?? (ushort)0;
return _actorObjectService.GetMinionOrMountCandidates(ownerEntityId, objIndex);
}).ConfigureAwait(false);
}
private async Task ResolveMinionOrMountAddressAsync(GameObjectHandler playerHandler, CancellationToken token)
{
if (playerHandler is null || playerHandler.Address == nint.Zero)
return nint.Zero;
var ownerEntityId = playerHandler.EntityId;
if (ownerEntityId != 0 && ownerEntityId != uint.MaxValue)
{
var owned = await _dalamudUtil.RunOnFrameworkThread(() =>
{
return _actorObjectService.TryFindOwnedObject(
ownerEntityId,
ObjectKind.MinionOrMount,
out var addr)
? addr
: nint.Zero;
}).ConfigureAwait(false);
if (owned != nint.Zero)
return owned;
}
try
{
return await _dalamudUtil.GetMinionOrMountAsync(playerHandler.Address).ConfigureAwait(false);
}
catch
{
return nint.Zero;
}
}
private static Dictionary> BuildFullChangeSet(CharacterData characterData)
{
var result = new Dictionary>();
foreach (var objectKind in Enum.GetValues())
{
var changes = new HashSet();
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? 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? left, IReadOnlyCollection? 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> 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> 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();
bool skipDecimationForPair = ShouldSkipDecimation();
var user = GetPrimaryUserData();
Dictionary<(string GamePath, string? Hash), string> moddedPaths;
List missingReplacements = [];
if (updateModdedPaths)
{
if (cachedModdedPaths is not null)
{
moddedPaths = new Dictionary<(string GamePath, string? Hash), string>(cachedModdedPaths, cachedModdedPaths.Comparer);
}
else
{
int attempts = 0;
List 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;
await (_pairDownloadTask = Task.Run(async () =>
await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, downloadToken, skipDownscaleForPair, skipDecimationForPair)
.ConfigureAwait(false))).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 r => r.GamePaths.Any(static p => p.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)))
.Select(static r => r.Hash)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (downloadedTextureHashes.Count > 0)
await _textureDownscaleService.WaitForPendingJobsAsync(downloadedTextureHashes, downloadToken).ConfigureAwait(false);
}
if (!skipDecimationForPair)
{
var downloadedModelHashes = toDownloadReplacements
.Where(static r => r.GamePaths.Any(static p => p.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)))
.Select(static r => r.Hash)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (downloadedModelHashes.Count > 0)
await _modelDecimationService.WaitForPendingJobsAsync(downloadedModelHashes, 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.Exists(r => !IsForbiddenHash(r.Hash));
var hasDownloadableCriticalMissing = hasCriticalMissing
&& missingReplacements.Exists(r => !IsForbiddenHash(r.Hash) && IsCriticalModReplacement(r));
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;
}
var needsOwnedCollectionAssign =
_ipcManager.Penumbra.APIAvailable
&& updatedData.Any(kvp =>
kvp.Key != ObjectKind.Player
&& kvp.Value.Contains(PlayerChanges.ModFiles));
var wantsOwnedCollectionAssignNow = needsOwnedCollectionAssign;
Guid ownedAssignCollection = Guid.Empty;
if (wantsOwnedCollectionAssignNow)
{
ownedAssignCollection = EnsureOwnedPenumbraCollection();
}
_applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource();
var token = _applicationCancellationTokenSource.Token;
_applicationTask = ApplyCharacterDataAsync(
applicationBase,
handlerForApply,
charaData,
updatedData,
updateModdedPaths,
updateManip,
moddedPaths,
wantsModApply,
pendingModReapply,
token);
if (wantsOwnedCollectionAssignNow && ownedAssignCollection != Guid.Empty)
{
var applyTaskSnapshot = _applicationTask;
var updatedSnapshot = updatedData.ToDictionary(k => k.Key, v => new HashSet(v.Value));
var ownedCollectionSnapshot = ownedAssignCollection;
_ = Task.Run(async () =>
{
try
{
await applyTaskSnapshot.ConfigureAwait(false);
token.ThrowIfCancellationRequested();
foreach (var kvp in updatedSnapshot)
{
if (kvp.Key == ObjectKind.Player)
continue;
if (!kvp.Value.Contains(PlayerChanges.ModFiles))
continue;
var delay = OwnedRetryInitialDelay;
while (!token.IsCancellationRequested)
{
IReadOnlyList ownedPtrs;
if (kvp.Key == ObjectKind.MinionOrMount)
ownedPtrs = await ResolveMinionOrMountCandidatesAsync(handlerForApply, token).ConfigureAwait(false);
else
ownedPtrs = new[] { await ResolveOtherOwnedPtrAsync(kvp.Key, handlerForApply.Address).ConfigureAwait(false) };
ownedPtrs = ownedPtrs.Where(p => p != nint.Zero).Distinct().ToArray();
if (ownedPtrs.Count > 0)
{
foreach (var ptr in ownedPtrs)
{
using var ownedHandler = await _gameObjectHandlerFactory
.Create(kvp.Key, () => ptr, isWatched: false)
.ConfigureAwait(false);
if (ownedHandler.Address == nint.Zero)
continue;
var objIndex = await _dalamudUtil.RunOnFrameworkThread(() =>
{
var go = ownedHandler.GetGameObject();
return go?.ObjectIndex;
}).ConfigureAwait(false);
if (!objIndex.HasValue)
continue;
await _ipcManager.Penumbra
.AssignTemporaryCollectionAsync(Logger, ownedCollectionSnapshot, objIndex.Value)
.ConfigureAwait(false);
await _ipcManager.Penumbra
.RedrawAsync(Logger, ownedHandler, Guid.NewGuid(), token)
.ConfigureAwait(false);
Logger.LogDebug("Assigned OWNED temp collection {collection} to {kind} candidate idx={idx} for {handler}",
ownedCollectionSnapshot, kvp.Key, objIndex.Value, GetLogIdentifier());
}
break;
}
await Task.Delay(delay, token).ConfigureAwait(false);
delay = IncreaseRetryDelay(delay);
}
}
}
catch (OperationCanceledException)
{
Logger.LogTrace("Owned object collection assignment task cancelled for {handler}", GetLogIdentifier());
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Owned object collection assignment task failed for {handler}", GetLogIdentifier());
}
}, CancellationToken.None);
}
async Task ResolveOtherOwnedPtrAsync(ObjectKind kind, nint playerPtr)
{
return kind switch
{
ObjectKind.MinionOrMount => await _dalamudUtil.GetMinionOrMountAsync(playerPtr).ConfigureAwait(false),
ObjectKind.Pet => await _dalamudUtil.GetPetAsync(playerPtr).ConfigureAwait(false),
ObjectKind.Companion => await _dalamudUtil.GetCompanionAsync(playerPtr).ConfigureAwait(false),
_ => nint.Zero
};
}
}
finally
{
await concurrencyLease.DisposeAsync().ConfigureAwait(false);
}
}
private async Task ApplyCharacterDataAsync(
Guid applicationBase,
GameObjectHandler handlerForApply,
CharacterData charaData,
Dictionary> 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 {handler}", _applicationId, handlerForApply);
await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handlerForApply, _applicationId, 30000, token).ConfigureAwait(false);
if (handlerForApply.Address != nint.Zero)
{
var fullyLoaded = await _actorObjectService
.WaitForFullyLoadedAsync(handlerForApply.Address, token, FullyLoadedTimeoutMsPlayer)
.ConfigureAwait(false);
if (!fullyLoaded)
{
Logger.LogDebug("[BASE-{applicationId}] Timed out waiting for {handler} to fully load, caching data for later application",
applicationBase, GetLogIdentifier());
_cachedData = charaData;
_pairStateCache.Store(Ident, charaData);
_forceFullReapply = true;
RecordFailure("Actor not fully loaded within timeout", "FullyLoadedTimeout");
return;
}
}
token.ThrowIfCancellationRequested();
static bool IsPlayerIpcOnly(PlayerChanges c) =>
c is PlayerChanges.Honorific or PlayerChanges.Moodles or PlayerChanges.PetNames or PlayerChanges.Heels;
// Determine if only IPC-only changes are present
bool playerHasDelta = updatedData.TryGetValue(ObjectKind.Player, out var playerDelta) && playerDelta.Count > 0;
bool playerIsIpcOnlyDelta = false;
if (playerDelta != null)
{
playerIsIpcOnlyDelta = playerHasDelta && playerDelta.All(IsPlayerIpcOnly);
}
bool anyNonPlayerDelta = updatedData.Any(kvp => kvp.Key != ObjectKind.Player && kvp.Value.Count > 0);
bool updatePlayerMods = updateModdedPaths || updateManip;
bool updateOwnedMods = updatedData.Any(kvp => kvp.Key != ObjectKind.Player && kvp.Value.Contains(PlayerChanges.ModFiles));
bool isPureIpcOnly = playerIsIpcOnlyDelta && !anyNonPlayerDelta && !updatePlayerMods && !updateOwnedMods;
// Short-circuit if only IPC changes
Guid playerCollection = Guid.Empty;
Guid ownedCollection = Guid.Empty;
if (!isPureIpcOnly)
{
if (updatePlayerMods)
{
playerCollection = EnsurePenumbraCollection();
if (playerCollection == Guid.Empty)
{
_cachedData = charaData;
_pairStateCache.Store(Ident, charaData);
_forceFullReapply = true;
RecordFailure("Penumbra player collection unavailable", "PenumbraUnavailablePlayer");
return;
}
}
if (updateOwnedMods)
{
ownedCollection = EnsureOwnedPenumbraCollection();
if (ownedCollection == Guid.Empty)
{
_cachedData = charaData;
_pairStateCache.Store(Ident, charaData);
_forceFullReapply = true;
RecordFailure("Penumbra owned collection unavailable", "PenumbraUnavailableOwned");
return;
}
}
if ((updatePlayerMods || updateOwnedMods) && (moddedPaths.Count == 0))
{
Logger.LogWarning(
"[{applicationId}] ModdedPaths missing but updatePlayerMods={up} updateOwnedMods={uo}. Rebuilding.",
_applicationId, updatePlayerMods, updateOwnedMods);
_ = TryCalculateModdedDictionary(applicationBase, charaData, out var rebuilt, token);
moddedPaths = rebuilt;
}
}
if (!isPureIpcOnly && updatePlayerMods)
{
// Get object index on framework thread
var objIndex = await _dalamudUtil.RunOnFrameworkThread(() =>
{
var gameObject = handlerForApply.GetGameObject();
return gameObject?.ObjectIndex;
}).ConfigureAwait(false);
// Ensure object index is available
if (!objIndex.HasValue)
{
_cachedData = charaData;
_pairStateCache.Store(Ident, charaData);
_forceFullReapply = true;
RecordFailure("Game object not available for application", "GameObjectUnavailable");
return;
}
// Filter modded paths to player only
var playerGamePaths = charaData.FileReplacements.TryGetValue(ObjectKind.Player, out var repls) && repls?.Count > 0
? repls.SelectMany(r => r.GamePaths).Where(p => !string.IsNullOrEmpty(p)).ToHashSet(StringComparer.OrdinalIgnoreCase)
: new HashSet(StringComparer.OrdinalIgnoreCase);
// Construct player modded dictionary
var playerModded = new Dictionary<(string GamePath, string? Hash), string>(moddedPaths.Comparer);
foreach (var kv in moddedPaths)
if (playerGamePaths.Contains(kv.Key.GamePath))
playerModded[kv.Key] = kv.Value;
// Handle PAP mappings separately to check compatibility
SplitPapMappings(playerModded, out var withoutPap, out var papOnly);
// Assign collection via IPC
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, playerCollection, objIndex.Value).ConfigureAwait(false);
// Ensure fully loaded before applying PAP mappings
if (handlerForApply.Address != nint.Zero)
await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false);
// Strip incompatible PAP mappings before applying
var removedPap = await StripIncompatiblePapAsync(handlerForApply, charaData, papOnly, token).ConfigureAwait(false);
if (removedPap > 0)
Logger.LogTrace("[{applicationId}] Removed {removedPap} incompatible PAP mappings for {handler}",
_applicationId, removedPap, GetLogIdentifier());
// Merge back PAP mappings
var merged = new Dictionary<(string GamePath, string? Hash), string>(withoutPap, withoutPap.Comparer);
foreach (var kv in papOnly)
merged[kv.Key] = kv.Value;
// Apply mods via IPC
await _ipcManager.Penumbra.SetTemporaryModsAsync(
Logger, _applicationId, playerCollection,
merged.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal),
scope: "Player")
.ConfigureAwait(false);
// Final redraw
await _ipcManager.Penumbra.RedrawAsync(Logger, handlerForApply, _applicationId, token).ConfigureAwait(false);
if (handlerForApply.Address != nint.Zero)
{
await _actorObjectService
.WaitForFullyLoadedAsync(handlerForApply.Address, token, FullyLoadedTimeoutMsPlayer)
.ConfigureAwait(false);
}
// Cache last applied modded paths
_lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(moddedPaths, moddedPaths.Comparer);
}
if (!isPureIpcOnly && updateOwnedMods && ownedCollection != Guid.Empty)
{
// Filter modded paths to owned only
var ownedGamePaths = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var k in new[] { ObjectKind.MinionOrMount, ObjectKind.Pet, ObjectKind.Companion })
{
if (charaData.FileReplacements.TryGetValue(k, out var repls) && repls?.Count > 0)
{
foreach (var p in repls.SelectMany(r => r.GamePaths))
if (!string.IsNullOrEmpty(p))
ownedGamePaths.Add(p);
}
}
// Construct owned modded dictionary
var ownedModded = new Dictionary<(string GamePath, string? Hash), string>(moddedPaths.Comparer);
foreach (var kv in moddedPaths)
if (ownedGamePaths.Contains(kv.Key.GamePath))
ownedModded[kv.Key] = kv.Value;
// Apply owned mods via IPC
if (ownedModded.Count > 0)
{
await _ipcManager.Penumbra.SetTemporaryModsAsync(
Logger, _applicationId, ownedCollection,
ownedModded.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal),
scope: "Owned")
.ConfigureAwait(false);
}
}
// Apply manipulation data if needed
if (!isPureIpcOnly && updateManip && playerCollection != Guid.Empty)
{
// Apply manipulation data via IPC
await _ipcManager.Penumbra.SetManipulationDataAsync(
Logger, _applicationId, playerCollection, charaData.ManipulationData)
.ConfigureAwait(false);
}
token.ThrowIfCancellationRequested();
// Apply changes for each object kind
foreach (var kind in updatedData.Keys.OrderBy(k => k == ObjectKind.Player ? -1 : (int)k))
{
token.ThrowIfCancellationRequested();
var changeSet = updatedData[kind];
if (changeSet.Count == 0)
continue;
Guid collectionToUse;
// Determine which collection to use
if (!changeSet.Contains(PlayerChanges.ModFiles))
{
collectionToUse = kind != ObjectKind.Player ? Guid.Empty : playerCollection;
}
else
{
collectionToUse = kind == ObjectKind.Player ? playerCollection : ownedCollection;
}
// Owned objects may fail to apply if they are not fully loaded yet.
var applied = await _ownedObjectHandler.ApplyAsync(
_applicationId,
kind,
changeSet,
charaData,
handlerForApply,
collectionToUse,
_customizeIds,
token)
.ConfigureAwait(false);
if (applied)
ClearOwnedObjectRetry(kind);
else if (kind != ObjectKind.Player)
ScheduleOwnedObjectRetry(kind, changeSet);
}
_cachedData = charaData;
_pairStateCache.Store(Ident, charaData);
if (wantsModApply)
_pendingModReapply = pendingModReapply;
_forceFullReapply = _pendingModReapply;
_needsCollectionRebuild = false;
StorePerformanceMetrics(charaData);
_lastSuccessfulDataHash = GetDataHashSafe(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)
{
Logger.LogWarning(ex, "[{applicationId}] Application failed", _applicationId);
_cachedData = charaData;
_pairStateCache.Store(Ident, charaData);
_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 KickOwnedObjectRetryFromTracked()
{
if (!IsVisible || IsPaused() || !CanApplyNow() || _charaHandler is null || _charaHandler.Address == nint.Zero)
return;
var data = _cachedData ?? LastReceivedCharacterData ?? _pairStateCache.TryLoad(Ident);
if (data is null)
return;
static HashSet BuildOwnedChanges(CharacterData d, ObjectKind k)
{
var set = new HashSet();
if (d.FileReplacements.TryGetValue(k, out var repls) && repls is { Count: > 0 })
set.Add(PlayerChanges.ModFiles);
if (d.GlamourerData.TryGetValue(k, out var g) && !string.IsNullOrEmpty(g))
set.Add(PlayerChanges.Glamourer);
if (d.CustomizePlusData.TryGetValue(k, out var c) && !string.IsNullOrEmpty(c))
set.Add(PlayerChanges.Customize);
if (set.Count > 0)
set.Add(PlayerChanges.ForcedRedraw);
return set;
}
var kinds = new[] { ObjectKind.MinionOrMount, ObjectKind.Pet, ObjectKind.Companion };
lock (_ownedRetryGate)
{
foreach (var k in kinds)
{
if (!HasAppearanceDataForKind(data, k))
continue;
var changes = BuildOwnedChanges(data, k);
if (changes.Count == 0)
continue;
_pendingOwnedChanges[k] = changes;
}
if (_pendingOwnedChanges.Count == 0)
return;
_ownedRetryCts = _ownedRetryCts?.CancelRecreate() ?? new CancellationTokenSource();
if (_ownedRetryTask.IsCompleted)
_ownedRetryTask = Task.Run(() => OwnedObjectRetryLoopAsync(_ownedRetryCts.Token), CancellationToken.None);
}
Logger.LogDebug("{handler}: Kicked owned-object retry from ActorTracked (pending: {pending})",
GetLogIdentifier(), string.Join(", ", _pendingOwnedChanges.Keys));
}
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();
ClearAllOwnedObjectRetries();
_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();
UpdateLastKnownActor(_charaHandler.Address, name);
var user = GetPrimaryUserData();
if (!string.IsNullOrEmpty(user.UID))
{
_serverConfigManager.AutoPopulateNoteForUid(user.UID, name);
}
Mediator.Subscribe(this, _message =>
{
var honorificData = _cachedData?.HonorificData;
if (string.IsNullOrEmpty(honorificData))
return;
_ = ReapplyHonorificAsync(honorificData!);
});
Mediator.Subscribe(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)
{
// Players have their own object kind.
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)
{
// Minions and mounts share the same object kind.
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)
{
// Pets share the same object kind.
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)
{
// Companions share the same object kind.
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 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();
int hasMigrationChanges = 0;
bool skipDownscaleForPair = ShouldSkipDownscale();
bool skipDecimationForPair = ShouldSkipDecimation();
try
{
RefreshPapBlockCacheIfAnimSettingsChanged();
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 is null)
{
Logger.LogTrace("Missing file: {hash}", item.Hash);
missingFiles.Add(item);
return;
}
if (string.IsNullOrEmpty(Path.GetExtension(fileCache.ResolvedFilepath)))
{
var anyGamePath = item.GamePaths.FirstOrDefault();
if (!string.IsNullOrEmpty(anyGamePath))
{
var ext = Path.GetExtension(anyGamePath);
var extNoDot = ext.StartsWith('.') ? ext[1..] : ext;
if (!string.IsNullOrEmpty(extNoDot))
{
Interlocked.Exchange(ref hasMigrationChanges, 1);
fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, extNoDot);
}
}
}
foreach (var gamePath in item.GamePaths)
{
var mode = _configService.Current.AnimationValidationMode;
if (mode != AnimationValidationMode.Unsafe
&& gamePath.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrEmpty(item.Hash)
&& _blockedPapHashes.ContainsKey(item.Hash))
{
continue;
}
var preferredPath = fileCache.ResolvedFilepath;
// Only downscale textures.
if (!skipDownscaleForPair && gamePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))
{
preferredPath = _textureDownscaleService.GetPreferredPath(item.Hash, preferredPath);
}
// Only decimate models.
if (!skipDecimationForPair && gamePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase))
{
preferredPath = _modelDecimationService.GetPreferredPath(item.Hash, preferredPath);
}
outputDict[(gamePath, item.Hash)] = preferredPath;
}
});
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 == 1)
_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(StringComparer.Ordinal)).ConfigureAwait(false);
await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, applicationId, penumbraCollection, string.Empty).ConfigureAwait(false);
}
}
var kinds = new HashSet(_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;
LastAppliedApproximateEffectiveTris = -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)
&& string.Equals(hashedCid, Ident, StringComparison.Ordinal))
{
if (descriptor.Address == nint.Zero)
return;
UpdateLastKnownActor(descriptor);
RefreshTrackedHandler(descriptor);
QueueActorInitialization(descriptor);
return;
}
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
return;
var ownerId = descriptor.OwnerEntityId;
if (ownerId == 0 || ownerId != _charaHandler.EntityId)
return;
if (!TryMapOwnedKind(descriptor, out var ownedKind))
return;
var data = _cachedData
?? LastReceivedCharacterData
?? _pairStateCache.TryLoad(Ident);
if (data is null)
return;
if (!HasAppearanceDataForKind(data, ownedKind))
return;
var changes = BuildOwnedChangeSetForKind(data, ownedKind);
if (changes.Count == 0)
return;
ScheduleOwnedObjectRetry(ownedKind, changes);
KickOwnedObjectRetryFromTracked();
}
private static HashSet BuildOwnedChangeSetForKind(CharacterData data, ObjectKind kind)
{
var changes = new HashSet();
if (data.FileReplacements.TryGetValue(kind, out var repls) && repls is { Count: > 0 })
changes.Add(PlayerChanges.ModFiles);
if (data.GlamourerData.TryGetValue(kind, out var glamourer) && !string.IsNullOrEmpty(glamourer))
changes.Add(PlayerChanges.Glamourer);
if (data.CustomizePlusData.TryGetValue(kind, out var customize) && !string.IsNullOrEmpty(customize))
changes.Add(PlayerChanges.Customize);
return changes;
}
private static unsafe bool TryMapOwnedKind(ActorObjectService.ActorDescriptor descriptor, out ObjectKind kind)
{
kind = default;
switch (descriptor.ObjectKind)
{
case DalamudObjectKind.MountType:
kind = ObjectKind.MinionOrMount;
return true;
case DalamudObjectKind.Companion:
kind = ObjectKind.Companion;
return true;
case DalamudObjectKind.BattleNpc:
{
if (descriptor.Address == nint.Zero)
return false;
var go = (GameObject*)descriptor.Address;
if (go == null)
return false;
var subKind = go->BattleNpcSubKind;
if (subKind == BattleNpcSubKind.Pet)
{
kind = ObjectKind.Pet;
return true;
}
if (subKind == BattleNpcSubKind.Buddy)
{
kind = ObjectKind.Companion;
return true;
}
return false;
}
default:
return false;
}
}
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(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 (!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);
return;
}
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
return;
var localEntityId = _charaHandler.EntityId;
if (localEntityId != 0 && localEntityId != uint.MaxValue
&& descriptor.OwnerEntityId != 0
&& descriptor.OwnerEntityId == localEntityId)
{
switch (descriptor.ObjectKind)
{
case DalamudObjectKind.MountType:
ClearOwnedObjectRetry(ObjectKind.MinionOrMount);
return;
case DalamudObjectKind.Companion:
ClearOwnedObjectRetry(ObjectKind.Companion);
return;
case DalamudObjectKind.BattleNpc:
ClearOwnedObjectRetry(ObjectKind.Pet);
ClearOwnedObjectRetry(ObjectKind.Companion);
return;
}
}
if (descriptor.Address == _charaHandler.Address)
{
HandleVisibilityLoss(logChange: false);
}
}
private static 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 void UpdateLastKnownActor(ActorObjectService.ActorDescriptor descriptor)
{
_lastKnownAddress = descriptor.Address;
_lastKnownObjectIndex = descriptor.ObjectIndex;
if (!string.IsNullOrEmpty(descriptor.Name))
{
_lastKnownName = descriptor.Name;
}
}
private void UpdateLastKnownActor(nint address, string? name)
{
if (address != nint.Zero)
{
_lastKnownAddress = address;
}
if (!string.IsNullOrEmpty(name))
{
_lastKnownName = name;
}
}
private void RefreshPapBlockCacheIfAnimSettingsChanged()
{
var cfg = _configService.Current;
if (cfg.AnimationValidationMode != _lastAnimMode
|| cfg.AnimationAllowOneBasedShift != _lastAllowOneBasedShift
|| cfg.AnimationAllowNeighborIndexTolerance != _lastAllowNeighborTolerance)
{
_lastAnimMode = cfg.AnimationValidationMode;
_lastAllowOneBasedShift = cfg.AnimationAllowOneBasedShift;
_lastAllowNeighborTolerance = cfg.AnimationAllowNeighborIndexTolerance;
_blockedPapHashes.Clear();
_dumpedRemoteSkeletonForHash.Clear();
Logger.LogDebug("{handler}: Cleared blocked PAP cache due to animation setting change (mode={mode}, shift={shift}, neigh={neigh})",
GetLogIdentifier(), _lastAnimMode, _lastAllowOneBasedShift, _lastAllowNeighborTolerance);
}
}
private static void SplitPapMappings(
Dictionary<(string GamePath, string? Hash), string> moddedPaths,
out Dictionary<(string GamePath, string? Hash), string> withoutPap,
out Dictionary<(string GamePath, string? Hash), string> papOnly)
{
withoutPap = new(moddedPaths.Comparer);
papOnly = new(moddedPaths.Comparer);
foreach (var kv in moddedPaths)
{
var gamePath = kv.Key.GamePath;
if (gamePath.EndsWith(".pap", StringComparison.OrdinalIgnoreCase))
papOnly[kv.Key] = kv.Value;
else
withoutPap[kv.Key] = kv.Value;
}
}
private async Task StripIncompatiblePapAsync(
GameObjectHandler handlerForApply,
CharacterData charaData,
Dictionary<(string GamePath, string? Hash), string> papOnly,
CancellationToken token)
{
RefreshPapBlockCacheIfAnimSettingsChanged();
var mode = _configService.Current.AnimationValidationMode;
var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift;
var allowNeighborIndex = _configService.Current.AnimationAllowNeighborIndexTolerance;
if (mode == AnimationValidationMode.Unsafe || papOnly.Count == 0)
return 0;
var boneIndices = await _dalamudUtil.RunOnFrameworkThread(
() => _modelAnalyzer.GetSkeletonBoneIndices(handlerForApply))
.ConfigureAwait(false);
if (boneIndices == null || boneIndices.Count == 0)
{
var removedCount = papOnly.Count;
papOnly.Clear();
return removedCount;
}
var localBoneSets = new Dictionary>(StringComparer.OrdinalIgnoreCase);
foreach (var (rawKey, list) in boneIndices)
{
var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawKey);
if (string.IsNullOrEmpty(key) || list == null || list.Count == 0)
continue;
if (!localBoneSets.TryGetValue(key, out var set))
localBoneSets[key] = set = new HashSet();
foreach (var v in list)
set.Add(v);
}
if (localBoneSets.Count == 0)
{
var removedCount = papOnly.Count;
papOnly.Clear();
return removedCount;
}
int removed = 0;
var groups = papOnly
.Where(kvp => !string.IsNullOrEmpty(kvp.Key.Hash))
.GroupBy(kvp => kvp.Key.Hash!, StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var grp in groups)
{
token.ThrowIfCancellationRequested();
var hash = grp.Key;
var papPath = grp.Select(x => x.Value)
.FirstOrDefault(p => !string.IsNullOrEmpty(p) && File.Exists(p));
if (string.IsNullOrEmpty(papPath))
continue;
var havokBytes = await Task.Run(() => XivDataAnalyzer.ReadHavokBytesFromPap(papPath), token)
.ConfigureAwait(false);
if (havokBytes is not { Length: > 8 })
continue;
Dictionary>? papIndices;
await _papParseLimiter.WaitAsync(token).ConfigureAwait(false);
try
{
papIndices = await _dalamudUtil.RunOnFrameworkThread(
() => _modelAnalyzer.ParseHavokBytesOnFrameworkThread(havokBytes, hash, persistToConfig: false))
.ConfigureAwait(false);
}
finally
{
_papParseLimiter.Release();
}
if (papIndices == null || papIndices.Count == 0)
continue;
if (papIndices.All(k => k.Value == null || k.Value.Count == 0 || k.Value.Max() <= 105))
continue;
if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allowNeighborIndex, out var reason))
continue;
var keysToRemove = grp.Select(x => x.Key).ToList();
foreach (var k in keysToRemove)
papOnly.Remove(k);
removed += keysToRemove.Count;
if (_blockedPapHashes.TryAdd(hash, 0))
Logger.LogWarning("Blocked remote object PAP {papPath} (hash {hash}) for {handler}: {reason}",
papPath, hash, GetLogIdentifier(), reason);
if (charaData.FileReplacements.TryGetValue(ObjectKind.Player, out var list))
{
list.RemoveAll(r =>
string.Equals(r.Hash, hash, StringComparison.OrdinalIgnoreCase) &&
r.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
}
}
var nullHashKeys = papOnly.Keys.Where(k => string.IsNullOrEmpty(k.Hash)).ToList();
foreach (var k in nullHashKeys)
{
papOnly.Remove(k);
removed++;
}
return removed;
}
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);
}
}