916 lines
44 KiB
C#
916 lines
44 KiB
C#
using MareSynchronos.API.Data;
|
|
using MareSynchronos.FileCache;
|
|
using MareSynchronos.Interop.Ipc;
|
|
using MareSynchronos.MareConfiguration;
|
|
using MareSynchronos.PlayerData.Factories;
|
|
using MareSynchronos.PlayerData.Pairs;
|
|
using MareSynchronos.Services;
|
|
using MareSynchronos.Services.Events;
|
|
using MareSynchronos.Services.Mediator;
|
|
using MareSynchronos.Services.ServerConfiguration;
|
|
using MareSynchronos.Utils;
|
|
using MareSynchronos.WebAPI.Files;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using ObjectKind = MareSynchronos.API.Data.Enum.ObjectKind;
|
|
|
|
namespace MareSynchronos.PlayerData.Handlers;
|
|
|
|
public sealed class PairHandler : DisposableMediatorSubscriberBase
|
|
{
|
|
private sealed record CombatData(Guid ApplicationId, CharacterData CharacterData, bool Forced);
|
|
|
|
private readonly MareConfigService _configService;
|
|
private readonly DalamudUtilService _dalamudUtil;
|
|
private readonly FileDownloadManager _downloadManager;
|
|
private readonly FileCacheManager _fileDbManager;
|
|
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
|
|
private readonly IpcManager _ipcManager;
|
|
private readonly PlayerPerformanceService _playerPerformanceService;
|
|
private readonly ServerConfigurationManager _serverConfigManager;
|
|
private readonly PluginWarningNotificationService _pluginWarningNotificationManager;
|
|
private readonly VisibilityService _visibilityService;
|
|
private readonly NoSnapService _noSnapService;
|
|
private CancellationTokenSource? _applicationCancellationTokenSource = new();
|
|
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 = new();
|
|
private bool _forceApplyMods = false;
|
|
private bool _isVisible;
|
|
private Guid _deferred = Guid.Empty;
|
|
private Guid _penumbraCollection = Guid.Empty;
|
|
private bool _redrawOnNextApplication = false;
|
|
|
|
public PairHandler(ILogger<PairHandler> logger, Pair pair, PairAnalyzer pairAnalyzer,
|
|
GameObjectHandlerFactory gameObjectHandlerFactory,
|
|
IpcManager ipcManager, FileDownloadManager transferManager,
|
|
PluginWarningNotificationService pluginWarningNotificationManager,
|
|
DalamudUtilService dalamudUtil, IHostApplicationLifetime lifetime,
|
|
FileCacheManager fileDbManager, MareMediator mediator,
|
|
PlayerPerformanceService playerPerformanceService,
|
|
ServerConfigurationManager serverConfigManager,
|
|
MareConfigService configService, VisibilityService visibilityService,
|
|
NoSnapService noSnapService) : base(logger, mediator)
|
|
{
|
|
Pair = pair;
|
|
PairAnalyzer = pairAnalyzer;
|
|
_gameObjectHandlerFactory = gameObjectHandlerFactory;
|
|
_ipcManager = ipcManager;
|
|
_downloadManager = transferManager;
|
|
_pluginWarningNotificationManager = pluginWarningNotificationManager;
|
|
_dalamudUtil = dalamudUtil;
|
|
_fileDbManager = fileDbManager;
|
|
_playerPerformanceService = playerPerformanceService;
|
|
_serverConfigManager = serverConfigManager;
|
|
_configService = configService;
|
|
_visibilityService = visibilityService;
|
|
_noSnapService = noSnapService;
|
|
|
|
_visibilityService.StartTracking(Pair.Ident);
|
|
|
|
Mediator.SubscribeKeyed<PlayerVisibilityMessage>(this, Pair.Ident, (msg) => UpdateVisibility(msg.IsVisible, msg.Invalidate));
|
|
|
|
Mediator.Subscribe<ZoneSwitchStartMessage>(this, (_) =>
|
|
{
|
|
_downloadCancellationTokenSource?.CancelDispose();
|
|
_charaHandler?.Invalidate();
|
|
IsVisible = false;
|
|
});
|
|
Mediator.Subscribe<PenumbraInitializedMessage>(this, (_) =>
|
|
{
|
|
_penumbraCollection = Guid.Empty;
|
|
if (!IsVisible && _charaHandler != null)
|
|
{
|
|
PlayerName = string.Empty;
|
|
_charaHandler.Dispose();
|
|
_charaHandler = null;
|
|
}
|
|
});
|
|
Mediator.Subscribe<ClassJobChangedMessage>(this, (msg) =>
|
|
{
|
|
if (msg.GameObjectHandler == _charaHandler)
|
|
{
|
|
_redrawOnNextApplication = true;
|
|
}
|
|
});
|
|
Mediator.Subscribe<CombatOrPerformanceEndMessage>(this, (msg) =>
|
|
{
|
|
if (IsVisible && _dataReceivedInDowntime != null)
|
|
{
|
|
ApplyCharacterData(_dataReceivedInDowntime.ApplicationId,
|
|
_dataReceivedInDowntime.CharacterData, _dataReceivedInDowntime.Forced);
|
|
_dataReceivedInDowntime = null;
|
|
}
|
|
});
|
|
Mediator.Subscribe<CombatOrPerformanceStartMessage>(this, _ =>
|
|
{
|
|
if (_configService.Current.HoldCombatApplication)
|
|
{
|
|
_dataReceivedInDowntime = null;
|
|
_downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate();
|
|
_applicationCancellationTokenSource = _applicationCancellationTokenSource?.CancelRecreate();
|
|
}
|
|
});
|
|
Mediator.Subscribe<RecalculatePerformanceMessage>(this, (msg) =>
|
|
{
|
|
if (msg.UID != null && !msg.UID.Equals(Pair.UserData.UID, StringComparison.Ordinal)) return;
|
|
Logger.LogDebug("Recalculating performance for {uid}", Pair.UserData.UID);
|
|
pair.ApplyLastReceivedData(forced: true);
|
|
});
|
|
|
|
LastAppliedDataBytes = -1;
|
|
}
|
|
|
|
public bool IsVisible
|
|
{
|
|
get => _isVisible;
|
|
private set
|
|
{
|
|
if (_isVisible != value)
|
|
{
|
|
_isVisible = value;
|
|
string text = "User Visibility Changed, now: " + (_isVisible ? "Is Visible" : "Is not Visible");
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler),
|
|
EventSeverity.Informational, text)));
|
|
}
|
|
}
|
|
}
|
|
|
|
public long LastAppliedDataBytes { get; private set; }
|
|
public Pair Pair { get; private init; }
|
|
public PairAnalyzer PairAnalyzer { get; private init; }
|
|
public nint PlayerCharacter => _charaHandler?.Address ?? nint.Zero;
|
|
public unsafe uint PlayerCharacterId => (_charaHandler?.Address ?? nint.Zero) == nint.Zero
|
|
? uint.MaxValue
|
|
: ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)_charaHandler!.Address)->EntityId;
|
|
public string? PlayerName { get; private set; }
|
|
public string PlayerNameHash => Pair.Ident;
|
|
|
|
public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = false)
|
|
{
|
|
if (_configService.Current.HoldCombatApplication && _dalamudUtil.IsInCombatOrPerforming)
|
|
{
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning,
|
|
"Cannot apply character data: you are in combat or performing music, deferring application")));
|
|
Logger.LogDebug("[BASE-{appBase}] Received data but player is in combat or performing", applicationBase);
|
|
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
|
|
SetUploading(isUploading: false);
|
|
return;
|
|
}
|
|
|
|
if (_charaHandler == null || (PlayerCharacter == IntPtr.Zero))
|
|
{
|
|
if (_deferred != Guid.Empty)
|
|
{
|
|
_isVisible = false;
|
|
_visibilityService.StopTracking(Pair.Ident);
|
|
_visibilityService.StartTracking(Pair.Ident);
|
|
}
|
|
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), 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 || (PlayerCharacter == IntPtr.Zero && _cachedData == null);
|
|
_cachedData = characterData;
|
|
Mediator.Publish(new PairDataAppliedMessage(Pair.UserData.UID, characterData));
|
|
Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods);
|
|
// Ensure that this deferred application actually occurs by forcing visibiltiy to re-proc
|
|
// Set _deferred as a silencing flag to avoid spamming logs once per frame with failed applications
|
|
_isVisible = false;
|
|
_deferred = applicationBase;
|
|
_visibilityService.StopTracking(Pair.Ident);
|
|
_visibilityService.StartTracking(Pair.Ident);
|
|
return;
|
|
}
|
|
|
|
_deferred = Guid.Empty;
|
|
|
|
SetUploading(isUploading: false);
|
|
|
|
if (Pair.IsDownloadBlocked)
|
|
{
|
|
var reasons = string.Join(", ", Pair.HoldDownloadReasons);
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning,
|
|
$"Not applying character data: {reasons}")));
|
|
Logger.LogDebug("[BASE-{appBase}] Not applying due to hold: {reasons}", applicationBase, reasons);
|
|
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 || (PlayerCharacter == IntPtr.Zero && _cachedData == null);
|
|
_cachedData = characterData;
|
|
Mediator.Publish(new PairDataAppliedMessage(Pair.UserData.UID, characterData));
|
|
Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods);
|
|
return;
|
|
}
|
|
|
|
Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, this, 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 (string.Equals(characterData.DataHash.Value, _cachedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal) && !forceApplyCustomization) return;
|
|
|
|
if (_dalamudUtil.IsInCutscene || _dalamudUtil.IsInGpose || !_ipcManager.Penumbra.APIAvailable || !_ipcManager.Glamourer.APIAvailable)
|
|
{
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning,
|
|
"Cannot apply character data: you are in GPose, a Cutscene or Penumbra/Glamourer is not available")));
|
|
Logger.LogInformation("[BASE-{appbase}] Application of data for {player} while in cutscene/gpose or Penumbra/Glamourer unavailable, returning", applicationBase, this);
|
|
return;
|
|
}
|
|
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Informational,
|
|
"Applying Character Data")));
|
|
|
|
_forceApplyMods |= forceApplyCustomization;
|
|
|
|
var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, forceApplyCustomization, _forceApplyMods);
|
|
|
|
if (_charaHandler != null && _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(Pair.UserData, PlayerName!, playerChanges);
|
|
}
|
|
|
|
Logger.LogDebug("[BASE-{appbase}] Downloading and applying character for {name}", applicationBase, this);
|
|
|
|
DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate);
|
|
}
|
|
|
|
public override string ToString()
|
|
{
|
|
return Pair == null
|
|
? base.ToString() ?? string.Empty
|
|
: Pair.UserData.AliasOrUID + ":" + PlayerName + ":" + (PlayerCharacter != nint.Zero ? "HasChar" : "NoChar");
|
|
}
|
|
|
|
internal void SetUploading(bool isUploading = true)
|
|
{
|
|
Logger.LogTrace("Setting {this} uploading {uploading}", this, isUploading);
|
|
if (_charaHandler != null)
|
|
{
|
|
Mediator.Publish(new PlayerUploadingMessage(_charaHandler, isUploading));
|
|
}
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
base.Dispose(disposing);
|
|
|
|
if (!disposing) return;
|
|
|
|
_visibilityService.StopTracking(Pair.Ident);
|
|
|
|
SetUploading(isUploading: false);
|
|
var name = PlayerName;
|
|
Logger.LogDebug("Disposing {name} ({user})", name, Pair);
|
|
try
|
|
{
|
|
Guid applicationId = Guid.NewGuid();
|
|
|
|
if (!string.IsNullOrEmpty(name))
|
|
{
|
|
Mediator.Publish(new EventMessage(new Event(name, Pair.UserData, nameof(PairHandler), EventSeverity.Informational, "Disposing User")));
|
|
}
|
|
|
|
UndoApplicationAsync(applicationId).GetAwaiter().GetResult();
|
|
|
|
_applicationCancellationTokenSource?.Dispose();
|
|
_applicationCancellationTokenSource = null;
|
|
_downloadCancellationTokenSource?.Dispose();
|
|
_downloadCancellationTokenSource = null;
|
|
_charaHandler?.Dispose();
|
|
_charaHandler = null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarning(ex, "Error on disposal of {name}", name);
|
|
}
|
|
finally
|
|
{
|
|
PlayerName = null;
|
|
_cachedData = null;
|
|
Mediator.Publish(new PairDataAppliedMessage(Pair.UserData.UID, null));
|
|
Logger.LogDebug("Disposing {name} complete", name);
|
|
}
|
|
}
|
|
|
|
public void UndoApplication(Guid applicationId = default)
|
|
{
|
|
_ = Task.Run(async () => {
|
|
await UndoApplicationAsync(applicationId).ConfigureAwait(false);
|
|
});
|
|
}
|
|
|
|
private void RegisterGposeClones()
|
|
{
|
|
var name = PlayerName;
|
|
if (name == null)
|
|
return;
|
|
_ = _dalamudUtil.RunOnFrameworkThread(() =>
|
|
{
|
|
foreach (var actor in _dalamudUtil.GetGposeCharactersFromObjectTable())
|
|
{
|
|
if (actor == null) continue;
|
|
var gposeName = actor.Name.TextValue;
|
|
if (!name.Equals(gposeName, StringComparison.Ordinal))
|
|
continue;
|
|
_noSnapService.AddGposer(actor.ObjectIndex);
|
|
}
|
|
});
|
|
}
|
|
|
|
private async Task UndoApplicationAsync(Guid applicationId = default)
|
|
{
|
|
Logger.LogDebug($"Undoing application of {Pair.UserPair}");
|
|
var name = PlayerName;
|
|
try
|
|
{
|
|
if (applicationId == default)
|
|
applicationId = Guid.NewGuid();
|
|
_applicationCancellationTokenSource = _applicationCancellationTokenSource?.CancelRecreate();
|
|
_downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate();
|
|
|
|
Logger.LogDebug("[{applicationId}] Removing Temp Collection for {name} ({user})", applicationId, name, Pair.UserPair);
|
|
if (_penumbraCollection != Guid.Empty)
|
|
{
|
|
await _ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, _penumbraCollection).ConfigureAwait(false);
|
|
_penumbraCollection = Guid.Empty;
|
|
RegisterGposeClones();
|
|
}
|
|
|
|
if (_dalamudUtil is { IsZoning: false, IsInCutscene: false } && !string.IsNullOrEmpty(name))
|
|
{
|
|
Logger.LogTrace("[{applicationId}] Restoring state for {name} ({OnlineUser})", applicationId, name, Pair.UserPair);
|
|
if (!IsVisible)
|
|
{
|
|
Logger.LogDebug("[{applicationId}] Restoring Glamourer for {name} ({user})", applicationId, name, Pair.UserPair);
|
|
await _ipcManager.Glamourer.RevertByNameAsync(Logger, name, applicationId).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
using var cts = new CancellationTokenSource();
|
|
cts.CancelAfter(TimeSpan.FromSeconds(60));
|
|
|
|
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
|
|
{
|
|
await RevertCustomizationDataAsync(item.Key, name, applicationId, cts.Token).ConfigureAwait(false);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
Logger.LogWarning(ex, "Failed disposing player (not present anymore?)");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else if (_dalamudUtil.IsInCutscene && !string.IsNullOrEmpty(name))
|
|
{
|
|
_noSnapService.AddGposerNamed(name);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarning(ex, "Error on undoing application of {name}", 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.GetCompanion(ptr), isWatched: false).ConfigureAwait(false),
|
|
ObjectKind.MinionOrMount => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetMinionOrMount(ptr), isWatched: false).ConfigureAwait(false),
|
|
ObjectKind.Pet => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetPet(ptr), isWatched: false).ConfigureAwait(false),
|
|
_ => throw new NotSupportedException("ObjectKind not supported: " + changes.Key)
|
|
};
|
|
|
|
async Task processApplication(IEnumerable<PlayerChanges> changeList)
|
|
{
|
|
foreach (var change in changeList)
|
|
{
|
|
Logger.LogDebug("[{applicationId}{ft}] Processing {change} for {handler}", applicationId, _dalamudUtil.IsOnFrameworkThread ? "*" : "", 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, allowImmediate: true).ConfigureAwait(false);
|
|
}
|
|
break;
|
|
|
|
case PlayerChanges.PetNames:
|
|
await _ipcManager.PetNames.SetPlayerData(handler.Address, charaData.PetNamesData).ConfigureAwait(false);
|
|
break;
|
|
|
|
case PlayerChanges.Moodles:
|
|
await _ipcManager.Moodles.SetStatusAsync(handler.Address, charaData.MoodlesData).ConfigureAwait(false);
|
|
break;
|
|
|
|
case PlayerChanges.ForcedRedraw:
|
|
await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false);
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
token.ThrowIfCancellationRequested();
|
|
}
|
|
}
|
|
|
|
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();
|
|
if (_configService.Current.SerialApplication)
|
|
{
|
|
var serialChangeList = changes.Value.Where(p => p <= PlayerChanges.ForcedRedraw).OrderBy(p => (int)p);
|
|
var asyncChangeList = changes.Value.Where(p => p > PlayerChanges.ForcedRedraw).OrderBy(p => (int)p);
|
|
await _dalamudUtil.RunOnFrameworkThread(async () => await processApplication(serialChangeList).ConfigureAwait(false)).ConfigureAwait(false);
|
|
await Task.Run(async () => await processApplication(asyncChangeList).ConfigureAwait(false), CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
_ = processApplication(changes.Value.OrderBy(p => (int)p));
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (handler != _charaHandler) handler.Dispose();
|
|
}
|
|
}
|
|
|
|
private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData)
|
|
{
|
|
if (!updatedData.Any())
|
|
{
|
|
Logger.LogDebug("[BASE-{appBase}] Nothing to update for {obj}", applicationBase, this);
|
|
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));
|
|
|
|
_downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource();
|
|
var downloadToken = _downloadCancellationTokenSource.Token;
|
|
|
|
_ = Task.Run(async () => {
|
|
await DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, downloadToken).ConfigureAwait(false);
|
|
});
|
|
}
|
|
|
|
private Task? _pairDownloadTask;
|
|
|
|
private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData,
|
|
bool updateModdedPaths, bool updateManip, CancellationToken downloadToken)
|
|
{
|
|
Logger.LogTrace("[BASE-{appBase}] DownloadAndApplyCharacterAsync", applicationBase);
|
|
Dictionary<(string GamePath, string? Hash), string> moddedPaths = [];
|
|
|
|
if (updateModdedPaths)
|
|
{
|
|
Logger.LogTrace("[BASE-{appBase}] DownloadAndApplyCharacterAsync > updateModdedPaths", applicationBase);
|
|
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, Pair.UserData, nameof(PairHandler), EventSeverity.Informational,
|
|
$"Starting download for {toDownloadReplacements.Count} files")));
|
|
var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false);
|
|
|
|
if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles))
|
|
{
|
|
Pair.HoldApplication("IndividualPerformanceThreshold", maxValue: 1);
|
|
_downloadManager.ClearDownload();
|
|
return;
|
|
}
|
|
|
|
_pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false), downloadToken);
|
|
|
|
await _pairDownloadTask.ConfigureAwait(false);
|
|
|
|
if (downloadToken.IsCancellationRequested)
|
|
{
|
|
Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase);
|
|
return;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
try
|
|
{
|
|
Mediator.Publish(new HaltScanMessage(nameof(PlayerPerformanceService.ShrinkTextures)));
|
|
if (await _playerPerformanceService.ShrinkTextures(this, charaData, downloadToken).ConfigureAwait(false))
|
|
_ = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken);
|
|
}
|
|
finally
|
|
{
|
|
Mediator.Publish(new ResumeScanMessage(nameof(PlayerPerformanceService.ShrinkTextures)));
|
|
}
|
|
|
|
bool exceedsThreshold = !await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false);
|
|
|
|
if (exceedsThreshold)
|
|
Pair.HoldApplication("IndividualPerformanceThreshold", maxValue: 1);
|
|
else
|
|
Pair.UnholdApplication("IndividualPerformanceThreshold");
|
|
|
|
if (exceedsThreshold)
|
|
{
|
|
Logger.LogTrace("[BASE-{appBase}] Not applying due to performance thresholds", applicationBase);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (Pair.IsApplicationBlocked)
|
|
{
|
|
var reasons = string.Join(", ", Pair.HoldApplicationReasons);
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning,
|
|
$"Not applying character data: {reasons}")));
|
|
Logger.LogTrace("[BASE-{appBase}] Not applying due to hold: {reasons}", applicationBase, reasons);
|
|
return;
|
|
}
|
|
|
|
downloadToken.ThrowIfCancellationRequested();
|
|
|
|
var appToken = _applicationCancellationTokenSource?.Token;
|
|
while ((!_applicationTask?.IsCompleted ?? false)
|
|
&& !downloadToken.IsCancellationRequested
|
|
&& (!appToken?.IsCancellationRequested ?? false))
|
|
{
|
|
// block until current application is done
|
|
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)) return;
|
|
|
|
_applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource();
|
|
var token = _applicationCancellationTokenSource.Token;
|
|
|
|
_applicationTask = ApplyCharacterDataAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, token);
|
|
}
|
|
|
|
private async Task ApplyCharacterDataAsync(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData, bool updateModdedPaths, bool updateManip,
|
|
Dictionary<(string GamePath, string? Hash), string> moddedPaths, CancellationToken token)
|
|
{
|
|
ushort objIndex = ushort.MaxValue;
|
|
try
|
|
{
|
|
_applicationId = Guid.NewGuid();
|
|
Logger.LogDebug("[BASE-{applicationId}] Starting application task for {this}: {appId}", applicationBase, this, _applicationId);
|
|
|
|
if (_penumbraCollection == Guid.Empty)
|
|
{
|
|
if (objIndex == ushort.MaxValue)
|
|
objIndex = await _dalamudUtil.RunOnFrameworkThread(() => _charaHandler!.GetGameObject()!.ObjectIndex).ConfigureAwait(false);
|
|
_penumbraCollection = await _ipcManager.Penumbra.CreateTemporaryCollectionAsync(Logger, Pair.UserData.UID).ConfigureAwait(false);
|
|
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, _penumbraCollection, objIndex).ConfigureAwait(false);
|
|
}
|
|
|
|
Logger.LogDebug("[{applicationId}] Waiting for initial draw for for {handler}", _applicationId, _charaHandler);
|
|
await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, _charaHandler!, _applicationId, 30000, token).ConfigureAwait(false);
|
|
|
|
token.ThrowIfCancellationRequested();
|
|
|
|
if (updateModdedPaths)
|
|
{
|
|
// ensure collection is set
|
|
if (objIndex == ushort.MaxValue)
|
|
objIndex = await _dalamudUtil.RunOnFrameworkThread(() => _charaHandler!.GetGameObject()!.ObjectIndex).ConfigureAwait(false);
|
|
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, _penumbraCollection, objIndex).ConfigureAwait(false);
|
|
|
|
await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, _applicationId, _penumbraCollection,
|
|
moddedPaths.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)).ConfigureAwait(false);
|
|
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;
|
|
Mediator.Publish(new PairDataAppliedMessage(Pair.UserData.UID, charaData));
|
|
|
|
Logger.LogDebug("[{applicationId}] Application finished", _applicationId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException))
|
|
{
|
|
IsVisible = false;
|
|
_forceApplyMods = true;
|
|
_cachedData = charaData;
|
|
Mediator.Publish(new PairDataAppliedMessage(Pair.UserData.UID, charaData));
|
|
Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId);
|
|
}
|
|
else
|
|
{
|
|
Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void UpdateVisibility(bool nowVisible, bool invalidate = false)
|
|
{
|
|
if (string.IsNullOrEmpty(PlayerName))
|
|
{
|
|
var pc = _dalamudUtil.FindPlayerByNameHash(Pair.Ident);
|
|
if (pc.ObjectId == 0) return;
|
|
Logger.LogDebug("One-Time Initializing {this}", this);
|
|
Initialize(pc.Name);
|
|
Logger.LogDebug("One-Time Initialized {this}", this);
|
|
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Informational,
|
|
$"Initializing User For Character {pc.Name}")));
|
|
}
|
|
|
|
// This was triggered by the character becoming handled by Mare, so unapply everything
|
|
// There seems to be a good chance that this races Mare and then crashes
|
|
if (!nowVisible && invalidate)
|
|
{
|
|
bool wasVisible = IsVisible;
|
|
IsVisible = false;
|
|
_charaHandler?.Invalidate();
|
|
_downloadCancellationTokenSource?.CancelDispose();
|
|
_downloadCancellationTokenSource = null;
|
|
if (wasVisible)
|
|
Logger.LogTrace("{this} visibility changed, now: {visi}", this, IsVisible);
|
|
Logger.LogDebug("Invalidating {this}", this);
|
|
UndoApplication();
|
|
return;
|
|
}
|
|
|
|
if (!IsVisible && nowVisible)
|
|
{
|
|
// This is deferred application attempt, avoid any log output
|
|
if (_deferred != Guid.Empty)
|
|
{
|
|
_isVisible = true;
|
|
_ = Task.Run(() =>
|
|
{
|
|
ApplyCharacterData(_deferred, _cachedData!, forceApplyCustomization: true);
|
|
});
|
|
}
|
|
|
|
IsVisible = true;
|
|
Mediator.Publish(new PairHandlerVisibleMessage(this));
|
|
if (_cachedData != null)
|
|
{
|
|
Guid appData = Guid.NewGuid();
|
|
Logger.LogTrace("[BASE-{appBase}] {this} visibility changed, now: {visi}, cached data exists", appData, this, IsVisible);
|
|
|
|
_ = Task.Run(() =>
|
|
{
|
|
ApplyCharacterData(appData, _cachedData!, forceApplyCustomization: true);
|
|
});
|
|
}
|
|
else
|
|
{
|
|
Logger.LogTrace("{this} visibility changed, now: {visi}, no cached data exists", this, IsVisible);
|
|
}
|
|
}
|
|
else if (IsVisible && !nowVisible)
|
|
{
|
|
IsVisible = false;
|
|
_charaHandler?.Invalidate();
|
|
_downloadCancellationTokenSource?.CancelDispose();
|
|
_downloadCancellationTokenSource = null;
|
|
Logger.LogTrace("{this} visibility changed, now: {visi}", this, IsVisible);
|
|
}
|
|
}
|
|
|
|
private void Initialize(string name)
|
|
{
|
|
PlayerName = name;
|
|
_charaHandler = _gameObjectHandlerFactory.Create(ObjectKind.Player, () => _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Pair.Ident), isWatched: false).GetAwaiter().GetResult();
|
|
|
|
Mediator.Subscribe<HonorificReadyMessage>(this, msg =>
|
|
{
|
|
if (string.IsNullOrEmpty(_cachedData?.HonorificData)) return;
|
|
Logger.LogTrace("Reapplying Honorific data for {this}", this);
|
|
_ = Task.Run(async () => await _ipcManager.Honorific.SetTitleAsync(PlayerCharacter, _cachedData.HonorificData).ConfigureAwait(false), CancellationToken.None);
|
|
});
|
|
|
|
Mediator.Subscribe<PetNamesReadyMessage>(this, msg =>
|
|
{
|
|
if (string.IsNullOrEmpty(_cachedData?.PetNamesData)) return;
|
|
Logger.LogTrace("Reapplying Pet Names data for {this}", this);
|
|
_ = Task.Run(async () => await _ipcManager.PetNames.SetPlayerData(PlayerCharacter, _cachedData.PetNamesData).ConfigureAwait(false), CancellationToken.None);
|
|
});
|
|
}
|
|
|
|
private async Task RevertCustomizationDataAsync(ObjectKind objectKind, string name, Guid applicationId, CancellationToken cancelToken)
|
|
{
|
|
nint address = _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Pair.Ident);
|
|
if (address == nint.Zero) return;
|
|
|
|
Logger.LogDebug("[{applicationId}] Reverting all Customization for {alias}/{name} {objectKind}", applicationId, Pair.UserData.AliasOrUID, 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, Pair.UserData.AliasOrUID, name);
|
|
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
|
|
tempHandler.CompareNameAndThrow(name);
|
|
Logger.LogDebug("[{applicationId}] Restoring Heels for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
|
|
await _ipcManager.Heels.RestoreOffsetForPlayerAsync(address).ConfigureAwait(false);
|
|
tempHandler.CompareNameAndThrow(name);
|
|
Logger.LogDebug("[{applicationId}] Restoring C+ for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
|
|
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
|
|
tempHandler.CompareNameAndThrow(name);
|
|
Logger.LogDebug("[{applicationId}] Restoring Honorific for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
|
|
await _ipcManager.Honorific.ClearTitleAsync(address).ConfigureAwait(false);
|
|
Logger.LogDebug("[{applicationId}] Restoring Pet Nicknames for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
|
|
await _ipcManager.PetNames.ClearPlayerData(address).ConfigureAwait(false);
|
|
Logger.LogDebug("[{applicationId}] Restoring Moodles for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
|
|
await _ipcManager.Moodles.RevertStatusAsync(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.Pet, () => 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;
|
|
|
|
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, preferSubst: true);
|
|
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)
|
|
{
|
|
outputDict[(gamePath, item.Hash)] = fileCache.ResolvedFilepath;
|
|
}
|
|
}
|
|
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)
|
|
{
|
|
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];
|
|
}
|
|
} |