3001 lines
117 KiB
C#
3001 lines
117 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using Dalamud.Plugin.Services;
|
|
using LightlessSync.API.Data;
|
|
using LightlessSync.API.Data.Enum;
|
|
using LightlessSync.API.Data.Extensions;
|
|
using LightlessSync.FileCache;
|
|
using LightlessSync.Interop.Ipc;
|
|
using LightlessSync.LightlessConfiguration;
|
|
using LightlessSync.PlayerData.Factories;
|
|
using LightlessSync.PlayerData.Handlers;
|
|
using LightlessSync.Services;
|
|
using LightlessSync.Services.ActorTracking;
|
|
using LightlessSync.Services.Events;
|
|
using LightlessSync.Services.Mediator;
|
|
using LightlessSync.Services.ModelDecimation;
|
|
using LightlessSync.Services.PairProcessing;
|
|
using LightlessSync.Services.ServerConfiguration;
|
|
using LightlessSync.Services.TextureCompression;
|
|
using LightlessSync.Utils;
|
|
using LightlessSync.WebAPI.Files;
|
|
using LightlessSync.WebAPI.Files.Models;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
|
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
|
using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer;
|
|
|
|
namespace LightlessSync.PlayerData.Pairs;
|
|
|
|
/// <summary>
|
|
/// handles lifecycle, visibility, queued data, character data for a paired user
|
|
/// </summary>
|
|
internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPairHandlerAdapter
|
|
{
|
|
private sealed record CombatData(Guid ApplicationId, CharacterData CharacterData, bool Forced);
|
|
|
|
private readonly DalamudUtilService _dalamudUtil;
|
|
private readonly ActorObjectService _actorObjectService;
|
|
private readonly FileDownloadManager _downloadManager;
|
|
private readonly FileCacheManager _fileDbManager;
|
|
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
|
|
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
|
|
private readonly IpcManager _ipcManager;
|
|
private readonly IHostApplicationLifetime _lifetime;
|
|
private readonly PlayerPerformanceService _playerPerformanceService;
|
|
private readonly PairProcessingLimiter _pairProcessingLimiter;
|
|
private readonly ServerConfigurationManager _serverConfigManager;
|
|
private readonly PluginWarningNotificationService _pluginWarningNotificationManager;
|
|
private readonly TextureDownscaleService _textureDownscaleService;
|
|
private readonly ModelDecimationService _modelDecimationService;
|
|
private readonly PairStateCache _pairStateCache;
|
|
private readonly PairPerformanceMetricsCache _performanceMetricsCache;
|
|
private readonly XivDataAnalyzer _modelAnalyzer;
|
|
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
|
|
private readonly LightlessConfigService _configService;
|
|
private readonly PairManager _pairManager;
|
|
private readonly IFramework _framework;
|
|
private CancellationTokenSource? _applicationCancellationTokenSource;
|
|
private Guid _applicationId;
|
|
private Task? _applicationTask;
|
|
private CharacterData? _cachedData = null;
|
|
private GameObjectHandler? _charaHandler;
|
|
private readonly Dictionary<ObjectKind, Guid?> _customizeIds = [];
|
|
private CombatData? _dataReceivedInDowntime;
|
|
private CancellationTokenSource? _downloadCancellationTokenSource;
|
|
private bool _forceApplyMods = false;
|
|
private bool _forceFullReapply;
|
|
private Dictionary<(string GamePath, string? Hash), string>? _lastAppliedModdedPaths;
|
|
private bool _needsCollectionRebuild;
|
|
private bool _pendingModReapply;
|
|
private bool _lastModApplyDeferred;
|
|
private int _lastMissingCriticalMods;
|
|
private int _lastMissingNonCriticalMods;
|
|
private int _lastMissingForbiddenMods;
|
|
private bool _lastMissingCachedFiles;
|
|
private string? _lastSuccessfulDataHash;
|
|
private bool _isVisible;
|
|
private Guid _penumbraCollection;
|
|
private readonly object _collectionGate = new();
|
|
private bool _redrawOnNextApplication = false;
|
|
private readonly object _initializationGate = new();
|
|
private readonly object _pauseLock = new();
|
|
private Task _pauseTransitionTask = Task.CompletedTask;
|
|
private bool _pauseRequested;
|
|
private DateTime? _lastDataReceivedAt;
|
|
private DateTime? _lastApplyAttemptAt;
|
|
private DateTime? _lastSuccessfulApplyAt;
|
|
private string? _lastFailureReason;
|
|
private IReadOnlyList<string> _lastBlockingConditions = Array.Empty<string>();
|
|
private readonly object _visibilityGraceGate = new();
|
|
private CancellationTokenSource? _visibilityGraceCts;
|
|
private static readonly TimeSpan VisibilityEvictionGrace = TimeSpan.FromMinutes(1);
|
|
private readonly object _ownedRetryGate = new();
|
|
private readonly Dictionary<ObjectKind, HashSet<PlayerChanges>> _pendingOwnedChanges = new();
|
|
private CancellationTokenSource? _ownedRetryCts;
|
|
private Task _ownedRetryTask = Task.CompletedTask;
|
|
|
|
|
|
|
|
private static readonly TimeSpan OwnedRetryInitialDelay = TimeSpan.FromSeconds(1);
|
|
private static readonly TimeSpan OwnedRetryMaxDelay = TimeSpan.FromSeconds(10);
|
|
private static readonly TimeSpan OwnedRetryStaleDataGrace = TimeSpan.FromMinutes(5);
|
|
private static readonly HashSet<string> NonPriorityModExtensions = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
".tmb",
|
|
".pap",
|
|
".atex",
|
|
".avfx",
|
|
".scd"
|
|
};
|
|
|
|
private readonly ConcurrentDictionary<string, byte> _blockedPapHashes = new(StringComparer.OrdinalIgnoreCase);
|
|
private AnimationValidationMode _lastAnimMode = (AnimationValidationMode)(-1);
|
|
private bool _lastAllowOneBasedShift;
|
|
private bool _lastAllowNeighborTolerance;
|
|
private readonly ConcurrentDictionary<string, byte> _dumpedRemoteSkeletonForHash = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
private DateTime? _invisibleSinceUtc;
|
|
private DateTime? _visibilityEvictionDueAtUtc;
|
|
private DateTime _nextActorLookupUtc = DateTime.MinValue;
|
|
private static readonly TimeSpan ActorLookupInterval = TimeSpan.FromSeconds(1);
|
|
private static readonly SemaphoreSlim ActorInitializationLimiter = new(1, 1);
|
|
private const int FullyLoadedTimeoutMsPlayer = 30000;
|
|
private const int FullyLoadedTimeoutMsOther = 5000;
|
|
private readonly object _actorInitializationGate = new();
|
|
private ActorObjectService.ActorDescriptor? _pendingActorDescriptor;
|
|
private bool _actorInitializationInProgress;
|
|
private bool _frameworkUpdateSubscribed;
|
|
private nint _lastKnownAddress = nint.Zero;
|
|
private ushort _lastKnownObjectIndex = ushort.MaxValue;
|
|
private string? _lastKnownName;
|
|
|
|
public DateTime? InvisibleSinceUtc => _invisibleSinceUtc;
|
|
public DateTime? VisibilityEvictionDueAtUtc => _visibilityEvictionDueAtUtc;
|
|
public string Ident { get; }
|
|
public bool Initialized { get; private set; }
|
|
public bool ScheduledForDeletion { get; set; }
|
|
|
|
public bool IsVisible
|
|
{
|
|
get => _isVisible;
|
|
private set
|
|
{
|
|
if (_isVisible == value) return;
|
|
|
|
_isVisible = value;
|
|
|
|
if (!_isVisible)
|
|
{
|
|
DisableSync();
|
|
|
|
_invisibleSinceUtc = DateTime.UtcNow;
|
|
_visibilityEvictionDueAtUtc = _invisibleSinceUtc.Value.Add(VisibilityEvictionGrace);
|
|
|
|
StartVisibilityGraceTask();
|
|
}
|
|
else
|
|
{
|
|
CancelVisibilityGraceTask();
|
|
|
|
_invisibleSinceUtc = null;
|
|
_visibilityEvictionDueAtUtc = null;
|
|
|
|
ScheduledForDeletion = false;
|
|
|
|
if (_charaHandler is not null && _charaHandler.Address != nint.Zero)
|
|
_ = EnsurePenumbraCollection();
|
|
}
|
|
|
|
var user = GetPrimaryUserData();
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter),
|
|
EventSeverity.Informational, "User Visibility Changed, now: " + (_isVisible ? "Is Visible" : "Is not Visible"))));
|
|
Mediator.Publish(new RefreshUiMessage());
|
|
Mediator.Publish(new VisibilityChange());
|
|
}
|
|
}
|
|
|
|
public long LastAppliedDataBytes { get; private set; }
|
|
public long LastAppliedDataTris { get; set; } = -1;
|
|
public long LastAppliedApproximateEffectiveTris { get; set; } = -1;
|
|
public long LastAppliedApproximateVRAMBytes { get; set; } = -1;
|
|
public long LastAppliedApproximateEffectiveVRAMBytes { get; set; } = -1;
|
|
public CharacterData? LastReceivedCharacterData { get; private set; }
|
|
public bool PendingModReapply => _pendingModReapply;
|
|
public bool ModApplyDeferred => _lastModApplyDeferred;
|
|
public int MissingCriticalMods => _lastMissingCriticalMods;
|
|
public int MissingNonCriticalMods => _lastMissingNonCriticalMods;
|
|
public int MissingForbiddenMods => _lastMissingForbiddenMods;
|
|
public DateTime? LastDataReceivedAt => _lastDataReceivedAt;
|
|
public DateTime? LastApplyAttemptAt => _lastApplyAttemptAt;
|
|
public DateTime? LastSuccessfulApplyAt => _lastSuccessfulApplyAt;
|
|
public string? LastFailureReason => _lastFailureReason;
|
|
public IReadOnlyList<string> LastBlockingConditions => _lastBlockingConditions;
|
|
public bool IsApplying => _applicationTask is { IsCompleted: false };
|
|
public bool IsDownloading => _downloadManager.IsDownloading;
|
|
public int PendingDownloadCount => _downloadManager.CurrentDownloads.Count;
|
|
public int ForbiddenDownloadCount => _downloadManager.ForbiddenTransfers.Count;
|
|
|
|
public PairHandlerAdapter(
|
|
ILogger<PairHandlerAdapter> logger,
|
|
LightlessMediator mediator,
|
|
PairManager pairManager,
|
|
string ident,
|
|
GameObjectHandlerFactory gameObjectHandlerFactory,
|
|
IpcManager ipcManager,
|
|
FileDownloadManager transferManager,
|
|
PluginWarningNotificationService pluginWarningNotificationManager,
|
|
DalamudUtilService dalamudUtil,
|
|
IFramework framework,
|
|
ActorObjectService actorObjectService,
|
|
IHostApplicationLifetime lifetime,
|
|
FileCacheManager fileDbManager,
|
|
PlayerPerformanceConfigService playerPerformanceConfigService,
|
|
PlayerPerformanceService playerPerformanceService,
|
|
PairProcessingLimiter pairProcessingLimiter,
|
|
ServerConfigurationManager serverConfigManager,
|
|
TextureDownscaleService textureDownscaleService,
|
|
ModelDecimationService modelDecimationService,
|
|
PairStateCache pairStateCache,
|
|
PairPerformanceMetricsCache performanceMetricsCache,
|
|
PenumbraTempCollectionJanitor tempCollectionJanitor,
|
|
XivDataAnalyzer modelAnalyzer,
|
|
LightlessConfigService configService) : base(logger, mediator)
|
|
{
|
|
_pairManager = pairManager;
|
|
Ident = ident;
|
|
_gameObjectHandlerFactory = gameObjectHandlerFactory;
|
|
_ipcManager = ipcManager;
|
|
_downloadManager = transferManager;
|
|
_pluginWarningNotificationManager = pluginWarningNotificationManager;
|
|
_dalamudUtil = dalamudUtil;
|
|
_framework = framework;
|
|
_actorObjectService = actorObjectService;
|
|
_lifetime = lifetime;
|
|
_fileDbManager = fileDbManager;
|
|
_playerPerformanceConfigService = playerPerformanceConfigService;
|
|
_playerPerformanceService = playerPerformanceService;
|
|
_pairProcessingLimiter = pairProcessingLimiter;
|
|
_serverConfigManager = serverConfigManager;
|
|
_textureDownscaleService = textureDownscaleService;
|
|
_modelDecimationService = modelDecimationService;
|
|
_pairStateCache = pairStateCache;
|
|
_performanceMetricsCache = performanceMetricsCache;
|
|
_tempCollectionJanitor = tempCollectionJanitor;
|
|
_modelAnalyzer = modelAnalyzer;
|
|
_configService = configService;
|
|
}
|
|
|
|
public void Initialize()
|
|
{
|
|
EnsureInitialized();
|
|
}
|
|
|
|
private void EnsureInitialized()
|
|
{
|
|
if (Initialized)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ActorObjectService.ActorDescriptor? trackedDescriptor = null;
|
|
lock (_initializationGate)
|
|
{
|
|
if (Initialized)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (LastAppliedDataBytes < 0 || LastAppliedDataTris < 0 || LastAppliedApproximateEffectiveTris < 0
|
|
|| LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0)
|
|
{
|
|
_forceApplyMods = true;
|
|
}
|
|
|
|
var useFrameworkUpdate = !_actorObjectService.HooksActive;
|
|
if (useFrameworkUpdate)
|
|
{
|
|
Mediator.Subscribe<FrameworkUpdateMessage>(this, _ => FrameworkUpdate());
|
|
_frameworkUpdateSubscribed = true;
|
|
}
|
|
Mediator.Subscribe<ZoneSwitchStartMessage>(this, _ =>
|
|
{
|
|
_downloadCancellationTokenSource?.CancelDispose();
|
|
_charaHandler?.Invalidate();
|
|
IsVisible = false;
|
|
});
|
|
Mediator.Subscribe<PenumbraInitializedMessage>(this, _ =>
|
|
{
|
|
ResetPenumbraCollection(releaseFromPenumbra: false, reason: "PenumbraInitialized");
|
|
if (!IsVisible && _charaHandler is not null)
|
|
{
|
|
PlayerName = string.Empty;
|
|
_charaHandler.Dispose();
|
|
_charaHandler = null;
|
|
}
|
|
EnableSync();
|
|
});
|
|
Mediator.Subscribe<PenumbraDisposedMessage>(this, _ => ResetPenumbraCollection(releaseFromPenumbra: false, reason: "PenumbraDisposed"));
|
|
Mediator.Subscribe<ClassJobChangedMessage>(this, msg =>
|
|
{
|
|
if (msg.GameObjectHandler == _charaHandler)
|
|
{
|
|
_redrawOnNextApplication = true;
|
|
}
|
|
});
|
|
Mediator.Subscribe<CombatEndMessage>(this, _ => EnableSync());
|
|
Mediator.Subscribe<CombatStartMessage>(this, _ => DisableSync());
|
|
Mediator.Subscribe<PerformanceEndMessage>(this, _ => EnableSync());
|
|
Mediator.Subscribe<PerformanceStartMessage>(this, _ => DisableSync());
|
|
Mediator.Subscribe<InstanceOrDutyStartMessage>(this, _ => DisableSync());
|
|
Mediator.Subscribe<InstanceOrDutyEndMessage>(this, _ => EnableSync());
|
|
Mediator.Subscribe<CutsceneStartMessage>(this, _ => DisableSync());
|
|
Mediator.Subscribe<CutsceneEndMessage>(this, _ => EnableSync());
|
|
Mediator.Subscribe<GposeStartMessage>(this, _ => DisableSync());
|
|
Mediator.Subscribe<GposeEndMessage>(this, _ => EnableSync());
|
|
Mediator.Subscribe<ActorTrackedMessage>(this, msg => HandleActorTracked(msg.Descriptor));
|
|
Mediator.Subscribe<ActorUntrackedMessage>(this, msg => HandleActorUntracked(msg.Descriptor));
|
|
Mediator.Subscribe<DownloadFinishedMessage>(this, msg =>
|
|
{
|
|
if (_charaHandler is null || !ReferenceEquals(msg.DownloadId, _charaHandler))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_pendingModReapply && IsVisible)
|
|
{
|
|
if (LastReceivedCharacterData is not null)
|
|
{
|
|
Logger.LogDebug("Downloads finished for {handler}, reapplying pending mod data", GetLogIdentifier());
|
|
ApplyLastReceivedData(forced: true);
|
|
return;
|
|
}
|
|
|
|
if (_cachedData is not null)
|
|
{
|
|
Logger.LogDebug("Downloads finished for {handler}, reapplying pending mod data from cache", GetLogIdentifier());
|
|
ApplyCharacterData(Guid.NewGuid(), _cachedData, forceApplyCustomization: true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
TryApplyQueuedData();
|
|
});
|
|
|
|
if (!useFrameworkUpdate
|
|
&& _actorObjectService.TryGetActorByHash(Ident, out var descriptor)
|
|
&& descriptor.Address != nint.Zero)
|
|
{
|
|
trackedDescriptor = descriptor;
|
|
}
|
|
|
|
Initialized = true;
|
|
}
|
|
|
|
if (trackedDescriptor.HasValue)
|
|
{
|
|
HandleActorTracked(trackedDescriptor.Value);
|
|
}
|
|
}
|
|
|
|
private IReadOnlyList<PairConnection> GetCurrentPairs()
|
|
{
|
|
return _pairManager.GetPairsByIdent(Ident);
|
|
}
|
|
|
|
private PairConnection? GetPrimaryPair()
|
|
{
|
|
var pairs = GetCurrentPairs();
|
|
var direct = pairs.FirstOrDefault(p => p.IsDirectlyPaired);
|
|
if (direct is not null)
|
|
{
|
|
return direct;
|
|
}
|
|
|
|
var online = pairs.FirstOrDefault(p => p.IsOnline);
|
|
if (online is not null)
|
|
{
|
|
return online;
|
|
}
|
|
|
|
return pairs.FirstOrDefault();
|
|
}
|
|
|
|
private UserData GetPrimaryUserData()
|
|
{
|
|
return GetPrimaryPair()?.User ?? new UserData(Ident);
|
|
}
|
|
|
|
private string GetPrimaryAliasOrUid()
|
|
{
|
|
var pair = GetPrimaryPair();
|
|
if (pair?.User is null)
|
|
{
|
|
return Ident;
|
|
}
|
|
|
|
return string.IsNullOrEmpty(pair.User.AliasOrUID) ? Ident : pair.User.AliasOrUID;
|
|
}
|
|
|
|
private string GetPrimaryAliasOrUidSafe()
|
|
{
|
|
try
|
|
{
|
|
return GetPrimaryAliasOrUid();
|
|
}
|
|
catch
|
|
{
|
|
return Ident;
|
|
}
|
|
}
|
|
|
|
private UserData GetPrimaryUserDataSafe()
|
|
{
|
|
try
|
|
{
|
|
return GetPrimaryUserData();
|
|
}
|
|
catch
|
|
{
|
|
return new UserData(Ident);
|
|
}
|
|
}
|
|
|
|
private string GetLogIdentifier()
|
|
{
|
|
var alias = GetPrimaryAliasOrUidSafe();
|
|
return string.Equals(alias, Ident, StringComparison.Ordinal) ? alias : $"{alias} ({Ident})";
|
|
}
|
|
|
|
private Guid EnsurePenumbraCollection()
|
|
{
|
|
if (!IsVisible)
|
|
{
|
|
return Guid.Empty;
|
|
}
|
|
|
|
if (_penumbraCollection != Guid.Empty)
|
|
{
|
|
return _penumbraCollection;
|
|
}
|
|
|
|
lock (_collectionGate)
|
|
{
|
|
if (_penumbraCollection != Guid.Empty)
|
|
{
|
|
return _penumbraCollection;
|
|
}
|
|
|
|
var cached = _pairStateCache.TryGetTemporaryCollection(Ident);
|
|
if (cached.HasValue && cached.Value != Guid.Empty)
|
|
{
|
|
_penumbraCollection = cached.Value;
|
|
return _penumbraCollection;
|
|
}
|
|
|
|
if (!_ipcManager.Penumbra.APIAvailable)
|
|
{
|
|
return Guid.Empty;
|
|
}
|
|
|
|
var user = GetPrimaryUserDataSafe();
|
|
var uid = !string.IsNullOrEmpty(user.UID) ? user.UID : Ident;
|
|
var created = _ipcManager.Penumbra.CreateTemporaryCollectionAsync(Logger, uid)
|
|
.ConfigureAwait(false).GetAwaiter().GetResult();
|
|
if (created != Guid.Empty)
|
|
{
|
|
_penumbraCollection = created;
|
|
_pairStateCache.StoreTemporaryCollection(Ident, created);
|
|
_tempCollectionJanitor.Register(created);
|
|
}
|
|
|
|
return _penumbraCollection;
|
|
}
|
|
}
|
|
|
|
private void ResetPenumbraCollection(bool releaseFromPenumbra = true, string? reason = null, bool awaitIpc = true)
|
|
{
|
|
Guid toRelease = Guid.Empty;
|
|
bool hadCollection = false;
|
|
lock (_collectionGate)
|
|
{
|
|
if (_penumbraCollection != Guid.Empty)
|
|
{
|
|
toRelease = _penumbraCollection;
|
|
_penumbraCollection = Guid.Empty;
|
|
hadCollection = true;
|
|
}
|
|
}
|
|
|
|
var cached = _pairStateCache.ClearTemporaryCollection(Ident);
|
|
if (cached.HasValue && cached.Value != Guid.Empty)
|
|
{
|
|
toRelease = cached.Value;
|
|
hadCollection = true;
|
|
}
|
|
|
|
if (hadCollection)
|
|
{
|
|
_needsCollectionRebuild = true;
|
|
_forceFullReapply = true;
|
|
_forceApplyMods = true;
|
|
_tempCollectionJanitor.Unregister(toRelease);
|
|
}
|
|
|
|
if (!releaseFromPenumbra || toRelease == Guid.Empty || !_ipcManager.Penumbra.APIAvailable)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var applicationId = Guid.NewGuid();
|
|
if (awaitIpc)
|
|
{
|
|
try
|
|
{
|
|
Logger.LogTrace("[{applicationId}] Removing temp collection {CollectionId} for {handler} ({reason})", applicationId, toRelease, GetLogIdentifier(), reason ?? "Cleanup");
|
|
_ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, toRelease).GetAwaiter().GetResult();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogDebug(ex, "Failed to remove temporary Penumbra collection for {handler}", GetLogIdentifier());
|
|
}
|
|
return;
|
|
}
|
|
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
Logger.LogTrace("[{applicationId}] Removing temp collection {CollectionId} for {handler} ({reason})", applicationId, toRelease, GetLogIdentifier(), reason ?? "Cleanup");
|
|
await _ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, toRelease).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogDebug(ex, "Failed to remove temporary Penumbra collection for {handler}", GetLogIdentifier());
|
|
}
|
|
});
|
|
}
|
|
|
|
private bool AnyPair(Func<PairConnection, bool> predicate)
|
|
{
|
|
return GetCurrentPairs().Any(predicate);
|
|
}
|
|
|
|
private bool IsPreferredDirectPair()
|
|
{
|
|
return GetCurrentPairs().Any(p => p.IsDirectlyPaired && p.SelfToOtherPermissions.IsSticky());
|
|
}
|
|
|
|
private bool ShouldSkipDownscale()
|
|
{
|
|
if (!_playerPerformanceConfigService.Current.SkipTextureDownscaleForPreferredPairs)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return IsPreferredDirectPair();
|
|
}
|
|
|
|
private bool ShouldSkipDecimation()
|
|
{
|
|
if (!_playerPerformanceConfigService.Current.SkipModelDecimationForPreferredPairs)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return IsPreferredDirectPair();
|
|
}
|
|
|
|
private bool IsPaused()
|
|
{
|
|
var pairs = GetCurrentPairs();
|
|
return pairs.Count > 0 && pairs.Any(p => p.IsPaused);
|
|
}
|
|
|
|
bool IPairPerformanceSubject.IsPaused => IsPaused();
|
|
|
|
bool IPairPerformanceSubject.IsDirectlyPaired => AnyPair(p => p.IsDirectlyPaired);
|
|
|
|
bool IPairPerformanceSubject.HasStickyPermissions => AnyPair(p => p.SelfToOtherPermissions.HasFlag(UserPermissions.Sticky));
|
|
|
|
UserData IPairPerformanceSubject.UserData => GetPrimaryUserData();
|
|
|
|
string IPairPerformanceSubject.PlayerName => PlayerName ?? GetPrimaryAliasOrUidSafe();
|
|
private UserPermissions GetCombinedPermissions()
|
|
{
|
|
var pairs = GetCurrentPairs();
|
|
if (pairs.Count == 0)
|
|
{
|
|
return UserPermissions.NoneSet;
|
|
}
|
|
|
|
var combined = pairs[0].SelfToOtherPermissions | pairs[0].OtherToSelfPermissions;
|
|
for (int i = 1; i < pairs.Count; i++)
|
|
{
|
|
var perms = pairs[i].SelfToOtherPermissions | pairs[i].OtherToSelfPermissions;
|
|
combined &= perms;
|
|
}
|
|
|
|
return combined;
|
|
}
|
|
public nint PlayerCharacter => _charaHandler?.Address ?? nint.Zero;
|
|
public uint PlayerCharacterId => _charaHandler?.EntityId ?? uint.MaxValue;
|
|
public string? PlayerName { get; private set; }
|
|
public string PlayerNameHash => Ident;
|
|
|
|
public void ApplyData(CharacterData data)
|
|
{
|
|
EnsureInitialized();
|
|
LastReceivedCharacterData = data;
|
|
_lastDataReceivedAt = DateTime.UtcNow;
|
|
ApplyLastReceivedData();
|
|
}
|
|
|
|
public void LoadCachedCharacterData(CharacterData data)
|
|
{
|
|
if (data is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
LastReceivedCharacterData = data;
|
|
_cachedData = null;
|
|
_forceApplyMods = true;
|
|
LastAppliedDataBytes = -1;
|
|
LastAppliedDataTris = -1;
|
|
LastAppliedApproximateEffectiveTris = -1;
|
|
LastAppliedApproximateVRAMBytes = -1;
|
|
LastAppliedApproximateEffectiveVRAMBytes = -1;
|
|
}
|
|
|
|
public void ApplyLastReceivedData(bool forced = false)
|
|
{
|
|
EnsureInitialized();
|
|
if (LastReceivedCharacterData is null)
|
|
{
|
|
Logger.LogTrace("No cached data to apply for {Ident}", Ident);
|
|
return;
|
|
}
|
|
|
|
var hasMissingCachedFiles = HasMissingCachedFiles(LastReceivedCharacterData);
|
|
var missingStarted = !_lastMissingCachedFiles && hasMissingCachedFiles;
|
|
var missingResolved = _lastMissingCachedFiles && !hasMissingCachedFiles;
|
|
_lastMissingCachedFiles = hasMissingCachedFiles;
|
|
var shouldForce = forced || missingStarted || missingResolved;
|
|
var forceApplyCustomization = forced;
|
|
|
|
if (IsPaused())
|
|
{
|
|
Logger.LogTrace("Permissions paused for {Ident}, skipping reapply", Ident);
|
|
return;
|
|
}
|
|
|
|
var sanitized = CloneAndSanitizeLastReceived(out var dataHash);
|
|
if (sanitized is null)
|
|
{
|
|
Logger.LogTrace("Sanitized data null for {Ident}", Ident);
|
|
return;
|
|
}
|
|
var dataApplied = !string.IsNullOrEmpty(dataHash)
|
|
&& string.Equals(dataHash, _lastSuccessfulDataHash ?? string.Empty, StringComparison.Ordinal);
|
|
var needsApply = !dataApplied;
|
|
var modFilesChanged = PlayerModFilesChanged(sanitized, _cachedData);
|
|
var shouldForceMods = shouldForce || modFilesChanged;
|
|
forceApplyCustomization = forced || needsApply;
|
|
var suppressForcedModRedraw = !forced && hasMissingCachedFiles && dataApplied;
|
|
|
|
if (shouldForceMods)
|
|
{
|
|
_forceApplyMods = true;
|
|
_forceFullReapply = true;
|
|
LastAppliedDataBytes = -1;
|
|
LastAppliedDataTris = -1;
|
|
LastAppliedApproximateEffectiveTris = -1;
|
|
LastAppliedApproximateVRAMBytes = -1;
|
|
LastAppliedApproximateEffectiveVRAMBytes = -1;
|
|
}
|
|
|
|
_pairStateCache.Store(Ident, sanitized);
|
|
|
|
if (!IsVisible && !_pauseRequested)
|
|
{
|
|
if (_charaHandler is not null && _charaHandler.Address == nint.Zero)
|
|
{
|
|
_charaHandler.Refresh();
|
|
}
|
|
|
|
if (PlayerCharacter != nint.Zero)
|
|
{
|
|
IsVisible = true;
|
|
}
|
|
}
|
|
|
|
if (!IsVisible)
|
|
{
|
|
Logger.LogTrace("Handler for {Ident} not visible, caching sanitized data for later", Ident);
|
|
_cachedData = sanitized;
|
|
_forceFullReapply = true;
|
|
return;
|
|
}
|
|
|
|
ApplyCharacterData(Guid.NewGuid(), sanitized, forceApplyCustomization, suppressForcedModRedraw);
|
|
}
|
|
|
|
public bool FetchPerformanceMetricsFromCache()
|
|
{
|
|
EnsureInitialized();
|
|
var sanitized = CloneAndSanitizeLastReceived(out var dataHash);
|
|
if (sanitized is null || string.IsNullOrEmpty(dataHash))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!TryApplyCachedMetrics(dataHash))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
_cachedData = sanitized;
|
|
_pairStateCache.Store(Ident, sanitized);
|
|
return true;
|
|
}
|
|
|
|
private CharacterData? CloneAndSanitizeLastReceived(out string? dataHash)
|
|
{
|
|
dataHash = null;
|
|
if (LastReceivedCharacterData is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var sanitized = RemoveNotSyncedFiles(LastReceivedCharacterData.DeepClone());
|
|
if (sanitized is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
dataHash = GetDataHashSafe(sanitized);
|
|
return sanitized;
|
|
}
|
|
|
|
private string? GetDataHashSafe(CharacterData data)
|
|
{
|
|
try
|
|
{
|
|
return data.DataHash.Value;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogDebug(ex, "Failed to compute character data hash for {Ident}", Ident);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private bool TryApplyCachedMetrics(string? dataHash)
|
|
{
|
|
if (string.IsNullOrEmpty(dataHash))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!_performanceMetricsCache.TryGetMetrics(Ident, dataHash, out var metrics))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
ApplyCachedMetrics(metrics);
|
|
return true;
|
|
}
|
|
|
|
private void ApplyCachedMetrics(PairPerformanceMetrics metrics)
|
|
{
|
|
LastAppliedDataTris = metrics.TriangleCount;
|
|
LastAppliedApproximateEffectiveTris = metrics.ApproximateEffectiveTris;
|
|
LastAppliedApproximateVRAMBytes = metrics.ApproximateVramBytes;
|
|
LastAppliedApproximateEffectiveVRAMBytes = metrics.ApproximateEffectiveVramBytes;
|
|
}
|
|
|
|
private void StorePerformanceMetrics(CharacterData charaData)
|
|
{
|
|
if (LastAppliedDataTris < 0
|
|
|| LastAppliedApproximateEffectiveTris < 0
|
|
|| LastAppliedApproximateVRAMBytes < 0
|
|
|| LastAppliedApproximateEffectiveVRAMBytes < 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var dataHash = GetDataHashSafe(charaData);
|
|
if (string.IsNullOrEmpty(dataHash))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_performanceMetricsCache.StoreMetrics(
|
|
Ident,
|
|
dataHash,
|
|
new PairPerformanceMetrics(LastAppliedDataTris, LastAppliedApproximateVRAMBytes, LastAppliedApproximateEffectiveVRAMBytes, LastAppliedApproximateEffectiveTris));
|
|
}
|
|
|
|
private bool HasMissingCachedFiles(CharacterData characterData)
|
|
{
|
|
try
|
|
{
|
|
HashSet<string> inspectedHashes = new(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var replacements in characterData.FileReplacements.Values)
|
|
{
|
|
foreach (var replacement in replacements)
|
|
{
|
|
if (!string.IsNullOrEmpty(replacement.FileSwapPath))
|
|
{
|
|
if (Path.IsPathRooted(replacement.FileSwapPath) && !File.Exists(replacement.FileSwapPath))
|
|
{
|
|
Logger.LogTrace("Missing file swap path {Path} detected for {Handler}", replacement.FileSwapPath, GetLogIdentifier());
|
|
return true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(replacement.Hash) || !inspectedHashes.Add(replacement.Hash))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var cacheEntry = _fileDbManager.GetFileCacheByHash(replacement.Hash);
|
|
if (cacheEntry is null)
|
|
{
|
|
Logger.LogTrace("Missing cached file {Hash} detected for {Handler}", replacement.Hash, GetLogIdentifier());
|
|
return true;
|
|
}
|
|
|
|
if (!File.Exists(cacheEntry.ResolvedFilepath))
|
|
{
|
|
Logger.LogTrace("Cached file {Hash} missing on disk for {Handler}, removing cache entry", replacement.Hash, GetLogIdentifier());
|
|
_fileDbManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogDebug(ex, "Failed to determine cache availability for {Handler}", GetLogIdentifier());
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private CharacterData? RemoveNotSyncedFiles(CharacterData? data)
|
|
{
|
|
Logger.LogTrace("Removing not synced files for {Ident}", Ident);
|
|
if (data is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var permissions = GetCombinedPermissions();
|
|
bool disableAnimations = permissions.IsDisableAnimations();
|
|
bool disableVfx = permissions.IsDisableVFX();
|
|
bool disableSounds = permissions.IsDisableSounds();
|
|
|
|
if (!(disableAnimations || disableVfx || disableSounds))
|
|
{
|
|
return data;
|
|
}
|
|
|
|
foreach (var objectKind in data.FileReplacements.Keys.ToList())
|
|
{
|
|
var replacements = data.FileReplacements[objectKind];
|
|
if (disableSounds)
|
|
{
|
|
replacements = replacements
|
|
.Where(f => !f.GamePaths.Any(p => p.EndsWith("scd", StringComparison.OrdinalIgnoreCase)))
|
|
.ToList();
|
|
}
|
|
|
|
if (disableAnimations)
|
|
{
|
|
replacements = replacements
|
|
.Where(f => !f.GamePaths.Any(p =>
|
|
p.EndsWith("tmb", StringComparison.OrdinalIgnoreCase) ||
|
|
p.EndsWith("pap", StringComparison.OrdinalIgnoreCase)))
|
|
.ToList();
|
|
}
|
|
|
|
if (disableVfx)
|
|
{
|
|
replacements = replacements
|
|
.Where(f => !f.GamePaths.Any(p =>
|
|
p.EndsWith("atex", StringComparison.OrdinalIgnoreCase) ||
|
|
p.EndsWith("avfx", StringComparison.OrdinalIgnoreCase)))
|
|
.ToList();
|
|
}
|
|
|
|
data.FileReplacements[objectKind] = replacements;
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
private bool HasValidCachedModdedPaths()
|
|
{
|
|
if (_lastAppliedModdedPaths is null || _lastAppliedModdedPaths.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
foreach (var entry in _lastAppliedModdedPaths)
|
|
{
|
|
if (string.IsNullOrEmpty(entry.Value) || !File.Exists(entry.Value))
|
|
{
|
|
Logger.LogDebug("Cached file path {path} missing for {handler}, forcing recalculation", entry.Value ?? "empty", GetLogIdentifier());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private bool IsForbiddenHash(string hash)
|
|
=> _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, hash, StringComparison.Ordinal));
|
|
|
|
private static bool IsNonPriorityModPath(string? gamePath)
|
|
{
|
|
if (string.IsNullOrEmpty(gamePath))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var extension = Path.GetExtension(gamePath);
|
|
return !string.IsNullOrEmpty(extension) && NonPriorityModExtensions.Contains(extension);
|
|
}
|
|
|
|
private static bool IsCriticalModReplacement(FileReplacementData replacement)
|
|
{
|
|
foreach (var gamePath in replacement.GamePaths)
|
|
{
|
|
if (!IsNonPriorityModPath(gamePath))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private void CountMissingReplacements(IEnumerable<FileReplacementData> missing, out int critical, out int nonCritical, out int forbidden)
|
|
{
|
|
critical = 0;
|
|
nonCritical = 0;
|
|
forbidden = 0;
|
|
|
|
foreach (var replacement in missing)
|
|
{
|
|
if (IsForbiddenHash(replacement.Hash))
|
|
{
|
|
forbidden++;
|
|
}
|
|
|
|
if (IsCriticalModReplacement(replacement))
|
|
{
|
|
critical++;
|
|
}
|
|
else
|
|
{
|
|
nonCritical++;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void RemoveModApplyChanges(Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData)
|
|
{
|
|
foreach (var changes in updatedData.Values)
|
|
{
|
|
changes.Remove(PlayerChanges.ModFiles);
|
|
changes.Remove(PlayerChanges.ModManip);
|
|
changes.Remove(PlayerChanges.ForcedRedraw);
|
|
}
|
|
}
|
|
|
|
private bool CanApplyNow()
|
|
{
|
|
return !_dalamudUtil.IsInCombat
|
|
&& !_dalamudUtil.IsPerforming
|
|
&& !_dalamudUtil.IsInInstance
|
|
&& !_dalamudUtil.IsInCutscene
|
|
&& !_dalamudUtil.IsInGpose
|
|
&& _ipcManager.Penumbra.APIAvailable
|
|
&& _ipcManager.Glamourer.APIAvailable;
|
|
}
|
|
|
|
private void RecordFailure(string reason, params string[] conditions)
|
|
{
|
|
_lastFailureReason = reason;
|
|
_lastBlockingConditions = conditions.Length == 0 ? Array.Empty<string>() : conditions.ToArray();
|
|
}
|
|
|
|
private void ClearFailureState()
|
|
{
|
|
_lastFailureReason = null;
|
|
_lastBlockingConditions = Array.Empty<string>();
|
|
}
|
|
|
|
private void DeferApplication(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization, UserData user, string reason,
|
|
string failureKey, LogLevel logLevel, string logMessage, params object?[] logArgs)
|
|
{
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, reason)));
|
|
Logger.Log(logLevel, logMessage, logArgs);
|
|
RecordFailure(reason, failureKey);
|
|
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
|
|
SetUploading(false);
|
|
}
|
|
|
|
public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = false, bool suppressForcedModRedraw = false)
|
|
{
|
|
_lastApplyAttemptAt = DateTime.UtcNow;
|
|
ClearFailureState();
|
|
|
|
if (characterData is null)
|
|
{
|
|
RecordFailure("Received null character data", "InvalidData");
|
|
Logger.LogWarning("[BASE-{appBase}] Received null character data, skipping application for {handler}", applicationBase, GetLogIdentifier());
|
|
SetUploading(false);
|
|
return;
|
|
}
|
|
|
|
var user = GetPrimaryUserData();
|
|
if (_dalamudUtil.IsInCombat)
|
|
{
|
|
const string reason = "Cannot apply character data: you are in combat, deferring application";
|
|
DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "Combat", LogLevel.Debug,
|
|
"[BASE-{appBase}] Received data but player is in combat", applicationBase);
|
|
return;
|
|
}
|
|
|
|
if (_dalamudUtil.IsPerforming)
|
|
{
|
|
const string reason = "Cannot apply character data: you are performing music, deferring application";
|
|
DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "Performance", LogLevel.Debug,
|
|
"[BASE-{appBase}] Received data but player is performing", applicationBase);
|
|
return;
|
|
}
|
|
|
|
if (_dalamudUtil.IsInInstance)
|
|
{
|
|
const string reason = "Cannot apply character data: you are in an instance, deferring application";
|
|
DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "Instance", LogLevel.Debug,
|
|
"[BASE-{appBase}] Received data but player is in instance", applicationBase);
|
|
return;
|
|
}
|
|
|
|
if (_dalamudUtil.IsInCutscene)
|
|
{
|
|
const string reason = "Cannot apply character data: you are in a cutscene, deferring application";
|
|
DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "Cutscene", LogLevel.Debug,
|
|
"[BASE-{appBase}] Received data but player is in a cutscene", applicationBase);
|
|
return;
|
|
}
|
|
|
|
if (_dalamudUtil.IsInGpose)
|
|
{
|
|
const string reason = "Cannot apply character data: you are in GPose, deferring application";
|
|
DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "GPose", LogLevel.Debug,
|
|
"[BASE-{appBase}] Received data but player is in GPose", applicationBase);
|
|
return;
|
|
}
|
|
|
|
if (!_ipcManager.Penumbra.APIAvailable || !_ipcManager.Glamourer.APIAvailable)
|
|
{
|
|
const string reason = "Cannot apply character data: Penumbra or Glamourer is not available, deferring application";
|
|
DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "PluginUnavailable", LogLevel.Information,
|
|
"[BASE-{appbase}] Application of data for {player} while Penumbra/Glamourer unavailable, returning", applicationBase, GetLogIdentifier());
|
|
return;
|
|
}
|
|
|
|
var handlerReady = _charaHandler is not null && PlayerCharacter != IntPtr.Zero;
|
|
|
|
if (!handlerReady)
|
|
{
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning,
|
|
"Cannot apply character data: Receiving Player is in an invalid state, deferring application")));
|
|
Logger.LogDebug("[BASE-{appBase}] Received data but player was in invalid state, charaHandlerIsNull: {charaIsNull}, playerPointerIsNull: {ptrIsNull}",
|
|
applicationBase, _charaHandler == null, PlayerCharacter == IntPtr.Zero);
|
|
var hasDiffMods = characterData.CheckUpdatedData(applicationBase, _cachedData, Logger,
|
|
this, forceApplyCustomization, forceApplyMods: false)
|
|
.Any(p => p.Value.Contains(PlayerChanges.ModManip) || p.Value.Contains(PlayerChanges.ModFiles));
|
|
_forceApplyMods = hasDiffMods || _forceApplyMods || _cachedData == null;
|
|
_cachedData = characterData;
|
|
_forceFullReapply = true;
|
|
Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods);
|
|
}
|
|
|
|
SetUploading(false);
|
|
|
|
Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, GetLogIdentifier(), forceApplyCustomization, _forceApplyMods);
|
|
Logger.LogDebug("[BASE-{appbase}] Hash for data is {newHash}, current cache hash is {oldHash}", applicationBase, characterData.DataHash.Value, _cachedData?.DataHash.Value ?? "NODATA");
|
|
|
|
if (handlerReady
|
|
&& string.Equals(characterData.DataHash.Value, _cachedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal)
|
|
&& !forceApplyCustomization && !_forceApplyMods)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational,
|
|
"Applying Character Data")));
|
|
|
|
var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this,
|
|
forceApplyCustomization, _forceApplyMods, suppressForcedModRedraw);
|
|
|
|
if (handlerReady && _forceApplyMods)
|
|
{
|
|
_forceApplyMods = false;
|
|
}
|
|
|
|
if (_redrawOnNextApplication && charaDataToUpdate.TryGetValue(ObjectKind.Player, out var player))
|
|
{
|
|
player.Add(PlayerChanges.ForcedRedraw);
|
|
_redrawOnNextApplication = false;
|
|
}
|
|
|
|
if (charaDataToUpdate.TryGetValue(ObjectKind.Player, out var playerChanges))
|
|
{
|
|
_pluginWarningNotificationManager.NotifyForMissingPlugins(user, PlayerName!, playerChanges);
|
|
}
|
|
|
|
Logger.LogDebug("[BASE-{appbase}] Downloading and applying character for {name}", applicationBase, GetPrimaryAliasOrUidSafe());
|
|
|
|
var forceFullReapply = _forceFullReapply
|
|
|| LastAppliedApproximateVRAMBytes < 0 || LastAppliedDataTris < 0 || LastAppliedApproximateEffectiveTris < 0;
|
|
|
|
DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate, forceFullReapply);
|
|
}
|
|
|
|
public override string ToString()
|
|
{
|
|
var alias = GetPrimaryAliasOrUidSafe();
|
|
return $"{alias}:{PlayerName ?? string.Empty}:{(PlayerCharacter != nint.Zero ? "HasChar" : "NoChar")}";
|
|
}
|
|
|
|
public void SetUploading(bool uploading)
|
|
{
|
|
Logger.LogTrace("Setting {name} uploading {uploading}", GetPrimaryAliasOrUidSafe(), uploading);
|
|
if (_charaHandler != null)
|
|
{
|
|
Mediator.Publish(new PlayerUploadingMessage(_charaHandler, uploading));
|
|
}
|
|
}
|
|
|
|
public void SetPaused(bool paused)
|
|
{
|
|
lock (_pauseLock)
|
|
{
|
|
if (_pauseRequested == paused)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_pauseRequested = paused;
|
|
_pauseTransitionTask = _pauseTransitionTask
|
|
.ContinueWith(_ => paused ? PauseInternalAsync() : ResumeInternalAsync(), TaskScheduler.Default)
|
|
.Unwrap();
|
|
}
|
|
}
|
|
|
|
private void CancelVisibilityGraceTask()
|
|
{
|
|
lock (_visibilityGraceGate)
|
|
{
|
|
_visibilityGraceCts?.CancelDispose();
|
|
_visibilityGraceCts = null;
|
|
}
|
|
}
|
|
|
|
private void StartVisibilityGraceTask()
|
|
{
|
|
CancellationToken token;
|
|
lock (_visibilityGraceGate)
|
|
{
|
|
_visibilityGraceCts = _visibilityGraceCts?.CancelRecreate() ?? new CancellationTokenSource();
|
|
token = _visibilityGraceCts.Token;
|
|
}
|
|
|
|
_visibilityGraceTask = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
await Task.Delay(VisibilityEvictionGrace, token).ConfigureAwait(false);
|
|
token.ThrowIfCancellationRequested();
|
|
if (IsVisible) return;
|
|
|
|
ScheduledForDeletion = true;
|
|
ResetPenumbraCollection(reason: "VisibilityLostTimeout");
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// operation cancelled, do nothing
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogDebug(ex, "Visibility grace task failed for {handler}", GetLogIdentifier());
|
|
}
|
|
}, CancellationToken.None);
|
|
}
|
|
|
|
private void ScheduleOwnedObjectRetry(ObjectKind kind, HashSet<PlayerChanges> changes)
|
|
{
|
|
if (kind == ObjectKind.Player || changes.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_ownedRetryGate)
|
|
{
|
|
_pendingOwnedChanges[kind] = new HashSet<PlayerChanges>(changes);
|
|
if (!_ownedRetryTask.IsCompleted)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_ownedRetryCts = _ownedRetryCts?.CancelRecreate() ?? new CancellationTokenSource();
|
|
var token = _ownedRetryCts.Token;
|
|
_ownedRetryTask = Task.Run(() => OwnedObjectRetryLoopAsync(token), CancellationToken.None);
|
|
}
|
|
}
|
|
|
|
private void ClearOwnedObjectRetry(ObjectKind kind)
|
|
{
|
|
lock (_ownedRetryGate)
|
|
{
|
|
if (!_pendingOwnedChanges.Remove(kind))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void ClearAllOwnedObjectRetries()
|
|
{
|
|
lock (_ownedRetryGate)
|
|
{
|
|
_pendingOwnedChanges.Clear();
|
|
}
|
|
}
|
|
|
|
private bool IsOwnedRetryDataStale()
|
|
{
|
|
if (!_lastDataReceivedAt.HasValue)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return DateTime.UtcNow - _lastDataReceivedAt.Value > OwnedRetryStaleDataGrace;
|
|
}
|
|
|
|
private async Task OwnedObjectRetryLoopAsync(CancellationToken token)
|
|
{
|
|
var delay = OwnedRetryInitialDelay;
|
|
try
|
|
{
|
|
while (!token.IsCancellationRequested)
|
|
{
|
|
if (IsOwnedRetryDataStale())
|
|
{
|
|
ClearAllOwnedObjectRetries();
|
|
return;
|
|
}
|
|
|
|
Dictionary<ObjectKind, HashSet<PlayerChanges>> pending;
|
|
lock (_ownedRetryGate)
|
|
{
|
|
if (_pendingOwnedChanges.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
pending = _pendingOwnedChanges.ToDictionary(kvp => kvp.Key, kvp => new HashSet<PlayerChanges>(kvp.Value));
|
|
}
|
|
|
|
if (!IsVisible || IsPaused() || !CanApplyNow() || PlayerCharacter == nint.Zero || _charaHandler is null)
|
|
{
|
|
await Task.Delay(delay, token).ConfigureAwait(false);
|
|
delay = IncreaseRetryDelay(delay);
|
|
continue;
|
|
}
|
|
|
|
if ((_applicationTask?.IsCompleted ?? true) == false || (_pairDownloadTask?.IsCompleted ?? true) == false)
|
|
{
|
|
await Task.Delay(delay, token).ConfigureAwait(false);
|
|
delay = IncreaseRetryDelay(delay);
|
|
continue;
|
|
}
|
|
|
|
var sanitized = CloneAndSanitizeLastReceived(out _);
|
|
if (sanitized is null)
|
|
{
|
|
await Task.Delay(delay, token).ConfigureAwait(false);
|
|
delay = IncreaseRetryDelay(delay);
|
|
continue;
|
|
}
|
|
|
|
bool anyApplied = false;
|
|
foreach (var entry in pending)
|
|
{
|
|
if (!HasAppearanceDataForKind(sanitized, entry.Key))
|
|
{
|
|
ClearOwnedObjectRetry(entry.Key);
|
|
continue;
|
|
}
|
|
|
|
var applied = await ApplyCustomizationDataAsync(Guid.NewGuid(), entry, sanitized, token).ConfigureAwait(false);
|
|
if (applied)
|
|
{
|
|
ClearOwnedObjectRetry(entry.Key);
|
|
anyApplied = true;
|
|
}
|
|
}
|
|
|
|
if (!anyApplied)
|
|
{
|
|
await Task.Delay(delay, token).ConfigureAwait(false);
|
|
delay = IncreaseRetryDelay(delay);
|
|
}
|
|
else
|
|
{
|
|
delay = OwnedRetryInitialDelay;
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// ignore
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogDebug(ex, "Owned object retry task failed for {handler}", GetLogIdentifier());
|
|
}
|
|
}
|
|
|
|
private static TimeSpan IncreaseRetryDelay(TimeSpan delay)
|
|
{
|
|
var nextMs = Math.Min(delay.TotalMilliseconds * 2, OwnedRetryMaxDelay.TotalMilliseconds);
|
|
return TimeSpan.FromMilliseconds(nextMs);
|
|
}
|
|
|
|
private static bool HasAppearanceDataForKind(CharacterData data, ObjectKind kind)
|
|
{
|
|
if (data.FileReplacements.TryGetValue(kind, out var replacements) && replacements.Count > 0)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (data.GlamourerData.TryGetValue(kind, out var glamourer) && !string.IsNullOrEmpty(glamourer))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (data.CustomizePlusData.TryGetValue(kind, out var customize) && !string.IsNullOrEmpty(customize))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
base.Dispose(disposing);
|
|
|
|
SetUploading(false);
|
|
var name = PlayerName;
|
|
if (!string.IsNullOrEmpty(name))
|
|
{
|
|
_lastKnownName = name;
|
|
}
|
|
|
|
var currentAddress = PlayerCharacter;
|
|
if (currentAddress != nint.Zero)
|
|
{
|
|
_lastKnownAddress = currentAddress;
|
|
}
|
|
|
|
var user = GetPrimaryUserDataSafe();
|
|
var alias = GetPrimaryAliasOrUidSafe();
|
|
Logger.LogDebug("Disposing {name} ({user})", name, alias);
|
|
try
|
|
{
|
|
Guid applicationId = Guid.NewGuid();
|
|
_applicationCancellationTokenSource?.CancelDispose();
|
|
_applicationCancellationTokenSource = null;
|
|
_downloadCancellationTokenSource?.CancelDispose();
|
|
_downloadCancellationTokenSource = null;
|
|
ClearAllOwnedObjectRetries();
|
|
_ownedRetryCts?.CancelDispose();
|
|
_ownedRetryCts = null;
|
|
_downloadManager.Dispose();
|
|
_charaHandler?.Dispose();
|
|
CancelVisibilityGraceTask();
|
|
_charaHandler = null;
|
|
_invisibleSinceUtc = null;
|
|
_visibilityEvictionDueAtUtc = null;
|
|
|
|
if (!string.IsNullOrEmpty(name))
|
|
{
|
|
Mediator.Publish(new EventMessage(new Event(name, user, nameof(PairHandlerAdapter), EventSeverity.Informational, "Disposing User")));
|
|
}
|
|
|
|
if (IsFrameworkUnloading())
|
|
{
|
|
Logger.LogWarning("Framework is unloading, skipping disposal for {name} ({user})", name, alias);
|
|
return;
|
|
}
|
|
|
|
var isStopping = _lifetime.ApplicationStopping.IsCancellationRequested;
|
|
if (isStopping)
|
|
{
|
|
ResetPenumbraCollection(reason: "DisposeStopping", awaitIpc: false);
|
|
ScheduleSafeRevertOnDisposal(applicationId, name, alias);
|
|
return;
|
|
}
|
|
|
|
var canCleanup = !string.IsNullOrEmpty(name)
|
|
&& _dalamudUtil.IsLoggedIn
|
|
&& !_dalamudUtil.IsZoning
|
|
&& !_dalamudUtil.IsInCutscene;
|
|
|
|
if (!canCleanup)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Logger.LogTrace("[{applicationId}] Restoring state for {name} ({user})", applicationId, name, alias);
|
|
Logger.LogDebug("[{applicationId}] Removing Temp Collection for {name} ({user})", applicationId, name, alias);
|
|
ResetPenumbraCollection(reason: nameof(Dispose));
|
|
if (!IsVisible)
|
|
{
|
|
Logger.LogDebug("[{applicationId}] Restoring Glamourer for {name} ({user})", applicationId, name, alias);
|
|
_ipcManager.Glamourer.RevertByNameAsync(Logger, name, applicationId).GetAwaiter().GetResult();
|
|
}
|
|
else
|
|
{
|
|
using var cts = new CancellationTokenSource();
|
|
cts.CancelAfter(TimeSpan.FromSeconds(60));
|
|
|
|
var effectiveCachedData = _cachedData ?? _pairStateCache.TryLoad(Ident);
|
|
if (effectiveCachedData is not null)
|
|
{
|
|
_cachedData = effectiveCachedData;
|
|
}
|
|
|
|
Logger.LogInformation("[{applicationId}] CachedData is null {isNull}, contains things: {contains}",
|
|
applicationId, _cachedData == null, _cachedData?.FileReplacements.Any() ?? false);
|
|
|
|
foreach (KeyValuePair<ObjectKind, List<FileReplacementData>> item in _cachedData?.FileReplacements ?? [])
|
|
{
|
|
try
|
|
{
|
|
RevertCustomizationDataAsync(item.Key, name, applicationId, cts.Token).GetAwaiter().GetResult();
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
Logger.LogWarning(ex, "Failed disposing player (not present anymore?)");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarning(ex, "Error on disposal of {name}", name);
|
|
}
|
|
finally
|
|
{
|
|
PlayerName = null;
|
|
_cachedData = null;
|
|
_lastSuccessfulDataHash = null;
|
|
_lastAppliedModdedPaths = null;
|
|
_needsCollectionRebuild = false;
|
|
_performanceMetricsCache.Clear(Ident);
|
|
Logger.LogDebug("Disposing {name} complete", name);
|
|
}
|
|
}
|
|
|
|
private bool IsFrameworkUnloading()
|
|
{
|
|
try
|
|
{
|
|
var prop = _framework.GetType().GetProperty("IsFrameworkUnloading");
|
|
if (prop?.PropertyType == typeof(bool))
|
|
{
|
|
return (bool)prop.GetValue(_framework)!;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private void ScheduleSafeRevertOnDisposal(Guid applicationId, string? name, string alias)
|
|
{
|
|
var cleanupName = !string.IsNullOrEmpty(name) ? name : _lastKnownName;
|
|
var cleanupAddress = _lastKnownAddress != nint.Zero
|
|
? _lastKnownAddress
|
|
: _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Ident);
|
|
var cleanupObjectIndex = _lastKnownObjectIndex;
|
|
var cleanupIdent = Ident;
|
|
var customizeIds = _customizeIds.Values.Where(id => id.HasValue)
|
|
.Select(id => id!.Value)
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
if (string.IsNullOrEmpty(cleanupName)
|
|
&& cleanupAddress == nint.Zero
|
|
&& cleanupObjectIndex == ushort.MaxValue
|
|
&& customizeIds.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_ = Task.Run(() => SafeRevertOnDisposalAsync(
|
|
applicationId,
|
|
cleanupName,
|
|
cleanupAddress,
|
|
cleanupObjectIndex,
|
|
cleanupIdent,
|
|
customizeIds,
|
|
alias));
|
|
}
|
|
|
|
private async Task SafeRevertOnDisposalAsync(
|
|
Guid applicationId,
|
|
string? cleanupName,
|
|
nint cleanupAddress,
|
|
ushort cleanupObjectIndex,
|
|
string cleanupIdent,
|
|
IReadOnlyList<Guid> customizeIds,
|
|
string alias)
|
|
{
|
|
try
|
|
{
|
|
if (IsFrameworkUnloading())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(cleanupName) && _ipcManager.Glamourer.APIAvailable)
|
|
{
|
|
Logger.LogDebug("[{applicationId}] Restoring Glamourer for {name} ({user})", applicationId, cleanupName, alias);
|
|
await _ipcManager.Glamourer.RevertByNameAsync(Logger, cleanupName, applicationId).ConfigureAwait(false);
|
|
}
|
|
|
|
if (_ipcManager.CustomizePlus.APIAvailable && customizeIds.Count > 0)
|
|
{
|
|
foreach (var customizeId in customizeIds)
|
|
{
|
|
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
var address = cleanupAddress;
|
|
if (address == nint.Zero && cleanupObjectIndex != ushort.MaxValue)
|
|
{
|
|
address = await _dalamudUtil.RunOnFrameworkThread(() =>
|
|
{
|
|
var obj = _dalamudUtil.GetCharacterFromObjectTableByIndex(cleanupObjectIndex);
|
|
if (obj is not Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter player)
|
|
{
|
|
return nint.Zero;
|
|
}
|
|
|
|
if (!DalamudUtilService.TryGetHashedCID(player, out var hash)
|
|
|| !string.Equals(hash, cleanupIdent, StringComparison.Ordinal))
|
|
{
|
|
return nint.Zero;
|
|
}
|
|
|
|
return player.Address;
|
|
}).ConfigureAwait(false);
|
|
}
|
|
|
|
if (address == nint.Zero)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_ipcManager.CustomizePlus.APIAvailable)
|
|
{
|
|
await _ipcManager.CustomizePlus.RevertAsync(address).ConfigureAwait(false);
|
|
}
|
|
|
|
if (_ipcManager.Heels.APIAvailable)
|
|
{
|
|
await _ipcManager.Heels.RestoreOffsetForPlayerAsync(address).ConfigureAwait(false);
|
|
}
|
|
|
|
if (_ipcManager.Honorific.APIAvailable)
|
|
{
|
|
await _ipcManager.Honorific.ClearTitleAsync(address).ConfigureAwait(false);
|
|
}
|
|
|
|
if (_ipcManager.Moodles.APIAvailable)
|
|
{
|
|
await _ipcManager.Moodles.RevertStatusAsync(address).ConfigureAwait(false);
|
|
}
|
|
|
|
if (_ipcManager.PetNames.APIAvailable)
|
|
{
|
|
await _ipcManager.PetNames.ClearPlayerData(address).ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogDebug(ex, "Failed shutdown cleanup for {name}", cleanupName ?? cleanupIdent);
|
|
}
|
|
}
|
|
|
|
private async Task<bool> ApplyCustomizationDataAsync(Guid applicationId, KeyValuePair<ObjectKind, HashSet<PlayerChanges>> changes, CharacterData charaData, CancellationToken token)
|
|
{
|
|
if (PlayerCharacter == nint.Zero) return false;
|
|
var ptr = PlayerCharacter;
|
|
|
|
var handler = changes.Key switch
|
|
{
|
|
ObjectKind.Player => _charaHandler!,
|
|
ObjectKind.Companion => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetCompanionPtr(ptr), isWatched: false).ConfigureAwait(false),
|
|
ObjectKind.MinionOrMount => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetMinionOrMountPtr(ptr), isWatched: false).ConfigureAwait(false),
|
|
ObjectKind.Pet => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetPetPtr(ptr), isWatched: false).ConfigureAwait(false),
|
|
_ => throw new NotSupportedException("ObjectKind not supported: " + changes.Key)
|
|
};
|
|
|
|
try
|
|
{
|
|
if (handler.Address == nint.Zero)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
Logger.LogDebug("[{applicationId}] Applying Customization Data for {handler}", applicationId, handler);
|
|
await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false);
|
|
if (handler.ObjectKind != ObjectKind.Player
|
|
&& handler.CurrentDrawCondition == GameObjectHandler.DrawCondition.DrawObjectZero)
|
|
{
|
|
Logger.LogDebug("[{applicationId}] Skipping customization apply for {handler}, draw object not available", applicationId, handler);
|
|
return false;
|
|
}
|
|
|
|
var drawTimeoutMs = handler.ObjectKind == ObjectKind.Player ? 30000 : 5000;
|
|
var fullyLoadedTimeoutMs = handler.ObjectKind == ObjectKind.Player ? FullyLoadedTimeoutMsPlayer : FullyLoadedTimeoutMsOther;
|
|
await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handler, applicationId, drawTimeoutMs, token).ConfigureAwait(false);
|
|
if (handler.Address != nint.Zero)
|
|
{
|
|
var fullyLoaded = await _actorObjectService.WaitForFullyLoadedAsync(handler.Address, token, fullyLoadedTimeoutMs).ConfigureAwait(false);
|
|
if (!fullyLoaded)
|
|
{
|
|
Logger.LogDebug("[{applicationId}] Timed out waiting for {handler} to fully load, skipping customization apply", applicationId, handler);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
token.ThrowIfCancellationRequested();
|
|
var tasks = new List<Task>();
|
|
bool needsRedraw = false;
|
|
foreach (var change in changes.Value.OrderBy(p => (int)p))
|
|
{
|
|
Logger.LogDebug("[{applicationId}] Processing {change} for {handler}", applicationId, change, handler);
|
|
switch (change)
|
|
{
|
|
case PlayerChanges.Customize:
|
|
if (charaData.CustomizePlusData.TryGetValue(changes.Key, out var customizePlusData))
|
|
{
|
|
tasks.Add(ApplyCustomizeAsync(handler.Address, customizePlusData, changes.Key));
|
|
}
|
|
else if (_customizeIds.TryGetValue(changes.Key, out var customizeId))
|
|
{
|
|
tasks.Add(RevertCustomizeAsync(customizeId, changes.Key));
|
|
}
|
|
break;
|
|
|
|
case PlayerChanges.Heels:
|
|
tasks.Add(_ipcManager.Heels.SetOffsetForPlayerAsync(handler.Address, charaData.HeelsData));
|
|
break;
|
|
|
|
case PlayerChanges.Honorific:
|
|
tasks.Add(_ipcManager.Honorific.SetTitleAsync(handler.Address, charaData.HonorificData));
|
|
break;
|
|
|
|
case PlayerChanges.Glamourer:
|
|
if (charaData.GlamourerData.TryGetValue(changes.Key, out var glamourerData))
|
|
{
|
|
tasks.Add(_ipcManager.Glamourer.ApplyAllAsync(Logger, handler, glamourerData, applicationId, token));
|
|
}
|
|
break;
|
|
|
|
case PlayerChanges.Moodles:
|
|
tasks.Add(_ipcManager.Moodles.SetStatusAsync(handler.Address, charaData.MoodlesData));
|
|
break;
|
|
|
|
case PlayerChanges.PetNames:
|
|
tasks.Add(_ipcManager.PetNames.SetPlayerData(handler.Address, charaData.PetNamesData));
|
|
break;
|
|
|
|
case PlayerChanges.ForcedRedraw:
|
|
needsRedraw = true;
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
token.ThrowIfCancellationRequested();
|
|
}
|
|
|
|
if (tasks.Count > 0)
|
|
{
|
|
await Task.WhenAll(tasks).ConfigureAwait(false);
|
|
}
|
|
|
|
if (needsRedraw)
|
|
{
|
|
await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
finally
|
|
{
|
|
if (handler != _charaHandler) handler.Dispose();
|
|
}
|
|
}
|
|
|
|
|
|
private static Dictionary<ObjectKind, HashSet<PlayerChanges>> BuildFullChangeSet(CharacterData characterData)
|
|
{
|
|
var result = new Dictionary<ObjectKind, HashSet<PlayerChanges>>();
|
|
|
|
foreach (var objectKind in Enum.GetValues<ObjectKind>())
|
|
{
|
|
var changes = new HashSet<PlayerChanges>();
|
|
|
|
if (characterData.FileReplacements.TryGetValue(objectKind, out var replacements) && replacements.Count > 0)
|
|
{
|
|
changes.Add(PlayerChanges.ModFiles);
|
|
if (objectKind == ObjectKind.Player)
|
|
{
|
|
changes.Add(PlayerChanges.ForcedRedraw);
|
|
}
|
|
}
|
|
|
|
if (characterData.GlamourerData.TryGetValue(objectKind, out var glamourer) && !string.IsNullOrEmpty(glamourer))
|
|
{
|
|
changes.Add(PlayerChanges.Glamourer);
|
|
}
|
|
|
|
if (characterData.CustomizePlusData.TryGetValue(objectKind, out var customize) && !string.IsNullOrEmpty(customize))
|
|
{
|
|
changes.Add(PlayerChanges.Customize);
|
|
}
|
|
|
|
if (objectKind == ObjectKind.Player)
|
|
{
|
|
if (!string.IsNullOrEmpty(characterData.ManipulationData))
|
|
{
|
|
changes.Add(PlayerChanges.ModManip);
|
|
changes.Add(PlayerChanges.ForcedRedraw);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(characterData.HeelsData))
|
|
{
|
|
changes.Add(PlayerChanges.Heels);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(characterData.HonorificData))
|
|
{
|
|
changes.Add(PlayerChanges.Honorific);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(characterData.MoodlesData))
|
|
{
|
|
changes.Add(PlayerChanges.Moodles);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(characterData.PetNamesData))
|
|
{
|
|
changes.Add(PlayerChanges.PetNames);
|
|
}
|
|
}
|
|
|
|
if (changes.Count > 0)
|
|
{
|
|
result[objectKind] = changes;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static bool PlayerModFilesChanged(CharacterData newData, CharacterData? previousData)
|
|
{
|
|
return !FileReplacementListsEqual(
|
|
TryGetFileReplacementList(newData, ObjectKind.Player),
|
|
TryGetFileReplacementList(previousData, ObjectKind.Player));
|
|
}
|
|
|
|
private static IReadOnlyCollection<FileReplacementData>? TryGetFileReplacementList(CharacterData? data, ObjectKind objectKind)
|
|
{
|
|
if (data is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return data.FileReplacements.TryGetValue(objectKind, out var list) ? list : null;
|
|
}
|
|
|
|
private static bool FileReplacementListsEqual(IReadOnlyCollection<FileReplacementData>? left, IReadOnlyCollection<FileReplacementData>? right)
|
|
{
|
|
if (left is null || left.Count == 0)
|
|
{
|
|
return right is null || right.Count == 0;
|
|
}
|
|
|
|
if (right is null || right.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var comparer = FileReplacementDataComparer.Instance;
|
|
return !left.Except(right, comparer).Any() && !right.Except(left, comparer).Any();
|
|
}
|
|
|
|
private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData, bool forceFullReapply)
|
|
{
|
|
if (!updatedData.Any())
|
|
{
|
|
if (forceFullReapply)
|
|
{
|
|
updatedData = BuildFullChangeSet(charaData);
|
|
}
|
|
|
|
if (!updatedData.Any())
|
|
{
|
|
Logger.LogDebug("[BASE-{appBase}] Nothing to update for {obj}", applicationBase, GetLogIdentifier());
|
|
_forceFullReapply = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
var updateModdedPaths = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModFiles));
|
|
var updateManip = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModManip));
|
|
var needsCollectionRebuild = _needsCollectionRebuild;
|
|
var reuseCachedModdedPaths = !updateModdedPaths && needsCollectionRebuild && _lastAppliedModdedPaths is not null;
|
|
updateModdedPaths = updateModdedPaths || needsCollectionRebuild;
|
|
updateManip = updateManip || needsCollectionRebuild;
|
|
Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths = null;
|
|
if (reuseCachedModdedPaths)
|
|
{
|
|
if (HasValidCachedModdedPaths())
|
|
{
|
|
cachedModdedPaths = _lastAppliedModdedPaths;
|
|
}
|
|
else
|
|
{
|
|
Logger.LogDebug("{handler}: Cached files missing, recalculating mappings", GetLogIdentifier());
|
|
_lastAppliedModdedPaths = null;
|
|
}
|
|
}
|
|
|
|
_downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource();
|
|
var downloadToken = _downloadCancellationTokenSource.Token;
|
|
_ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, cachedModdedPaths, downloadToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
private Task? _pairDownloadTask;
|
|
private Task _visibilityGraceTask;
|
|
|
|
private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData,
|
|
bool updateModdedPaths, bool updateManip, Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths, CancellationToken downloadToken)
|
|
{
|
|
var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
bool skipDownscaleForPair = ShouldSkipDownscale();
|
|
bool skipDecimationForPair = ShouldSkipDecimation();
|
|
var user = GetPrimaryUserData();
|
|
Dictionary<(string GamePath, string? Hash), string> moddedPaths;
|
|
List<FileReplacementData> missingReplacements = [];
|
|
|
|
if (updateModdedPaths)
|
|
{
|
|
if (cachedModdedPaths is not null)
|
|
{
|
|
moddedPaths = new Dictionary<(string GamePath, string? Hash), string>(cachedModdedPaths, cachedModdedPaths.Comparer);
|
|
}
|
|
else
|
|
{
|
|
int attempts = 0;
|
|
List<FileReplacementData> toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken);
|
|
missingReplacements = toDownloadReplacements;
|
|
|
|
while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested)
|
|
{
|
|
if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted)
|
|
{
|
|
Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData);
|
|
await _pairDownloadTask.ConfigureAwait(false);
|
|
}
|
|
|
|
Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData);
|
|
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational,
|
|
$"Starting download for {toDownloadReplacements.Count} files")));
|
|
var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false);
|
|
|
|
if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles))
|
|
{
|
|
RecordFailure("Auto pause triggered by VRAM usage thresholds", "VRAMThreshold");
|
|
_downloadManager.ClearDownload();
|
|
return;
|
|
}
|
|
|
|
var handlerForDownload = _charaHandler;
|
|
_pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, downloadToken, skipDownscaleForPair, skipDecimationForPair).ConfigureAwait(false));
|
|
|
|
await _pairDownloadTask.ConfigureAwait(false);
|
|
|
|
if (downloadToken.IsCancellationRequested)
|
|
{
|
|
Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase);
|
|
RecordFailure("Download cancelled", "Cancellation");
|
|
return;
|
|
}
|
|
|
|
if (!skipDownscaleForPair)
|
|
{
|
|
var downloadedTextureHashes = toDownloadReplacements
|
|
.Where(static replacement => replacement.GamePaths.Any(static path => path.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)))
|
|
.Select(static replacement => replacement.Hash)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
if (downloadedTextureHashes.Count > 0)
|
|
{
|
|
await _textureDownscaleService.WaitForPendingJobsAsync(downloadedTextureHashes, downloadToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
if (!skipDecimationForPair)
|
|
{
|
|
var downloadedModelHashes = toDownloadReplacements
|
|
.Where(static replacement => replacement.GamePaths.Any(static path => path.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)))
|
|
.Select(static replacement => replacement.Hash)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
if (downloadedModelHashes.Count > 0)
|
|
{
|
|
await _modelDecimationService.WaitForPendingJobsAsync(downloadedModelHashes, downloadToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken);
|
|
missingReplacements = toDownloadReplacements;
|
|
|
|
if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal))))
|
|
{
|
|
break;
|
|
}
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(2), downloadToken).ConfigureAwait(false);
|
|
}
|
|
|
|
if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false))
|
|
{
|
|
RecordFailure("Auto pause triggered by performance thresholds", "PerformanceThreshold");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
moddedPaths = cachedModdedPaths is not null
|
|
? new Dictionary<(string GamePath, string? Hash), string>(cachedModdedPaths, cachedModdedPaths.Comparer)
|
|
: [];
|
|
}
|
|
|
|
var wantsModApply = updateModdedPaths || updateManip;
|
|
var pendingModReapply = false;
|
|
var deferModApply = false;
|
|
|
|
if (wantsModApply && missingReplacements.Count > 0)
|
|
{
|
|
CountMissingReplacements(missingReplacements, out var missingCritical, out var missingNonCritical, out var missingForbidden);
|
|
_lastMissingCriticalMods = missingCritical;
|
|
_lastMissingNonCriticalMods = missingNonCritical;
|
|
_lastMissingForbiddenMods = missingForbidden;
|
|
|
|
var hasCriticalMissing = missingCritical > 0;
|
|
var hasNonCriticalMissing = missingNonCritical > 0;
|
|
var hasDownloadableMissing = missingReplacements.Any(replacement => !IsForbiddenHash(replacement.Hash));
|
|
var hasDownloadableCriticalMissing = hasCriticalMissing
|
|
&& missingReplacements.Any(replacement => !IsForbiddenHash(replacement.Hash) && IsCriticalModReplacement(replacement));
|
|
|
|
pendingModReapply = hasDownloadableMissing;
|
|
_lastModApplyDeferred = false;
|
|
|
|
if (hasDownloadableCriticalMissing)
|
|
{
|
|
deferModApply = true;
|
|
_lastModApplyDeferred = true;
|
|
Logger.LogDebug("[BASE-{appBase}] Critical mod files missing for {handler}, deferring mod apply ({count} missing)",
|
|
applicationBase, GetLogIdentifier(), missingReplacements.Count);
|
|
}
|
|
else if (hasNonCriticalMissing && hasDownloadableMissing)
|
|
{
|
|
Logger.LogDebug("[BASE-{appBase}] Non-critical mod files missing for {handler}, applying partial mods and reapplying after downloads ({count} missing)",
|
|
applicationBase, GetLogIdentifier(), missingReplacements.Count);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_lastMissingCriticalMods = 0;
|
|
_lastMissingNonCriticalMods = 0;
|
|
_lastMissingForbiddenMods = 0;
|
|
_lastModApplyDeferred = false;
|
|
}
|
|
|
|
if (deferModApply)
|
|
{
|
|
updateModdedPaths = false;
|
|
updateManip = false;
|
|
RemoveModApplyChanges(updatedData);
|
|
}
|
|
|
|
downloadToken.ThrowIfCancellationRequested();
|
|
|
|
var handlerForApply = _charaHandler;
|
|
if (handlerForApply is null || handlerForApply.Address == nint.Zero)
|
|
{
|
|
Logger.LogDebug("[BASE-{appBase}] Handler not available for {player}, cached data for later application", applicationBase, GetLogIdentifier());
|
|
_cachedData = charaData;
|
|
_pairStateCache.Store(Ident, charaData);
|
|
_forceFullReapply = true;
|
|
RecordFailure("Handler not available for application", "HandlerUnavailable");
|
|
return;
|
|
}
|
|
|
|
var appToken = _applicationCancellationTokenSource?.Token;
|
|
while ((!_applicationTask?.IsCompleted ?? false)
|
|
&& !downloadToken.IsCancellationRequested
|
|
&& (!appToken?.IsCancellationRequested ?? false))
|
|
{
|
|
Logger.LogDebug("[BASE-{appBase}] Waiting for current data application (Id: {id}) for player ({handler}) to finish",
|
|
applicationBase, _applicationId, PlayerName);
|
|
await Task.Delay(250).ConfigureAwait(false);
|
|
}
|
|
|
|
if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false))
|
|
{
|
|
_forceFullReapply = true;
|
|
RecordFailure("Application cancelled", "Cancellation");
|
|
return;
|
|
}
|
|
|
|
_applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource();
|
|
var token = _applicationCancellationTokenSource.Token;
|
|
|
|
_applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, wantsModApply, pendingModReapply, token);
|
|
}
|
|
finally
|
|
{
|
|
await concurrencyLease.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async Task ApplyCharacterDataAsync(Guid applicationBase, GameObjectHandler handlerForApply, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData, bool updateModdedPaths, bool updateManip,
|
|
Dictionary<(string GamePath, string? Hash), string> moddedPaths, bool wantsModApply, bool pendingModReapply, CancellationToken token)
|
|
{
|
|
try
|
|
{
|
|
_applicationId = Guid.NewGuid();
|
|
Logger.LogDebug("[BASE-{applicationId}] Starting application task for {handler}: {appId}", applicationBase, GetLogIdentifier(), _applicationId);
|
|
|
|
Logger.LogDebug("[{applicationId}] Waiting for initial draw for for {handler}", _applicationId, handlerForApply);
|
|
await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handlerForApply, _applicationId, 30000, token).ConfigureAwait(false);
|
|
if (handlerForApply.Address != nint.Zero)
|
|
{
|
|
var fullyLoaded = await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token, FullyLoadedTimeoutMsPlayer).ConfigureAwait(false);
|
|
if (!fullyLoaded)
|
|
{
|
|
Logger.LogDebug("[BASE-{applicationId}] Timed out waiting for {handler} to fully load, caching data for later application",
|
|
applicationBase, GetLogIdentifier());
|
|
_cachedData = charaData;
|
|
_pairStateCache.Store(Ident, charaData);
|
|
_forceFullReapply = true;
|
|
RecordFailure("Actor not fully loaded within timeout", "FullyLoadedTimeout");
|
|
return;
|
|
}
|
|
}
|
|
|
|
token.ThrowIfCancellationRequested();
|
|
|
|
Guid penumbraCollection = Guid.Empty;
|
|
if (updateModdedPaths || updateManip)
|
|
{
|
|
penumbraCollection = EnsurePenumbraCollection();
|
|
if (penumbraCollection == Guid.Empty)
|
|
{
|
|
Logger.LogTrace("[BASE-{applicationId}] Penumbra collection unavailable for {handler}, caching data for later application", applicationBase, GetLogIdentifier());
|
|
_cachedData = charaData;
|
|
_pairStateCache.Store(Ident, charaData);
|
|
_forceFullReapply = true;
|
|
RecordFailure("Penumbra collection unavailable", "PenumbraUnavailable");
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (updateModdedPaths)
|
|
{
|
|
// ensure collection is set
|
|
var objIndex = await _dalamudUtil.RunOnFrameworkThread(() =>
|
|
{
|
|
var gameObject = handlerForApply.GetGameObject();
|
|
return gameObject?.ObjectIndex;
|
|
}).ConfigureAwait(false);
|
|
|
|
if (!objIndex.HasValue)
|
|
{
|
|
Logger.LogDebug("[BASE-{applicationId}] GameObject not available for {handler}, caching data for later application", applicationBase, GetLogIdentifier());
|
|
_cachedData = charaData;
|
|
_pairStateCache.Store(Ident, charaData);
|
|
_forceFullReapply = true;
|
|
RecordFailure("Game object not available for application", "GameObjectUnavailable");
|
|
return;
|
|
}
|
|
|
|
SplitPapMappings(moddedPaths, out var withoutPap, out var papOnly);
|
|
|
|
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false);
|
|
|
|
await _ipcManager.Penumbra.SetTemporaryModsAsync(
|
|
Logger, _applicationId, penumbraCollection,
|
|
withoutPap.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal))
|
|
.ConfigureAwait(false);
|
|
|
|
await _ipcManager.Penumbra.RedrawAsync(Logger, handlerForApply, _applicationId, token).ConfigureAwait(false);
|
|
if (handlerForApply.Address != nint.Zero)
|
|
await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false);
|
|
|
|
var removedPap = await StripIncompatiblePapAsync(handlerForApply, charaData, papOnly, token).ConfigureAwait(false);
|
|
if (removedPap > 0)
|
|
{
|
|
Logger.LogTrace("[{applicationId}] Removed {removedPap} incompatible PAP mappings found for {handler}", _applicationId, removedPap, GetLogIdentifier());
|
|
}
|
|
|
|
var merged = new Dictionary<(string GamePath, string? Hash), string>(withoutPap, withoutPap.Comparer);
|
|
foreach (var kv in papOnly)
|
|
merged[kv.Key] = kv.Value;
|
|
|
|
await _ipcManager.Penumbra.SetTemporaryModsAsync(
|
|
Logger, _applicationId, penumbraCollection,
|
|
merged.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal))
|
|
.ConfigureAwait(false);
|
|
|
|
_lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(merged, merged.Comparer);
|
|
|
|
LastAppliedDataBytes = -1;
|
|
foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists))
|
|
{
|
|
if (LastAppliedDataBytes == -1) LastAppliedDataBytes = 0;
|
|
|
|
LastAppliedDataBytes += path.Length;
|
|
}
|
|
}
|
|
|
|
if (updateManip)
|
|
{
|
|
await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, _applicationId, penumbraCollection, charaData.ManipulationData).ConfigureAwait(false);
|
|
}
|
|
|
|
token.ThrowIfCancellationRequested();
|
|
|
|
foreach (var kind in updatedData)
|
|
{
|
|
var applied = await ApplyCustomizationDataAsync(_applicationId, kind, charaData, token).ConfigureAwait(false);
|
|
if (applied)
|
|
{
|
|
ClearOwnedObjectRetry(kind.Key);
|
|
}
|
|
else if (kind.Key != ObjectKind.Player)
|
|
{
|
|
ScheduleOwnedObjectRetry(kind.Key, kind.Value);
|
|
}
|
|
token.ThrowIfCancellationRequested();
|
|
}
|
|
|
|
_cachedData = charaData;
|
|
_pairStateCache.Store(Ident, charaData);
|
|
if (wantsModApply)
|
|
{
|
|
_pendingModReapply = pendingModReapply;
|
|
}
|
|
_forceFullReapply = _pendingModReapply;
|
|
_needsCollectionRebuild = false;
|
|
if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0)
|
|
{
|
|
_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List<DownloadFileTransfer>());
|
|
}
|
|
|
|
if (LastAppliedDataTris < 0 || LastAppliedApproximateEffectiveTris < 0)
|
|
{
|
|
await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(false);
|
|
}
|
|
|
|
StorePerformanceMetrics(charaData);
|
|
_lastSuccessfulDataHash = GetDataHashSafe(charaData);
|
|
_lastSuccessfulApplyAt = DateTime.UtcNow;
|
|
ClearFailureState();
|
|
Logger.LogDebug("[{applicationId}] Application finished", _applicationId);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier());
|
|
_cachedData = charaData;
|
|
_pairStateCache.Store(Ident, charaData);
|
|
_forceFullReapply = true;
|
|
RecordFailure("Application cancelled", "Cancellation");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException))
|
|
{
|
|
IsVisible = false;
|
|
_forceApplyMods = true;
|
|
_cachedData = charaData;
|
|
_pairStateCache.Store(Ident, charaData);
|
|
_forceFullReapply = true;
|
|
Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId);
|
|
}
|
|
else
|
|
{
|
|
Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId);
|
|
_forceFullReapply = true;
|
|
}
|
|
RecordFailure($"Application failed: {ex.Message}", "Exception");
|
|
}
|
|
}
|
|
|
|
private void FrameworkUpdate()
|
|
{
|
|
if (string.IsNullOrEmpty(PlayerName) && _charaHandler is null)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
if (now < _nextActorLookupUtc)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_nextActorLookupUtc = now + ActorLookupInterval;
|
|
var pc = _dalamudUtil.FindPlayerByNameHash(Ident);
|
|
if (pc == default((string, nint))) return;
|
|
Logger.LogDebug("One-Time Initializing {handler}", GetLogIdentifier());
|
|
Initialize(pc.Name);
|
|
Logger.LogDebug("One-Time Initialized {handler}", GetLogIdentifier());
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, GetPrimaryUserData(), nameof(PairHandlerAdapter), EventSeverity.Informational,
|
|
$"Initializing User For Character {pc.Name}")));
|
|
}
|
|
|
|
TryHandleVisibilityUpdate();
|
|
}
|
|
|
|
private void TryHandleVisibilityUpdate()
|
|
{
|
|
if (_charaHandler?.Address != nint.Zero && !IsVisible && !_pauseRequested)
|
|
{
|
|
Guid appData = Guid.NewGuid();
|
|
IsVisible = true;
|
|
if (_cachedData is not null)
|
|
{
|
|
var cachedData = _cachedData;
|
|
Logger.LogTrace("[BASE-{appBase}] {handler} visibility changed, now: {visi}, cached data exists", appData, GetLogIdentifier(), IsVisible);
|
|
|
|
_ = Task.Run(() =>
|
|
{
|
|
try
|
|
{
|
|
_forceFullReapply = true;
|
|
ApplyCharacterData(appData, cachedData!, forceApplyCustomization: true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "[BASE-{appBase}] Failed to apply cached character data for {handler}", appData, GetLogIdentifier());
|
|
}
|
|
});
|
|
}
|
|
else if (LastReceivedCharacterData is not null)
|
|
{
|
|
Logger.LogTrace("[BASE-{appBase}] {handler} visibility changed, now: {visi}, last received data exists", appData, GetLogIdentifier(), IsVisible);
|
|
|
|
_ = Task.Run(() =>
|
|
{
|
|
try
|
|
{
|
|
_forceFullReapply = true;
|
|
ApplyLastReceivedData(forced: true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "[BASE-{appBase}] Failed to reapply last received data for {handler}", appData, GetLogIdentifier());
|
|
}
|
|
});
|
|
}
|
|
else
|
|
{
|
|
Logger.LogTrace("{handler} visibility changed, now: {visi}, no cached or received data exists", GetLogIdentifier(), IsVisible);
|
|
}
|
|
}
|
|
else if (_charaHandler?.Address == nint.Zero && IsVisible)
|
|
{
|
|
HandleVisibilityLoss(logChange: true);
|
|
}
|
|
|
|
TryApplyQueuedData();
|
|
}
|
|
|
|
private void HandleVisibilityLoss(bool logChange)
|
|
{
|
|
IsVisible = false;
|
|
_charaHandler?.Invalidate();
|
|
ClearAllOwnedObjectRetries();
|
|
_downloadCancellationTokenSource?.CancelDispose();
|
|
_downloadCancellationTokenSource = null;
|
|
if (logChange)
|
|
{
|
|
Logger.LogTrace("{handler} visibility changed, now: {visi}", GetLogIdentifier(), IsVisible);
|
|
}
|
|
}
|
|
|
|
private void Initialize(string name)
|
|
{
|
|
PlayerName = name;
|
|
_charaHandler = _gameObjectHandlerFactory.Create(ObjectKind.Player, () => _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Ident), isWatched: false).GetAwaiter().GetResult();
|
|
UpdateLastKnownActor(_charaHandler.Address, name);
|
|
|
|
var user = GetPrimaryUserData();
|
|
if (!string.IsNullOrEmpty(user.UID))
|
|
{
|
|
_serverConfigManager.AutoPopulateNoteForUid(user.UID, name);
|
|
}
|
|
|
|
Mediator.Subscribe<HonorificReadyMessage>(this, _message =>
|
|
{
|
|
var honorificData = _cachedData?.HonorificData;
|
|
if (string.IsNullOrEmpty(honorificData))
|
|
return;
|
|
|
|
_ = ReapplyHonorificAsync(honorificData!);
|
|
});
|
|
|
|
Mediator.Subscribe<PetNamesReadyMessage>(this, _message =>
|
|
{
|
|
var petNamesData = _cachedData?.PetNamesData;
|
|
if (string.IsNullOrEmpty(petNamesData))
|
|
return;
|
|
|
|
_ = ReapplyPetNamesAsync(petNamesData!);
|
|
});
|
|
}
|
|
|
|
private async Task ReapplyHonorificAsync(string honorificData)
|
|
{
|
|
Logger.LogTrace("Reapplying Honorific data for {handler}", GetLogIdentifier());
|
|
await _ipcManager.Honorific.SetTitleAsync(PlayerCharacter, honorificData).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task ReapplyPetNamesAsync(string petNamesData)
|
|
{
|
|
Logger.LogTrace("Reapplying Pet Names data for {handler}", GetLogIdentifier());
|
|
await _ipcManager.PetNames.SetPlayerData(PlayerCharacter, petNamesData).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task RevertCustomizationDataAsync(ObjectKind objectKind, string name, Guid applicationId, CancellationToken cancelToken)
|
|
{
|
|
nint address = _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Ident);
|
|
if (address == nint.Zero) return;
|
|
|
|
var alias = GetPrimaryAliasOrUid();
|
|
Logger.LogDebug("[{applicationId}] Reverting all Customization for {alias}/{name} {objectKind}", applicationId, alias, name, objectKind);
|
|
|
|
if (_customizeIds.TryGetValue(objectKind, out var customizeId))
|
|
{
|
|
_customizeIds.Remove(objectKind);
|
|
}
|
|
|
|
if (objectKind == ObjectKind.Player)
|
|
{
|
|
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Player, () => address, isWatched: false).ConfigureAwait(false);
|
|
tempHandler.CompareNameAndThrow(name);
|
|
Logger.LogDebug("[{applicationId}] Restoring Customization and Equipment for {alias}/{name}", applicationId, alias, name);
|
|
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
|
|
tempHandler.CompareNameAndThrow(name);
|
|
Logger.LogDebug("[{applicationId}] Restoring Heels for {alias}/{name}", applicationId, alias, name);
|
|
await _ipcManager.Heels.RestoreOffsetForPlayerAsync(address).ConfigureAwait(false);
|
|
tempHandler.CompareNameAndThrow(name);
|
|
Logger.LogDebug("[{applicationId}] Restoring C+ for {alias}/{name}", applicationId, alias, name);
|
|
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
|
|
tempHandler.CompareNameAndThrow(name);
|
|
Logger.LogDebug("[{applicationId}] Restoring Honorific for {alias}/{name}", applicationId, alias, name);
|
|
await _ipcManager.Honorific.ClearTitleAsync(address).ConfigureAwait(false);
|
|
Logger.LogDebug("[{applicationId}] Restoring Moodles for {alias}/{name}", applicationId, alias, name);
|
|
await _ipcManager.Moodles.RevertStatusAsync(address).ConfigureAwait(false);
|
|
Logger.LogDebug("[{applicationId}] Restoring Pet Nicknames for {alias}/{name}", applicationId, alias, name);
|
|
await _ipcManager.PetNames.ClearPlayerData(address).ConfigureAwait(false);
|
|
}
|
|
else if (objectKind == ObjectKind.MinionOrMount)
|
|
{
|
|
var minionOrMount = await _dalamudUtil.GetMinionOrMountAsync(address).ConfigureAwait(false);
|
|
if (minionOrMount != nint.Zero)
|
|
{
|
|
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
|
|
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.MinionOrMount, () => minionOrMount, isWatched: false).ConfigureAwait(false);
|
|
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
|
|
await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
else if (objectKind == ObjectKind.Pet)
|
|
{
|
|
var pet = await _dalamudUtil.GetPetAsync(address).ConfigureAwait(false);
|
|
if (pet != nint.Zero)
|
|
{
|
|
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
|
|
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Pet, () => pet, isWatched: false).ConfigureAwait(false);
|
|
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
|
|
await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
else if (objectKind == ObjectKind.Companion)
|
|
{
|
|
var companion = await _dalamudUtil.GetCompanionAsync(address).ConfigureAwait(false);
|
|
if (companion != nint.Zero)
|
|
{
|
|
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
|
|
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Companion, () => companion, isWatched: false).ConfigureAwait(false);
|
|
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
|
|
await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
private List<FileReplacementData> TryCalculateModdedDictionary(Guid applicationBase, CharacterData charaData, out Dictionary<(string GamePath, string? Hash), string> moddedDictionary, CancellationToken token)
|
|
{
|
|
Stopwatch st = Stopwatch.StartNew();
|
|
ConcurrentBag<FileReplacementData> missingFiles = [];
|
|
moddedDictionary = [];
|
|
ConcurrentDictionary<(string GamePath, string? Hash), string> outputDict = new();
|
|
bool hasMigrationChanges = false;
|
|
bool skipDownscaleForPair = ShouldSkipDownscale();
|
|
bool skipDecimationForPair = ShouldSkipDecimation();
|
|
|
|
try
|
|
{
|
|
RefreshPapBlockCacheIfAnimSettingsChanged();
|
|
|
|
var replacementList = charaData.FileReplacements.SelectMany(k => k.Value.Where(v => string.IsNullOrEmpty(v.FileSwapPath))).ToList();
|
|
Parallel.ForEach(replacementList, new ParallelOptions()
|
|
{
|
|
CancellationToken = token,
|
|
MaxDegreeOfParallelism = 4
|
|
},
|
|
(item) =>
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var fileCache = _fileDbManager.GetFileCacheByHash(item.Hash);
|
|
if (fileCache is not null && !File.Exists(fileCache.ResolvedFilepath))
|
|
{
|
|
Logger.LogTrace("[BASE-{appBase}] Cached path {Path} missing on disk for hash {Hash}, removing cache entry", applicationBase, fileCache.ResolvedFilepath, item.Hash);
|
|
_fileDbManager.RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
|
|
fileCache = null;
|
|
}
|
|
|
|
if (fileCache != null)
|
|
{
|
|
if (string.IsNullOrEmpty(new FileInfo(fileCache.ResolvedFilepath).Extension))
|
|
{
|
|
hasMigrationChanges = true;
|
|
var anyGamePath = item.GamePaths.FirstOrDefault();
|
|
|
|
if (!string.IsNullOrEmpty(anyGamePath))
|
|
{
|
|
var ext = Path.GetExtension(anyGamePath);
|
|
var extNoDot = ext.StartsWith('.') ? ext[1..] : ext;
|
|
|
|
if (!string.IsNullOrEmpty(extNoDot))
|
|
{
|
|
hasMigrationChanges = true;
|
|
fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, extNoDot);
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var gamePath in item.GamePaths)
|
|
{
|
|
var mode = _configService.Current.AnimationValidationMode;
|
|
|
|
if (mode != AnimationValidationMode.Unsafe
|
|
&& gamePath.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)
|
|
&& !string.IsNullOrEmpty(item.Hash)
|
|
&& _blockedPapHashes.ContainsKey(item.Hash))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var preferredPath = skipDownscaleForPair
|
|
? fileCache.ResolvedFilepath
|
|
: _textureDownscaleService.GetPreferredPath(item.Hash, fileCache.ResolvedFilepath);
|
|
|
|
outputDict[(gamePath, item.Hash)] = preferredPath;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Logger.LogTrace("Missing file: {hash}", item.Hash);
|
|
missingFiles.Add(item);
|
|
}
|
|
});
|
|
|
|
moddedDictionary = outputDict.ToDictionary(k => k.Key, k => k.Value);
|
|
|
|
foreach (var item in charaData.FileReplacements.SelectMany(k => k.Value.Where(v => !string.IsNullOrEmpty(v.FileSwapPath))).ToList())
|
|
{
|
|
foreach (var gamePath in item.GamePaths)
|
|
{
|
|
Logger.LogTrace("[BASE-{appBase}] Adding file swap for {path}: {fileSwap}", applicationBase, gamePath, item.FileSwapPath);
|
|
moddedDictionary[(gamePath, null)] = item.FileSwapPath;
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
Logger.LogTrace("[BASE-{appBase}] Modded path calculation cancelled", applicationBase);
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "[BASE-{appBase}] Something went wrong during calculation replacements", applicationBase);
|
|
}
|
|
if (hasMigrationChanges) _fileDbManager.WriteOutFullCsv();
|
|
st.Stop();
|
|
Logger.LogDebug("[BASE-{appBase}] ModdedPaths calculated in {time}ms, missing files: {count}, total files: {total}", applicationBase, st.ElapsedMilliseconds, missingFiles.Count, moddedDictionary.Keys.Count);
|
|
return [.. missingFiles];
|
|
}
|
|
|
|
private async Task PauseInternalAsync()
|
|
{
|
|
try
|
|
{
|
|
Logger.LogDebug("Pausing handler {handler}", GetLogIdentifier());
|
|
DisableSync();
|
|
|
|
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
|
|
{
|
|
IsVisible = false;
|
|
return;
|
|
}
|
|
|
|
var applicationId = Guid.NewGuid();
|
|
await RevertToRestoredAsync(applicationId).ConfigureAwait(false);
|
|
IsVisible = false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarning(ex, "Failed to pause handler {handler}", GetLogIdentifier());
|
|
}
|
|
}
|
|
|
|
private async Task ResumeInternalAsync()
|
|
{
|
|
try
|
|
{
|
|
Logger.LogDebug("Resuming handler {handler}", GetLogIdentifier());
|
|
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!IsVisible)
|
|
{
|
|
IsVisible = true;
|
|
}
|
|
|
|
if (LastReceivedCharacterData is not null)
|
|
{
|
|
ApplyLastReceivedData(forced: true);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarning(ex, "Failed to resume handler {handler}", GetLogIdentifier());
|
|
}
|
|
}
|
|
|
|
private async Task RevertToRestoredAsync(Guid applicationId)
|
|
{
|
|
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var gameObject = await _dalamudUtil.RunOnFrameworkThread(() => _charaHandler.GetGameObject()).ConfigureAwait(false);
|
|
if (gameObject is not Dalamud.Game.ClientState.Objects.Types.ICharacter character)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_ipcManager.Penumbra.APIAvailable)
|
|
{
|
|
var penumbraCollection = EnsurePenumbraCollection();
|
|
if (penumbraCollection != Guid.Empty)
|
|
{
|
|
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, character.ObjectIndex).ConfigureAwait(false);
|
|
await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, applicationId, penumbraCollection, new Dictionary<string, string>(StringComparer.Ordinal)).ConfigureAwait(false);
|
|
await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, applicationId, penumbraCollection, string.Empty).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
var kinds = new HashSet<ObjectKind>(_customizeIds.Keys);
|
|
if (_cachedData is not null)
|
|
{
|
|
foreach (var kind in _cachedData.FileReplacements.Keys)
|
|
{
|
|
kinds.Add(kind);
|
|
}
|
|
}
|
|
|
|
kinds.Add(ObjectKind.Player);
|
|
|
|
var characterName = character.Name.TextValue;
|
|
if (string.IsNullOrEmpty(characterName))
|
|
{
|
|
characterName = character.Name.ToString();
|
|
}
|
|
if (string.IsNullOrEmpty(characterName))
|
|
{
|
|
Logger.LogWarning("[{applicationId}] Failed to determine character name for {handler} while reverting", applicationId, GetLogIdentifier());
|
|
return;
|
|
}
|
|
|
|
foreach (var kind in kinds)
|
|
{
|
|
await RevertCustomizationDataAsync(kind, characterName, applicationId, CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
|
|
_cachedData = null;
|
|
LastAppliedDataBytes = -1;
|
|
LastAppliedDataTris = -1;
|
|
LastAppliedApproximateEffectiveTris = -1;
|
|
LastAppliedApproximateVRAMBytes = -1;
|
|
LastAppliedApproximateEffectiveVRAMBytes = -1;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarning(ex, "Failed to revert handler {handler} during pause", GetLogIdentifier());
|
|
}
|
|
}
|
|
|
|
private void DisableSync()
|
|
{
|
|
_downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate();
|
|
_applicationCancellationTokenSource = _applicationCancellationTokenSource?.CancelRecreate();
|
|
}
|
|
|
|
private void EnableSync()
|
|
{
|
|
TryApplyQueuedData();
|
|
}
|
|
|
|
private void TryApplyQueuedData()
|
|
{
|
|
var pending = _dataReceivedInDowntime;
|
|
if (pending is null || !IsVisible)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!CanApplyNow())
|
|
{
|
|
return;
|
|
}
|
|
|
|
_dataReceivedInDowntime = null;
|
|
_ = Task.Run(() =>
|
|
{
|
|
try
|
|
{
|
|
ApplyCharacterData(pending.ApplicationId, pending.CharacterData, pending.Forced);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Failed applying queued data for {handler}", GetLogIdentifier());
|
|
}
|
|
});
|
|
}
|
|
|
|
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
|
|
{
|
|
if (!TryResolveDescriptorHash(descriptor, out var hashedCid))
|
|
return;
|
|
|
|
if (!string.Equals(hashedCid, Ident, StringComparison.Ordinal))
|
|
return;
|
|
|
|
if (descriptor.Address == nint.Zero)
|
|
return;
|
|
|
|
UpdateLastKnownActor(descriptor);
|
|
RefreshTrackedHandler(descriptor);
|
|
QueueActorInitialization(descriptor);
|
|
}
|
|
|
|
private void QueueActorInitialization(ActorObjectService.ActorDescriptor descriptor)
|
|
{
|
|
lock (_actorInitializationGate)
|
|
{
|
|
_pendingActorDescriptor = descriptor;
|
|
if (_actorInitializationInProgress)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_actorInitializationInProgress = true;
|
|
}
|
|
|
|
_ = Task.Run(InitializeFromTrackedAsync);
|
|
}
|
|
|
|
private async Task InitializeFromTrackedAsync()
|
|
{
|
|
try
|
|
{
|
|
await ActorInitializationLimiter.WaitAsync().ConfigureAwait(false);
|
|
while (true)
|
|
{
|
|
ActorObjectService.ActorDescriptor? descriptor;
|
|
lock (_actorInitializationGate)
|
|
{
|
|
descriptor = _pendingActorDescriptor;
|
|
_pendingActorDescriptor = null;
|
|
}
|
|
|
|
if (!descriptor.HasValue)
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (_frameworkUpdateSubscribed && _actorObjectService.HooksActive)
|
|
{
|
|
Mediator.Unsubscribe<FrameworkUpdateMessage>(this);
|
|
_frameworkUpdateSubscribed = false;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(PlayerName) || _charaHandler is null)
|
|
{
|
|
Logger.LogDebug("Actor tracked for {handler}, initializing from hook", GetLogIdentifier());
|
|
Initialize(descriptor.Value.Name);
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, GetPrimaryUserData(), nameof(PairHandlerAdapter), EventSeverity.Informational,
|
|
$"Initializing User For Character {descriptor.Value.Name}")));
|
|
}
|
|
|
|
RefreshTrackedHandler(descriptor.Value);
|
|
TryHandleVisibilityUpdate();
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
ActorInitializationLimiter.Release();
|
|
lock (_actorInitializationGate)
|
|
{
|
|
_actorInitializationInProgress = false;
|
|
if (_pendingActorDescriptor.HasValue)
|
|
{
|
|
_actorInitializationInProgress = true;
|
|
_ = Task.Run(InitializeFromTrackedAsync);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void RefreshTrackedHandler(ActorObjectService.ActorDescriptor descriptor)
|
|
{
|
|
if (_charaHandler is null)
|
|
return;
|
|
|
|
if (descriptor.Address == nint.Zero)
|
|
return;
|
|
|
|
if (_charaHandler.Address == descriptor.Address)
|
|
return;
|
|
|
|
_charaHandler.Refresh();
|
|
}
|
|
|
|
private void HandleActorUntracked(ActorObjectService.ActorDescriptor descriptor)
|
|
{
|
|
if (!TryResolveDescriptorHash(descriptor, out var hashedCid))
|
|
{
|
|
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
|
|
return;
|
|
|
|
if (descriptor.Address != _charaHandler.Address)
|
|
return;
|
|
}
|
|
else if (!string.Equals(hashedCid, Ident, StringComparison.Ordinal))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
|
|
return;
|
|
|
|
if (descriptor.Address != _charaHandler.Address)
|
|
return;
|
|
|
|
HandleVisibilityLoss(logChange: false);
|
|
}
|
|
|
|
private static bool TryResolveDescriptorHash(ActorObjectService.ActorDescriptor descriptor, out string hashedCid)
|
|
{
|
|
hashedCid = descriptor.HashedContentId ?? string.Empty;
|
|
if (!string.IsNullOrEmpty(hashedCid))
|
|
return true;
|
|
|
|
if (descriptor.ObjectKind != DalamudObjectKind.Player || descriptor.Address == nint.Zero)
|
|
return false;
|
|
|
|
hashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(descriptor.Address);
|
|
return !string.IsNullOrEmpty(hashedCid);
|
|
}
|
|
|
|
private void UpdateLastKnownActor(ActorObjectService.ActorDescriptor descriptor)
|
|
{
|
|
_lastKnownAddress = descriptor.Address;
|
|
_lastKnownObjectIndex = descriptor.ObjectIndex;
|
|
if (!string.IsNullOrEmpty(descriptor.Name))
|
|
{
|
|
_lastKnownName = descriptor.Name;
|
|
}
|
|
}
|
|
|
|
private void UpdateLastKnownActor(nint address, string? name)
|
|
{
|
|
if (address != nint.Zero)
|
|
{
|
|
_lastKnownAddress = address;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(name))
|
|
{
|
|
_lastKnownName = name;
|
|
}
|
|
}
|
|
|
|
private void RefreshPapBlockCacheIfAnimSettingsChanged()
|
|
{
|
|
var cfg = _configService.Current;
|
|
|
|
if (cfg.AnimationValidationMode != _lastAnimMode
|
|
|| cfg.AnimationAllowOneBasedShift != _lastAllowOneBasedShift
|
|
|| cfg.AnimationAllowNeighborIndexTolerance != _lastAllowNeighborTolerance)
|
|
{
|
|
_lastAnimMode = cfg.AnimationValidationMode;
|
|
_lastAllowOneBasedShift = cfg.AnimationAllowOneBasedShift;
|
|
_lastAllowNeighborTolerance = cfg.AnimationAllowNeighborIndexTolerance;
|
|
|
|
_blockedPapHashes.Clear();
|
|
_dumpedRemoteSkeletonForHash.Clear();
|
|
|
|
Logger.LogDebug("{handler}: Cleared blocked PAP cache due to animation setting change (mode={mode}, shift={shift}, neigh={neigh})",
|
|
GetLogIdentifier(), _lastAnimMode, _lastAllowOneBasedShift, _lastAllowNeighborTolerance);
|
|
}
|
|
}
|
|
|
|
private static void SplitPapMappings(
|
|
Dictionary<(string GamePath, string? Hash), string> moddedPaths,
|
|
out Dictionary<(string GamePath, string? Hash), string> withoutPap,
|
|
out Dictionary<(string GamePath, string? Hash), string> papOnly)
|
|
{
|
|
withoutPap = new(moddedPaths.Comparer);
|
|
papOnly = new(moddedPaths.Comparer);
|
|
|
|
foreach (var kv in moddedPaths)
|
|
{
|
|
var gamePath = kv.Key.GamePath;
|
|
if (gamePath.EndsWith(".pap", StringComparison.OrdinalIgnoreCase))
|
|
papOnly[kv.Key] = kv.Value;
|
|
else
|
|
withoutPap[kv.Key] = kv.Value;
|
|
}
|
|
}
|
|
|
|
private async Task<int> StripIncompatiblePapAsync(
|
|
GameObjectHandler handlerForApply,
|
|
CharacterData charaData,
|
|
Dictionary<(string GamePath, string? Hash), string> papOnly,
|
|
CancellationToken token)
|
|
{
|
|
RefreshPapBlockCacheIfAnimSettingsChanged();
|
|
|
|
var mode = _configService.Current.AnimationValidationMode;
|
|
var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift;
|
|
var allownNightIndex = _configService.Current.AnimationAllowNeighborIndexTolerance;
|
|
|
|
if (mode == AnimationValidationMode.Unsafe || papOnly.Count == 0)
|
|
return 0;
|
|
|
|
var boneIndices = await _dalamudUtil.RunOnFrameworkThread(
|
|
() => _modelAnalyzer.GetSkeletonBoneIndices(handlerForApply))
|
|
.ConfigureAwait(false);
|
|
|
|
if (boneIndices == null || boneIndices.Count == 0)
|
|
{
|
|
var removedCount = papOnly.Count;
|
|
papOnly.Clear();
|
|
return removedCount;
|
|
}
|
|
|
|
var localBoneSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var (rawKey, list) in boneIndices)
|
|
{
|
|
var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawKey);
|
|
if (string.IsNullOrEmpty(key)) continue;
|
|
|
|
if (!localBoneSets.TryGetValue(key, out var set))
|
|
localBoneSets[key] = set = [];
|
|
|
|
foreach (var v in list)
|
|
set.Add(v);
|
|
}
|
|
|
|
int removed = 0;
|
|
|
|
foreach (var hash in papOnly.Keys.Select(k => k.Hash).Where(h => !string.IsNullOrEmpty(h)).Distinct(StringComparer.OrdinalIgnoreCase).ToList())
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
|
|
var papIndices = await _dalamudUtil.RunOnFrameworkThread(
|
|
() => _modelAnalyzer.GetBoneIndicesFromPap(hash!))
|
|
.ConfigureAwait(false);
|
|
|
|
if (papIndices == null || papIndices.Count == 0)
|
|
continue;
|
|
|
|
if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105))
|
|
continue;
|
|
|
|
if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason))
|
|
continue;
|
|
|
|
var keysToRemove = papOnly.Keys.Where(k => string.Equals(k.Hash, hash, StringComparison.OrdinalIgnoreCase)).ToList();
|
|
foreach (var k in keysToRemove)
|
|
papOnly.Remove(k);
|
|
|
|
removed += keysToRemove.Count;
|
|
|
|
if (_blockedPapHashes.TryAdd(hash!, 0))
|
|
Logger.LogWarning("Blocked remote object PAP (hash {hash}) for {handler}: {reason}", hash, GetLogIdentifier(), reason);
|
|
|
|
if (charaData.FileReplacements.TryGetValue(ObjectKind.Player, out var list))
|
|
{
|
|
list.RemoveAll(r => string.Equals(r.Hash, hash, StringComparison.OrdinalIgnoreCase)
|
|
&& r.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
|
|
}
|
|
}
|
|
|
|
var nullHashKeys = papOnly.Keys.Where(k => string.IsNullOrEmpty(k.Hash)).ToList();
|
|
foreach (var k in nullHashKeys)
|
|
{
|
|
papOnly.Remove(k);
|
|
removed++;
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
private async Task ApplyCustomizeAsync(nint address, string customizeData, ObjectKind kind)
|
|
{
|
|
_customizeIds[kind] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task RevertCustomizeAsync(Guid? customizeId, ObjectKind kind)
|
|
{
|
|
if (!customizeId.HasValue)
|
|
return;
|
|
|
|
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId.Value).ConfigureAwait(false);
|
|
_customizeIds.Remove(kind);
|
|
}
|
|
}
|