All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m27s
2.0.0 Changes: - Reworked shell finder UI with compact or list view with profile tags showing with the listing, allowing moderators to broadcast the syncshell as well to have it be used more. - Reworked user list in syncshell admin screen to have filter visible and moved away from table to its own thing, allowing to copy uid/note/alias when clicking on the name. - Reworked download bars and download box to make it look more modern, removed the jitter around, so it shouldn't vibrate around much. - Chat has been added to the top menu, working in Zone or in Syncshells to be used there. - Paired system has been revamped to make pausing and unpausing faster, and loading people should be faster as well. - Moved to the internal object table to have faster load times for users; people should load in faster - Compactor is running on a multi-threaded level instead of single-threaded; this should increase the speed of compacting files - Nameplate Service has been reworked so it wouldn't use the nameplate handler anymore. - Files can be resized when downloading to reduce load on users if they aren't compressed. (can be toggled to resize all). - Penumbra Collections are now only made when people are visible, reducing the load on boot-up when having many syncshells in your list. - Lightfinder plates have been moved away from using Nameplates, but will use an overlay. - Main UI has been changed a bit with a gradient, and on hover will glow up now. - Reworked Profile UI for Syncshell and Users to be more user-facing with more customizable items. - Reworked Settings UI to look more modern. - Performance should be better due to new systems that would dispose of the collections and better caching of items. Co-authored-by: defnotken <itsdefnotken@gmail.com> Co-authored-by: azyges <aaaaaa@aaa.aaa> Co-authored-by: choco <choco@patat.nl> Co-authored-by: cake <admin@cakeandbanana.nl> Co-authored-by: Minmoose <KennethBohr@outlook.com> Reviewed-on: #92
1984 lines
79 KiB
C#
1984 lines
79 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using LightlessSync.API.Data;
|
|
using LightlessSync.API.Data.Enum;
|
|
using LightlessSync.API.Data.Extensions;
|
|
using LightlessSync.FileCache;
|
|
using LightlessSync.Interop.Ipc;
|
|
using LightlessSync.PlayerData.Factories;
|
|
using LightlessSync.PlayerData.Handlers;
|
|
using LightlessSync.Services;
|
|
using LightlessSync.Services.Events;
|
|
using LightlessSync.Services.Mediator;
|
|
using LightlessSync.Services.PairProcessing;
|
|
using LightlessSync.Services.ServerConfiguration;
|
|
using LightlessSync.Services.TextureCompression;
|
|
using LightlessSync.Utils;
|
|
using LightlessSync.WebAPI.Files;
|
|
using LightlessSync.WebAPI.Files.Models;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
|
using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer;
|
|
|
|
namespace LightlessSync.PlayerData.Pairs;
|
|
|
|
/// <summary>
|
|
/// handles lifecycle, visibility, queued data, character data for a paired user
|
|
/// </summary>
|
|
internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPairHandlerAdapter
|
|
{
|
|
private sealed record CombatData(Guid ApplicationId, CharacterData CharacterData, bool Forced);
|
|
|
|
private readonly DalamudUtilService _dalamudUtil;
|
|
private readonly FileDownloadManager _downloadManager;
|
|
private readonly FileCacheManager _fileDbManager;
|
|
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 PairStateCache _pairStateCache;
|
|
private readonly PairPerformanceMetricsCache _performanceMetricsCache;
|
|
private readonly PairManager _pairManager;
|
|
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 _isVisible;
|
|
private Guid _penumbraCollection;
|
|
private readonly object _collectionGate = new();
|
|
private bool _redrawOnNextApplication = false;
|
|
private bool _explicitRedrawQueued;
|
|
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 DateTime? _invisibleSinceUtc;
|
|
private DateTime? _visibilityEvictionDueAtUtc;
|
|
|
|
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 LastAppliedApproximateVRAMBytes { get; set; } = -1;
|
|
public long LastAppliedApproximateEffectiveVRAMBytes { get; set; } = -1;
|
|
public CharacterData? LastReceivedCharacterData { get; private set; }
|
|
public DateTime? LastDataReceivedAt => _lastDataReceivedAt;
|
|
public DateTime? LastApplyAttemptAt => _lastApplyAttemptAt;
|
|
public DateTime? LastSuccessfulApplyAt => _lastSuccessfulApplyAt;
|
|
public string? LastFailureReason => _lastFailureReason;
|
|
public IReadOnlyList<string> LastBlockingConditions => _lastBlockingConditions;
|
|
public bool IsApplying => _applicationTask is { IsCompleted: false };
|
|
public bool IsDownloading => _downloadManager.IsDownloading;
|
|
public int PendingDownloadCount => _downloadManager.CurrentDownloads.Count;
|
|
public int ForbiddenDownloadCount => _downloadManager.ForbiddenTransfers.Count;
|
|
|
|
public PairHandlerAdapter(
|
|
ILogger<PairHandlerAdapter> logger,
|
|
LightlessMediator mediator,
|
|
PairManager pairManager,
|
|
string ident,
|
|
GameObjectHandlerFactory gameObjectHandlerFactory,
|
|
IpcManager ipcManager,
|
|
FileDownloadManager transferManager,
|
|
PluginWarningNotificationService pluginWarningNotificationManager,
|
|
DalamudUtilService dalamudUtil,
|
|
IHostApplicationLifetime lifetime,
|
|
FileCacheManager fileDbManager,
|
|
PlayerPerformanceService playerPerformanceService,
|
|
PairProcessingLimiter pairProcessingLimiter,
|
|
ServerConfigurationManager serverConfigManager,
|
|
TextureDownscaleService textureDownscaleService,
|
|
PairStateCache pairStateCache,
|
|
PairPerformanceMetricsCache performanceMetricsCache) : base(logger, mediator)
|
|
{
|
|
_pairManager = pairManager;
|
|
Ident = ident;
|
|
_gameObjectHandlerFactory = gameObjectHandlerFactory;
|
|
_ipcManager = ipcManager;
|
|
_downloadManager = transferManager;
|
|
_pluginWarningNotificationManager = pluginWarningNotificationManager;
|
|
_dalamudUtil = dalamudUtil;
|
|
_lifetime = lifetime;
|
|
_fileDbManager = fileDbManager;
|
|
_playerPerformanceService = playerPerformanceService;
|
|
_pairProcessingLimiter = pairProcessingLimiter;
|
|
_serverConfigManager = serverConfigManager;
|
|
_textureDownscaleService = textureDownscaleService;
|
|
_pairStateCache = pairStateCache;
|
|
_performanceMetricsCache = performanceMetricsCache;
|
|
LastAppliedDataBytes = -1;
|
|
}
|
|
|
|
public void Initialize()
|
|
{
|
|
EnsureInitialized();
|
|
}
|
|
|
|
private void EnsureInitialized()
|
|
{
|
|
if (Initialized)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_initializationGate)
|
|
{
|
|
if (Initialized)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (LastAppliedDataBytes < 0 || LastAppliedDataTris < 0
|
|
|| LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0)
|
|
{
|
|
_forceApplyMods = true;
|
|
}
|
|
|
|
Mediator.Subscribe<FrameworkUpdateMessage>(this, _ => FrameworkUpdate());
|
|
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<DownloadFinishedMessage>(this, msg =>
|
|
{
|
|
if (_charaHandler is null || !ReferenceEquals(msg.DownloadId, _charaHandler))
|
|
{
|
|
return;
|
|
}
|
|
TryApplyQueuedData();
|
|
});
|
|
|
|
Initialized = true;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
return _penumbraCollection;
|
|
}
|
|
}
|
|
|
|
private void ResetPenumbraCollection(bool releaseFromPenumbra = true, string? reason = null)
|
|
{
|
|
Guid toRelease = Guid.Empty;
|
|
bool hadCollection = false;
|
|
lock (_collectionGate)
|
|
{
|
|
if (_penumbraCollection != Guid.Empty)
|
|
{
|
|
toRelease = _penumbraCollection;
|
|
_penumbraCollection = Guid.Empty;
|
|
hadCollection = true;
|
|
}
|
|
}
|
|
|
|
var cached = _pairStateCache.ClearTemporaryCollection(Ident);
|
|
if (cached.HasValue && cached.Value != Guid.Empty)
|
|
{
|
|
toRelease = cached.Value;
|
|
hadCollection = true;
|
|
}
|
|
|
|
if (hadCollection)
|
|
{
|
|
_needsCollectionRebuild = true;
|
|
_forceFullReapply = true;
|
|
_forceApplyMods = true;
|
|
}
|
|
|
|
if (!releaseFromPenumbra || toRelease == Guid.Empty || !_ipcManager.Penumbra.APIAvailable)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var applicationId = Guid.NewGuid();
|
|
Logger.LogTrace("[{applicationId}] Removing temp collection {CollectionId} for {handler} ({reason})", applicationId, toRelease, GetLogIdentifier(), reason ?? "Cleanup");
|
|
_ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, toRelease).GetAwaiter().GetResult();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogDebug(ex, "Failed to remove temporary Penumbra collection for {handler}", GetLogIdentifier());
|
|
}
|
|
}
|
|
|
|
private bool AnyPair(Func<PairConnection, bool> predicate)
|
|
{
|
|
return GetCurrentPairs().Any(predicate);
|
|
}
|
|
|
|
private bool ShouldSkipDownscale()
|
|
{
|
|
return GetCurrentPairs().Any(p => p.IsDirectlyPaired && p.SelfToOtherPermissions.IsSticky());
|
|
}
|
|
|
|
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;
|
|
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 shouldForce = forced || HasMissingCachedFiles(LastReceivedCharacterData);
|
|
|
|
if (IsPaused())
|
|
{
|
|
Logger.LogTrace("Permissions paused for {Ident}, skipping reapply", Ident);
|
|
return;
|
|
}
|
|
|
|
if (shouldForce)
|
|
{
|
|
_forceApplyMods = true;
|
|
_forceFullReapply = true;
|
|
LastAppliedDataBytes = -1;
|
|
LastAppliedDataTris = -1;
|
|
LastAppliedApproximateVRAMBytes = -1;
|
|
LastAppliedApproximateEffectiveVRAMBytes = -1;
|
|
}
|
|
|
|
var sanitized = CloneAndSanitizeLastReceived(out _);
|
|
if (sanitized is null)
|
|
{
|
|
Logger.LogTrace("Sanitized data null for {Ident}", Ident);
|
|
return;
|
|
}
|
|
|
|
_pairStateCache.Store(Ident, sanitized);
|
|
|
|
if (!IsVisible)
|
|
{
|
|
Logger.LogTrace("Handler for {Ident} not visible, caching sanitized data for later", Ident);
|
|
_cachedData = sanitized;
|
|
_forceFullReapply = true;
|
|
return;
|
|
}
|
|
|
|
ApplyCharacterData(Guid.NewGuid(), sanitized, shouldForce);
|
|
}
|
|
|
|
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;
|
|
LastAppliedApproximateVRAMBytes = metrics.ApproximateVramBytes;
|
|
LastAppliedApproximateEffectiveVRAMBytes = metrics.ApproximateEffectiveVramBytes;
|
|
}
|
|
|
|
private void StorePerformanceMetrics(CharacterData charaData)
|
|
{
|
|
if (LastAppliedDataTris < 0
|
|
|| LastAppliedApproximateVRAMBytes < 0
|
|
|| LastAppliedApproximateEffectiveVRAMBytes < 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var dataHash = GetDataHashSafe(charaData);
|
|
if (string.IsNullOrEmpty(dataHash))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_performanceMetricsCache.StoreMetrics(
|
|
Ident,
|
|
dataHash,
|
|
new PairPerformanceMetrics(LastAppliedDataTris, LastAppliedApproximateVRAMBytes, LastAppliedApproximateEffectiveVRAMBytes));
|
|
}
|
|
|
|
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 (!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 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>();
|
|
}
|
|
|
|
public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = 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";
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning,
|
|
reason)));
|
|
Logger.LogDebug("[BASE-{appBase}] Received data but player is in combat", applicationBase);
|
|
RecordFailure(reason, "Combat");
|
|
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
|
|
SetUploading(false);
|
|
return;
|
|
}
|
|
|
|
if (_dalamudUtil.IsPerforming)
|
|
{
|
|
const string reason = "Cannot apply character data: you are performing music, deferring application";
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning,
|
|
reason)));
|
|
Logger.LogDebug("[BASE-{appBase}] Received data but player is performing", applicationBase);
|
|
RecordFailure(reason, "Performance");
|
|
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
|
|
SetUploading(false);
|
|
return;
|
|
}
|
|
|
|
if (_dalamudUtil.IsInInstance)
|
|
{
|
|
const string reason = "Cannot apply character data: you are in an instance, deferring application";
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning,
|
|
reason)));
|
|
Logger.LogDebug("[BASE-{appBase}] Received data but player is in instance", applicationBase);
|
|
RecordFailure(reason, "Instance");
|
|
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
|
|
SetUploading(false);
|
|
return;
|
|
}
|
|
|
|
if (_dalamudUtil.IsInCutscene)
|
|
{
|
|
const string reason = "Cannot apply character data: you are in a cutscene, deferring application";
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning,
|
|
reason)));
|
|
Logger.LogDebug("[BASE-{appBase}] Received data but player is in a cutscene", applicationBase);
|
|
RecordFailure(reason, "Cutscene");
|
|
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
|
|
SetUploading(false);
|
|
return;
|
|
}
|
|
|
|
if (_dalamudUtil.IsInGpose)
|
|
{
|
|
const string reason = "Cannot apply character data: you are in GPose, deferring application";
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning,
|
|
reason)));
|
|
Logger.LogDebug("[BASE-{appBase}] Received data but player is in GPose", applicationBase);
|
|
RecordFailure(reason, "GPose");
|
|
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
|
|
SetUploading(false);
|
|
return;
|
|
}
|
|
|
|
if (!_ipcManager.Penumbra.APIAvailable || !_ipcManager.Glamourer.APIAvailable)
|
|
{
|
|
const string reason = "Cannot apply character data: Penumbra or Glamourer is not available, deferring application";
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning,
|
|
reason)));
|
|
Logger.LogInformation("[BASE-{appbase}] Application of data for {player} while Penumbra/Glamourer unavailable, returning", applicationBase, GetLogIdentifier());
|
|
RecordFailure(reason, "PluginUnavailable");
|
|
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
|
|
SetUploading(false);
|
|
return;
|
|
}
|
|
|
|
var handlerReady = _charaHandler is not null && PlayerCharacter != IntPtr.Zero;
|
|
|
|
if (!handlerReady)
|
|
{
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning,
|
|
"Cannot apply character data: Receiving Player is in an invalid state, deferring application")));
|
|
Logger.LogDebug("[BASE-{appBase}] Received data but player was in invalid state, charaHandlerIsNull: {charaIsNull}, playerPointerIsNull: {ptrIsNull}",
|
|
applicationBase, _charaHandler == null, PlayerCharacter == IntPtr.Zero);
|
|
var hasDiffMods = characterData.CheckUpdatedData(applicationBase, _cachedData, Logger,
|
|
this, forceApplyCustomization, forceApplyMods: false)
|
|
.Any(p => p.Value.Contains(PlayerChanges.ModManip) || p.Value.Contains(PlayerChanges.ModFiles));
|
|
_forceApplyMods = hasDiffMods || _forceApplyMods || _cachedData == null;
|
|
_cachedData = characterData;
|
|
_forceFullReapply = true;
|
|
Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods);
|
|
}
|
|
|
|
SetUploading(false);
|
|
|
|
Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, GetLogIdentifier(), forceApplyCustomization, _forceApplyMods);
|
|
Logger.LogDebug("[BASE-{appbase}] Hash for data is {newHash}, current cache hash is {oldHash}", applicationBase, characterData.DataHash.Value, _cachedData?.DataHash.Value ?? "NODATA");
|
|
|
|
if (handlerReady
|
|
&& string.Equals(characterData.DataHash.Value, _cachedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal)
|
|
&& !forceApplyCustomization && !_forceApplyMods)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational,
|
|
"Applying Character Data")));
|
|
|
|
var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, forceApplyCustomization, _forceApplyMods);
|
|
|
|
if (handlerReady && _forceApplyMods)
|
|
{
|
|
_forceApplyMods = false;
|
|
}
|
|
|
|
_explicitRedrawQueued = false;
|
|
|
|
if (_redrawOnNextApplication && charaDataToUpdate.TryGetValue(ObjectKind.Player, out var player))
|
|
{
|
|
player.Add(PlayerChanges.ForcedRedraw);
|
|
_redrawOnNextApplication = false;
|
|
_explicitRedrawQueued = true;
|
|
}
|
|
|
|
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;
|
|
|
|
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);
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
base.Dispose(disposing);
|
|
|
|
SetUploading(false);
|
|
var name = PlayerName;
|
|
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;
|
|
_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 (_lifetime.ApplicationStopping.IsCancellationRequested) return;
|
|
|
|
if (_dalamudUtil is { IsZoning: false, IsInCutscene: false } && !string.IsNullOrEmpty(name))
|
|
{
|
|
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;
|
|
_lastAppliedModdedPaths = null;
|
|
_needsCollectionRebuild = false;
|
|
_performanceMetricsCache.Clear(Ident);
|
|
Logger.LogDebug("Disposing {name} complete", name);
|
|
}
|
|
}
|
|
|
|
private async Task ApplyCustomizationDataAsync(Guid applicationId, KeyValuePair<ObjectKind, HashSet<PlayerChanges>> changes, CharacterData charaData, CancellationToken token)
|
|
{
|
|
if (PlayerCharacter == nint.Zero) return;
|
|
var ptr = PlayerCharacter;
|
|
|
|
var handler = changes.Key switch
|
|
{
|
|
ObjectKind.Player => _charaHandler!,
|
|
ObjectKind.Companion => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetCompanionPtr(ptr), isWatched: false).ConfigureAwait(false),
|
|
ObjectKind.MinionOrMount => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetMinionOrMountPtr(ptr), isWatched: false).ConfigureAwait(false),
|
|
ObjectKind.Pet => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetPetPtr(ptr), isWatched: false).ConfigureAwait(false),
|
|
_ => throw new NotSupportedException("ObjectKind not supported: " + changes.Key)
|
|
};
|
|
|
|
try
|
|
{
|
|
if (handler.Address == nint.Zero)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Logger.LogDebug("[{applicationId}] Applying Customization Data for {handler}", applicationId, handler);
|
|
await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handler, applicationId, 30000, token).ConfigureAwait(false);
|
|
token.ThrowIfCancellationRequested();
|
|
foreach (var change in changes.Value.OrderBy(p => (int)p))
|
|
{
|
|
Logger.LogDebug("[{applicationId}] Processing {change} for {handler}", applicationId, change, handler);
|
|
switch (change)
|
|
{
|
|
case PlayerChanges.Customize:
|
|
if (charaData.CustomizePlusData.TryGetValue(changes.Key, out var customizePlusData))
|
|
{
|
|
_customizeIds[changes.Key] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(handler.Address, customizePlusData).ConfigureAwait(false);
|
|
}
|
|
else if (_customizeIds.TryGetValue(changes.Key, out var customizeId))
|
|
{
|
|
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
|
|
_customizeIds.Remove(changes.Key);
|
|
}
|
|
break;
|
|
|
|
case PlayerChanges.Heels:
|
|
await _ipcManager.Heels.SetOffsetForPlayerAsync(handler.Address, charaData.HeelsData).ConfigureAwait(false);
|
|
break;
|
|
|
|
case PlayerChanges.Honorific:
|
|
await _ipcManager.Honorific.SetTitleAsync(handler.Address, charaData.HonorificData).ConfigureAwait(false);
|
|
break;
|
|
|
|
case PlayerChanges.Glamourer:
|
|
if (charaData.GlamourerData.TryGetValue(changes.Key, out var glamourerData))
|
|
{
|
|
await _ipcManager.Glamourer.ApplyAllAsync(Logger, handler, glamourerData, applicationId, token).ConfigureAwait(false);
|
|
}
|
|
break;
|
|
|
|
case PlayerChanges.Moodles:
|
|
await _ipcManager.Moodles.SetStatusAsync(handler.Address, charaData.MoodlesData).ConfigureAwait(false);
|
|
break;
|
|
|
|
case PlayerChanges.PetNames:
|
|
await _ipcManager.PetNames.SetPlayerData(handler.Address, charaData.PetNamesData).ConfigureAwait(false);
|
|
break;
|
|
|
|
case PlayerChanges.ForcedRedraw:
|
|
if (!ShouldPerformForcedRedraw(changes.Key, changes.Value, charaData))
|
|
{
|
|
Logger.LogTrace("[{applicationId}] Skipping forced redraw for {handler}", applicationId, handler);
|
|
break;
|
|
}
|
|
await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false);
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
token.ThrowIfCancellationRequested();
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (handler != _charaHandler) handler.Dispose();
|
|
}
|
|
}
|
|
|
|
private bool ShouldPerformForcedRedraw(ObjectKind objectKind, ICollection<PlayerChanges> changeSet, CharacterData newData)
|
|
{
|
|
if (objectKind != ObjectKind.Player)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var hasModFiles = changeSet.Contains(PlayerChanges.ModFiles);
|
|
var hasManip = changeSet.Contains(PlayerChanges.ModManip);
|
|
var modsChanged = hasModFiles && PlayerModFilesChanged(newData, _cachedData);
|
|
var manipChanged = hasManip && !string.Equals(_cachedData?.ManipulationData, newData.ManipulationData, StringComparison.Ordinal);
|
|
|
|
if (modsChanged)
|
|
{
|
|
_explicitRedrawQueued = false;
|
|
return true;
|
|
}
|
|
|
|
if (manipChanged)
|
|
{
|
|
_explicitRedrawQueued = false;
|
|
return true;
|
|
}
|
|
|
|
if (_explicitRedrawQueued)
|
|
{
|
|
_explicitRedrawQueued = false;
|
|
return true;
|
|
}
|
|
|
|
if ((hasModFiles || hasManip) && (_forceFullReapply || _needsCollectionRebuild))
|
|
{
|
|
_explicitRedrawQueued = false;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
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();
|
|
var user = GetPrimaryUserData();
|
|
Dictionary<(string GamePath, string? Hash), string> moddedPaths;
|
|
|
|
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);
|
|
|
|
while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested)
|
|
{
|
|
if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted)
|
|
{
|
|
Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData);
|
|
await _pairDownloadTask.ConfigureAwait(false);
|
|
}
|
|
|
|
Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData);
|
|
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational,
|
|
$"Starting download for {toDownloadReplacements.Count} files")));
|
|
var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false);
|
|
|
|
if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles))
|
|
{
|
|
RecordFailure("Auto pause triggered by VRAM usage thresholds", "VRAMThreshold");
|
|
_downloadManager.ClearDownload();
|
|
return;
|
|
}
|
|
|
|
var handlerForDownload = _charaHandler;
|
|
_pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, downloadToken, skipDownscaleForPair).ConfigureAwait(false));
|
|
|
|
await _pairDownloadTask.ConfigureAwait(false);
|
|
|
|
if (downloadToken.IsCancellationRequested)
|
|
{
|
|
Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase);
|
|
RecordFailure("Download cancelled", "Cancellation");
|
|
return;
|
|
}
|
|
|
|
if (!skipDownscaleForPair)
|
|
{
|
|
var downloadedTextureHashes = toDownloadReplacements
|
|
.Where(static replacement => replacement.GamePaths.Any(static path => path.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)))
|
|
.Select(static replacement => replacement.Hash)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
if (downloadedTextureHashes.Count > 0)
|
|
{
|
|
await _textureDownscaleService.WaitForPendingJobsAsync(downloadedTextureHashes, downloadToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken);
|
|
|
|
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)
|
|
: [];
|
|
}
|
|
|
|
downloadToken.ThrowIfCancellationRequested();
|
|
|
|
var handlerForApply = _charaHandler;
|
|
if (handlerForApply is null || handlerForApply.Address == nint.Zero)
|
|
{
|
|
Logger.LogDebug("[BASE-{appBase}] Handler not available for {player}, cached data for later application", applicationBase, GetLogIdentifier());
|
|
_cachedData = charaData;
|
|
_pairStateCache.Store(Ident, charaData);
|
|
_forceFullReapply = true;
|
|
RecordFailure("Handler not available for application", "HandlerUnavailable");
|
|
return;
|
|
}
|
|
|
|
var appToken = _applicationCancellationTokenSource?.Token;
|
|
while ((!_applicationTask?.IsCompleted ?? false)
|
|
&& !downloadToken.IsCancellationRequested
|
|
&& (!appToken?.IsCancellationRequested ?? false))
|
|
{
|
|
Logger.LogDebug("[BASE-{appBase}] Waiting for current data application (Id: {id}) for player ({handler}) to finish", applicationBase, _applicationId, PlayerName);
|
|
await Task.Delay(250).ConfigureAwait(false);
|
|
}
|
|
|
|
if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false))
|
|
{
|
|
_forceFullReapply = true;
|
|
RecordFailure("Application cancelled", "Cancellation");
|
|
return;
|
|
}
|
|
|
|
_applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource();
|
|
var token = _applicationCancellationTokenSource.Token;
|
|
|
|
_applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, token);
|
|
}
|
|
finally
|
|
{
|
|
await concurrencyLease.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async Task ApplyCharacterDataAsync(Guid applicationBase, GameObjectHandler handlerForApply, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData, bool updateModdedPaths, bool updateManip,
|
|
Dictionary<(string GamePath, string? Hash), string> moddedPaths, CancellationToken token)
|
|
{
|
|
try
|
|
{
|
|
_applicationId = Guid.NewGuid();
|
|
Logger.LogDebug("[BASE-{applicationId}] Starting application task for {handler}: {appId}", applicationBase, GetLogIdentifier(), _applicationId);
|
|
|
|
Logger.LogDebug("[{applicationId}] Waiting for initial draw for for {handler}", _applicationId, handlerForApply);
|
|
await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handlerForApply, _applicationId, 30000, token).ConfigureAwait(false);
|
|
|
|
token.ThrowIfCancellationRequested();
|
|
|
|
Guid penumbraCollection = Guid.Empty;
|
|
if (updateModdedPaths || updateManip)
|
|
{
|
|
penumbraCollection = EnsurePenumbraCollection();
|
|
if (penumbraCollection == Guid.Empty)
|
|
{
|
|
Logger.LogTrace("[BASE-{applicationId}] Penumbra collection unavailable for {handler}, caching data for later application", applicationBase, GetLogIdentifier());
|
|
_cachedData = charaData;
|
|
_pairStateCache.Store(Ident, charaData);
|
|
_forceFullReapply = true;
|
|
RecordFailure("Penumbra collection unavailable", "PenumbraUnavailable");
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (updateModdedPaths)
|
|
{
|
|
// ensure collection is set
|
|
var objIndex = await _dalamudUtil.RunOnFrameworkThread(() =>
|
|
{
|
|
var gameObject = handlerForApply.GetGameObject();
|
|
return gameObject?.ObjectIndex;
|
|
}).ConfigureAwait(false);
|
|
|
|
if (!objIndex.HasValue)
|
|
{
|
|
Logger.LogDebug("[BASE-{applicationId}] GameObject not available for {handler}, caching data for later application", applicationBase, GetLogIdentifier());
|
|
_cachedData = charaData;
|
|
_pairStateCache.Store(Ident, charaData);
|
|
_forceFullReapply = true;
|
|
RecordFailure("Game object not available for application", "GameObjectUnavailable");
|
|
return;
|
|
}
|
|
|
|
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false);
|
|
|
|
await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, _applicationId, penumbraCollection,
|
|
moddedPaths.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)).ConfigureAwait(false);
|
|
_lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(moddedPaths, moddedPaths.Comparer);
|
|
LastAppliedDataBytes = -1;
|
|
foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists))
|
|
{
|
|
if (LastAppliedDataBytes == -1) LastAppliedDataBytes = 0;
|
|
|
|
LastAppliedDataBytes += path.Length;
|
|
}
|
|
}
|
|
|
|
if (updateManip)
|
|
{
|
|
await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, _applicationId, penumbraCollection, charaData.ManipulationData).ConfigureAwait(false);
|
|
}
|
|
|
|
token.ThrowIfCancellationRequested();
|
|
|
|
foreach (var kind in updatedData)
|
|
{
|
|
await ApplyCustomizationDataAsync(_applicationId, kind, charaData, token).ConfigureAwait(false);
|
|
token.ThrowIfCancellationRequested();
|
|
}
|
|
|
|
_cachedData = charaData;
|
|
_pairStateCache.Store(Ident, charaData);
|
|
_forceFullReapply = false;
|
|
_needsCollectionRebuild = false;
|
|
if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0)
|
|
{
|
|
_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List<DownloadFileTransfer>());
|
|
}
|
|
if (LastAppliedDataTris < 0)
|
|
{
|
|
await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(false);
|
|
}
|
|
|
|
StorePerformanceMetrics(charaData);
|
|
_lastSuccessfulApplyAt = DateTime.UtcNow;
|
|
ClearFailureState();
|
|
Logger.LogDebug("[{applicationId}] Application finished", _applicationId);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier());
|
|
_cachedData = charaData;
|
|
_pairStateCache.Store(Ident, charaData);
|
|
_forceFullReapply = true;
|
|
RecordFailure("Application cancelled", "Cancellation");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException))
|
|
{
|
|
IsVisible = false;
|
|
_forceApplyMods = true;
|
|
_cachedData = charaData;
|
|
_pairStateCache.Store(Ident, charaData);
|
|
_forceFullReapply = true;
|
|
Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId);
|
|
}
|
|
else
|
|
{
|
|
Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId);
|
|
_forceFullReapply = true;
|
|
}
|
|
RecordFailure($"Application failed: {ex.Message}", "Exception");
|
|
}
|
|
}
|
|
|
|
private void FrameworkUpdate()
|
|
{
|
|
if (string.IsNullOrEmpty(PlayerName))
|
|
{
|
|
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}")));
|
|
}
|
|
|
|
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)
|
|
{
|
|
IsVisible = false;
|
|
_charaHandler.Invalidate();
|
|
_downloadCancellationTokenSource?.CancelDispose();
|
|
_downloadCancellationTokenSource = null;
|
|
Logger.LogTrace("{handler} visibility changed, now: {visi}", GetLogIdentifier(), IsVisible);
|
|
}
|
|
|
|
TryApplyQueuedData();
|
|
}
|
|
|
|
private void Initialize(string name)
|
|
{
|
|
PlayerName = name;
|
|
_charaHandler = _gameObjectHandlerFactory.Create(ObjectKind.Player, () => _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Ident), isWatched: false).GetAwaiter().GetResult();
|
|
|
|
var user = GetPrimaryUserData();
|
|
if (!string.IsNullOrEmpty(user.UID))
|
|
{
|
|
_serverConfigManager.AutoPopulateNoteForUid(user.UID, name);
|
|
}
|
|
|
|
Mediator.Subscribe<HonorificReadyMessage>(this, _message =>
|
|
{
|
|
var honorificData = _cachedData?.HonorificData;
|
|
if (string.IsNullOrEmpty(honorificData))
|
|
return;
|
|
|
|
_ = ReapplyHonorificAsync(honorificData!);
|
|
});
|
|
|
|
Mediator.Subscribe<PetNamesReadyMessage>(this, _message =>
|
|
{
|
|
var petNamesData = _cachedData?.PetNamesData;
|
|
if (string.IsNullOrEmpty(petNamesData))
|
|
return;
|
|
|
|
_ = ReapplyPetNamesAsync(petNamesData!);
|
|
});
|
|
}
|
|
|
|
private async Task ReapplyHonorificAsync(string honorificData)
|
|
{
|
|
Logger.LogTrace("Reapplying Honorific data for {handler}", GetLogIdentifier());
|
|
await _ipcManager.Honorific.SetTitleAsync(PlayerCharacter, honorificData).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task ReapplyPetNamesAsync(string petNamesData)
|
|
{
|
|
Logger.LogTrace("Reapplying Pet Names data for {handler}", GetLogIdentifier());
|
|
await _ipcManager.PetNames.SetPlayerData(PlayerCharacter, petNamesData).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task RevertCustomizationDataAsync(ObjectKind objectKind, string name, Guid applicationId, CancellationToken cancelToken)
|
|
{
|
|
nint address = _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Ident);
|
|
if (address == nint.Zero) return;
|
|
|
|
var alias = GetPrimaryAliasOrUid();
|
|
Logger.LogDebug("[{applicationId}] Reverting all Customization for {alias}/{name} {objectKind}", applicationId, alias, name, objectKind);
|
|
|
|
if (_customizeIds.TryGetValue(objectKind, out var customizeId))
|
|
{
|
|
_customizeIds.Remove(objectKind);
|
|
}
|
|
|
|
if (objectKind == ObjectKind.Player)
|
|
{
|
|
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Player, () => address, isWatched: false).ConfigureAwait(false);
|
|
tempHandler.CompareNameAndThrow(name);
|
|
Logger.LogDebug("[{applicationId}] Restoring Customization and Equipment for {alias}/{name}", applicationId, alias, name);
|
|
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
|
|
tempHandler.CompareNameAndThrow(name);
|
|
Logger.LogDebug("[{applicationId}] Restoring Heels for {alias}/{name}", applicationId, alias, name);
|
|
await _ipcManager.Heels.RestoreOffsetForPlayerAsync(address).ConfigureAwait(false);
|
|
tempHandler.CompareNameAndThrow(name);
|
|
Logger.LogDebug("[{applicationId}] Restoring C+ for {alias}/{name}", applicationId, alias, name);
|
|
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
|
|
tempHandler.CompareNameAndThrow(name);
|
|
Logger.LogDebug("[{applicationId}] Restoring Honorific for {alias}/{name}", applicationId, alias, name);
|
|
await _ipcManager.Honorific.ClearTitleAsync(address).ConfigureAwait(false);
|
|
Logger.LogDebug("[{applicationId}] Restoring Moodles for {alias}/{name}", applicationId, alias, name);
|
|
await _ipcManager.Moodles.RevertStatusAsync(address).ConfigureAwait(false);
|
|
Logger.LogDebug("[{applicationId}] Restoring Pet Nicknames for {alias}/{name}", applicationId, alias, name);
|
|
await _ipcManager.PetNames.ClearPlayerData(address).ConfigureAwait(false);
|
|
}
|
|
else if (objectKind == ObjectKind.MinionOrMount)
|
|
{
|
|
var minionOrMount = await _dalamudUtil.GetMinionOrMountAsync(address).ConfigureAwait(false);
|
|
if (minionOrMount != nint.Zero)
|
|
{
|
|
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
|
|
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.MinionOrMount, () => minionOrMount, isWatched: false).ConfigureAwait(false);
|
|
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
|
|
await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
else if (objectKind == ObjectKind.Pet)
|
|
{
|
|
var pet = await _dalamudUtil.GetPetAsync(address).ConfigureAwait(false);
|
|
if (pet != nint.Zero)
|
|
{
|
|
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
|
|
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Pet, () => pet, isWatched: false).ConfigureAwait(false);
|
|
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
|
|
await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
else if (objectKind == ObjectKind.Companion)
|
|
{
|
|
var companion = await _dalamudUtil.GetCompanionAsync(address).ConfigureAwait(false);
|
|
if (companion != nint.Zero)
|
|
{
|
|
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
|
|
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Companion, () => companion, isWatched: false).ConfigureAwait(false);
|
|
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
|
|
await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
private List<FileReplacementData> TryCalculateModdedDictionary(Guid applicationBase, CharacterData charaData, out Dictionary<(string GamePath, string? Hash), string> moddedDictionary, CancellationToken token)
|
|
{
|
|
Stopwatch st = Stopwatch.StartNew();
|
|
ConcurrentBag<FileReplacementData> missingFiles = [];
|
|
moddedDictionary = [];
|
|
ConcurrentDictionary<(string GamePath, string? Hash), string> outputDict = new();
|
|
bool hasMigrationChanges = false;
|
|
bool skipDownscaleForPair = ShouldSkipDownscale();
|
|
|
|
try
|
|
{
|
|
var replacementList = charaData.FileReplacements.SelectMany(k => k.Value.Where(v => string.IsNullOrEmpty(v.FileSwapPath))).ToList();
|
|
Parallel.ForEach(replacementList, new ParallelOptions()
|
|
{
|
|
CancellationToken = token,
|
|
MaxDegreeOfParallelism = 4
|
|
},
|
|
(item) =>
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var fileCache = _fileDbManager.GetFileCacheByHash(item.Hash);
|
|
if (fileCache is not null && !File.Exists(fileCache.ResolvedFilepath))
|
|
{
|
|
Logger.LogTrace("[BASE-{appBase}] Cached path {Path} missing on disk for hash {Hash}, removing cache entry", applicationBase, fileCache.ResolvedFilepath, item.Hash);
|
|
_fileDbManager.RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
|
|
fileCache = null;
|
|
}
|
|
|
|
if (fileCache != null)
|
|
{
|
|
if (string.IsNullOrEmpty(new FileInfo(fileCache.ResolvedFilepath).Extension))
|
|
{
|
|
hasMigrationChanges = true;
|
|
fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, item.GamePaths[0].Split(".")[^1]);
|
|
}
|
|
|
|
foreach (var gamePath in item.GamePaths)
|
|
{
|
|
var preferredPath = skipDownscaleForPair
|
|
? fileCache.ResolvedFilepath
|
|
: _textureDownscaleService.GetPreferredPath(item.Hash, fileCache.ResolvedFilepath);
|
|
outputDict[(gamePath, item.Hash)] = preferredPath;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Logger.LogTrace("Missing file: {hash}", item.Hash);
|
|
missingFiles.Add(item);
|
|
}
|
|
});
|
|
|
|
moddedDictionary = outputDict.ToDictionary(k => k.Key, k => k.Value);
|
|
|
|
foreach (var item in charaData.FileReplacements.SelectMany(k => k.Value.Where(v => !string.IsNullOrEmpty(v.FileSwapPath))).ToList())
|
|
{
|
|
foreach (var gamePath in item.GamePaths)
|
|
{
|
|
Logger.LogTrace("[BASE-{appBase}] Adding file swap for {path}: {fileSwap}", applicationBase, gamePath, item.FileSwapPath);
|
|
moddedDictionary[(gamePath, null)] = item.FileSwapPath;
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
Logger.LogTrace("[BASE-{appBase}] Modded path calculation cancelled", applicationBase);
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "[BASE-{appBase}] Something went wrong during calculation replacements", applicationBase);
|
|
}
|
|
if (hasMigrationChanges) _fileDbManager.WriteOutFullCsv();
|
|
st.Stop();
|
|
Logger.LogDebug("[BASE-{appBase}] ModdedPaths calculated in {time}ms, missing files: {count}, total files: {total}", applicationBase, st.ElapsedMilliseconds, missingFiles.Count, moddedDictionary.Keys.Count);
|
|
return [.. missingFiles];
|
|
}
|
|
|
|
private async Task PauseInternalAsync()
|
|
{
|
|
try
|
|
{
|
|
Logger.LogDebug("Pausing handler {handler}", GetLogIdentifier());
|
|
DisableSync();
|
|
|
|
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
|
|
{
|
|
IsVisible = false;
|
|
return;
|
|
}
|
|
|
|
var applicationId = Guid.NewGuid();
|
|
await RevertToRestoredAsync(applicationId).ConfigureAwait(false);
|
|
IsVisible = false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarning(ex, "Failed to pause handler {handler}", GetLogIdentifier());
|
|
}
|
|
}
|
|
|
|
private async Task ResumeInternalAsync()
|
|
{
|
|
try
|
|
{
|
|
Logger.LogDebug("Resuming handler {handler}", GetLogIdentifier());
|
|
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!IsVisible)
|
|
{
|
|
IsVisible = true;
|
|
}
|
|
|
|
if (LastReceivedCharacterData is not null)
|
|
{
|
|
ApplyLastReceivedData(forced: true);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarning(ex, "Failed to resume handler {handler}", GetLogIdentifier());
|
|
}
|
|
}
|
|
|
|
private async Task RevertToRestoredAsync(Guid applicationId)
|
|
{
|
|
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var gameObject = await _dalamudUtil.RunOnFrameworkThread(() => _charaHandler.GetGameObject()).ConfigureAwait(false);
|
|
if (gameObject is not Dalamud.Game.ClientState.Objects.Types.ICharacter character)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_ipcManager.Penumbra.APIAvailable)
|
|
{
|
|
var penumbraCollection = EnsurePenumbraCollection();
|
|
if (penumbraCollection != Guid.Empty)
|
|
{
|
|
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, character.ObjectIndex).ConfigureAwait(false);
|
|
await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, applicationId, penumbraCollection, new Dictionary<string, string>(StringComparer.Ordinal)).ConfigureAwait(false);
|
|
await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, applicationId, penumbraCollection, string.Empty).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
var kinds = new HashSet<ObjectKind>(_customizeIds.Keys);
|
|
if (_cachedData is not null)
|
|
{
|
|
foreach (var kind in _cachedData.FileReplacements.Keys)
|
|
{
|
|
kinds.Add(kind);
|
|
}
|
|
}
|
|
|
|
kinds.Add(ObjectKind.Player);
|
|
|
|
var characterName = character.Name.TextValue;
|
|
if (string.IsNullOrEmpty(characterName))
|
|
{
|
|
characterName = character.Name.ToString();
|
|
}
|
|
if (string.IsNullOrEmpty(characterName))
|
|
{
|
|
Logger.LogWarning("[{applicationId}] Failed to determine character name for {handler} while reverting", applicationId, GetLogIdentifier());
|
|
return;
|
|
}
|
|
|
|
foreach (var kind in kinds)
|
|
{
|
|
await RevertCustomizationDataAsync(kind, characterName, applicationId, CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
|
|
_cachedData = null;
|
|
LastAppliedDataBytes = -1;
|
|
LastAppliedDataTris = -1;
|
|
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;
|
|
ApplyCharacterData(pending.ApplicationId,
|
|
pending.CharacterData, pending.Forced);
|
|
}
|
|
}
|