Added seperate collections for other states, moved clean up of penumbra collection out of config. Safe read of ptr on process, fixed notfications on popup and notifications with flags.
This commit is contained in:
@@ -21,6 +21,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
private long _currentFileProgress = 0;
|
||||
private CancellationTokenSource _scanCancellationTokenSource = new();
|
||||
private readonly CancellationTokenSource _periodicCalculationTokenSource = new();
|
||||
private readonly SemaphoreSlim _dbGate = new(1, 1);
|
||||
public static readonly IImmutableList<string> AllowedFileExtensions = [".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".pbd", ".scd", ".skp", ".shpk", ".kdb"];
|
||||
private static readonly HashSet<string> AllowedFileExtensionSet = new(AllowedFileExtensions, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -68,6 +69,9 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
{
|
||||
Logger.LogInformation("Starting Periodic Storage Directory Calculation Task");
|
||||
var token = _periodicCalculationTokenSource.Token;
|
||||
while (IsHalted() && !token.IsCancellationRequested)
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false);
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
@@ -91,6 +95,9 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
public long CurrentFileProgress => _currentFileProgress;
|
||||
public long FileCacheSize { get; set; }
|
||||
public long FileCacheDriveFree { get; set; }
|
||||
|
||||
private int _haltCount;
|
||||
private bool IsHalted() => Volatile.Read(ref _haltCount) > 0;
|
||||
public ConcurrentDictionary<string, int> HaltScanLocks { get; set; } = new(StringComparer.Ordinal);
|
||||
public bool IsScanRunning => CurrentFileProgress > 0 || TotalFiles > 0;
|
||||
public long TotalFiles { get; private set; }
|
||||
@@ -98,14 +105,36 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
|
||||
public void HaltScan(string source)
|
||||
{
|
||||
if (!HaltScanLocks.ContainsKey(source)) HaltScanLocks[source] = 0;
|
||||
HaltScanLocks[source]++;
|
||||
HaltScanLocks.AddOrUpdate(source, 1, (_, v) => v + 1);
|
||||
Interlocked.Increment(ref _haltCount);
|
||||
}
|
||||
|
||||
record WatcherChange(WatcherChangeTypes ChangeType, string? OldPath = null);
|
||||
private readonly record struct CacheEvictionCandidate(string FullPath, long Size, DateTime LastAccessTime);
|
||||
private readonly Dictionary<string, WatcherChange> _watcherChanges = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, WatcherChange> _lightlessChanges = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _penumbraGate = new();
|
||||
private Dictionary<string, WatcherChange> _watcherChanges = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly object _lightlessGate = new();
|
||||
private Dictionary<string, WatcherChange> _lightlessChanges = new(StringComparer.OrdinalIgnoreCase);
|
||||
private Dictionary<string, WatcherChange> DrainPenumbraChanges()
|
||||
{
|
||||
lock (_penumbraGate)
|
||||
{
|
||||
var snapshot = _watcherChanges;
|
||||
_watcherChanges = new(StringComparer.OrdinalIgnoreCase);
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, WatcherChange> DrainLightlessChanges()
|
||||
{
|
||||
lock (_lightlessGate)
|
||||
{
|
||||
var snapshot = _lightlessChanges;
|
||||
_lightlessChanges = new(StringComparer.OrdinalIgnoreCase);
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
public void StopMonitoring()
|
||||
{
|
||||
@@ -168,7 +197,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
|
||||
if (!HasAllowedExtension(e.FullPath)) return;
|
||||
|
||||
lock (_watcherChanges)
|
||||
lock (_lightlessChanges)
|
||||
{
|
||||
_lightlessChanges[e.FullPath] = new(e.ChangeType);
|
||||
}
|
||||
@@ -279,67 +308,58 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
|
||||
private async Task LightlessWatcherExecution()
|
||||
{
|
||||
_lightlessFswCts = _lightlessFswCts.CancelRecreate();
|
||||
_lightlessFswCts = _lightlessFswCts.CancelRecreate();
|
||||
var token = _lightlessFswCts.Token;
|
||||
var delay = TimeSpan.FromSeconds(5);
|
||||
Dictionary<string, WatcherChange> changes;
|
||||
lock (_lightlessChanges)
|
||||
changes = _lightlessChanges.ToDictionary(t => t.Key, t => t.Value, StringComparer.Ordinal);
|
||||
|
||||
try
|
||||
{
|
||||
do
|
||||
{
|
||||
await Task.Delay(delay, token).ConfigureAwait(false);
|
||||
} while (HaltScanLocks.Any(f => f.Value > 0));
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return;
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(false);
|
||||
while (IsHalted() && !token.IsCancellationRequested)
|
||||
await Task.Delay(250, token).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException) { return; }
|
||||
|
||||
lock (_lightlessChanges)
|
||||
{
|
||||
foreach (var key in changes.Keys)
|
||||
{
|
||||
_lightlessChanges.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
HandleChanges(changes);
|
||||
var changes = DrainLightlessChanges();
|
||||
if (changes.Count > 0)
|
||||
_ = HandleChangesAsync(changes, token);
|
||||
}
|
||||
|
||||
private void HandleChanges(Dictionary<string, WatcherChange> changes)
|
||||
private async Task HandleChangesAsync(Dictionary<string, WatcherChange> changes, CancellationToken token)
|
||||
{
|
||||
lock (_fileDbManager)
|
||||
await _dbGate.WaitAsync(token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var deletedEntries = changes.Where(c => c.Value.ChangeType == WatcherChangeTypes.Deleted).Select(c => c.Key);
|
||||
var renamedEntries = changes.Where(c => c.Value.ChangeType == WatcherChangeTypes.Renamed);
|
||||
var remainingEntries = changes.Where(c => c.Value.ChangeType != WatcherChangeTypes.Deleted).Select(c => c.Key);
|
||||
var deleted = changes.Where(c => c.Value.ChangeType == WatcherChangeTypes.Deleted).Select(c => c.Key);
|
||||
var renamed = changes.Where(c => c.Value.ChangeType == WatcherChangeTypes.Renamed);
|
||||
var remaining = changes.Where(c => c.Value.ChangeType != WatcherChangeTypes.Deleted).Select(c => c.Key);
|
||||
|
||||
foreach (var entry in deletedEntries)
|
||||
foreach (var entry in deleted)
|
||||
{
|
||||
Logger.LogDebug("FSW Change: Deletion - {val}", entry);
|
||||
}
|
||||
|
||||
foreach (var entry in renamedEntries)
|
||||
foreach (var entry in renamed)
|
||||
{
|
||||
Logger.LogDebug("FSW Change: Renamed - {oldVal} => {val}", entry.Value.OldPath, entry.Key);
|
||||
}
|
||||
|
||||
foreach (var entry in remainingEntries)
|
||||
foreach (var entry in remaining)
|
||||
{
|
||||
Logger.LogDebug("FSW Change: Creation or Change - {val}", entry);
|
||||
}
|
||||
|
||||
var allChanges = deletedEntries
|
||||
.Concat(renamedEntries.Select(c => c.Value.OldPath!))
|
||||
.Concat(renamedEntries.Select(c => c.Key))
|
||||
.Concat(remainingEntries)
|
||||
var allChanges = deleted
|
||||
.Concat(renamed.Select(c => c.Value.OldPath!))
|
||||
.Concat(renamed.Select(c => c.Key))
|
||||
.Concat(remaining)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
_ = _fileDbManager.GetFileCachesByPaths(allChanges);
|
||||
|
||||
_fileDbManager.WriteOutFullCsv();
|
||||
await _fileDbManager.WriteOutFullCsvAsync(token).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_dbGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,77 +367,97 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
{
|
||||
_penumbraFswCts = _penumbraFswCts.CancelRecreate();
|
||||
var token = _penumbraFswCts.Token;
|
||||
Dictionary<string, WatcherChange> changes;
|
||||
lock (_watcherChanges)
|
||||
changes = _watcherChanges.ToDictionary(t => t.Key, t => t.Value, StringComparer.Ordinal);
|
||||
var delay = TimeSpan.FromSeconds(10);
|
||||
|
||||
try
|
||||
{
|
||||
do
|
||||
{
|
||||
await Task.Delay(delay, token).ConfigureAwait(false);
|
||||
} while (HaltScanLocks.Any(f => f.Value > 0));
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return;
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(false);
|
||||
while (IsHalted() && !token.IsCancellationRequested)
|
||||
await Task.Delay(250, token).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException) { return; }
|
||||
|
||||
lock (_watcherChanges)
|
||||
{
|
||||
foreach (var key in changes.Keys)
|
||||
{
|
||||
_watcherChanges.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
HandleChanges(changes);
|
||||
var changes = DrainPenumbraChanges();
|
||||
if (changes.Count > 0)
|
||||
_ = HandleChangesAsync(changes, token);
|
||||
}
|
||||
|
||||
public void InvokeScan()
|
||||
{
|
||||
TotalFiles = 0;
|
||||
_currentFileProgress = 0;
|
||||
TotalFilesStorage = 0;
|
||||
Interlocked.Exchange(ref _currentFileProgress, 0);
|
||||
|
||||
_scanCancellationTokenSource = _scanCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource();
|
||||
var token = _scanCancellationTokenSource.Token;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
Logger.LogDebug("Starting Full File Scan");
|
||||
TotalFiles = 0;
|
||||
_currentFileProgress = 0;
|
||||
while (_dalamudUtil.IsOnFrameworkThread)
|
||||
{
|
||||
Logger.LogWarning("Scanner is on framework, waiting for leaving thread before continuing");
|
||||
await Task.Delay(250, token).ConfigureAwait(false);
|
||||
}
|
||||
TaskCompletionSource scanTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Thread scanThread = new(() =>
|
||||
try
|
||||
{
|
||||
try
|
||||
Logger.LogDebug("Starting Full File Scan");
|
||||
|
||||
while (IsHalted() && !token.IsCancellationRequested)
|
||||
{
|
||||
_performanceCollector.LogPerformance(this, $"FullFileScan", () => FullFileScan(token));
|
||||
Logger.LogDebug("Scan is halted, waiting...");
|
||||
await Task.Delay(250, token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
var scanThread = new Thread(() =>
|
||||
{
|
||||
Logger.LogError(ex, "Error during Full File Scan");
|
||||
}
|
||||
})
|
||||
{
|
||||
Priority = ThreadPriority.Lowest,
|
||||
IsBackground = true
|
||||
};
|
||||
scanThread.Start();
|
||||
while (scanThread.IsAlive)
|
||||
{
|
||||
await Task.Delay(250, token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
_performanceCollector.LogPerformance(this, $"FullFileScan",
|
||||
() => FullFileScan(token));
|
||||
|
||||
scanTcs.TrySetResult();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
scanTcs.TrySetCanceled(token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error during Full File Scan");
|
||||
scanTcs.TrySetException(ex);
|
||||
}
|
||||
})
|
||||
{
|
||||
Priority = ThreadPriority.Lowest,
|
||||
IsBackground = true,
|
||||
Name = "LightlessSync.FullFileScan"
|
||||
};
|
||||
|
||||
scanThread.Start();
|
||||
|
||||
using var _ = token.Register(() => scanTcs.TrySetCanceled(token));
|
||||
|
||||
await scanTcs.Task.ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
Logger.LogInformation("Full File Scan was canceled.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Unexpected error in InvokeScan task");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TotalFiles = 0;
|
||||
TotalFilesStorage = 0;
|
||||
Interlocked.Exchange(ref _currentFileProgress, 0);
|
||||
}
|
||||
TotalFiles = 0;
|
||||
_currentFileProgress = 0;
|
||||
}, token);
|
||||
}
|
||||
|
||||
public void RecalculateFileCacheSize(CancellationToken token)
|
||||
{
|
||||
if (IsHalted()) return;
|
||||
|
||||
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) ||
|
||||
!Directory.Exists(_configService.Current.CacheFolder))
|
||||
{
|
||||
@@ -594,10 +634,20 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
|
||||
public void ResumeScan(string source)
|
||||
{
|
||||
if (!HaltScanLocks.ContainsKey(source)) HaltScanLocks[source] = 0;
|
||||
int delta = 0;
|
||||
|
||||
HaltScanLocks[source]--;
|
||||
if (HaltScanLocks[source] < 0) HaltScanLocks[source] = 0;
|
||||
HaltScanLocks.AddOrUpdate(source,
|
||||
addValueFactory: _ => 0,
|
||||
updateValueFactory: (_, v) =>
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(_);
|
||||
if (v <= 0) return 0;
|
||||
delta = 1;
|
||||
return v - 1;
|
||||
});
|
||||
|
||||
if (delta == 1)
|
||||
Interlocked.Decrement(ref _haltCount);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
@@ -621,201 +671,81 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
private void FullFileScan(CancellationToken ct)
|
||||
{
|
||||
TotalFiles = 1;
|
||||
_currentFileProgress = 0;
|
||||
|
||||
var penumbraDir = _ipcManager.Penumbra.ModDirectory;
|
||||
bool penDirExists = true;
|
||||
bool cacheDirExists = true;
|
||||
var cacheFolder = _configService.Current.CacheFolder;
|
||||
|
||||
if (string.IsNullOrEmpty(penumbraDir) || !Directory.Exists(penumbraDir))
|
||||
{
|
||||
penDirExists = false;
|
||||
Logger.LogWarning("Penumbra directory is not set or does not exist.");
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) || !Directory.Exists(_configService.Current.CacheFolder))
|
||||
|
||||
if (string.IsNullOrEmpty(cacheFolder) || !Directory.Exists(cacheFolder))
|
||||
{
|
||||
cacheDirExists = false;
|
||||
Logger.LogWarning("Lightless Cache directory is not set or does not exist.");
|
||||
}
|
||||
if (!penDirExists || !cacheDirExists)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var previousThreadPriority = Thread.CurrentThread.Priority;
|
||||
var prevPriority = Thread.CurrentThread.Priority;
|
||||
Thread.CurrentThread.Priority = ThreadPriority.Lowest;
|
||||
Logger.LogDebug("Getting files from {penumbra} and {storage}", penumbraDir, _configService.Current.CacheFolder);
|
||||
|
||||
Dictionary<string, string[]> penumbraFiles = new(StringComparer.Ordinal);
|
||||
foreach (var folder in Directory.EnumerateDirectories(penumbraDir!))
|
||||
try
|
||||
{
|
||||
try
|
||||
Logger.LogDebug("Getting files from {penumbra} and {storage}", penumbraDir, cacheFolder);
|
||||
|
||||
var onDiskPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
static bool IsExcludedPenumbraPath(string path)
|
||||
=> path.Contains(@"\bg\", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.Contains(@"\bgcommon\", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.Contains(@"\ui\", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var folder in Directory.EnumerateDirectories(penumbraDir))
|
||||
{
|
||||
penumbraFiles[folder] =
|
||||
[
|
||||
.. Directory.GetFiles(folder, "*.*", SearchOption.AllDirectories)
|
||||
.AsParallel()
|
||||
.Where(f => HasAllowedExtension(f)
|
||||
&& !f.Contains(@"\bg\", StringComparison.OrdinalIgnoreCase)
|
||||
&& !f.Contains(@"\bgcommon\", StringComparison.OrdinalIgnoreCase)
|
||||
&& !f.Contains(@"\ui\", StringComparison.OrdinalIgnoreCase)),
|
||||
];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Could not enumerate path {path}", folder);
|
||||
}
|
||||
Thread.Sleep(50);
|
||||
if (ct.IsCancellationRequested) return;
|
||||
}
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var allCacheFiles = Directory.GetFiles(_configService.Current.CacheFolder, "*.*", SearchOption.TopDirectoryOnly)
|
||||
.AsParallel()
|
||||
.Where(f =>
|
||||
{
|
||||
var val = f.Split('\\')[^1];
|
||||
return val.Length == 40 || (val.Split('.').FirstOrDefault()?.Length ?? 0) == 40;
|
||||
});
|
||||
|
||||
if (ct.IsCancellationRequested) return;
|
||||
|
||||
var allScannedFiles = (penumbraFiles.SelectMany(k => k.Value))
|
||||
.Concat(allCacheFiles)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(t => t.ToLowerInvariant(), t => false, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
TotalFiles = allScannedFiles.Count;
|
||||
Thread.CurrentThread.Priority = previousThreadPriority;
|
||||
|
||||
Thread.Sleep(TimeSpan.FromSeconds(2));
|
||||
|
||||
if (ct.IsCancellationRequested) return;
|
||||
|
||||
// scan files from database
|
||||
var threadCount = Math.Clamp((int)(Environment.ProcessorCount / 2.0f), 2, 8);
|
||||
|
||||
List<FileCacheEntity> entitiesToRemove = [];
|
||||
List<FileCacheEntity> entitiesToUpdate = [];
|
||||
Lock sync = new();
|
||||
Thread[] workerThreads = new Thread[threadCount];
|
||||
|
||||
ConcurrentQueue<FileCacheEntity> fileCaches = new(_fileDbManager.GetAllFileCaches());
|
||||
|
||||
TotalFilesStorage = fileCaches.Count;
|
||||
|
||||
for (int i = 0; i < threadCount; i++)
|
||||
{
|
||||
Logger.LogTrace("Creating Thread {i}", i);
|
||||
workerThreads[i] = new((tcounter) =>
|
||||
{
|
||||
var threadNr = (int)tcounter!;
|
||||
Logger.LogTrace("Spawning Worker Thread {i}", threadNr);
|
||||
while (!ct.IsCancellationRequested && fileCaches.TryDequeue(out var workload))
|
||||
try
|
||||
{
|
||||
try
|
||||
foreach (var file in Directory.EnumerateFiles(folder, "*.*", SearchOption.AllDirectories))
|
||||
{
|
||||
if (ct.IsCancellationRequested) return;
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (!_ipcManager.Penumbra.APIAvailable)
|
||||
{
|
||||
Logger.LogWarning("Penumbra not available");
|
||||
return;
|
||||
}
|
||||
if (!HasAllowedExtension(file)) continue;
|
||||
if (IsExcludedPenumbraPath(file)) continue;
|
||||
|
||||
var validatedCacheResult = _fileDbManager.ValidateFileCacheEntity(workload);
|
||||
if (validatedCacheResult.State != FileState.RequireDeletion)
|
||||
{
|
||||
lock (sync) { allScannedFiles[validatedCacheResult.FileCache.ResolvedFilepath] = true; }
|
||||
}
|
||||
if (validatedCacheResult.State == FileState.RequireUpdate)
|
||||
{
|
||||
Logger.LogTrace("To update: {path}", validatedCacheResult.FileCache.ResolvedFilepath);
|
||||
lock (sync) { entitiesToUpdate.Add(validatedCacheResult.FileCache); }
|
||||
}
|
||||
else if (validatedCacheResult.State == FileState.RequireDeletion)
|
||||
{
|
||||
Logger.LogTrace("To delete: {path}", validatedCacheResult.FileCache.ResolvedFilepath);
|
||||
lock (sync) { entitiesToRemove.Add(validatedCacheResult.FileCache); }
|
||||
}
|
||||
onDiskPaths.Add(file);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (workload != null)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed validating {path}", workload.ResolvedFilepath);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed validating unknown workload");
|
||||
}
|
||||
}
|
||||
Interlocked.Increment(ref _currentFileProgress);
|
||||
}
|
||||
|
||||
Logger.LogTrace("Ending Worker Thread {i}", threadNr);
|
||||
})
|
||||
{
|
||||
Priority = ThreadPriority.Lowest,
|
||||
IsBackground = true
|
||||
};
|
||||
workerThreads[i].Start(i);
|
||||
}
|
||||
|
||||
while (!ct.IsCancellationRequested && workerThreads.Any(u => u.IsAlive))
|
||||
{
|
||||
Thread.Sleep(1000);
|
||||
}
|
||||
|
||||
if (ct.IsCancellationRequested) return;
|
||||
|
||||
Logger.LogTrace("Threads exited");
|
||||
|
||||
if (!_ipcManager.Penumbra.APIAvailable)
|
||||
{
|
||||
Logger.LogWarning("Penumbra not available");
|
||||
return;
|
||||
}
|
||||
|
||||
if (entitiesToUpdate.Count != 0 || entitiesToRemove.Count != 0)
|
||||
{
|
||||
foreach (var entity in entitiesToUpdate)
|
||||
{
|
||||
_fileDbManager.UpdateHashedFile(entity);
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Could not enumerate path {path}", folder);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entity in entitiesToRemove)
|
||||
foreach (var file in Directory.EnumerateFiles(cacheFolder, "*.*", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
_fileDbManager.RemoveHashedFile(entity.Hash, entity.PrefixedFilePath);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var name = Path.GetFileName(file);
|
||||
var stem = Path.GetFileNameWithoutExtension(file);
|
||||
|
||||
if (name.Length == 40 || stem.Length == 40)
|
||||
onDiskPaths.Add(file);
|
||||
}
|
||||
|
||||
_fileDbManager.WriteOutFullCsv();
|
||||
}
|
||||
var threadCount = Math.Clamp((int)(Environment.ProcessorCount / 2.0f), 2, 8);
|
||||
|
||||
Logger.LogTrace("Scanner validated existing db files");
|
||||
var fileCacheList = _fileDbManager.GetAllFileCaches();
|
||||
var fileCaches = new ConcurrentQueue<FileCacheEntity>(fileCacheList);
|
||||
|
||||
if (!_ipcManager.Penumbra.APIAvailable)
|
||||
{
|
||||
Logger.LogWarning("Penumbra not available");
|
||||
return;
|
||||
}
|
||||
TotalFilesStorage = fileCaches.Count;
|
||||
TotalFiles = onDiskPaths.Count + TotalFilesStorage;
|
||||
|
||||
if (ct.IsCancellationRequested) return;
|
||||
|
||||
var newFiles = allScannedFiles.Where(c => !c.Value).Select(c => c.Key).ToList();
|
||||
foreach (var cachePath in newFiles)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
ProcessOne(cachePath);
|
||||
Interlocked.Increment(ref _currentFileProgress);
|
||||
}
|
||||
|
||||
Logger.LogTrace("Scanner added {count} new files to db", newFiles.Count);
|
||||
|
||||
void ProcessOne(string? cachePath)
|
||||
{
|
||||
if (_fileDbManager == null || _ipcManager?.Penumbra == null || cachePath == null)
|
||||
{
|
||||
Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}",
|
||||
_fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
|
||||
return;
|
||||
}
|
||||
var validOrPresentInDb = new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
|
||||
var entitiesToUpdate = new ConcurrentBag<FileCacheEntity>();
|
||||
var entitiesToRemove = new ConcurrentBag<FileCacheEntity>();
|
||||
|
||||
if (!_ipcManager.Penumbra.APIAvailable)
|
||||
{
|
||||
@@ -823,33 +753,161 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
Thread[] workerThreads = new Thread[threadCount];
|
||||
for (int i = 0; i < threadCount; i++)
|
||||
{
|
||||
var entry = _fileDbManager.CreateFileEntry(cachePath);
|
||||
if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath);
|
||||
workerThreads[i] = new Thread(tcounter =>
|
||||
{
|
||||
var threadNr = (int)tcounter!;
|
||||
Logger.LogTrace("Spawning Worker Thread {i}", threadNr);
|
||||
|
||||
while (!ct.IsCancellationRequested && fileCaches.TryDequeue(out var workload))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
if (!_ipcManager.Penumbra.APIAvailable)
|
||||
break;
|
||||
|
||||
var validated = _fileDbManager.ValidateFileCacheEntity(workload);
|
||||
|
||||
if (validated.State != FileState.RequireDeletion)
|
||||
{
|
||||
validOrPresentInDb.TryAdd(validated.FileCache.ResolvedFilepath, 0);
|
||||
}
|
||||
|
||||
if (validated.State == FileState.RequireUpdate)
|
||||
{
|
||||
Logger.LogTrace("To update: {path}", validated.FileCache.ResolvedFilepath);
|
||||
entitiesToUpdate.Add(validated.FileCache);
|
||||
}
|
||||
else if (validated.State == FileState.RequireDeletion)
|
||||
{
|
||||
Logger.LogTrace("To delete: {path}", validated.FileCache.ResolvedFilepath);
|
||||
entitiesToRemove.Add(validated.FileCache);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (workload != null)
|
||||
Logger.LogWarning(ex, "Failed validating {path}", workload.ResolvedFilepath);
|
||||
else
|
||||
Logger.LogWarning(ex, "Failed validating unknown workload");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Increment(ref _currentFileProgress);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.LogTrace("Ending Worker Thread {i}", threadNr);
|
||||
})
|
||||
{
|
||||
Priority = ThreadPriority.Lowest,
|
||||
IsBackground = true
|
||||
};
|
||||
|
||||
workerThreads[i].Start(i);
|
||||
}
|
||||
catch (IOException ioex)
|
||||
|
||||
while (!ct.IsCancellationRequested && workerThreads.Any(t => t.IsAlive))
|
||||
{
|
||||
Logger.LogDebug(ioex, "File busy or locked: {file}", cachePath);
|
||||
ct.WaitHandle.WaitOne(250);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
if (ct.IsCancellationRequested) return;
|
||||
|
||||
Logger.LogTrace("Scanner validated existing db files");
|
||||
|
||||
if (!_ipcManager.Penumbra.APIAvailable)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed adding {file}", cachePath);
|
||||
Logger.LogWarning("Penumbra not available");
|
||||
return;
|
||||
}
|
||||
|
||||
var didMutateDb = false;
|
||||
|
||||
foreach (var entity in entitiesToUpdate)
|
||||
{
|
||||
didMutateDb = true;
|
||||
_fileDbManager.UpdateHashedFile(entity);
|
||||
}
|
||||
|
||||
foreach (var entity in entitiesToRemove)
|
||||
{
|
||||
didMutateDb = true;
|
||||
_fileDbManager.RemoveHashedFile(entity.Hash, entity.PrefixedFilePath);
|
||||
}
|
||||
|
||||
if (didMutateDb)
|
||||
_fileDbManager.WriteOutFullCsv();
|
||||
|
||||
if (ct.IsCancellationRequested) return;
|
||||
|
||||
var newFiles = onDiskPaths.Where(p => !validOrPresentInDb.ContainsKey(p)).ToList();
|
||||
|
||||
foreach (var path in newFiles)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
ProcessOne(path);
|
||||
Interlocked.Increment(ref _currentFileProgress);
|
||||
}
|
||||
|
||||
Logger.LogTrace("Scanner added {count} new files to db", newFiles.Count);
|
||||
|
||||
void ProcessOne(string? filePath)
|
||||
{
|
||||
if (filePath == null)
|
||||
return;
|
||||
|
||||
if (!_ipcManager.Penumbra.APIAvailable)
|
||||
{
|
||||
Logger.LogWarning("Penumbra not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var entry = _fileDbManager.CreateFileEntry(filePath);
|
||||
if (entry == null)
|
||||
_ = _fileDbManager.CreateCacheEntry(filePath);
|
||||
}
|
||||
catch (IOException ioex)
|
||||
{
|
||||
Logger.LogDebug(ioex, "File busy or locked: {file}", filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed adding {file}", filePath);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.LogDebug("Scan complete");
|
||||
|
||||
TotalFiles = 0;
|
||||
_currentFileProgress = 0;
|
||||
|
||||
if (!_configService.Current.InitialScanComplete)
|
||||
{
|
||||
_configService.Current.InitialScanComplete = true;
|
||||
_configService.Save();
|
||||
|
||||
StartLightlessWatcher(cacheFolder);
|
||||
StartPenumbraWatcher(penumbraDir);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.LogDebug("Scan complete");
|
||||
TotalFiles = 0;
|
||||
_currentFileProgress = 0;
|
||||
entitiesToRemove.Clear();
|
||||
allScannedFiles.Clear();
|
||||
|
||||
if (!_configService.Current.InitialScanComplete)
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_configService.Current.InitialScanComplete = true;
|
||||
_configService.Save();
|
||||
StartLightlessWatcher(_configService.Current.CacheFolder);
|
||||
StartPenumbraWatcher(penumbraDir);
|
||||
// normal cancellation
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error during Full File Scan");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Thread.CurrentThread.Priority = prevPriority;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,7 +213,7 @@ public sealed partial class FileCompactor : IDisposable
|
||||
/// <param name="bytes">Bytes that have to be written</param>
|
||||
/// <param name="token">Cancellation Token for interupts</param>
|
||||
/// <returns>Writing Task</returns>
|
||||
public async Task WriteAllBytesAsync(string filePath, byte[] bytes, CancellationToken token)
|
||||
public async Task WriteAllBytesAsync(string filePath, byte[] bytes, CancellationToken token, bool enqueueCompaction = true)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
@@ -221,6 +221,12 @@ public sealed partial class FileCompactor : IDisposable
|
||||
|
||||
await File.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false);
|
||||
|
||||
if (enqueueCompaction && _lightlessConfigService.Current.UseCompactor)
|
||||
EnqueueCompaction(filePath);
|
||||
}
|
||||
|
||||
public void RequestCompaction(string filePath)
|
||||
{
|
||||
if (_lightlessConfigService.Current.UseCompactor)
|
||||
EnqueueCompaction(filePath);
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
foreach (var handler in _playerRelatedPointers)
|
||||
{
|
||||
var address = (nint)handler.Address;
|
||||
var address = handler.Address;
|
||||
if (address != nint.Zero)
|
||||
{
|
||||
tempMap[address] = handler;
|
||||
|
||||
@@ -81,7 +81,10 @@ public sealed class IpcCallerPenumbra : IpcServiceBase
|
||||
=> _collections.RemoveTemporaryCollectionAsync(logger, applicationId, collectionId);
|
||||
|
||||
public Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths)
|
||||
=> _collections.SetTemporaryModsAsync(logger, applicationId, collectionId, modPaths);
|
||||
=> _collections.SetTemporaryModsAsync(logger, applicationId, collectionId, modPaths, "Player");
|
||||
|
||||
public Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths, string scope)
|
||||
=> _collections.SetTemporaryModsAsync(logger, applicationId, collectionId, modPaths, scope);
|
||||
|
||||
public Task SetManipulationDataAsync(ILogger logger, Guid applicationId, Guid collectionId, string manipulationData)
|
||||
=> _collections.SetManipulationDataAsync(logger, applicationId, collectionId, manipulationData);
|
||||
|
||||
@@ -92,25 +92,43 @@ public sealed class PenumbraCollections : PenumbraBase
|
||||
_activeTemporaryCollections.TryRemove(collectionId, out _);
|
||||
}
|
||||
|
||||
public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths)
|
||||
public async Task SetTemporaryModsAsync(
|
||||
ILogger logger,
|
||||
Guid applicationId,
|
||||
Guid collectionId,
|
||||
Dictionary<string, string> modPaths,
|
||||
string scope)
|
||||
{
|
||||
if (!IsAvailable || collectionId == Guid.Empty)
|
||||
{
|
||||
return;
|
||||
|
||||
var modName = $"LightlessChara_Files_{applicationId:N}_{scope}";
|
||||
|
||||
var normalized = new Dictionary<string, string>(modPaths.Count, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kvp in modPaths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kvp.Key) || string.IsNullOrWhiteSpace(kvp.Value))
|
||||
continue;
|
||||
|
||||
var gamePath = kvp.Key.Replace('\\', '/').ToLowerInvariant();
|
||||
normalized[gamePath] = kvp.Value;
|
||||
}
|
||||
|
||||
await DalamudUtil.RunOnFrameworkThread(() =>
|
||||
{
|
||||
foreach (var mod in modPaths)
|
||||
{
|
||||
logger.LogTrace("[{ApplicationId}] Change: {From} => {To}", applicationId, mod.Key, mod.Value);
|
||||
}
|
||||
foreach (var mod in normalized)
|
||||
logger.LogTrace("[{ApplicationId}] {ModName}: {From} => {To}", applicationId, modName, mod.Key, mod.Value);
|
||||
|
||||
var removeResult = _removeTemporaryMod.Invoke("LightlessChara_Files", collectionId, 0);
|
||||
logger.LogTrace("[{ApplicationId}] Removing temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, removeResult);
|
||||
var removeResult = _removeTemporaryMod.Invoke(modName, collectionId, 0);
|
||||
logger.LogTrace("[{ApplicationId}] Removing temp mod {ModName} for {CollectionId}, Success: {Result}",
|
||||
applicationId, modName, collectionId, removeResult);
|
||||
|
||||
var addResult = _addTemporaryMod.Invoke("LightlessChara_Files", collectionId, modPaths, string.Empty, 0);
|
||||
logger.LogTrace("[{ApplicationId}] Setting temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, addResult);
|
||||
if (normalized.Count == 0)
|
||||
return;
|
||||
|
||||
var addResult = _addTemporaryMod.Invoke(modName, collectionId, normalized, string.Empty, 0);
|
||||
logger.LogTrace("[{ApplicationId}] Setting temp mod {ModName} for {CollectionId}, Success: {Result}",
|
||||
applicationId, modName, collectionId, addResult);
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -171,7 +189,7 @@ public sealed class PenumbraCollections : PenumbraBase
|
||||
Logger.LogDebug("Cleaning up stale temporary collection {CollectionName} ({CollectionId})", name, collectionId);
|
||||
var deleteResult = await DalamudUtil.RunOnFrameworkThread(() =>
|
||||
{
|
||||
var result = (PenumbraApiEc)_removeTemporaryCollection.Invoke(collectionId);
|
||||
var result = _removeTemporaryCollection.Invoke(collectionId);
|
||||
Logger.LogTrace("Cleanup RemoveTemporaryCollection result for {CollectionName} ({CollectionId}): {Result}", name, collectionId, result);
|
||||
return result;
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
@@ -158,7 +158,6 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
public string? SelectedFinderSyncshell { get; set; } = null;
|
||||
public string LastSeenVersion { get; set; } = string.Empty;
|
||||
public bool EnableParticleEffects { get; set; } = true;
|
||||
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
|
||||
public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Unsafe;
|
||||
public bool AnimationAllowOneBasedShift { get; set; } = false;
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace LightlessSync.LightlessConfiguration.Configurations;
|
||||
|
||||
public class PenumbraJanitorConfig : ILightlessConfiguration
|
||||
{
|
||||
public int Version { get; set; } = 0;
|
||||
|
||||
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using LightlessSync.LightlessConfiguration.Configurations;
|
||||
|
||||
namespace LightlessSync.LightlessConfiguration;
|
||||
|
||||
public class PenumbraJanitorConfigService : ConfigurationServiceBase<PenumbraJanitorConfig>
|
||||
{
|
||||
public const string ConfigName = "penumbra-collections.json";
|
||||
|
||||
public PenumbraJanitorConfigService(string configDir) : base(configDir)
|
||||
{
|
||||
}
|
||||
|
||||
public override string ConfigurationName => ConfigName;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services.Mediator;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Dalamud.Utility;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Object;
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.Interop.Ipc;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
@@ -9,11 +9,12 @@ using LightlessSync.PlayerData.Data;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
||||
|
||||
namespace LightlessSync.PlayerData.Factories;
|
||||
|
||||
@@ -119,45 +120,28 @@ public class PlayerDataFactory
|
||||
return null;
|
||||
}
|
||||
|
||||
private static readonly int _characterGameObjectOffset =
|
||||
(int)Marshal.OffsetOf<Character>(nameof(Character.GameObject));
|
||||
|
||||
private static readonly int _gameObjectDrawObjectOffset =
|
||||
(int)Marshal.OffsetOf<GameObject>(nameof(GameObject.DrawObject));
|
||||
|
||||
private async Task<bool> CheckForNullDrawObject(IntPtr playerPointer)
|
||||
=> await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
|
||||
=> await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectSafe(playerPointer))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
private unsafe static bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
|
||||
private static bool CheckForNullDrawObjectSafe(nint playerPointer)
|
||||
{
|
||||
if (playerPointer == IntPtr.Zero)
|
||||
if (playerPointer == nint.Zero)
|
||||
return true;
|
||||
|
||||
if (!IsPointerValid(playerPointer))
|
||||
var drawObjPtrAddress = playerPointer + _characterGameObjectOffset + _gameObjectDrawObjectOffset;
|
||||
|
||||
// Read the DrawObject pointer from memory
|
||||
if (!MemoryProcessProbe.TryReadIntPtr(drawObjPtrAddress, out var drawObj))
|
||||
return true;
|
||||
|
||||
var character = (Character*)playerPointer;
|
||||
if (character == null)
|
||||
return true;
|
||||
|
||||
var gameObject = &character->GameObject;
|
||||
if (gameObject == null)
|
||||
return true;
|
||||
|
||||
if (!IsPointerValid((IntPtr)gameObject))
|
||||
return true;
|
||||
|
||||
return gameObject->DrawObject == null;
|
||||
}
|
||||
|
||||
private static bool IsPointerValid(IntPtr ptr)
|
||||
{
|
||||
if (ptr == IntPtr.Zero)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
_ = Marshal.ReadByte(ptr);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return drawObj == nint.Zero;
|
||||
}
|
||||
|
||||
private static bool IsCacheFresh(CacheEntry entry)
|
||||
@@ -173,7 +157,7 @@ public class PlayerDataFactory
|
||||
if (_characterBuildCache.TryGetValue(key, out var cached) && IsCacheFresh(cached) && !_characterBuildInflight.ContainsKey(key))
|
||||
return cached.Fragment;
|
||||
|
||||
var buildTask = _characterBuildInflight.GetOrAdd(key, _ => BuildAndCacheAsync(obj, key));
|
||||
var buildTask = _characterBuildInflight.GetOrAdd(key, valueFactory: k => BuildAndCacheAsync(obj, k));
|
||||
|
||||
if (_characterBuildCache.TryGetValue(key, out cached))
|
||||
{
|
||||
|
||||
238
LightlessSync/PlayerData/Handlers/OwnedObjectHandler.cs
Normal file
238
LightlessSync/PlayerData/Handlers/OwnedObjectHandler.cs
Normal file
@@ -0,0 +1,238 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.PlayerData.Factories;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.ActorTracking;
|
||||
using LightlessSync.Interop.Ipc;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
|
||||
namespace LightlessSync.PlayerData.Handlers;
|
||||
|
||||
internal sealed class OwnedObjectHandler
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
private readonly GameObjectHandlerFactory _handlerFactory;
|
||||
private readonly IpcManager _ipc;
|
||||
private readonly ActorObjectService _actorObjectService;
|
||||
|
||||
private const int _fullyLoadedTimeoutMsPlayer = 30000;
|
||||
private const int _fullyLoadedTimeoutMsOther = 5000;
|
||||
|
||||
public OwnedObjectHandler(
|
||||
ILogger logger,
|
||||
DalamudUtilService dalamudUtil,
|
||||
GameObjectHandlerFactory handlerFactory,
|
||||
IpcManager ipc,
|
||||
ActorObjectService actorObjectService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dalamudUtil = dalamudUtil;
|
||||
_handlerFactory = handlerFactory;
|
||||
_ipc = ipc;
|
||||
_actorObjectService = actorObjectService;
|
||||
}
|
||||
|
||||
public async Task<bool> ApplyAsync(
|
||||
Guid applicationId,
|
||||
ObjectKind kind,
|
||||
HashSet<PlayerChanges> changes,
|
||||
CharacterData data,
|
||||
GameObjectHandler playerHandler,
|
||||
Guid penumbraCollection,
|
||||
Dictionary<ObjectKind, Guid?> customizeIds,
|
||||
CancellationToken token)
|
||||
{
|
||||
if (playerHandler.Address == nint.Zero)
|
||||
return false;
|
||||
|
||||
var handler = await CreateHandlerAsync(kind, playerHandler, token).ConfigureAwait(false);
|
||||
if (handler is null || handler.Address == nint.Zero)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
bool hasFileReplacements =
|
||||
kind != ObjectKind.Player
|
||||
&& data.FileReplacements.TryGetValue(kind, out var repls)
|
||||
&& repls is { Count: > 0 };
|
||||
|
||||
bool shouldAssignCollection =
|
||||
kind != ObjectKind.Player
|
||||
&& hasFileReplacements
|
||||
&& penumbraCollection != Guid.Empty
|
||||
&& _ipc.Penumbra.APIAvailable;
|
||||
|
||||
bool isPlayerIpcOnly =
|
||||
kind == ObjectKind.Player
|
||||
&& changes.Count > 0
|
||||
&& changes.All(c => c is PlayerChanges.Honorific
|
||||
or PlayerChanges.Moodles
|
||||
or PlayerChanges.PetNames
|
||||
or PlayerChanges.Heels);
|
||||
|
||||
await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false);
|
||||
|
||||
var drawTimeoutMs = handler.ObjectKind == ObjectKind.Player ? 30000 : 5000;
|
||||
var fullyLoadedTimeoutMs = handler.ObjectKind == ObjectKind.Player ? _fullyLoadedTimeoutMsPlayer : _fullyLoadedTimeoutMsOther;
|
||||
|
||||
await _dalamudUtil
|
||||
.WaitWhileCharacterIsDrawing(_logger, handler, applicationId, drawTimeoutMs, token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (handler.Address != nint.Zero)
|
||||
{
|
||||
var loaded = await _actorObjectService
|
||||
.WaitForFullyLoadedAsync(handler.Address, token, fullyLoadedTimeoutMs)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!loaded)
|
||||
{
|
||||
_logger.LogTrace("[{appId}] {kind}: not fully loaded in time, skipping for now", applicationId, kind);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
if (shouldAssignCollection)
|
||||
{
|
||||
var objIndex = await _dalamudUtil
|
||||
.RunOnFrameworkThread(() => handler.GetGameObject()?.ObjectIndex)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!objIndex.HasValue)
|
||||
{
|
||||
_logger.LogTrace("[{appId}] {kind}: ObjectIndex not available yet, cannot assign collection", applicationId, kind);
|
||||
return false;
|
||||
}
|
||||
|
||||
await _ipc.Penumbra
|
||||
.AssignTemporaryCollectionAsync(_logger, penumbraCollection, objIndex.Value)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var tasks = new List<Task>();
|
||||
|
||||
foreach (var change in changes.OrderBy(c => (int)c))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
switch (change)
|
||||
{
|
||||
case PlayerChanges.Customize:
|
||||
if (data.CustomizePlusData.TryGetValue(kind, out var customizeData) && !string.IsNullOrEmpty(customizeData))
|
||||
tasks.Add(ApplyCustomizeAsync(handler.Address, customizeData, kind, customizeIds));
|
||||
else if (customizeIds.TryGetValue(kind, out var existingId))
|
||||
tasks.Add(RevertCustomizeAsync(existingId, kind, customizeIds));
|
||||
break;
|
||||
|
||||
case PlayerChanges.Glamourer:
|
||||
if (data.GlamourerData.TryGetValue(kind, out var glamourerData) && !string.IsNullOrEmpty(glamourerData))
|
||||
tasks.Add(_ipc.Glamourer.ApplyAllAsync(_logger, handler, glamourerData, applicationId, token));
|
||||
break;
|
||||
|
||||
case PlayerChanges.Heels:
|
||||
if (kind == ObjectKind.Player && !string.IsNullOrEmpty(data.HeelsData))
|
||||
tasks.Add(_ipc.Heels.SetOffsetForPlayerAsync(handler.Address, data.HeelsData));
|
||||
break;
|
||||
|
||||
case PlayerChanges.Honorific:
|
||||
if (kind == ObjectKind.Player && !string.IsNullOrEmpty(data.HonorificData))
|
||||
tasks.Add(_ipc.Honorific.SetTitleAsync(handler.Address, data.HonorificData));
|
||||
break;
|
||||
|
||||
case PlayerChanges.Moodles:
|
||||
if (kind == ObjectKind.Player && !string.IsNullOrEmpty(data.MoodlesData))
|
||||
tasks.Add(_ipc.Moodles.SetStatusAsync(handler.Address, data.MoodlesData));
|
||||
break;
|
||||
|
||||
case PlayerChanges.PetNames:
|
||||
if (kind == ObjectKind.Player && !string.IsNullOrEmpty(data.PetNamesData))
|
||||
tasks.Add(_ipc.PetNames.SetPlayerData(handler.Address, data.PetNamesData));
|
||||
break;
|
||||
|
||||
case PlayerChanges.ModFiles:
|
||||
case PlayerChanges.ModManip:
|
||||
case PlayerChanges.ForcedRedraw:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.Count > 0)
|
||||
await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
bool needsRedraw =
|
||||
_ipc.Penumbra.APIAvailable
|
||||
&& (
|
||||
shouldAssignCollection
|
||||
|| changes.Contains(PlayerChanges.ForcedRedraw)
|
||||
|| changes.Contains(PlayerChanges.ModFiles)
|
||||
|| changes.Contains(PlayerChanges.ModManip)
|
||||
|| changes.Contains(PlayerChanges.Glamourer)
|
||||
|| changes.Contains(PlayerChanges.Customize)
|
||||
);
|
||||
|
||||
if (isPlayerIpcOnly)
|
||||
needsRedraw = false;
|
||||
|
||||
if (needsRedraw && _ipc.Penumbra.APIAvailable)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[{appId}] {kind}: Redrawing ownedTarget={isOwned} (needsRedraw={needsRedraw})",
|
||||
applicationId, kind, kind != ObjectKind.Player, needsRedraw);
|
||||
|
||||
await _ipc.Penumbra
|
||||
.RedrawAsync(_logger, handler, applicationId, token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!ReferenceEquals(handler, playerHandler))
|
||||
handler.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<GameObjectHandler?> CreateHandlerAsync(ObjectKind kind, GameObjectHandler playerHandler, CancellationToken token)
|
||||
{
|
||||
if (kind == ObjectKind.Player)
|
||||
return playerHandler;
|
||||
|
||||
var playerPtr = playerHandler.Address;
|
||||
nint ownedPtr = kind switch
|
||||
{
|
||||
ObjectKind.Companion => await _dalamudUtil.GetCompanionAsync(playerPtr).ConfigureAwait(false),
|
||||
ObjectKind.MinionOrMount => await _dalamudUtil.GetMinionOrMountAsync(playerPtr).ConfigureAwait(false),
|
||||
ObjectKind.Pet => await _dalamudUtil.GetPetAsync(playerPtr).ConfigureAwait(false),
|
||||
_ => nint.Zero
|
||||
};
|
||||
|
||||
if (ownedPtr == nint.Zero)
|
||||
return null;
|
||||
|
||||
return await _handlerFactory.Create(kind, () => ownedPtr, isWatched: false).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ApplyCustomizeAsync(nint address, string customizeData, ObjectKind kind, Dictionary<ObjectKind, Guid?> customizeIds)
|
||||
{
|
||||
customizeIds[kind] = await _ipc.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RevertCustomizeAsync(Guid? customizeId, ObjectKind kind, Dictionary<ObjectKind, Guid?> customizeIds)
|
||||
{
|
||||
if (!customizeId.HasValue)
|
||||
return;
|
||||
|
||||
await _ipc.CustomizePlus.RevertByIdAsync(customizeId.Value).ConfigureAwait(false);
|
||||
customizeIds.Remove(kind);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -428,6 +428,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
return cfg;
|
||||
});
|
||||
services.AddSingleton(sp => new ServerConfigService(configDir));
|
||||
services.AddSingleton(sp => new PenumbraJanitorConfigService(configDir));
|
||||
services.AddSingleton(sp => new NotesConfigService(configDir));
|
||||
services.AddSingleton(sp => new PairTagConfigService(configDir));
|
||||
services.AddSingleton(sp => new SyncshellTagConfigService(configDir));
|
||||
@@ -441,6 +442,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<UiThemeConfigService>());
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ChatConfigService>());
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ServerConfigService>());
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<PenumbraJanitorConfigService>());
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<NotesConfigService>());
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<PairTagConfigService>());
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<SyncshellTagConfigService>());
|
||||
|
||||
@@ -14,6 +14,7 @@ using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSu
|
||||
using IPlayerCharacter = Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter;
|
||||
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
||||
using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace LightlessSync.Services.ActorTracking;
|
||||
|
||||
@@ -57,6 +58,8 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
||||
private bool _hooksActive;
|
||||
private static readonly TimeSpan SnapshotRefreshInterval = TimeSpan.FromSeconds(1);
|
||||
private DateTime _nextRefreshAllowed = DateTime.MinValue;
|
||||
private int _warmStartQueued;
|
||||
private int _warmStartRan;
|
||||
|
||||
public ActorObjectService(
|
||||
ILogger<ActorObjectService> logger,
|
||||
@@ -74,7 +77,21 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
||||
_clientState = clientState;
|
||||
_condition = condition;
|
||||
_mediator = mediator;
|
||||
_mediator.Subscribe<PenumbraInitializedMessage>(this, _ =>
|
||||
{
|
||||
QueueWarmStart("PenumbraInitialized");
|
||||
});
|
||||
|
||||
_mediator.Subscribe<ConnectedMessage>(this, _ =>
|
||||
{
|
||||
QueueWarmStart("Connected");
|
||||
});
|
||||
|
||||
// Optional: helps after zoning
|
||||
_mediator.Subscribe<ZoneSwitchEndMessage>(this, _ =>
|
||||
{
|
||||
QueueWarmStart("ZoneSwitchEnd");
|
||||
});
|
||||
_mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
|
||||
{
|
||||
if (!msg.OwnedObject) return;
|
||||
@@ -96,7 +113,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
||||
}
|
||||
|
||||
private bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51];
|
||||
|
||||
private ActorSnapshot Snapshot => Volatile.Read(ref _snapshot);
|
||||
private GposeSnapshot CurrentGposeSnapshot => Volatile.Read(ref _gposeSnapshot);
|
||||
|
||||
@@ -341,6 +357,8 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_warmStartRan = 0;
|
||||
|
||||
DisposeHooks();
|
||||
_activePlayers.Clear();
|
||||
_gposePlayers.Clear();
|
||||
@@ -1147,6 +1165,57 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
||||
PublishGposeSnapshot();
|
||||
}
|
||||
|
||||
private void QueueWarmStart(string reason)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _warmStartQueued, 1) == 1)
|
||||
return;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Interlocked.Exchange(ref _warmStartRan, 1) == 1)
|
||||
return;
|
||||
|
||||
await Task.Delay(500).ConfigureAwait(false);
|
||||
|
||||
if (IsZoning)
|
||||
return;
|
||||
|
||||
await _framework.RunOnFrameworkThread(() =>
|
||||
{
|
||||
RefreshTrackedActorsInternal();
|
||||
|
||||
var snapshot = Snapshot;
|
||||
|
||||
var published = new HashSet<nint>();
|
||||
|
||||
foreach (var d in snapshot.PlayerDescriptors)
|
||||
{
|
||||
if (d.Address != nint.Zero && published.Add(d.Address))
|
||||
_mediator.Publish(new ActorTrackedMessage(d));
|
||||
}
|
||||
|
||||
foreach (var d in snapshot.OwnedDescriptors)
|
||||
{
|
||||
if (d.Address != nint.Zero && published.Add(d.Address))
|
||||
_mediator.Publish(new ActorTrackedMessage(d));
|
||||
}
|
||||
|
||||
_logger.LogDebug("WarmStart republished {count} actors ({reason})", published.Count, reason);
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "WarmStart failed ({reason})", reason);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _warmStartQueued, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private unsafe void TrackGposeObject(GameObject* gameObject)
|
||||
{
|
||||
if (gameObject == null)
|
||||
@@ -1240,6 +1309,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
||||
return true;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private readonly record struct LoadState(bool IsValid, bool IsLoaded)
|
||||
{
|
||||
public static LoadState Invalid => new(false, false);
|
||||
|
||||
@@ -68,6 +68,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
ImGuiWindowFlags.NoMove |
|
||||
ImGuiWindowFlags.NoSavedSettings |
|
||||
ImGuiWindowFlags.NoNav |
|
||||
ImGuiWindowFlags.NoFocusOnAppearing |
|
||||
ImGuiWindowFlags.NoInputs;
|
||||
|
||||
private readonly List<RectF> _uiRects = new(128);
|
||||
|
||||
@@ -138,5 +138,6 @@ public record GroupCollectionChangedMessage : MessageBase;
|
||||
public record OpenUserProfileMessage(UserData User) : MessageBase;
|
||||
public record LocationSharingMessage(UserData User, LocationInfo LocationInfo, DateTimeOffset ExpireAt) : MessageBase;
|
||||
public record MapChangedMessage(uint MapId) : MessageBase;
|
||||
public record PenumbraTempCollectionsCleanedMessage : MessageBase;
|
||||
#pragma warning restore S2094
|
||||
#pragma warning restore MA0048 // File name must match type name
|
||||
|
||||
@@ -8,14 +8,14 @@ namespace LightlessSync.Services;
|
||||
public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private readonly IpcManager _ipc;
|
||||
private readonly LightlessConfigService _config;
|
||||
private readonly PenumbraJanitorConfigService _config;
|
||||
private int _ran;
|
||||
|
||||
public PenumbraTempCollectionJanitor(
|
||||
ILogger<PenumbraTempCollectionJanitor> logger,
|
||||
LightlessMediator mediator,
|
||||
IpcManager ipc,
|
||||
LightlessConfigService config) : base(logger, mediator)
|
||||
PenumbraJanitorConfigService config) : base(logger, mediator)
|
||||
{
|
||||
_ipc = ipc;
|
||||
_config = config;
|
||||
@@ -67,5 +67,8 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber
|
||||
|
||||
_config.Current.OrphanableTempCollections.Clear();
|
||||
_config.Save();
|
||||
|
||||
// Notify cleanup complete
|
||||
Mediator.Publish(new PenumbraTempCollectionsCleanedMessage());
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ public sealed class DtrEntry : IDisposable, IHostedService
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_cancellationTokenSource.Cancel();
|
||||
await _cancellationTokenSource.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
if (_dalamudUtilService.IsOnFrameworkThread)
|
||||
{
|
||||
|
||||
@@ -33,13 +33,13 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase
|
||||
private readonly Dictionary<string, float> _notificationTargetYOffsets = [];
|
||||
private readonly Dictionary<string, Vector4> _notificationBackgrounds = [];
|
||||
|
||||
public LightlessNotificationUi(ILogger<LightlessNotificationUi> logger, LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessConfigService configService)
|
||||
public LightlessNotificationUi(ILogger<LightlessNotificationUi> logger, LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessConfigService configService)
|
||||
: base(logger, mediator, "Lightless Notifications##LightlessNotifications", performanceCollector)
|
||||
{
|
||||
_configService = configService;
|
||||
Flags = ImGuiWindowFlags.NoDecoration |
|
||||
ImGuiWindowFlags.NoMove |
|
||||
ImGuiWindowFlags.NoResize |
|
||||
ImGuiWindowFlags.NoMove |
|
||||
ImGuiWindowFlags.NoResize |
|
||||
ImGuiWindowFlags.NoSavedSettings |
|
||||
ImGuiWindowFlags.NoFocusOnAppearing |
|
||||
ImGuiWindowFlags.NoNav |
|
||||
@@ -47,6 +47,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase
|
||||
ImGuiWindowFlags.NoCollapse |
|
||||
ImGuiWindowFlags.NoTitleBar |
|
||||
ImGuiWindowFlags.NoScrollbar |
|
||||
ImGuiWindowFlags.NoFocusOnAppearing |
|
||||
ImGuiWindowFlags.AlwaysAutoResize;
|
||||
|
||||
PositionCondition = ImGuiCond.Always;
|
||||
|
||||
41
LightlessSync/Utils/MemoryProcessProbe.cs
Normal file
41
LightlessSync/Utils/MemoryProcessProbe.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace LightlessSync.Utils;
|
||||
|
||||
internal static class MemoryProcessProbe
|
||||
{
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern nint GetCurrentProcess();
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool ReadProcessMemory(
|
||||
nint hProcess,
|
||||
nint lpBaseAddress,
|
||||
byte[] lpBuffer,
|
||||
int dwSize,
|
||||
out nint lpNumberOfBytesRead);
|
||||
|
||||
private static readonly nint _proc = GetCurrentProcess();
|
||||
|
||||
public static bool TryReadIntPtr(nint address, out nint value)
|
||||
{
|
||||
value = nint.Zero;
|
||||
|
||||
if (address == nint.Zero)
|
||||
return false;
|
||||
|
||||
if ((ulong)address < 0x10000UL)
|
||||
return false;
|
||||
|
||||
var buf = new byte[IntPtr.Size];
|
||||
if (!ReadProcessMemory(_proc, address, buf, buf.Length, out var read) || read != (nint)buf.Length)
|
||||
return false;
|
||||
|
||||
value = IntPtr.Size == 8
|
||||
? (nint)BitConverter.ToInt64(buf, 0)
|
||||
: (nint)BitConverter.ToInt32(buf, 0);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@ using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LightlessSync.Utils;
|
||||
@@ -56,164 +54,168 @@ public static class VariousExtensions
|
||||
return new CancellationTokenSource();
|
||||
}
|
||||
|
||||
public static Dictionary<ObjectKind, HashSet<PlayerChanges>> CheckUpdatedData(this CharacterData newData, Guid applicationBase,
|
||||
CharacterData? oldData, ILogger logger, IPairPerformanceSubject cachedPlayer, bool forceApplyCustomization, bool forceApplyMods,
|
||||
public static Dictionary<ObjectKind, HashSet<PlayerChanges>> CheckUpdatedData(
|
||||
this CharacterData newData,
|
||||
Guid applicationBase,
|
||||
CharacterData? oldData,
|
||||
ILogger logger,
|
||||
IPairPerformanceSubject cachedPlayer,
|
||||
bool forceApplyCustomization,
|
||||
bool forceApplyMods,
|
||||
bool suppressForcedRedrawOnForcedModApply = false)
|
||||
{
|
||||
oldData ??= new();
|
||||
|
||||
static bool HasFiles(List<FileReplacementData>? list) => list is { Count: > 0 };
|
||||
static bool HasText(string? s) => !string.IsNullOrEmpty(s);
|
||||
static string Norm(string? s) => s ?? string.Empty;
|
||||
|
||||
var forceRedrawOnForcedApply = forceApplyMods && !suppressForcedRedrawOnForcedModApply;
|
||||
|
||||
var charaDataToUpdate = new Dictionary<ObjectKind, HashSet<PlayerChanges>>();
|
||||
|
||||
foreach (ObjectKind objectKind in Enum.GetValues<ObjectKind>())
|
||||
{
|
||||
charaDataToUpdate[objectKind] = [];
|
||||
oldData.FileReplacements.TryGetValue(objectKind, out var existingFileReplacements);
|
||||
newData.FileReplacements.TryGetValue(objectKind, out var newFileReplacements);
|
||||
oldData.GlamourerData.TryGetValue(objectKind, out var existingGlamourerData);
|
||||
newData.GlamourerData.TryGetValue(objectKind, out var newGlamourerData);
|
||||
var set = new HashSet<PlayerChanges>();
|
||||
|
||||
bool hasNewButNotOldFileReplacements = newFileReplacements != null && existingFileReplacements == null;
|
||||
bool hasOldButNotNewFileReplacements = existingFileReplacements != null && newFileReplacements == null;
|
||||
oldData.FileReplacements.TryGetValue(objectKind, out var oldFileRepls);
|
||||
newData.FileReplacements.TryGetValue(objectKind, out var newFileRepls);
|
||||
|
||||
bool hasNewButNotOldGlamourerData = newGlamourerData != null && existingGlamourerData == null;
|
||||
bool hasOldButNotNewGlamourerData = existingGlamourerData != null && newGlamourerData == null;
|
||||
oldData.GlamourerData.TryGetValue(objectKind, out var oldGlam);
|
||||
newData.GlamourerData.TryGetValue(objectKind, out var newGlam);
|
||||
|
||||
bool hasNewAndOldFileReplacements = newFileReplacements != null && existingFileReplacements != null;
|
||||
bool hasNewAndOldGlamourerData = newGlamourerData != null && existingGlamourerData != null;
|
||||
var forceRedrawOnForcedApply = forceApplyMods && !suppressForcedRedrawOnForcedModApply;
|
||||
var oldHasFiles = HasFiles(oldFileRepls);
|
||||
var newHasFiles = HasFiles(newFileRepls);
|
||||
|
||||
if (hasNewButNotOldFileReplacements || hasOldButNotNewFileReplacements || hasNewButNotOldGlamourerData || hasOldButNotNewGlamourerData)
|
||||
if (oldHasFiles != newHasFiles)
|
||||
{
|
||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Some new data arrived: NewButNotOldFiles:{hasNewButNotOldFileReplacements}," +
|
||||
" OldButNotNewFiles:{hasOldButNotNewFileReplacements}, NewButNotOldGlam:{hasNewButNotOldGlamourerData}, OldButNotNewGlam:{hasOldButNotNewGlamourerData}) => {change}, {change2}",
|
||||
applicationBase,
|
||||
cachedPlayer, objectKind, hasNewButNotOldFileReplacements, hasOldButNotNewFileReplacements, hasNewButNotOldGlamourerData, hasOldButNotNewGlamourerData, PlayerChanges.ModFiles, PlayerChanges.Glamourer);
|
||||
charaDataToUpdate[objectKind].Add(PlayerChanges.ModFiles);
|
||||
charaDataToUpdate[objectKind].Add(PlayerChanges.Glamourer);
|
||||
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (hasNewAndOldFileReplacements)
|
||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (File presence changed old={old} new={new}) => {change}",
|
||||
applicationBase, cachedPlayer, objectKind, oldHasFiles, newHasFiles, PlayerChanges.ModFiles);
|
||||
|
||||
set.Add(PlayerChanges.ModFiles);
|
||||
if (objectKind != ObjectKind.Player || forceRedrawOnForcedApply)
|
||||
{
|
||||
var oldList = oldData.FileReplacements[objectKind];
|
||||
var newList = newData.FileReplacements[objectKind];
|
||||
var listsAreEqual = oldList.SequenceEqual(newList, PlayerData.Data.FileReplacementDataComparer.Instance);
|
||||
if (!listsAreEqual || forceApplyMods)
|
||||
{
|
||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (FileReplacements not equal) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModFiles);
|
||||
charaDataToUpdate[objectKind].Add(PlayerChanges.ModFiles);
|
||||
if (objectKind != ObjectKind.Player || forceRedrawOnForcedApply)
|
||||
{
|
||||
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
|
||||
}
|
||||
else
|
||||
{
|
||||
var existingFace = existingFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/face/", StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var existingHair = existingFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/hair/", StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var existingTail = existingFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/tail/", StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var newFace = newFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/face/", StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var newHair = newFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/hair/", StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var newTail = newFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/tail/", StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var existingTransients = existingFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl") && !g.EndsWith("tex") && !g.EndsWith("mtrl")))
|
||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var newTransients = newFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl") && !g.EndsWith("tex") && !g.EndsWith("mtrl")))
|
||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
logger.LogTrace("[BASE-{appbase}] ExistingFace: {of}, NewFace: {fc}; ExistingHair: {eh}, NewHair: {nh}; ExistingTail: {et}, NewTail: {nt}; ExistingTransient: {etr}, NewTransient: {ntr}", applicationBase,
|
||||
existingFace.Count, newFace.Count, existingHair.Count, newHair.Count, existingTail.Count, newTail.Count, existingTransients.Count, newTransients.Count);
|
||||
|
||||
var differentFace = !existingFace.SequenceEqual(newFace, PlayerData.Data.FileReplacementDataComparer.Instance);
|
||||
var differentHair = !existingHair.SequenceEqual(newHair, PlayerData.Data.FileReplacementDataComparer.Instance);
|
||||
var differentTail = !existingTail.SequenceEqual(newTail, PlayerData.Data.FileReplacementDataComparer.Instance);
|
||||
var differenTransients = !existingTransients.SequenceEqual(newTransients, PlayerData.Data.FileReplacementDataComparer.Instance);
|
||||
if (differentFace || differentHair || differentTail || differenTransients)
|
||||
{
|
||||
logger.LogDebug("[BASE-{appbase}] Different Subparts: Face: {face}, Hair: {hair}, Tail: {tail}, Transients: {transients} => {change}", applicationBase,
|
||||
differentFace, differentHair, differentTail, differenTransients, PlayerChanges.ForcedRedraw);
|
||||
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
|
||||
}
|
||||
}
|
||||
}
|
||||
set.Add(PlayerChanges.ForcedRedraw);
|
||||
}
|
||||
}
|
||||
else if (newHasFiles)
|
||||
{
|
||||
var listsAreEqual = oldFileRepls!.SequenceEqual(newFileRepls!, PlayerData.Data.FileReplacementDataComparer.Instance);
|
||||
|
||||
if (hasNewAndOldGlamourerData)
|
||||
if (!listsAreEqual || forceApplyMods)
|
||||
{
|
||||
bool glamourerDataDifferent = !string.Equals(oldData.GlamourerData[objectKind], newData.GlamourerData[objectKind], StringComparison.Ordinal);
|
||||
if (glamourerDataDifferent || forceApplyCustomization)
|
||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (FileReplacements changed or forceApplyMods) => {change}",
|
||||
applicationBase, cachedPlayer, objectKind, PlayerChanges.ModFiles);
|
||||
|
||||
set.Add(PlayerChanges.ModFiles);
|
||||
|
||||
if (objectKind != ObjectKind.Player || forceRedrawOnForcedApply)
|
||||
{
|
||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Glamourer different) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Glamourer);
|
||||
charaDataToUpdate[objectKind].Add(PlayerChanges.Glamourer);
|
||||
set.Add(PlayerChanges.ForcedRedraw);
|
||||
}
|
||||
else
|
||||
{
|
||||
var existingFace = oldFileRepls.Where(g => g.GamePaths.Any(p => p.Contains("/face/", StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var existingHair = oldFileRepls.Where(g => g.GamePaths.Any(p => p.Contains("/hair/", StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var existingTail = oldFileRepls.Where(g => g.GamePaths.Any(p => p.Contains("/tail/", StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
var newFace = newFileRepls!.Where(g => g.GamePaths.Any(p => p.Contains("/face/", StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var newHair = newFileRepls.Where(g => g.GamePaths.Any(p => p.Contains("/hair/", StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var newTail = newFileRepls.Where(g => g.GamePaths.Any(p => p.Contains("/tail/", StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
var existingTransients = oldFileRepls.Where(g => g.GamePaths.Any(p => !p.EndsWith("mdl", StringComparison.OrdinalIgnoreCase)
|
||||
&& !p.EndsWith("tex", StringComparison.OrdinalIgnoreCase)
|
||||
&& !p.EndsWith("mtrl", StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
var newTransients = newFileRepls.Where(g => g.GamePaths.Any(p => !p.EndsWith("mdl", StringComparison.OrdinalIgnoreCase)
|
||||
&& !p.EndsWith("tex", StringComparison.OrdinalIgnoreCase)
|
||||
&& !p.EndsWith("mtrl", StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
var differentFace = !existingFace.SequenceEqual(newFace, PlayerData.Data.FileReplacementDataComparer.Instance);
|
||||
var differentHair = !existingHair.SequenceEqual(newHair, PlayerData.Data.FileReplacementDataComparer.Instance);
|
||||
var differentTail = !existingTail.SequenceEqual(newTail, PlayerData.Data.FileReplacementDataComparer.Instance);
|
||||
var differentTransients = !existingTransients.SequenceEqual(newTransients, PlayerData.Data.FileReplacementDataComparer.Instance);
|
||||
|
||||
if (differentFace || differentHair || differentTail || differentTransients)
|
||||
set.Add(PlayerChanges.ForcedRedraw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oldData.CustomizePlusData.TryGetValue(objectKind, out var oldCustomizePlusData);
|
||||
newData.CustomizePlusData.TryGetValue(objectKind, out var newCustomizePlusData);
|
||||
var oldGlamNorm = Norm(oldGlam);
|
||||
var newGlamNorm = Norm(newGlam);
|
||||
|
||||
oldCustomizePlusData ??= string.Empty;
|
||||
newCustomizePlusData ??= string.Empty;
|
||||
|
||||
bool customizeDataDifferent = !string.Equals(oldCustomizePlusData, newCustomizePlusData, StringComparison.Ordinal);
|
||||
if (customizeDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newCustomizePlusData)))
|
||||
if (!string.Equals(oldGlamNorm, newGlamNorm, StringComparison.Ordinal)
|
||||
|| (forceApplyCustomization && HasText(newGlamNorm)))
|
||||
{
|
||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff customize data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Customize);
|
||||
charaDataToUpdate[objectKind].Add(PlayerChanges.Customize);
|
||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Glamourer different) => {change}",
|
||||
applicationBase, cachedPlayer, objectKind, PlayerChanges.Glamourer);
|
||||
set.Add(PlayerChanges.Glamourer);
|
||||
}
|
||||
|
||||
if (objectKind != ObjectKind.Player) continue;
|
||||
oldData.CustomizePlusData.TryGetValue(objectKind, out var oldC);
|
||||
newData.CustomizePlusData.TryGetValue(objectKind, out var newC);
|
||||
|
||||
bool manipDataDifferent = !string.Equals(oldData.ManipulationData, newData.ManipulationData, StringComparison.Ordinal);
|
||||
if (manipDataDifferent || forceRedrawOnForcedApply)
|
||||
var oldCNorm = Norm(oldC);
|
||||
var newCNorm = Norm(newC);
|
||||
|
||||
if (!string.Equals(oldCNorm, newCNorm, StringComparison.Ordinal)
|
||||
|| (forceApplyCustomization && HasText(newCNorm)))
|
||||
{
|
||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff manip data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModManip);
|
||||
charaDataToUpdate[objectKind].Add(PlayerChanges.ModManip);
|
||||
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
|
||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Customize+ different) => {change}",
|
||||
applicationBase, cachedPlayer, objectKind, PlayerChanges.Customize);
|
||||
set.Add(PlayerChanges.Customize);
|
||||
}
|
||||
|
||||
bool heelsOffsetDifferent = !string.Equals(oldData.HeelsData, newData.HeelsData, StringComparison.Ordinal);
|
||||
if (heelsOffsetDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.HeelsData)))
|
||||
if (objectKind == ObjectKind.Player)
|
||||
{
|
||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff heels data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Heels);
|
||||
charaDataToUpdate[objectKind].Add(PlayerChanges.Heels);
|
||||
var oldManip = Norm(oldData.ManipulationData);
|
||||
var newManip = Norm(newData.ManipulationData);
|
||||
|
||||
if (!string.Equals(oldManip, newManip, StringComparison.Ordinal) || forceRedrawOnForcedApply)
|
||||
{
|
||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Manip different) => {change}",
|
||||
applicationBase, cachedPlayer, objectKind, PlayerChanges.ModManip);
|
||||
set.Add(PlayerChanges.ModManip);
|
||||
set.Add(PlayerChanges.ForcedRedraw);
|
||||
}
|
||||
|
||||
if (!string.Equals(Norm(oldData.HeelsData), Norm(newData.HeelsData), StringComparison.Ordinal)
|
||||
|| (forceApplyCustomization && HasText(newData.HeelsData)))
|
||||
set.Add(PlayerChanges.Heels);
|
||||
|
||||
if (!string.Equals(Norm(oldData.HonorificData), Norm(newData.HonorificData), StringComparison.Ordinal)
|
||||
|| (forceApplyCustomization && HasText(newData.HonorificData)))
|
||||
set.Add(PlayerChanges.Honorific);
|
||||
|
||||
if (!string.Equals(Norm(oldData.MoodlesData), Norm(newData.MoodlesData), StringComparison.Ordinal)
|
||||
|| (forceApplyCustomization && HasText(newData.MoodlesData)))
|
||||
set.Add(PlayerChanges.Moodles);
|
||||
|
||||
if (!string.Equals(Norm(oldData.PetNamesData), Norm(newData.PetNamesData), StringComparison.Ordinal)
|
||||
|| (forceApplyCustomization && HasText(newData.PetNamesData)))
|
||||
set.Add(PlayerChanges.PetNames);
|
||||
}
|
||||
|
||||
bool honorificDataDifferent = !string.Equals(oldData.HonorificData, newData.HonorificData, StringComparison.Ordinal);
|
||||
if (honorificDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.HonorificData)))
|
||||
{
|
||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff honorific data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Honorific);
|
||||
charaDataToUpdate[objectKind].Add(PlayerChanges.Honorific);
|
||||
}
|
||||
|
||||
bool moodlesDataDifferent = !string.Equals(oldData.MoodlesData, newData.MoodlesData, StringComparison.Ordinal);
|
||||
if (moodlesDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.MoodlesData)))
|
||||
{
|
||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff moodles data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Moodles);
|
||||
charaDataToUpdate[objectKind].Add(PlayerChanges.Moodles);
|
||||
}
|
||||
|
||||
bool petNamesDataDifferent = !string.Equals(oldData.PetNamesData, newData.PetNamesData, StringComparison.Ordinal);
|
||||
if (petNamesDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.PetNamesData)))
|
||||
{
|
||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff petnames data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.PetNames);
|
||||
charaDataToUpdate[objectKind].Add(PlayerChanges.PetNames);
|
||||
}
|
||||
if (set.Count > 0)
|
||||
charaDataToUpdate[objectKind] = set;
|
||||
}
|
||||
|
||||
foreach (KeyValuePair<ObjectKind, HashSet<PlayerChanges>> data in charaDataToUpdate.ToList())
|
||||
{
|
||||
if (!data.Value.Any()) charaDataToUpdate.Remove(data.Key);
|
||||
else charaDataToUpdate[data.Key] = [.. data.Value.OrderByDescending(p => (int)p)];
|
||||
}
|
||||
foreach (var k in charaDataToUpdate.Keys.ToList())
|
||||
charaDataToUpdate[k] = [.. charaDataToUpdate[k].OrderBy(p => (int)p)];
|
||||
|
||||
return charaDataToUpdate;
|
||||
}
|
||||
|
||||
|
||||
public static T DeepClone<T>(this T obj)
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(obj))!;
|
||||
|
||||
@@ -436,11 +436,9 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
Logger.LogDebug("GUID {requestId} on server {uri} for files {files}",
|
||||
requestId, transfers[0].DownloadUri, string.Join(", ", transfers.Select(c => c.Hash)));
|
||||
|
||||
// Wait for ready WITHOUT holding a slot
|
||||
SetStatus(statusKey, DownloadStatus.WaitingForQueue);
|
||||
await WaitForDownloadReady(transfers, requestId, ct).ConfigureAwait(false);
|
||||
|
||||
// Hold slot ONLY for the GET
|
||||
SetStatus(statusKey, DownloadStatus.WaitingForSlot);
|
||||
await using ((await AcquireSlotAsync(ct).ConfigureAwait(false)).ConfigureAwait(false))
|
||||
{
|
||||
@@ -462,7 +460,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
bool skipDecimation)
|
||||
{
|
||||
SetStatus(downloadStatusKey, DownloadStatus.Decompressing);
|
||||
MarkTransferredFiles(downloadStatusKey, 1);
|
||||
|
||||
var extracted = 0;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -471,6 +470,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
while (fileBlockStream.Position < fileBlockStream.Length)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
(string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream);
|
||||
|
||||
try
|
||||
@@ -480,72 +481,69 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
|
||||
var len = checked((int)fileLengthBytes);
|
||||
|
||||
if (fileBlockStream.Position + len > fileBlockStream.Length)
|
||||
throw new EndOfStreamException();
|
||||
|
||||
if (!replacementLookup.TryGetValue(fileHash, out var repl))
|
||||
{
|
||||
Logger.LogWarning("{dlName}: No replacement mapping for {fileHash}", downloadLabel, fileHash);
|
||||
// still need to skip bytes:
|
||||
var skip = checked((int)fileLengthBytes);
|
||||
fileBlockStream.Position += skip;
|
||||
Logger.LogWarning("{dlName}: No replacement mapping for {fileHash}, skipping {len} bytes",
|
||||
downloadLabel, fileHash, len);
|
||||
|
||||
fileBlockStream.Seek(len, SeekOrigin.Current);
|
||||
continue;
|
||||
}
|
||||
|
||||
var filePath = _fileDbManager.GetCacheFilePath(fileHash, repl.Extension);
|
||||
Logger.LogTrace("{dlName}: Decompressing {file}:{len} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath);
|
||||
Logger.LogTrace("{dlName}: Extracting {fileHash}:{len} => {dest}",
|
||||
downloadLabel, fileHash, len, filePath);
|
||||
|
||||
var compressed = new byte[len];
|
||||
|
||||
await ReadExactlyAsync(fileBlockStream, compressed.AsMemory(0, len), ct).ConfigureAwait(false);
|
||||
|
||||
MungeBuffer(compressed);
|
||||
var decompressed = LZ4Wrapper.Unwrap(compressed);
|
||||
|
||||
if (rawSizeLookup.TryGetValue(fileHash, out var expectedRawSize)
|
||||
&& expectedRawSize > 0
|
||||
&& decompressed.LongLength != expectedRawSize)
|
||||
{
|
||||
await _fileCompactor.WriteAllBytesAsync(filePath, Array.Empty<byte>(), ct).ConfigureAwait(false);
|
||||
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation);
|
||||
continue;
|
||||
}
|
||||
|
||||
MungeBuffer(compressed);
|
||||
|
||||
await _decompressGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
byte[] decompressed;
|
||||
try
|
||||
{
|
||||
// offload CPU-intensive decompression to threadpool to free up worker
|
||||
await Task.Run(async () =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
// decompress
|
||||
var decompressed = LZ4Wrapper.Unwrap(compressed);
|
||||
|
||||
Logger.LogTrace("{dlName}: Unwrap {fileHash} took {ms}ms (compressed {c} bytes, decompressed {d} bytes)",
|
||||
downloadLabel, fileHash, sw.ElapsedMilliseconds, compressed.Length, decompressed?.Length ?? -1);
|
||||
|
||||
// write to file without compacting during download
|
||||
await File.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false);
|
||||
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation);
|
||||
}, ct).ConfigureAwait(false);
|
||||
decompressed = await Task.Run(() => LZ4Wrapper.Unwrap(compressed), ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_decompressGate.Release();
|
||||
}
|
||||
|
||||
if (rawSizeLookup.TryGetValue(fileHash, out var expectedRawSize)
|
||||
&& expectedRawSize > 0
|
||||
&& decompressed.LongLength != expectedRawSize)
|
||||
{
|
||||
Logger.LogWarning(
|
||||
"{dlName}: Size mismatch for {fileHash} (expected {expected}, got {actual}). Treating as corrupt.",
|
||||
downloadLabel, fileHash, expectedRawSize, decompressed.LongLength);
|
||||
|
||||
try { if (File.Exists(filePath)) File.Delete(filePath); } catch { /* ignore */ }
|
||||
continue;
|
||||
}
|
||||
|
||||
await _fileCompactor.WriteAllBytesAsync(filePath, decompressed, ct, enqueueCompaction: false).ConfigureAwait(false);
|
||||
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation);
|
||||
|
||||
extracted++;
|
||||
MarkTransferredFiles(downloadStatusKey, extracted);
|
||||
}
|
||||
catch (EndOfStreamException)
|
||||
{
|
||||
Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", downloadLabel, fileHash);
|
||||
Logger.LogWarning("{dlName}: Block ended mid-entry while extracting {fileHash}", downloadLabel, fileHash);
|
||||
break;
|
||||
}
|
||||
catch (Exception e)
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(e, "{dlName}: Error during decompression", downloadLabel);
|
||||
Logger.LogWarning(ex, "{dlName}: Error extracting {fileHash} from block", downloadLabel, fileHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SetStatus(downloadStatusKey, DownloadStatus.Completed);
|
||||
SetStatus(downloadStatusKey, DownloadStatus.Completed);
|
||||
}
|
||||
}
|
||||
catch (EndOfStreamException)
|
||||
{
|
||||
@@ -601,11 +599,10 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
_orchestrator.ForbiddenTransfers.Add(new DownloadFileTransfer(dto));
|
||||
}
|
||||
|
||||
CurrentDownloads = downloadFileInfoFromService
|
||||
CurrentDownloads = [.. downloadFileInfoFromService
|
||||
.Distinct()
|
||||
.Select(d => new DownloadFileTransfer(d))
|
||||
.Where(d => d.CanBeTransferred)
|
||||
.ToList();
|
||||
.Where(d => d.CanBeTransferred)];
|
||||
|
||||
return CurrentDownloads;
|
||||
}
|
||||
@@ -1033,48 +1030,58 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
|
||||
private async Task ProcessDeferredCompressionsAsync(CancellationToken ct)
|
||||
{
|
||||
if (_deferredCompressionQueue.IsEmpty)
|
||||
if (_deferredCompressionQueue.IsEmpty || !_configService.Current.UseCompactor)
|
||||
return;
|
||||
|
||||
var filesToCompress = new List<string>();
|
||||
// Drain queue into a unique set (same file can be enqueued multiple times)
|
||||
var filesToCompact = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
while (_deferredCompressionQueue.TryDequeue(out var filePath))
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
filesToCompress.Add(filePath);
|
||||
if (!string.IsNullOrWhiteSpace(filePath))
|
||||
filesToCompact.Add(filePath);
|
||||
}
|
||||
|
||||
if (filesToCompress.Count == 0)
|
||||
if (filesToCompact.Count == 0)
|
||||
return;
|
||||
|
||||
Logger.LogDebug("Starting deferred compression of {count} files", filesToCompress.Count);
|
||||
Logger.LogDebug("Starting deferred compaction of {count} files", filesToCompact.Count);
|
||||
|
||||
var compressionWorkers = Math.Clamp(Environment.ProcessorCount / 4, 2, 4);
|
||||
var enqueueWorkers = Math.Clamp(Environment.ProcessorCount / 4, 1, 2);
|
||||
|
||||
await Parallel.ForEachAsync(filesToCompress,
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = compressionWorkers,
|
||||
CancellationToken = ct
|
||||
await Parallel.ForEachAsync(
|
||||
filesToCompact,
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = enqueueWorkers,
|
||||
CancellationToken = ct
|
||||
},
|
||||
async (filePath, token) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
return;
|
||||
|
||||
await Task.Yield();
|
||||
if (_configService.Current.UseCompactor && File.Exists(filePath))
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(filePath, token).ConfigureAwait(false);
|
||||
await _fileCompactor.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false);
|
||||
Logger.LogTrace("Compressed file: {filePath}", filePath);
|
||||
}
|
||||
|
||||
_fileCompactor.RequestCompaction(filePath);
|
||||
|
||||
Logger.LogTrace("Deferred compaction queued: {filePath}", filePath);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Logger.LogTrace("Deferred compaction cancelled for file: {filePath}", filePath);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to compress file: {filePath}", filePath);
|
||||
Logger.LogWarning(ex, "Failed to queue deferred compaction for file: {filePath}", filePath);
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Logger.LogDebug("Completed deferred compression of {count} files", filesToCompress.Count);
|
||||
Logger.LogDebug("Completed queuing deferred compaction of {count} files", filesToCompact.Count);
|
||||
}
|
||||
|
||||
private sealed class InlineProgress : IProgress<long>
|
||||
|
||||
Reference in New Issue
Block a user