4039 lines
154 KiB
C#
4039 lines
154 KiB
C#
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;
|
||
|
||
/// <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 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<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 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<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 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<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 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<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 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 uint MinionOrMountCharacterId { get; private set; } = uint.MaxValue;
|
||
public uint PetCharacterId { get; private set; } = uint.MaxValue;
|
||
public uint CompanionCharacterId { get; private set; } = uint.MaxValue;
|
||
public bool MinionPendingRetry
|
||
{
|
||
get
|
||
{
|
||
lock (_ownedRetryGate)
|
||
return _pendingOwnedChanges.ContainsKey(ObjectKind.MinionOrMount);
|
||
}
|
||
}
|
||
|
||
public IReadOnlyList<string> MinionPendingRetryChanges
|
||
{
|
||
get
|
||
{
|
||
lock (_ownedRetryGate)
|
||
{
|
||
if (_pendingOwnedChanges.TryGetValue(ObjectKind.MinionOrMount, out var set))
|
||
return [.. set.Select(static s => s.ToString())];
|
||
|
||
return [];
|
||
}
|
||
}
|
||
}
|
||
|
||
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<PairHandlerAdapter> 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<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 RefreshOwnedTargetIds()
|
||
{
|
||
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
|
||
{
|
||
MinionOrMountCharacterId = uint.MaxValue;
|
||
PetCharacterId = uint.MaxValue;
|
||
CompanionCharacterId = uint.MaxValue;
|
||
return;
|
||
}
|
||
|
||
var playerPtr = _charaHandler.Address;
|
||
|
||
_ = _dalamudUtil.RunOnFrameworkThread(() =>
|
||
{
|
||
try
|
||
{
|
||
var minPtr = _dalamudUtil.GetMinionOrMountPtr(playerPtr);
|
||
var petPtr = _dalamudUtil.GetPetPtr(playerPtr);
|
||
var compPtr = _dalamudUtil.GetCompanionPtr(playerPtr);
|
||
|
||
var minObj = _dalamudUtil.CreateGameObject(minPtr);
|
||
var petObj = _dalamudUtil.CreateGameObject(petPtr);
|
||
var compObj = _dalamudUtil.CreateGameObject(compPtr);
|
||
|
||
MinionOrMountCharacterId = minObj?.EntityId ?? uint.MaxValue;
|
||
PetCharacterId = petObj?.EntityId ?? uint.MaxValue;
|
||
CompanionCharacterId = compObj?.EntityId ?? uint.MaxValue;
|
||
}
|
||
catch
|
||
{
|
||
// don’t let this throw from framework thread
|
||
MinionOrMountCharacterId = uint.MaxValue;
|
||
PetCharacterId = uint.MaxValue;
|
||
CompanionCharacterId = uint.MaxValue;
|
||
}
|
||
});
|
||
}
|
||
|
||
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<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;
|
||
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 void HardReapplyLastData()
|
||
{
|
||
EnsureInitialized();
|
||
|
||
if (LastReceivedCharacterData is null && _cachedData is null)
|
||
return;
|
||
|
||
_ = Task.Run(async () =>
|
||
{
|
||
try
|
||
{
|
||
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
|
||
return;
|
||
|
||
if (!_ipcManager.Penumbra.APIAvailable)
|
||
{
|
||
ApplyLastReceivedData(forced: true);
|
||
return;
|
||
}
|
||
|
||
_needsCollectionRebuild = true;
|
||
_lastAppliedModdedPaths = null;
|
||
_forceApplyMods = true;
|
||
_forceFullReapply = true;
|
||
|
||
var flushId = Guid.NewGuid();
|
||
|
||
var playerCollection = EnsurePenumbraCollection();
|
||
if (playerCollection != Guid.Empty)
|
||
{
|
||
var objIndex = await _dalamudUtil.RunOnFrameworkThread(() =>
|
||
_charaHandler.GetGameObject()?.ObjectIndex).ConfigureAwait(false);
|
||
|
||
if (objIndex.HasValue)
|
||
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, playerCollection, objIndex.Value)
|
||
.ConfigureAwait(false);
|
||
|
||
await _ipcManager.Penumbra.SetTemporaryModsAsync(
|
||
Logger, flushId, playerCollection,
|
||
new Dictionary<string, string>(StringComparer.Ordinal),
|
||
scope: "Player")
|
||
.ConfigureAwait(false);
|
||
|
||
await _ipcManager.Penumbra.SetManipulationDataAsync(
|
||
Logger, flushId, playerCollection, string.Empty)
|
||
.ConfigureAwait(false);
|
||
|
||
await _ipcManager.Penumbra.RedrawAsync(Logger, _charaHandler, flushId, CancellationToken.None)
|
||
.ConfigureAwait(false);
|
||
}
|
||
|
||
var ownedCollection = EnsureOwnedPenumbraCollection();
|
||
if (ownedCollection != Guid.Empty)
|
||
{
|
||
await _ipcManager.Penumbra.SetTemporaryModsAsync(
|
||
Logger, flushId, ownedCollection,
|
||
new Dictionary<string, string>(StringComparer.Ordinal),
|
||
scope: "Owned")
|
||
.ConfigureAwait(false);
|
||
}
|
||
|
||
ApplyLastReceivedData(forced: true);
|
||
await Task.Delay(900).ConfigureAwait(false);
|
||
ApplyLastReceivedData(forced: true);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning(ex, "Hard reapply failed for {handler}", GetLogIdentifier());
|
||
}
|
||
});
|
||
}
|
||
private static readonly TimeSpan FileReadyTimeout = TimeSpan.FromSeconds(8);
|
||
private static readonly TimeSpan FileReadyPoll = TimeSpan.FromMilliseconds(75);
|
||
|
||
private static bool IsCriticalVisualPath(string gamePath)
|
||
=> gamePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)
|
||
|| gamePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)
|
||
|| gamePath.EndsWith(".mtrl", StringComparison.OrdinalIgnoreCase);
|
||
|
||
private static async Task WaitForFilesReadyAsync(
|
||
ILogger logger,
|
||
Guid appId,
|
||
IEnumerable<(string GamePath, string FilePath)> entries,
|
||
CancellationToken token)
|
||
{
|
||
var list = entries
|
||
.Where(e => !string.IsNullOrEmpty(e.FilePath) && Path.IsPathRooted(e.FilePath))
|
||
.Select(e => (e.GamePath, e.FilePath))
|
||
.DistinctBy(e => e.FilePath, StringComparer.OrdinalIgnoreCase)
|
||
.Take(200)
|
||
.ToList();
|
||
|
||
foreach (var (gamePath, filePath) in list)
|
||
{
|
||
token.ThrowIfCancellationRequested();
|
||
|
||
var sw = Stopwatch.StartNew();
|
||
while (sw.Elapsed < FileReadyTimeout && !token.IsCancellationRequested)
|
||
{
|
||
try
|
||
{
|
||
using var fs = new FileStream(
|
||
filePath,
|
||
FileMode.Open,
|
||
FileAccess.Read,
|
||
FileShare.ReadWrite | FileShare.Delete);
|
||
|
||
if (fs.Length > 0)
|
||
break;
|
||
}
|
||
catch (IOException)
|
||
{
|
||
// locked or being swapped
|
||
}
|
||
catch (UnauthorizedAccessException)
|
||
{
|
||
// transient access issues, treat like locked
|
||
}
|
||
|
||
await Task.Delay(FileReadyPoll, token).ConfigureAwait(false);
|
||
}
|
||
|
||
if (sw.Elapsed >= FileReadyTimeout)
|
||
{
|
||
logger.LogDebug(
|
||
"[{appId}] File still not ready after {ms}ms: {gamePath} -> {filePath}",
|
||
appId, (int)sw.Elapsed.TotalMilliseconds, gamePath, filePath);
|
||
}
|
||
}
|
||
}
|
||
|
||
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 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<PlayerChanges> 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<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) || !(_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<string>(StringComparer.OrdinalIgnoreCase);
|
||
var ownedFileSwaps = new Dictionary<string, string>(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<string, string>(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<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 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<Task>();
|
||
|
||
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<GameObjectHandler> 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<bool> 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<IReadOnlyList<nint>> 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<nint>();
|
||
|
||
var objIndex = playerHandler.GetGameObject()?.ObjectIndex ?? (ushort)0;
|
||
|
||
return _actorObjectService.GetMinionOrMountCandidates(ownerEntityId, objIndex);
|
||
}).ConfigureAwait(false);
|
||
}
|
||
|
||
private async Task<nint> 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<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;
|
||
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<PlayerChanges>(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<nint> 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<nint> 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<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 {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<string>(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;
|
||
|
||
await WaitForFilesReadyAsync(
|
||
Logger,
|
||
_applicationId,
|
||
merged.Select(kv => (kv.Key.GamePath, kv.Value))
|
||
.Where(x => IsCriticalVisualPath(x.GamePath)),
|
||
token)
|
||
.ConfigureAwait(false);
|
||
|
||
await _ipcManager.Penumbra.SetTemporaryModsAsync(
|
||
Logger, _applicationId, playerCollection,
|
||
merged.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal),
|
||
scope: "Player")
|
||
.ConfigureAwait(false);
|
||
|
||
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<string>(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 WaitForFilesReadyAsync(
|
||
Logger,
|
||
_applicationId,
|
||
ownedModded.Select(kv => (kv.Key.GamePath, kv.Value)).Where(x => IsCriticalVisualPath(x.GamePath)),
|
||
token).ConfigureAwait(false);
|
||
|
||
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<PlayerChanges> BuildOwnedChanges(CharacterData d, ObjectKind k)
|
||
{
|
||
var set = new HashSet<PlayerChanges>();
|
||
|
||
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);
|
||
}
|
||
|
||
if (_charaHandler?.Address != nint.Zero && IsVisible)
|
||
RefreshOwnedTargetIds();
|
||
|
||
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)
|
||
{
|
||
// 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<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();
|
||
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<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)
|
||
&& string.Equals(hashedCid, Ident, StringComparison.Ordinal))
|
||
{
|
||
if (descriptor.Address == nint.Zero)
|
||
return;
|
||
|
||
UpdateLastKnownActor(descriptor);
|
||
RefreshTrackedHandler(descriptor);
|
||
QueueActorInitialization(descriptor);
|
||
|
||
RefreshOwnedTargetIds();
|
||
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<PlayerChanges> BuildOwnedChangeSetForKind(CharacterData data, ObjectKind kind)
|
||
{
|
||
var changes = new HashSet<PlayerChanges>();
|
||
|
||
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<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 (!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<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 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<string, HashSet<ushort>>(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<ushort>();
|
||
|
||
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<string, List<ushort>>? 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);
|
||
}
|
||
}
|