Files
LightlessClient/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs
2026-01-05 16:41:30 +01:00

3001 lines
117 KiB
C#

using System.Collections.Concurrent;
using System.Diagnostics;
using Dalamud.Plugin.Services;
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 LightlessSync.WebAPI.Files.Models;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer;
namespace LightlessSync.PlayerData.Pairs;
/// <summary>
/// handles lifecycle, visibility, queued data, character data for a paired user
/// </summary>
internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPairHandlerAdapter
{
private sealed record CombatData(Guid ApplicationId, CharacterData CharacterData, bool Forced);
private readonly DalamudUtilService _dalamudUtil;
private readonly ActorObjectService _actorObjectService;
private readonly FileDownloadManager _downloadManager;
private readonly FileCacheManager _fileDbManager;
private readonly 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 IFramework _framework;
private CancellationTokenSource? _applicationCancellationTokenSource;
private Guid _applicationId;
private Task? _applicationTask;
private CharacterData? _cachedData = null;
private GameObjectHandler? _charaHandler;
private readonly Dictionary<ObjectKind, Guid?> _customizeIds = [];
private CombatData? _dataReceivedInDowntime;
private CancellationTokenSource? _downloadCancellationTokenSource;
private bool _forceApplyMods = false;
private bool _forceFullReapply;
private Dictionary<(string GamePath, string? Hash), string>? _lastAppliedModdedPaths;
private bool _needsCollectionRebuild;
private bool _pendingModReapply;
private bool _lastModApplyDeferred;
private int _lastMissingCriticalMods;
private int _lastMissingNonCriticalMods;
private int _lastMissingForbiddenMods;
private bool _lastMissingCachedFiles;
private string? _lastSuccessfulDataHash;
private bool _isVisible;
private Guid _penumbraCollection;
private readonly object _collectionGate = new();
private bool _redrawOnNextApplication = false;
private readonly object _initializationGate = new();
private readonly object _pauseLock = new();
private Task _pauseTransitionTask = Task.CompletedTask;
private bool _pauseRequested;
private DateTime? _lastDataReceivedAt;
private DateTime? _lastApplyAttemptAt;
private DateTime? _lastSuccessfulApplyAt;
private string? _lastFailureReason;
private IReadOnlyList<string> _lastBlockingConditions = Array.Empty<string>();
private readonly object _visibilityGraceGate = new();
private CancellationTokenSource? _visibilityGraceCts;
private static readonly TimeSpan VisibilityEvictionGrace = TimeSpan.FromMinutes(1);
private readonly object _ownedRetryGate = new();
private readonly Dictionary<ObjectKind, HashSet<PlayerChanges>> _pendingOwnedChanges = new();
private CancellationTokenSource? _ownedRetryCts;
private Task _ownedRetryTask = Task.CompletedTask;
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<string> NonPriorityModExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".tmb",
".pap",
".atex",
".avfx",
".scd"
};
private readonly ConcurrentDictionary<string, byte> _blockedPapHashes = new(StringComparer.OrdinalIgnoreCase);
private AnimationValidationMode _lastAnimMode = (AnimationValidationMode)(-1);
private bool _lastAllowOneBasedShift;
private bool _lastAllowNeighborTolerance;
private readonly ConcurrentDictionary<string, byte> _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 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;
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<string> LastBlockingConditions => _lastBlockingConditions;
public bool IsApplying => _applicationTask is { IsCompleted: false };
public bool IsDownloading => _downloadManager.IsDownloading;
public int PendingDownloadCount => _downloadManager.CurrentDownloads.Count;
public int ForbiddenDownloadCount => _downloadManager.ForbiddenTransfers.Count;
public PairHandlerAdapter(
ILogger<PairHandlerAdapter> logger,
LightlessMediator mediator,
PairManager pairManager,
string ident,
GameObjectHandlerFactory gameObjectHandlerFactory,
IpcManager ipcManager,
FileDownloadManager transferManager,
PluginWarningNotificationService pluginWarningNotificationManager,
DalamudUtilService dalamudUtil,
IFramework framework,
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;
}
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<FrameworkUpdateMessage>(this, _ => FrameworkUpdate());
_frameworkUpdateSubscribed = true;
}
Mediator.Subscribe<ZoneSwitchStartMessage>(this, _ =>
{
_downloadCancellationTokenSource?.CancelDispose();
_charaHandler?.Invalidate();
IsVisible = false;
});
Mediator.Subscribe<PenumbraInitializedMessage>(this, _ =>
{
ResetPenumbraCollection(releaseFromPenumbra: false, reason: "PenumbraInitialized");
if (!IsVisible && _charaHandler is not null)
{
PlayerName = string.Empty;
_charaHandler.Dispose();
_charaHandler = null;
}
EnableSync();
});
Mediator.Subscribe<PenumbraDisposedMessage>(this, _ => ResetPenumbraCollection(releaseFromPenumbra: false, reason: "PenumbraDisposed"));
Mediator.Subscribe<ClassJobChangedMessage>(this, msg =>
{
if (msg.GameObjectHandler == _charaHandler)
{
_redrawOnNextApplication = true;
}
});
Mediator.Subscribe<CombatEndMessage>(this, _ => EnableSync());
Mediator.Subscribe<CombatStartMessage>(this, _ => DisableSync());
Mediator.Subscribe<PerformanceEndMessage>(this, _ => EnableSync());
Mediator.Subscribe<PerformanceStartMessage>(this, _ => DisableSync());
Mediator.Subscribe<InstanceOrDutyStartMessage>(this, _ => DisableSync());
Mediator.Subscribe<InstanceOrDutyEndMessage>(this, _ => EnableSync());
Mediator.Subscribe<CutsceneStartMessage>(this, _ => DisableSync());
Mediator.Subscribe<CutsceneEndMessage>(this, _ => EnableSync());
Mediator.Subscribe<GposeStartMessage>(this, _ => DisableSync());
Mediator.Subscribe<GposeEndMessage>(this, _ => EnableSync());
Mediator.Subscribe<ActorTrackedMessage>(this, msg => HandleActorTracked(msg.Descriptor));
Mediator.Subscribe<ActorUntrackedMessage>(this, msg => HandleActorUntracked(msg.Descriptor));
Mediator.Subscribe<DownloadFinishedMessage>(this, msg =>
{
if (_charaHandler is null || !ReferenceEquals(msg.DownloadId, _charaHandler))
{
return;
}
if (_pendingModReapply && IsVisible)
{
if (LastReceivedCharacterData is not null)
{
Logger.LogDebug("Downloads finished for {handler}, reapplying pending mod data", GetLogIdentifier());
ApplyLastReceivedData(forced: true);
return;
}
if (_cachedData is not null)
{
Logger.LogDebug("Downloads finished for {handler}, reapplying pending mod data from cache", GetLogIdentifier());
ApplyCharacterData(Guid.NewGuid(), _cachedData, forceApplyCustomization: true);
return;
}
}
TryApplyQueuedData();
});
if (!useFrameworkUpdate
&& _actorObjectService.TryGetActorByHash(Ident, out var descriptor)
&& descriptor.Address != nint.Zero)
{
trackedDescriptor = descriptor;
}
Initialized = true;
}
if (trackedDescriptor.HasValue)
{
HandleActorTracked(trackedDescriptor.Value);
}
}
private IReadOnlyList<PairConnection> GetCurrentPairs()
{
return _pairManager.GetPairsByIdent(Ident);
}
private PairConnection? GetPrimaryPair()
{
var pairs = GetCurrentPairs();
var direct = pairs.FirstOrDefault(p => p.IsDirectlyPaired);
if (direct is not null)
{
return direct;
}
var online = pairs.FirstOrDefault(p => p.IsOnline);
if (online is not null)
{
return online;
}
return pairs.FirstOrDefault();
}
private UserData GetPrimaryUserData()
{
return GetPrimaryPair()?.User ?? new UserData(Ident);
}
private string GetPrimaryAliasOrUid()
{
var pair = GetPrimaryPair();
if (pair?.User is null)
{
return Ident;
}
return string.IsNullOrEmpty(pair.User.AliasOrUID) ? Ident : pair.User.AliasOrUID;
}
private string GetPrimaryAliasOrUidSafe()
{
try
{
return GetPrimaryAliasOrUid();
}
catch
{
return Ident;
}
}
private UserData GetPrimaryUserDataSafe()
{
try
{
return GetPrimaryUserData();
}
catch
{
return new UserData(Ident);
}
}
private string GetLogIdentifier()
{
var alias = GetPrimaryAliasOrUidSafe();
return string.Equals(alias, Ident, StringComparison.Ordinal) ? alias : $"{alias} ({Ident})";
}
private Guid EnsurePenumbraCollection()
{
if (!IsVisible)
{
return Guid.Empty;
}
if (_penumbraCollection != Guid.Empty)
{
return _penumbraCollection;
}
lock (_collectionGate)
{
if (_penumbraCollection != Guid.Empty)
{
return _penumbraCollection;
}
var cached = _pairStateCache.TryGetTemporaryCollection(Ident);
if (cached.HasValue && cached.Value != Guid.Empty)
{
_penumbraCollection = cached.Value;
return _penumbraCollection;
}
if (!_ipcManager.Penumbra.APIAvailable)
{
return Guid.Empty;
}
var user = GetPrimaryUserDataSafe();
var uid = !string.IsNullOrEmpty(user.UID) ? user.UID : Ident;
var created = _ipcManager.Penumbra.CreateTemporaryCollectionAsync(Logger, uid)
.ConfigureAwait(false).GetAwaiter().GetResult();
if (created != Guid.Empty)
{
_penumbraCollection = created;
_pairStateCache.StoreTemporaryCollection(Ident, created);
_tempCollectionJanitor.Register(created);
}
return _penumbraCollection;
}
}
private void ResetPenumbraCollection(bool releaseFromPenumbra = true, string? reason = null, bool awaitIpc = true)
{
Guid toRelease = Guid.Empty;
bool hadCollection = false;
lock (_collectionGate)
{
if (_penumbraCollection != Guid.Empty)
{
toRelease = _penumbraCollection;
_penumbraCollection = Guid.Empty;
hadCollection = true;
}
}
var cached = _pairStateCache.ClearTemporaryCollection(Ident);
if (cached.HasValue && cached.Value != Guid.Empty)
{
toRelease = cached.Value;
hadCollection = true;
}
if (hadCollection)
{
_needsCollectionRebuild = true;
_forceFullReapply = true;
_forceApplyMods = true;
_tempCollectionJanitor.Unregister(toRelease);
}
if (!releaseFromPenumbra || toRelease == Guid.Empty || !_ipcManager.Penumbra.APIAvailable)
{
return;
}
var applicationId = Guid.NewGuid();
if (awaitIpc)
{
try
{
Logger.LogTrace("[{applicationId}] Removing temp collection {CollectionId} for {handler} ({reason})", applicationId, toRelease, GetLogIdentifier(), reason ?? "Cleanup");
_ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, toRelease).GetAwaiter().GetResult();
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed to remove temporary Penumbra collection for {handler}", GetLogIdentifier());
}
return;
}
_ = Task.Run(async () =>
{
try
{
Logger.LogTrace("[{applicationId}] Removing temp collection {CollectionId} for {handler} ({reason})", applicationId, toRelease, GetLogIdentifier(), reason ?? "Cleanup");
await _ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, toRelease).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed to remove temporary Penumbra collection for {handler}", GetLogIdentifier());
}
});
}
private bool AnyPair(Func<PairConnection, bool> predicate)
{
return GetCurrentPairs().Any(predicate);
}
private bool 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;
var forceApplyCustomization = forced;
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;
forceApplyCustomization = forced || needsApply;
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<string> inspectedHashes = new(StringComparer.OrdinalIgnoreCase);
foreach (var replacements in characterData.FileReplacements.Values)
{
foreach (var replacement in replacements)
{
if (!string.IsNullOrEmpty(replacement.FileSwapPath))
{
if (Path.IsPathRooted(replacement.FileSwapPath) && !File.Exists(replacement.FileSwapPath))
{
Logger.LogTrace("Missing file swap path {Path} detected for {Handler}", replacement.FileSwapPath, GetLogIdentifier());
return true;
}
continue;
}
if (string.IsNullOrEmpty(replacement.Hash) || !inspectedHashes.Add(replacement.Hash))
{
continue;
}
var cacheEntry = _fileDbManager.GetFileCacheByHash(replacement.Hash);
if (cacheEntry is null)
{
Logger.LogTrace("Missing cached file {Hash} detected for {Handler}", replacement.Hash, GetLogIdentifier());
return true;
}
if (!File.Exists(cacheEntry.ResolvedFilepath))
{
Logger.LogTrace("Cached file {Hash} missing on disk for {Handler}, removing cache entry", replacement.Hash, GetLogIdentifier());
_fileDbManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath);
return true;
}
}
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed to determine cache availability for {Handler}", GetLogIdentifier());
}
return false;
}
private CharacterData? RemoveNotSyncedFiles(CharacterData? data)
{
Logger.LogTrace("Removing not synced files for {Ident}", Ident);
if (data is null)
{
return null;
}
var permissions = GetCombinedPermissions();
bool disableAnimations = permissions.IsDisableAnimations();
bool disableVfx = permissions.IsDisableVFX();
bool disableSounds = permissions.IsDisableSounds();
if (!(disableAnimations || disableVfx || disableSounds))
{
return data;
}
foreach (var objectKind in data.FileReplacements.Keys.ToList())
{
var replacements = data.FileReplacements[objectKind];
if (disableSounds)
{
replacements = replacements
.Where(f => !f.GamePaths.Any(p => p.EndsWith("scd", StringComparison.OrdinalIgnoreCase)))
.ToList();
}
if (disableAnimations)
{
replacements = replacements
.Where(f => !f.GamePaths.Any(p =>
p.EndsWith("tmb", StringComparison.OrdinalIgnoreCase) ||
p.EndsWith("pap", StringComparison.OrdinalIgnoreCase)))
.ToList();
}
if (disableVfx)
{
replacements = replacements
.Where(f => !f.GamePaths.Any(p =>
p.EndsWith("atex", StringComparison.OrdinalIgnoreCase) ||
p.EndsWith("avfx", StringComparison.OrdinalIgnoreCase)))
.ToList();
}
data.FileReplacements[objectKind] = replacements;
}
return data;
}
private bool HasValidCachedModdedPaths()
{
if (_lastAppliedModdedPaths is null || _lastAppliedModdedPaths.Count == 0)
{
return false;
}
foreach (var entry in _lastAppliedModdedPaths)
{
if (string.IsNullOrEmpty(entry.Value) || !File.Exists(entry.Value))
{
Logger.LogDebug("Cached file path {path} missing for {handler}, forcing recalculation", entry.Value ?? "empty", GetLogIdentifier());
return false;
}
}
return true;
}
private bool IsForbiddenHash(string hash)
=> _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, hash, StringComparison.Ordinal));
private static bool IsNonPriorityModPath(string? gamePath)
{
if (string.IsNullOrEmpty(gamePath))
{
return false;
}
var extension = Path.GetExtension(gamePath);
return !string.IsNullOrEmpty(extension) && NonPriorityModExtensions.Contains(extension);
}
private static bool IsCriticalModReplacement(FileReplacementData replacement)
{
foreach (var gamePath in replacement.GamePaths)
{
if (!IsNonPriorityModPath(gamePath))
{
return true;
}
}
return false;
}
private void CountMissingReplacements(IEnumerable<FileReplacementData> missing, out int critical, out int nonCritical, out int forbidden)
{
critical = 0;
nonCritical = 0;
forbidden = 0;
foreach (var replacement in missing)
{
if (IsForbiddenHash(replacement.Hash))
{
forbidden++;
}
if (IsCriticalModReplacement(replacement))
{
critical++;
}
else
{
nonCritical++;
}
}
}
private static void RemoveModApplyChanges(Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData)
{
foreach (var changes in updatedData.Values)
{
changes.Remove(PlayerChanges.ModFiles);
changes.Remove(PlayerChanges.ModManip);
changes.Remove(PlayerChanges.ForcedRedraw);
}
}
private bool CanApplyNow()
{
return !_dalamudUtil.IsInCombat
&& !_dalamudUtil.IsPerforming
&& !_dalamudUtil.IsInInstance
&& !_dalamudUtil.IsInCutscene
&& !_dalamudUtil.IsInGpose
&& _ipcManager.Penumbra.APIAvailable
&& _ipcManager.Glamourer.APIAvailable;
}
private void RecordFailure(string reason, params string[] conditions)
{
_lastFailureReason = reason;
_lastBlockingConditions = conditions.Length == 0 ? Array.Empty<string>() : conditions.ToArray();
}
private void ClearFailureState()
{
_lastFailureReason = null;
_lastBlockingConditions = Array.Empty<string>();
}
private void DeferApplication(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization, UserData user, string reason,
string failureKey, LogLevel logLevel, string logMessage, params object?[] logArgs)
{
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, reason)));
Logger.Log(logLevel, logMessage, logArgs);
RecordFailure(reason, failureKey);
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
SetUploading(false);
}
public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = false, 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 hasDiffMods = characterData.CheckUpdatedData(applicationBase, _cachedData, Logger,
this, forceApplyCustomization, forceApplyMods: false)
.Any(p => p.Value.Contains(PlayerChanges.ModManip) || p.Value.Contains(PlayerChanges.ModFiles));
_forceApplyMods = hasDiffMods || _forceApplyMods || _cachedData == null;
_cachedData = characterData;
_forceFullReapply = true;
Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods);
}
SetUploading(false);
Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, GetLogIdentifier(), forceApplyCustomization, _forceApplyMods);
Logger.LogDebug("[BASE-{appbase}] Hash for data is {newHash}, current cache hash is {oldHash}", applicationBase, characterData.DataHash.Value, _cachedData?.DataHash.Value ?? "NODATA");
if (handlerReady
&& string.Equals(characterData.DataHash.Value, _cachedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal)
&& !forceApplyCustomization && !_forceApplyMods)
{
return;
}
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational,
"Applying Character Data")));
var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this,
forceApplyCustomization, _forceApplyMods, 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<PlayerChanges> changes)
{
if (kind == ObjectKind.Player || changes.Count == 0)
{
return;
}
lock (_ownedRetryGate)
{
_pendingOwnedChanges[kind] = new HashSet<PlayerChanges>(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))
{
return;
}
}
}
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<ObjectKind, HashSet<PlayerChanges>> pending;
lock (_ownedRetryGate)
{
if (_pendingOwnedChanges.Count == 0)
{
return;
}
pending = _pendingOwnedChanges.ToDictionary(kvp => kvp.Key, kvp => new HashSet<PlayerChanges>(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) == false || (_pairDownloadTask?.IsCompleted ?? true) == false)
{
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;
}
bool anyApplied = false;
foreach (var entry in pending)
{
if (!HasAppearanceDataForKind(sanitized, entry.Key))
{
ClearOwnedObjectRetry(entry.Key);
continue;
}
var applied = await ApplyCustomizationDataAsync(Guid.NewGuid(), entry, sanitized, 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 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<ObjectKind, List<FileReplacementData>> item in _cachedData?.FileReplacements ?? [])
{
try
{
RevertCustomizationDataAsync(item.Key, name, applicationId, cts.Token).GetAwaiter().GetResult();
}
catch (InvalidOperationException ex)
{
Logger.LogWarning(ex, "Failed disposing player (not present anymore?)");
break;
}
}
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Error on disposal of {name}", name);
}
finally
{
PlayerName = null;
_cachedData = null;
_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<Guid> 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<bool> ApplyCustomizationDataAsync(Guid applicationId, KeyValuePair<ObjectKind, HashSet<PlayerChanges>> 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 _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetMinionOrMountPtr(ptr), isWatched: false).ConfigureAwait(false),
ObjectKind.Pet => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetPetPtr(ptr), isWatched: false).ConfigureAwait(false),
_ => throw new NotSupportedException("ObjectKind not supported: " + changes.Key)
};
try
{
if (handler.Address == nint.Zero)
{
return 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 tasks = new List<Task>();
bool needsRedraw = false;
foreach (var change in changes.Value.OrderBy(p => (int)p))
{
Logger.LogDebug("[{applicationId}] Processing {change} for {handler}", applicationId, change, handler);
switch (change)
{
case PlayerChanges.Customize:
if (charaData.CustomizePlusData.TryGetValue(changes.Key, out var customizePlusData))
{
tasks.Add(ApplyCustomizeAsync(handler.Address, customizePlusData, changes.Key));
}
else if (_customizeIds.TryGetValue(changes.Key, out var customizeId))
{
tasks.Add(RevertCustomizeAsync(customizeId, changes.Key));
}
break;
case PlayerChanges.Heels:
tasks.Add(_ipcManager.Heels.SetOffsetForPlayerAsync(handler.Address, charaData.HeelsData));
break;
case PlayerChanges.Honorific:
tasks.Add(_ipcManager.Honorific.SetTitleAsync(handler.Address, charaData.HonorificData));
break;
case PlayerChanges.Glamourer:
if (charaData.GlamourerData.TryGetValue(changes.Key, out var glamourerData))
{
tasks.Add(_ipcManager.Glamourer.ApplyAllAsync(Logger, handler, glamourerData, applicationId, token));
}
break;
case PlayerChanges.Moodles:
tasks.Add(_ipcManager.Moodles.SetStatusAsync(handler.Address, charaData.MoodlesData));
break;
case PlayerChanges.PetNames:
tasks.Add(_ipcManager.PetNames.SetPlayerData(handler.Address, charaData.PetNamesData));
break;
case PlayerChanges.ForcedRedraw:
needsRedraw = true;
break;
default:
break;
}
token.ThrowIfCancellationRequested();
}
if (tasks.Count > 0)
{
await Task.WhenAll(tasks).ConfigureAwait(false);
}
if (needsRedraw)
{
await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false);
}
return true;
}
finally
{
if (handler != _charaHandler) handler.Dispose();
}
}
private static Dictionary<ObjectKind, HashSet<PlayerChanges>> BuildFullChangeSet(CharacterData characterData)
{
var result = new Dictionary<ObjectKind, HashSet<PlayerChanges>>();
foreach (var objectKind in Enum.GetValues<ObjectKind>())
{
var changes = new HashSet<PlayerChanges>();
if (characterData.FileReplacements.TryGetValue(objectKind, out var replacements) && replacements.Count > 0)
{
changes.Add(PlayerChanges.ModFiles);
if (objectKind == ObjectKind.Player)
{
changes.Add(PlayerChanges.ForcedRedraw);
}
}
if (characterData.GlamourerData.TryGetValue(objectKind, out var glamourer) && !string.IsNullOrEmpty(glamourer))
{
changes.Add(PlayerChanges.Glamourer);
}
if (characterData.CustomizePlusData.TryGetValue(objectKind, out var customize) && !string.IsNullOrEmpty(customize))
{
changes.Add(PlayerChanges.Customize);
}
if (objectKind == ObjectKind.Player)
{
if (!string.IsNullOrEmpty(characterData.ManipulationData))
{
changes.Add(PlayerChanges.ModManip);
changes.Add(PlayerChanges.ForcedRedraw);
}
if (!string.IsNullOrEmpty(characterData.HeelsData))
{
changes.Add(PlayerChanges.Heels);
}
if (!string.IsNullOrEmpty(characterData.HonorificData))
{
changes.Add(PlayerChanges.Honorific);
}
if (!string.IsNullOrEmpty(characterData.MoodlesData))
{
changes.Add(PlayerChanges.Moodles);
}
if (!string.IsNullOrEmpty(characterData.PetNamesData))
{
changes.Add(PlayerChanges.PetNames);
}
}
if (changes.Count > 0)
{
result[objectKind] = changes;
}
}
return result;
}
private static bool PlayerModFilesChanged(CharacterData newData, CharacterData? previousData)
{
return !FileReplacementListsEqual(
TryGetFileReplacementList(newData, ObjectKind.Player),
TryGetFileReplacementList(previousData, ObjectKind.Player));
}
private static IReadOnlyCollection<FileReplacementData>? TryGetFileReplacementList(CharacterData? data, ObjectKind objectKind)
{
if (data is null)
{
return null;
}
return data.FileReplacements.TryGetValue(objectKind, out var list) ? list : null;
}
private static bool FileReplacementListsEqual(IReadOnlyCollection<FileReplacementData>? left, IReadOnlyCollection<FileReplacementData>? right)
{
if (left is null || left.Count == 0)
{
return right is null || right.Count == 0;
}
if (right is null || right.Count == 0)
{
return false;
}
var comparer = FileReplacementDataComparer.Instance;
return !left.Except(right, comparer).Any() && !right.Except(left, comparer).Any();
}
private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData, bool forceFullReapply)
{
if (!updatedData.Any())
{
if (forceFullReapply)
{
updatedData = BuildFullChangeSet(charaData);
}
if (!updatedData.Any())
{
Logger.LogDebug("[BASE-{appBase}] Nothing to update for {obj}", applicationBase, GetLogIdentifier());
_forceFullReapply = false;
return;
}
}
var updateModdedPaths = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModFiles));
var updateManip = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModManip));
var needsCollectionRebuild = _needsCollectionRebuild;
var reuseCachedModdedPaths = !updateModdedPaths && needsCollectionRebuild && _lastAppliedModdedPaths is not null;
updateModdedPaths = updateModdedPaths || needsCollectionRebuild;
updateManip = updateManip || needsCollectionRebuild;
Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths = null;
if (reuseCachedModdedPaths)
{
if (HasValidCachedModdedPaths())
{
cachedModdedPaths = _lastAppliedModdedPaths;
}
else
{
Logger.LogDebug("{handler}: Cached files missing, recalculating mappings", GetLogIdentifier());
_lastAppliedModdedPaths = null;
}
}
_downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource();
var downloadToken = _downloadCancellationTokenSource.Token;
_ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, cachedModdedPaths, downloadToken)
.ConfigureAwait(false);
}
private Task? _pairDownloadTask;
private Task _visibilityGraceTask;
private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData,
bool updateModdedPaths, bool updateManip, Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths, CancellationToken downloadToken)
{
var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false);
try
{
bool skipDownscaleForPair = ShouldSkipDownscale();
bool skipDecimationForPair = ShouldSkipDecimation();
var user = GetPrimaryUserData();
Dictionary<(string GamePath, string? Hash), string> moddedPaths;
List<FileReplacementData> missingReplacements = [];
if (updateModdedPaths)
{
if (cachedModdedPaths is not null)
{
moddedPaths = new Dictionary<(string GamePath, string? Hash), string>(cachedModdedPaths, cachedModdedPaths.Comparer);
}
else
{
int attempts = 0;
List<FileReplacementData> toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken);
missingReplacements = toDownloadReplacements;
while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested)
{
if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted)
{
Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData);
await _pairDownloadTask.ConfigureAwait(false);
}
Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData);
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational,
$"Starting download for {toDownloadReplacements.Count} files")));
var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false);
if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles))
{
RecordFailure("Auto pause triggered by VRAM usage thresholds", "VRAMThreshold");
_downloadManager.ClearDownload();
return;
}
var handlerForDownload = _charaHandler;
_pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, downloadToken, skipDownscaleForPair, skipDecimationForPair).ConfigureAwait(false));
await _pairDownloadTask.ConfigureAwait(false);
if (downloadToken.IsCancellationRequested)
{
Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase);
RecordFailure("Download cancelled", "Cancellation");
return;
}
if (!skipDownscaleForPair)
{
var downloadedTextureHashes = toDownloadReplacements
.Where(static replacement => replacement.GamePaths.Any(static path => path.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)))
.Select(static replacement => replacement.Hash)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (downloadedTextureHashes.Count > 0)
{
await _textureDownscaleService.WaitForPendingJobsAsync(downloadedTextureHashes, downloadToken).ConfigureAwait(false);
}
}
if (!skipDecimationForPair)
{
var downloadedModelHashes = toDownloadReplacements
.Where(static replacement => replacement.GamePaths.Any(static path => path.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)))
.Select(static replacement => replacement.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.Any(replacement => !IsForbiddenHash(replacement.Hash));
var hasDownloadableCriticalMissing = hasCriticalMissing
&& missingReplacements.Any(replacement => !IsForbiddenHash(replacement.Hash) && IsCriticalModReplacement(replacement));
pendingModReapply = hasDownloadableMissing;
_lastModApplyDeferred = false;
if (hasDownloadableCriticalMissing)
{
deferModApply = true;
_lastModApplyDeferred = true;
Logger.LogDebug("[BASE-{appBase}] Critical mod files missing for {handler}, deferring mod apply ({count} missing)",
applicationBase, GetLogIdentifier(), missingReplacements.Count);
}
else if (hasNonCriticalMissing && hasDownloadableMissing)
{
Logger.LogDebug("[BASE-{appBase}] Non-critical mod files missing for {handler}, applying partial mods and reapplying after downloads ({count} missing)",
applicationBase, GetLogIdentifier(), missingReplacements.Count);
}
}
else
{
_lastMissingCriticalMods = 0;
_lastMissingNonCriticalMods = 0;
_lastMissingForbiddenMods = 0;
_lastModApplyDeferred = false;
}
if (deferModApply)
{
updateModdedPaths = false;
updateManip = false;
RemoveModApplyChanges(updatedData);
}
downloadToken.ThrowIfCancellationRequested();
var handlerForApply = _charaHandler;
if (handlerForApply is null || handlerForApply.Address == nint.Zero)
{
Logger.LogDebug("[BASE-{appBase}] Handler not available for {player}, cached data for later application", applicationBase, GetLogIdentifier());
_cachedData = charaData;
_pairStateCache.Store(Ident, charaData);
_forceFullReapply = true;
RecordFailure("Handler not available for application", "HandlerUnavailable");
return;
}
var appToken = _applicationCancellationTokenSource?.Token;
while ((!_applicationTask?.IsCompleted ?? false)
&& !downloadToken.IsCancellationRequested
&& (!appToken?.IsCancellationRequested ?? false))
{
Logger.LogDebug("[BASE-{appBase}] Waiting for current data application (Id: {id}) for player ({handler}) to finish",
applicationBase, _applicationId, PlayerName);
await Task.Delay(250).ConfigureAwait(false);
}
if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false))
{
_forceFullReapply = true;
RecordFailure("Application cancelled", "Cancellation");
return;
}
_applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource();
var token = _applicationCancellationTokenSource.Token;
_applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, wantsModApply, pendingModReapply, token);
}
finally
{
await concurrencyLease.DisposeAsync().ConfigureAwait(false);
}
}
private async Task ApplyCharacterDataAsync(Guid applicationBase, GameObjectHandler handlerForApply, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData, bool updateModdedPaths, bool updateManip,
Dictionary<(string GamePath, string? Hash), string> moddedPaths, bool wantsModApply, bool pendingModReapply, CancellationToken token)
{
try
{
_applicationId = Guid.NewGuid();
Logger.LogDebug("[BASE-{applicationId}] Starting application task for {handler}: {appId}", applicationBase, GetLogIdentifier(), _applicationId);
Logger.LogDebug("[{applicationId}] Waiting for initial draw for for {handler}", _applicationId, handlerForApply);
await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handlerForApply, _applicationId, 30000, token).ConfigureAwait(false);
if (handlerForApply.Address != nint.Zero)
{
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();
Guid penumbraCollection = Guid.Empty;
if (updateModdedPaths || updateManip)
{
penumbraCollection = EnsurePenumbraCollection();
if (penumbraCollection == Guid.Empty)
{
Logger.LogTrace("[BASE-{applicationId}] Penumbra collection unavailable for {handler}, caching data for later application", applicationBase, GetLogIdentifier());
_cachedData = charaData;
_pairStateCache.Store(Ident, charaData);
_forceFullReapply = true;
RecordFailure("Penumbra collection unavailable", "PenumbraUnavailable");
return;
}
}
if (updateModdedPaths)
{
// ensure collection is set
var objIndex = await _dalamudUtil.RunOnFrameworkThread(() =>
{
var gameObject = handlerForApply.GetGameObject();
return gameObject?.ObjectIndex;
}).ConfigureAwait(false);
if (!objIndex.HasValue)
{
Logger.LogDebug("[BASE-{applicationId}] GameObject not available for {handler}, caching data for later application", applicationBase, GetLogIdentifier());
_cachedData = charaData;
_pairStateCache.Store(Ident, charaData);
_forceFullReapply = true;
RecordFailure("Game object not available for application", "GameObjectUnavailable");
return;
}
SplitPapMappings(moddedPaths, out var withoutPap, out var papOnly);
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false);
await _ipcManager.Penumbra.SetTemporaryModsAsync(
Logger, _applicationId, penumbraCollection,
withoutPap.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal))
.ConfigureAwait(false);
await _ipcManager.Penumbra.RedrawAsync(Logger, handlerForApply, _applicationId, token).ConfigureAwait(false);
if (handlerForApply.Address != nint.Zero)
await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false);
var removedPap = await StripIncompatiblePapAsync(handlerForApply, charaData, papOnly, token).ConfigureAwait(false);
if (removedPap > 0)
{
Logger.LogTrace("[{applicationId}] Removed {removedPap} incompatible PAP mappings found for {handler}", _applicationId, removedPap, GetLogIdentifier());
}
var merged = new Dictionary<(string GamePath, string? Hash), string>(withoutPap, withoutPap.Comparer);
foreach (var kv in papOnly)
merged[kv.Key] = kv.Value;
await _ipcManager.Penumbra.SetTemporaryModsAsync(
Logger, _applicationId, penumbraCollection,
merged.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal))
.ConfigureAwait(false);
_lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(merged, merged.Comparer);
LastAppliedDataBytes = -1;
foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists))
{
if (LastAppliedDataBytes == -1) LastAppliedDataBytes = 0;
LastAppliedDataBytes += path.Length;
}
}
if (updateManip)
{
await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, _applicationId, penumbraCollection, charaData.ManipulationData).ConfigureAwait(false);
}
token.ThrowIfCancellationRequested();
foreach (var kind in updatedData)
{
var applied = await ApplyCustomizationDataAsync(_applicationId, kind, charaData, token).ConfigureAwait(false);
if (applied)
{
ClearOwnedObjectRetry(kind.Key);
}
else if (kind.Key != ObjectKind.Player)
{
ScheduleOwnedObjectRetry(kind.Key, kind.Value);
}
token.ThrowIfCancellationRequested();
}
_cachedData = charaData;
_pairStateCache.Store(Ident, charaData);
if (wantsModApply)
{
_pendingModReapply = pendingModReapply;
}
_forceFullReapply = _pendingModReapply;
_needsCollectionRebuild = false;
if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0)
{
_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List<DownloadFileTransfer>());
}
if (LastAppliedDataTris < 0 || LastAppliedApproximateEffectiveTris < 0)
{
await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(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)
{
if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException))
{
IsVisible = false;
_forceApplyMods = true;
_cachedData = charaData;
_pairStateCache.Store(Ident, charaData);
_forceFullReapply = true;
Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId);
}
else
{
Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId);
_forceFullReapply = true;
}
RecordFailure($"Application failed: {ex.Message}", "Exception");
}
}
private void FrameworkUpdate()
{
if (string.IsNullOrEmpty(PlayerName) && _charaHandler is null)
{
var now = DateTime.UtcNow;
if (now < _nextActorLookupUtc)
{
return;
}
_nextActorLookupUtc = now + ActorLookupInterval;
var pc = _dalamudUtil.FindPlayerByNameHash(Ident);
if (pc == default((string, nint))) return;
Logger.LogDebug("One-Time Initializing {handler}", GetLogIdentifier());
Initialize(pc.Name);
Logger.LogDebug("One-Time Initialized {handler}", GetLogIdentifier());
Mediator.Publish(new EventMessage(new Event(PlayerName, GetPrimaryUserData(), nameof(PairHandlerAdapter), EventSeverity.Informational,
$"Initializing User For Character {pc.Name}")));
}
TryHandleVisibilityUpdate();
}
private void TryHandleVisibilityUpdate()
{
if (_charaHandler?.Address != nint.Zero && !IsVisible && !_pauseRequested)
{
Guid appData = Guid.NewGuid();
IsVisible = true;
if (_cachedData is not null)
{
var cachedData = _cachedData;
Logger.LogTrace("[BASE-{appBase}] {handler} visibility changed, now: {visi}, cached data exists", appData, GetLogIdentifier(), IsVisible);
_ = Task.Run(() =>
{
try
{
_forceFullReapply = true;
ApplyCharacterData(appData, cachedData!, forceApplyCustomization: true);
}
catch (Exception ex)
{
Logger.LogError(ex, "[BASE-{appBase}] Failed to apply cached character data for {handler}", appData, GetLogIdentifier());
}
});
}
else if (LastReceivedCharacterData is not null)
{
Logger.LogTrace("[BASE-{appBase}] {handler} visibility changed, now: {visi}, last received data exists", appData, GetLogIdentifier(), IsVisible);
_ = Task.Run(() =>
{
try
{
_forceFullReapply = true;
ApplyLastReceivedData(forced: true);
}
catch (Exception ex)
{
Logger.LogError(ex, "[BASE-{appBase}] Failed to reapply last received data for {handler}", appData, GetLogIdentifier());
}
});
}
else
{
Logger.LogTrace("{handler} visibility changed, now: {visi}, no cached or received data exists", GetLogIdentifier(), IsVisible);
}
}
else if (_charaHandler?.Address == nint.Zero && IsVisible)
{
HandleVisibilityLoss(logChange: true);
}
TryApplyQueuedData();
}
private void HandleVisibilityLoss(bool logChange)
{
IsVisible = false;
_charaHandler?.Invalidate();
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<HonorificReadyMessage>(this, _message =>
{
var honorificData = _cachedData?.HonorificData;
if (string.IsNullOrEmpty(honorificData))
return;
_ = ReapplyHonorificAsync(honorificData!);
});
Mediator.Subscribe<PetNamesReadyMessage>(this, _message =>
{
var petNamesData = _cachedData?.PetNamesData;
if (string.IsNullOrEmpty(petNamesData))
return;
_ = ReapplyPetNamesAsync(petNamesData!);
});
}
private async Task ReapplyHonorificAsync(string honorificData)
{
Logger.LogTrace("Reapplying Honorific data for {handler}", GetLogIdentifier());
await _ipcManager.Honorific.SetTitleAsync(PlayerCharacter, honorificData).ConfigureAwait(false);
}
private async Task ReapplyPetNamesAsync(string petNamesData)
{
Logger.LogTrace("Reapplying Pet Names data for {handler}", GetLogIdentifier());
await _ipcManager.PetNames.SetPlayerData(PlayerCharacter, petNamesData).ConfigureAwait(false);
}
private async Task RevertCustomizationDataAsync(ObjectKind objectKind, string name, Guid applicationId, CancellationToken cancelToken)
{
nint address = _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Ident);
if (address == nint.Zero) return;
var alias = GetPrimaryAliasOrUid();
Logger.LogDebug("[{applicationId}] Reverting all Customization for {alias}/{name} {objectKind}", applicationId, alias, name, objectKind);
if (_customizeIds.TryGetValue(objectKind, out var customizeId))
{
_customizeIds.Remove(objectKind);
}
if (objectKind == ObjectKind.Player)
{
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Player, () => address, isWatched: false).ConfigureAwait(false);
tempHandler.CompareNameAndThrow(name);
Logger.LogDebug("[{applicationId}] Restoring Customization and Equipment for {alias}/{name}", applicationId, alias, name);
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
tempHandler.CompareNameAndThrow(name);
Logger.LogDebug("[{applicationId}] Restoring Heels for {alias}/{name}", applicationId, alias, name);
await _ipcManager.Heels.RestoreOffsetForPlayerAsync(address).ConfigureAwait(false);
tempHandler.CompareNameAndThrow(name);
Logger.LogDebug("[{applicationId}] Restoring C+ for {alias}/{name}", applicationId, alias, name);
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
tempHandler.CompareNameAndThrow(name);
Logger.LogDebug("[{applicationId}] Restoring Honorific for {alias}/{name}", applicationId, alias, name);
await _ipcManager.Honorific.ClearTitleAsync(address).ConfigureAwait(false);
Logger.LogDebug("[{applicationId}] Restoring Moodles for {alias}/{name}", applicationId, alias, name);
await _ipcManager.Moodles.RevertStatusAsync(address).ConfigureAwait(false);
Logger.LogDebug("[{applicationId}] Restoring Pet Nicknames for {alias}/{name}", applicationId, alias, name);
await _ipcManager.PetNames.ClearPlayerData(address).ConfigureAwait(false);
}
else if (objectKind == ObjectKind.MinionOrMount)
{
var minionOrMount = await _dalamudUtil.GetMinionOrMountAsync(address).ConfigureAwait(false);
if (minionOrMount != nint.Zero)
{
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.MinionOrMount, () => minionOrMount, isWatched: false).ConfigureAwait(false);
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
}
}
else if (objectKind == ObjectKind.Pet)
{
var pet = await _dalamudUtil.GetPetAsync(address).ConfigureAwait(false);
if (pet != nint.Zero)
{
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Pet, () => pet, isWatched: false).ConfigureAwait(false);
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
}
}
else if (objectKind == ObjectKind.Companion)
{
var companion = await _dalamudUtil.GetCompanionAsync(address).ConfigureAwait(false);
if (companion != nint.Zero)
{
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Companion, () => companion, isWatched: false).ConfigureAwait(false);
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
}
}
}
private List<FileReplacementData> TryCalculateModdedDictionary(Guid applicationBase, CharacterData charaData, out Dictionary<(string GamePath, string? Hash), string> moddedDictionary, CancellationToken token)
{
Stopwatch st = Stopwatch.StartNew();
ConcurrentBag<FileReplacementData> missingFiles = [];
moddedDictionary = [];
ConcurrentDictionary<(string GamePath, string? Hash), string> outputDict = new();
bool hasMigrationChanges = false;
bool skipDownscaleForPair = ShouldSkipDownscale();
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 != null)
{
if (string.IsNullOrEmpty(new FileInfo(fileCache.ResolvedFilepath).Extension))
{
hasMigrationChanges = true;
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))
{
hasMigrationChanges = true;
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 = skipDownscaleForPair
? fileCache.ResolvedFilepath
: _textureDownscaleService.GetPreferredPath(item.Hash, fileCache.ResolvedFilepath);
outputDict[(gamePath, item.Hash)] = preferredPath;
}
}
else
{
Logger.LogTrace("Missing file: {hash}", item.Hash);
missingFiles.Add(item);
}
});
moddedDictionary = outputDict.ToDictionary(k => k.Key, k => k.Value);
foreach (var item in charaData.FileReplacements.SelectMany(k => k.Value.Where(v => !string.IsNullOrEmpty(v.FileSwapPath))).ToList())
{
foreach (var gamePath in item.GamePaths)
{
Logger.LogTrace("[BASE-{appBase}] Adding file swap for {path}: {fileSwap}", applicationBase, gamePath, item.FileSwapPath);
moddedDictionary[(gamePath, null)] = item.FileSwapPath;
}
}
}
catch (OperationCanceledException)
{
Logger.LogTrace("[BASE-{appBase}] Modded path calculation cancelled", applicationBase);
throw;
}
catch (Exception ex)
{
Logger.LogError(ex, "[BASE-{appBase}] Something went wrong during calculation replacements", applicationBase);
}
if (hasMigrationChanges) _fileDbManager.WriteOutFullCsv();
st.Stop();
Logger.LogDebug("[BASE-{appBase}] ModdedPaths calculated in {time}ms, missing files: {count}, total files: {total}", applicationBase, st.ElapsedMilliseconds, missingFiles.Count, moddedDictionary.Keys.Count);
return [.. missingFiles];
}
private async Task PauseInternalAsync()
{
try
{
Logger.LogDebug("Pausing handler {handler}", GetLogIdentifier());
DisableSync();
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
{
IsVisible = false;
return;
}
var applicationId = Guid.NewGuid();
await RevertToRestoredAsync(applicationId).ConfigureAwait(false);
IsVisible = false;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to pause handler {handler}", GetLogIdentifier());
}
}
private async Task ResumeInternalAsync()
{
try
{
Logger.LogDebug("Resuming handler {handler}", GetLogIdentifier());
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
{
return;
}
if (!IsVisible)
{
IsVisible = true;
}
if (LastReceivedCharacterData is not null)
{
ApplyLastReceivedData(forced: true);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to resume handler {handler}", GetLogIdentifier());
}
}
private async Task RevertToRestoredAsync(Guid applicationId)
{
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
{
return;
}
try
{
var gameObject = await _dalamudUtil.RunOnFrameworkThread(() => _charaHandler.GetGameObject()).ConfigureAwait(false);
if (gameObject is not Dalamud.Game.ClientState.Objects.Types.ICharacter character)
{
return;
}
if (_ipcManager.Penumbra.APIAvailable)
{
var penumbraCollection = EnsurePenumbraCollection();
if (penumbraCollection != Guid.Empty)
{
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, character.ObjectIndex).ConfigureAwait(false);
await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, applicationId, penumbraCollection, new Dictionary<string, string>(StringComparer.Ordinal)).ConfigureAwait(false);
await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, applicationId, penumbraCollection, string.Empty).ConfigureAwait(false);
}
}
var kinds = new HashSet<ObjectKind>(_customizeIds.Keys);
if (_cachedData is not null)
{
foreach (var kind in _cachedData.FileReplacements.Keys)
{
kinds.Add(kind);
}
}
kinds.Add(ObjectKind.Player);
var characterName = character.Name.TextValue;
if (string.IsNullOrEmpty(characterName))
{
characterName = character.Name.ToString();
}
if (string.IsNullOrEmpty(characterName))
{
Logger.LogWarning("[{applicationId}] Failed to determine character name for {handler} while reverting", applicationId, GetLogIdentifier());
return;
}
foreach (var kind in kinds)
{
await RevertCustomizationDataAsync(kind, characterName, applicationId, CancellationToken.None).ConfigureAwait(false);
}
_cachedData = null;
LastAppliedDataBytes = -1;
LastAppliedDataTris = -1;
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))
return;
if (!string.Equals(hashedCid, Ident, StringComparison.Ordinal))
return;
if (descriptor.Address == nint.Zero)
return;
UpdateLastKnownActor(descriptor);
RefreshTrackedHandler(descriptor);
QueueActorInitialization(descriptor);
}
private void QueueActorInitialization(ActorObjectService.ActorDescriptor descriptor)
{
lock (_actorInitializationGate)
{
_pendingActorDescriptor = descriptor;
if (_actorInitializationInProgress)
{
return;
}
_actorInitializationInProgress = true;
}
_ = Task.Run(InitializeFromTrackedAsync);
}
private async Task InitializeFromTrackedAsync()
{
try
{
await ActorInitializationLimiter.WaitAsync().ConfigureAwait(false);
while (true)
{
ActorObjectService.ActorDescriptor? descriptor;
lock (_actorInitializationGate)
{
descriptor = _pendingActorDescriptor;
_pendingActorDescriptor = null;
}
if (!descriptor.HasValue)
{
break;
}
if (_frameworkUpdateSubscribed && _actorObjectService.HooksActive)
{
Mediator.Unsubscribe<FrameworkUpdateMessage>(this);
_frameworkUpdateSubscribed = false;
}
if (string.IsNullOrEmpty(PlayerName) || _charaHandler is null)
{
Logger.LogDebug("Actor tracked for {handler}, initializing from hook", GetLogIdentifier());
Initialize(descriptor.Value.Name);
Mediator.Publish(new EventMessage(new Event(PlayerName, GetPrimaryUserData(), nameof(PairHandlerAdapter), EventSeverity.Informational,
$"Initializing User For Character {descriptor.Value.Name}")));
}
RefreshTrackedHandler(descriptor.Value);
TryHandleVisibilityUpdate();
}
}
finally
{
ActorInitializationLimiter.Release();
lock (_actorInitializationGate)
{
_actorInitializationInProgress = false;
if (_pendingActorDescriptor.HasValue)
{
_actorInitializationInProgress = true;
_ = Task.Run(InitializeFromTrackedAsync);
}
}
}
}
private void RefreshTrackedHandler(ActorObjectService.ActorDescriptor descriptor)
{
if (_charaHandler is null)
return;
if (descriptor.Address == nint.Zero)
return;
if (_charaHandler.Address == descriptor.Address)
return;
_charaHandler.Refresh();
}
private void HandleActorUntracked(ActorObjectService.ActorDescriptor descriptor)
{
if (!TryResolveDescriptorHash(descriptor, out var hashedCid))
{
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
return;
if (descriptor.Address != _charaHandler.Address)
return;
}
else if (!string.Equals(hashedCid, Ident, StringComparison.Ordinal))
{
return;
}
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
return;
if (descriptor.Address != _charaHandler.Address)
return;
HandleVisibilityLoss(logChange: false);
}
private 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<int> 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 allownNightIndex = _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<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
foreach (var (rawKey, list) in boneIndices)
{
var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawKey);
if (string.IsNullOrEmpty(key)) continue;
if (!localBoneSets.TryGetValue(key, out var set))
localBoneSets[key] = set = [];
foreach (var v in list)
set.Add(v);
}
int removed = 0;
foreach (var hash in papOnly.Keys.Select(k => k.Hash).Where(h => !string.IsNullOrEmpty(h)).Distinct(StringComparer.OrdinalIgnoreCase).ToList())
{
token.ThrowIfCancellationRequested();
var papIndices = await _dalamudUtil.RunOnFrameworkThread(
() => _modelAnalyzer.GetBoneIndicesFromPap(hash!))
.ConfigureAwait(false);
if (papIndices == null || papIndices.Count == 0)
continue;
if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105))
continue;
if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason))
continue;
var keysToRemove = papOnly.Keys.Where(k => string.Equals(k.Hash, hash, StringComparison.OrdinalIgnoreCase)).ToList();
foreach (var k in keysToRemove)
papOnly.Remove(k);
removed += keysToRemove.Count;
if (_blockedPapHashes.TryAdd(hash!, 0))
Logger.LogWarning("Blocked remote object PAP (hash {hash}) for {handler}: {reason}", 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);
}
}