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