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:
cake
2026-01-13 17:45:32 +01:00
parent 4502cadaeb
commit 73dee6d9a5
22 changed files with 1528 additions and 753 deletions

View File

@@ -21,6 +21,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
private long _currentFileProgress = 0; private long _currentFileProgress = 0;
private CancellationTokenSource _scanCancellationTokenSource = new(); private CancellationTokenSource _scanCancellationTokenSource = new();
private readonly CancellationTokenSource _periodicCalculationTokenSource = 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"]; 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); 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"); Logger.LogInformation("Starting Periodic Storage Directory Calculation Task");
var token = _periodicCalculationTokenSource.Token; var token = _periodicCalculationTokenSource.Token;
while (IsHalted() && !token.IsCancellationRequested)
await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false);
while (!token.IsCancellationRequested) while (!token.IsCancellationRequested)
{ {
try try
@@ -91,6 +95,9 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public long CurrentFileProgress => _currentFileProgress; public long CurrentFileProgress => _currentFileProgress;
public long FileCacheSize { get; set; } public long FileCacheSize { get; set; }
public long FileCacheDriveFree { 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 ConcurrentDictionary<string, int> HaltScanLocks { get; set; } = new(StringComparer.Ordinal);
public bool IsScanRunning => CurrentFileProgress > 0 || TotalFiles > 0; public bool IsScanRunning => CurrentFileProgress > 0 || TotalFiles > 0;
public long TotalFiles { get; private set; } public long TotalFiles { get; private set; }
@@ -98,14 +105,36 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public void HaltScan(string source) public void HaltScan(string source)
{ {
if (!HaltScanLocks.ContainsKey(source)) HaltScanLocks[source] = 0; HaltScanLocks.AddOrUpdate(source, 1, (_, v) => v + 1);
HaltScanLocks[source]++; Interlocked.Increment(ref _haltCount);
} }
record WatcherChange(WatcherChangeTypes ChangeType, string? OldPath = null); record WatcherChange(WatcherChangeTypes ChangeType, string? OldPath = null);
private readonly record struct CacheEvictionCandidate(string FullPath, long Size, DateTime LastAccessTime); private readonly record struct CacheEvictionCandidate(string FullPath, long Size, DateTime LastAccessTime);
private readonly Dictionary<string, WatcherChange> _watcherChanges = new(StringComparer.OrdinalIgnoreCase); private readonly object _penumbraGate = new();
private readonly Dictionary<string, WatcherChange> _lightlessChanges = new(StringComparer.OrdinalIgnoreCase); 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() public void StopMonitoring()
{ {
@@ -168,7 +197,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
if (!HasAllowedExtension(e.FullPath)) return; if (!HasAllowedExtension(e.FullPath)) return;
lock (_watcherChanges) lock (_lightlessChanges)
{ {
_lightlessChanges[e.FullPath] = new(e.ChangeType); _lightlessChanges[e.FullPath] = new(e.ChangeType);
} }
@@ -279,67 +308,58 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
private async Task LightlessWatcherExecution() private async Task LightlessWatcherExecution()
{ {
_lightlessFswCts = _lightlessFswCts.CancelRecreate(); _lightlessFswCts = _lightlessFswCts.CancelRecreate();
var token = _lightlessFswCts.Token; 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 try
{ {
do await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(false);
{ while (IsHalted() && !token.IsCancellationRequested)
await Task.Delay(delay, token).ConfigureAwait(false); await Task.Delay(250, token).ConfigureAwait(false);
} while (HaltScanLocks.Any(f => f.Value > 0));
}
catch (TaskCanceledException)
{
return;
} }
catch (TaskCanceledException) { return; }
lock (_lightlessChanges) var changes = DrainLightlessChanges();
{ if (changes.Count > 0)
foreach (var key in changes.Keys) _ = HandleChangesAsync(changes, token);
{
_lightlessChanges.Remove(key);
}
}
HandleChanges(changes);
} }
private async Task HandleChangesAsync(Dictionary<string, WatcherChange> changes, CancellationToken token)
private void HandleChanges(Dictionary<string, WatcherChange> changes)
{ {
lock (_fileDbManager) await _dbGate.WaitAsync(token).ConfigureAwait(false);
try
{ {
var deletedEntries = 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 renamedEntries = changes.Where(c => c.Value.ChangeType == WatcherChangeTypes.Renamed); var renamed = changes.Where(c => c.Value.ChangeType == WatcherChangeTypes.Renamed);
var remainingEntries = changes.Where(c => c.Value.ChangeType != WatcherChangeTypes.Deleted).Select(c => c.Key); 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); 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); 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); Logger.LogDebug("FSW Change: Creation or Change - {val}", entry);
} }
var allChanges = deletedEntries var allChanges = deleted
.Concat(renamedEntries.Select(c => c.Value.OldPath!)) .Concat(renamed.Select(c => c.Value.OldPath!))
.Concat(renamedEntries.Select(c => c.Key)) .Concat(renamed.Select(c => c.Key))
.Concat(remainingEntries) .Concat(remaining)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray(); .ToArray();
_ = _fileDbManager.GetFileCachesByPaths(allChanges); _ = _fileDbManager.GetFileCachesByPaths(allChanges);
await _fileDbManager.WriteOutFullCsvAsync(token).ConfigureAwait(false);
_fileDbManager.WriteOutFullCsv(); }
finally
{
_dbGate.Release();
} }
} }
@@ -347,77 +367,97 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
{ {
_penumbraFswCts = _penumbraFswCts.CancelRecreate(); _penumbraFswCts = _penumbraFswCts.CancelRecreate();
var token = _penumbraFswCts.Token; 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 try
{ {
do await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(false);
{ while (IsHalted() && !token.IsCancellationRequested)
await Task.Delay(delay, token).ConfigureAwait(false); await Task.Delay(250, token).ConfigureAwait(false);
} while (HaltScanLocks.Any(f => f.Value > 0));
}
catch (TaskCanceledException)
{
return;
} }
catch (TaskCanceledException) { return; }
lock (_watcherChanges) var changes = DrainPenumbraChanges();
{ if (changes.Count > 0)
foreach (var key in changes.Keys) _ = HandleChangesAsync(changes, token);
{
_watcherChanges.Remove(key);
}
}
HandleChanges(changes);
} }
public void InvokeScan() public void InvokeScan()
{ {
TotalFiles = 0; TotalFiles = 0;
_currentFileProgress = 0; TotalFilesStorage = 0;
Interlocked.Exchange(ref _currentFileProgress, 0);
_scanCancellationTokenSource = _scanCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource(); _scanCancellationTokenSource = _scanCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource();
var token = _scanCancellationTokenSource.Token; var token = _scanCancellationTokenSource.Token;
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
Logger.LogDebug("Starting Full File Scan"); TaskCompletionSource scanTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
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);
}
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"); try
} {
}) token.ThrowIfCancellationRequested();
{
Priority = ThreadPriority.Lowest, _performanceCollector.LogPerformance(this, $"FullFileScan",
IsBackground = true () => FullFileScan(token));
};
scanThread.Start(); scanTcs.TrySetResult();
while (scanThread.IsAlive) }
{ catch (OperationCanceledException)
await Task.Delay(250, token).ConfigureAwait(false); {
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); }, token);
} }
public void RecalculateFileCacheSize(CancellationToken token) public void RecalculateFileCacheSize(CancellationToken token)
{ {
if (IsHalted()) return;
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) || if (string.IsNullOrEmpty(_configService.Current.CacheFolder) ||
!Directory.Exists(_configService.Current.CacheFolder)) !Directory.Exists(_configService.Current.CacheFolder))
{ {
@@ -594,10 +634,20 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public void ResumeScan(string source) public void ResumeScan(string source)
{ {
if (!HaltScanLocks.ContainsKey(source)) HaltScanLocks[source] = 0; int delta = 0;
HaltScanLocks[source]--; HaltScanLocks.AddOrUpdate(source,
if (HaltScanLocks[source] < 0) HaltScanLocks[source] = 0; 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) protected override void Dispose(bool disposing)
@@ -621,201 +671,81 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
private void FullFileScan(CancellationToken ct) private void FullFileScan(CancellationToken ct)
{ {
TotalFiles = 1; TotalFiles = 1;
_currentFileProgress = 0;
var penumbraDir = _ipcManager.Penumbra.ModDirectory; var penumbraDir = _ipcManager.Penumbra.ModDirectory;
bool penDirExists = true; var cacheFolder = _configService.Current.CacheFolder;
bool cacheDirExists = true;
if (string.IsNullOrEmpty(penumbraDir) || !Directory.Exists(penumbraDir)) if (string.IsNullOrEmpty(penumbraDir) || !Directory.Exists(penumbraDir))
{ {
penDirExists = false;
Logger.LogWarning("Penumbra directory is not set or does not exist."); 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."); Logger.LogWarning("Lightless Cache directory is not set or does not exist.");
}
if (!penDirExists || !cacheDirExists)
{
return; return;
} }
var previousThreadPriority = Thread.CurrentThread.Priority; var prevPriority = Thread.CurrentThread.Priority;
Thread.CurrentThread.Priority = ThreadPriority.Lowest; Thread.CurrentThread.Priority = ThreadPriority.Lowest;
Logger.LogDebug("Getting files from {penumbra} and {storage}", penumbraDir, _configService.Current.CacheFolder);
Dictionary<string, string[]> penumbraFiles = new(StringComparer.Ordinal); try
foreach (var folder in Directory.EnumerateDirectories(penumbraDir!))
{ {
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] = ct.ThrowIfCancellationRequested();
[
.. 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;
}
var allCacheFiles = Directory.GetFiles(_configService.Current.CacheFolder, "*.*", SearchOption.TopDirectoryOnly) try
.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 foreach (var file in Directory.EnumerateFiles(folder, "*.*", SearchOption.AllDirectories))
{ {
if (ct.IsCancellationRequested) return; ct.ThrowIfCancellationRequested();
if (!_ipcManager.Penumbra.APIAvailable) if (!HasAllowedExtension(file)) continue;
{ if (IsExcludedPenumbraPath(file)) continue;
Logger.LogWarning("Penumbra not available");
return;
}
var validatedCacheResult = _fileDbManager.ValidateFileCacheEntity(workload); onDiskPaths.Add(file);
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); }
}
} }
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);
} }
catch (Exception ex)
Logger.LogTrace("Ending Worker Thread {i}", threadNr); {
}) Logger.LogWarning(ex, "Could not enumerate path {path}", folder);
{ }
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);
} }
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) TotalFilesStorage = fileCaches.Count;
{ TotalFiles = onDiskPaths.Count + TotalFilesStorage;
Logger.LogWarning("Penumbra not available");
return;
}
if (ct.IsCancellationRequested) return; var validOrPresentInDb = new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
var entitiesToUpdate = new ConcurrentBag<FileCacheEntity>();
var newFiles = allScannedFiles.Where(c => !c.Value).Select(c => c.Key).ToList(); var entitiesToRemove = new ConcurrentBag<FileCacheEntity>();
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;
}
if (!_ipcManager.Penumbra.APIAvailable) if (!_ipcManager.Penumbra.APIAvailable)
{ {
@@ -823,33 +753,161 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
return; return;
} }
try Thread[] workerThreads = new Thread[threadCount];
for (int i = 0; i < threadCount; i++)
{ {
var entry = _fileDbManager.CreateFileEntry(cachePath); workerThreads[i] = new Thread(tcounter =>
if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath); {
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);
} }
} }
catch (OperationCanceledException)
Logger.LogDebug("Scan complete");
TotalFiles = 0;
_currentFileProgress = 0;
entitiesToRemove.Clear();
allScannedFiles.Clear();
if (!_configService.Current.InitialScanComplete)
{ {
_configService.Current.InitialScanComplete = true; // normal cancellation
_configService.Save(); }
StartLightlessWatcher(_configService.Current.CacheFolder); catch (Exception ex)
StartPenumbraWatcher(penumbraDir); {
Logger.LogError(ex, "Error during Full File Scan");
}
finally
{
Thread.CurrentThread.Priority = prevPriority;
} }
} }
} }

View File

@@ -213,7 +213,7 @@ public sealed partial class FileCompactor : IDisposable
/// <param name="bytes">Bytes that have to be written</param> /// <param name="bytes">Bytes that have to be written</param>
/// <param name="token">Cancellation Token for interupts</param> /// <param name="token">Cancellation Token for interupts</param>
/// <returns>Writing Task</returns> /// <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); var dir = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) 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); await File.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false);
if (enqueueCompaction && _lightlessConfigService.Current.UseCompactor)
EnqueueCompaction(filePath);
}
public void RequestCompaction(string filePath)
{
if (_lightlessConfigService.Current.UseCompactor) if (_lightlessConfigService.Current.UseCompactor)
EnqueueCompaction(filePath); EnqueueCompaction(filePath);
} }

View File

@@ -321,7 +321,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
{ {
foreach (var handler in _playerRelatedPointers) foreach (var handler in _playerRelatedPointers)
{ {
var address = (nint)handler.Address; var address = handler.Address;
if (address != nint.Zero) if (address != nint.Zero)
{ {
tempMap[address] = handler; tempMap[address] = handler;

View File

@@ -81,7 +81,10 @@ public sealed class IpcCallerPenumbra : IpcServiceBase
=> _collections.RemoveTemporaryCollectionAsync(logger, applicationId, collectionId); => _collections.RemoveTemporaryCollectionAsync(logger, applicationId, collectionId);
public Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths) 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) public Task SetManipulationDataAsync(ILogger logger, Guid applicationId, Guid collectionId, string manipulationData)
=> _collections.SetManipulationDataAsync(logger, applicationId, collectionId, manipulationData); => _collections.SetManipulationDataAsync(logger, applicationId, collectionId, manipulationData);

View File

@@ -92,25 +92,43 @@ public sealed class PenumbraCollections : PenumbraBase
_activeTemporaryCollections.TryRemove(collectionId, out _); _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) if (!IsAvailable || collectionId == Guid.Empty)
{
return; 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(() => await DalamudUtil.RunOnFrameworkThread(() =>
{ {
foreach (var mod in modPaths) foreach (var mod in normalized)
{ logger.LogTrace("[{ApplicationId}] {ModName}: {From} => {To}", applicationId, modName, mod.Key, mod.Value);
logger.LogTrace("[{ApplicationId}] Change: {From} => {To}", applicationId, mod.Key, mod.Value);
}
var removeResult = _removeTemporaryMod.Invoke("LightlessChara_Files", collectionId, 0); var removeResult = _removeTemporaryMod.Invoke(modName, collectionId, 0);
logger.LogTrace("[{ApplicationId}] Removing temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, removeResult); 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); if (normalized.Count == 0)
logger.LogTrace("[{ApplicationId}] Setting temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, addResult); 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); }).ConfigureAwait(false);
} }
@@ -171,7 +189,7 @@ public sealed class PenumbraCollections : PenumbraBase
Logger.LogDebug("Cleaning up stale temporary collection {CollectionName} ({CollectionId})", name, collectionId); Logger.LogDebug("Cleaning up stale temporary collection {CollectionName} ({CollectionId})", name, collectionId);
var deleteResult = await DalamudUtil.RunOnFrameworkThread(() => 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); Logger.LogTrace("Cleanup RemoveTemporaryCollection result for {CollectionName} ({CollectionId}): {Result}", name, collectionId, result);
return result; return result;
}).ConfigureAwait(false); }).ConfigureAwait(false);

View File

@@ -158,7 +158,6 @@ public class LightlessConfig : ILightlessConfiguration
public string? SelectedFinderSyncshell { get; set; } = null; public string? SelectedFinderSyncshell { get; set; } = null;
public string LastSeenVersion { get; set; } = string.Empty; public string LastSeenVersion { get; set; } = string.Empty;
public bool EnableParticleEffects { get; set; } = true; public bool EnableParticleEffects { get; set; } = true;
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Unsafe; public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Unsafe;
public bool AnimationAllowOneBasedShift { get; set; } = false; public bool AnimationAllowOneBasedShift { get; set; } = false;

View File

@@ -0,0 +1,8 @@
namespace LightlessSync.LightlessConfiguration.Configurations;
public class PenumbraJanitorConfig : ILightlessConfiguration
{
public int Version { get; set; } = 0;
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
}

View File

@@ -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;
}

View File

@@ -1,4 +1,4 @@
using LightlessSync.API.Data.Enum;  using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto.User; using LightlessSync.API.Dto.User;
using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;

View File

@@ -1,6 +1,6 @@
using Dalamud.Utility; using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.Game.Character;
using LightlessSync.API.Data.Enum; using FFXIVClientStructs.FFXIV.Client.Game.Object;
using LightlessSync.FileCache; using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc; using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
@@ -9,11 +9,12 @@ using LightlessSync.PlayerData.Data;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics; using System.Diagnostics;
using System.Runtime.ExceptionServices;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.PlayerData.Factories; namespace LightlessSync.PlayerData.Factories;
@@ -119,45 +120,28 @@ public class PlayerDataFactory
return null; 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) 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; 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; return true;
var character = (Character*)playerPointer; return drawObj == nint.Zero;
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;
}
} }
private static bool IsCacheFresh(CacheEntry entry) 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)) if (_characterBuildCache.TryGetValue(key, out var cached) && IsCacheFresh(cached) && !_characterBuildInflight.ContainsKey(key))
return cached.Fragment; 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)) if (_characterBuildCache.TryGetValue(key, out cached))
{ {

View 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

View File

@@ -428,6 +428,7 @@ public sealed class Plugin : IDalamudPlugin
return cfg; return cfg;
}); });
services.AddSingleton(sp => new ServerConfigService(configDir)); services.AddSingleton(sp => new ServerConfigService(configDir));
services.AddSingleton(sp => new PenumbraJanitorConfigService(configDir));
services.AddSingleton(sp => new NotesConfigService(configDir)); services.AddSingleton(sp => new NotesConfigService(configDir));
services.AddSingleton(sp => new PairTagConfigService(configDir)); services.AddSingleton(sp => new PairTagConfigService(configDir));
services.AddSingleton(sp => new SyncshellTagConfigService(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<UiThemeConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ChatConfigService>()); services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ChatConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ServerConfigService>()); 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<NotesConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<PairTagConfigService>()); services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<PairTagConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<SyncshellTagConfigService>()); services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<SyncshellTagConfigService>());

View File

@@ -14,6 +14,7 @@ using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSu
using IPlayerCharacter = Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter; using IPlayerCharacter = Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind; using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
using System.Runtime.InteropServices;
namespace LightlessSync.Services.ActorTracking; namespace LightlessSync.Services.ActorTracking;
@@ -57,6 +58,8 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
private bool _hooksActive; private bool _hooksActive;
private static readonly TimeSpan SnapshotRefreshInterval = TimeSpan.FromSeconds(1); private static readonly TimeSpan SnapshotRefreshInterval = TimeSpan.FromSeconds(1);
private DateTime _nextRefreshAllowed = DateTime.MinValue; private DateTime _nextRefreshAllowed = DateTime.MinValue;
private int _warmStartQueued;
private int _warmStartRan;
public ActorObjectService( public ActorObjectService(
ILogger<ActorObjectService> logger, ILogger<ActorObjectService> logger,
@@ -74,7 +77,21 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
_clientState = clientState; _clientState = clientState;
_condition = condition; _condition = condition;
_mediator = mediator; _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) => _mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
{ {
if (!msg.OwnedObject) return; 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 bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51];
private ActorSnapshot Snapshot => Volatile.Read(ref _snapshot); private ActorSnapshot Snapshot => Volatile.Read(ref _snapshot);
private GposeSnapshot CurrentGposeSnapshot => Volatile.Read(ref _gposeSnapshot); private GposeSnapshot CurrentGposeSnapshot => Volatile.Read(ref _gposeSnapshot);
@@ -341,6 +357,8 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
public Task StopAsync(CancellationToken cancellationToken) public Task StopAsync(CancellationToken cancellationToken)
{ {
_warmStartRan = 0;
DisposeHooks(); DisposeHooks();
_activePlayers.Clear(); _activePlayers.Clear();
_gposePlayers.Clear(); _gposePlayers.Clear();
@@ -1147,6 +1165,57 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
PublishGposeSnapshot(); 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) private unsafe void TrackGposeObject(GameObject* gameObject)
{ {
if (gameObject == null) if (gameObject == null)
@@ -1240,6 +1309,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
return true; return true;
} }
[StructLayout(LayoutKind.Auto)]
private readonly record struct LoadState(bool IsValid, bool IsLoaded) private readonly record struct LoadState(bool IsValid, bool IsLoaded)
{ {
public static LoadState Invalid => new(false, false); public static LoadState Invalid => new(false, false);

View File

@@ -68,6 +68,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoMove |
ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoSavedSettings |
ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoNav |
ImGuiWindowFlags.NoFocusOnAppearing |
ImGuiWindowFlags.NoInputs; ImGuiWindowFlags.NoInputs;
private readonly List<RectF> _uiRects = new(128); private readonly List<RectF> _uiRects = new(128);

View File

@@ -138,5 +138,6 @@ public record GroupCollectionChangedMessage : MessageBase;
public record OpenUserProfileMessage(UserData User) : MessageBase; public record OpenUserProfileMessage(UserData User) : MessageBase;
public record LocationSharingMessage(UserData User, LocationInfo LocationInfo, DateTimeOffset ExpireAt) : MessageBase; public record LocationSharingMessage(UserData User, LocationInfo LocationInfo, DateTimeOffset ExpireAt) : MessageBase;
public record MapChangedMessage(uint MapId) : MessageBase; public record MapChangedMessage(uint MapId) : MessageBase;
public record PenumbraTempCollectionsCleanedMessage : MessageBase;
#pragma warning restore S2094 #pragma warning restore S2094
#pragma warning restore MA0048 // File name must match type name #pragma warning restore MA0048 // File name must match type name

View File

@@ -8,14 +8,14 @@ namespace LightlessSync.Services;
public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriberBase public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriberBase
{ {
private readonly IpcManager _ipc; private readonly IpcManager _ipc;
private readonly LightlessConfigService _config; private readonly PenumbraJanitorConfigService _config;
private int _ran; private int _ran;
public PenumbraTempCollectionJanitor( public PenumbraTempCollectionJanitor(
ILogger<PenumbraTempCollectionJanitor> logger, ILogger<PenumbraTempCollectionJanitor> logger,
LightlessMediator mediator, LightlessMediator mediator,
IpcManager ipc, IpcManager ipc,
LightlessConfigService config) : base(logger, mediator) PenumbraJanitorConfigService config) : base(logger, mediator)
{ {
_ipc = ipc; _ipc = ipc;
_config = config; _config = config;
@@ -67,5 +67,8 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber
_config.Current.OrphanableTempCollections.Clear(); _config.Current.OrphanableTempCollections.Clear();
_config.Save(); _config.Save();
// Notify cleanup complete
Mediator.Publish(new PenumbraTempCollectionsCleanedMessage());
} }
} }

View File

@@ -103,7 +103,7 @@ public sealed class DtrEntry : IDisposable, IHostedService
public async Task StopAsync(CancellationToken cancellationToken) public async Task StopAsync(CancellationToken cancellationToken)
{ {
_cancellationTokenSource.Cancel(); await _cancellationTokenSource.CancelAsync().ConfigureAwait(false);
if (_dalamudUtilService.IsOnFrameworkThread) if (_dalamudUtilService.IsOnFrameworkThread)
{ {

View File

@@ -33,13 +33,13 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase
private readonly Dictionary<string, float> _notificationTargetYOffsets = []; private readonly Dictionary<string, float> _notificationTargetYOffsets = [];
private readonly Dictionary<string, Vector4> _notificationBackgrounds = []; 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) : base(logger, mediator, "Lightless Notifications##LightlessNotifications", performanceCollector)
{ {
_configService = configService; _configService = configService;
Flags = ImGuiWindowFlags.NoDecoration | Flags = ImGuiWindowFlags.NoDecoration |
ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoMove |
ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoResize |
ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoSavedSettings |
ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoFocusOnAppearing |
ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoNav |
@@ -47,6 +47,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase
ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoCollapse |
ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoTitleBar |
ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollbar |
ImGuiWindowFlags.NoFocusOnAppearing |
ImGuiWindowFlags.AlwaysAutoResize; ImGuiWindowFlags.AlwaysAutoResize;
PositionCondition = ImGuiCond.Always; PositionCondition = ImGuiCond.Always;

View 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;
}
}

View File

@@ -3,8 +3,6 @@ using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Enum;
using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json; using System.Text.Json;
namespace LightlessSync.Utils; namespace LightlessSync.Utils;
@@ -56,164 +54,168 @@ public static class VariousExtensions
return new CancellationTokenSource(); return new CancellationTokenSource();
} }
public static Dictionary<ObjectKind, HashSet<PlayerChanges>> CheckUpdatedData(this CharacterData newData, Guid applicationBase, public static Dictionary<ObjectKind, HashSet<PlayerChanges>> CheckUpdatedData(
CharacterData? oldData, ILogger logger, IPairPerformanceSubject cachedPlayer, bool forceApplyCustomization, bool forceApplyMods, this CharacterData newData,
Guid applicationBase,
CharacterData? oldData,
ILogger logger,
IPairPerformanceSubject cachedPlayer,
bool forceApplyCustomization,
bool forceApplyMods,
bool suppressForcedRedrawOnForcedModApply = false) bool suppressForcedRedrawOnForcedModApply = false)
{ {
oldData ??= new(); 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>>(); var charaDataToUpdate = new Dictionary<ObjectKind, HashSet<PlayerChanges>>();
foreach (ObjectKind objectKind in Enum.GetValues<ObjectKind>()) foreach (ObjectKind objectKind in Enum.GetValues<ObjectKind>())
{ {
charaDataToUpdate[objectKind] = []; var set = new HashSet<PlayerChanges>();
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);
bool hasNewButNotOldFileReplacements = newFileReplacements != null && existingFileReplacements == null; oldData.FileReplacements.TryGetValue(objectKind, out var oldFileRepls);
bool hasOldButNotNewFileReplacements = existingFileReplacements != null && newFileReplacements == null; newData.FileReplacements.TryGetValue(objectKind, out var newFileRepls);
bool hasNewButNotOldGlamourerData = newGlamourerData != null && existingGlamourerData == null; oldData.GlamourerData.TryGetValue(objectKind, out var oldGlam);
bool hasOldButNotNewGlamourerData = existingGlamourerData != null && newGlamourerData == null; newData.GlamourerData.TryGetValue(objectKind, out var newGlam);
bool hasNewAndOldFileReplacements = newFileReplacements != null && existingFileReplacements != null; var oldHasFiles = HasFiles(oldFileRepls);
bool hasNewAndOldGlamourerData = newGlamourerData != null && existingGlamourerData != null; var newHasFiles = HasFiles(newFileRepls);
var forceRedrawOnForcedApply = forceApplyMods && !suppressForcedRedrawOnForcedModApply;
if (hasNewButNotOldFileReplacements || hasOldButNotNewFileReplacements || hasNewButNotOldGlamourerData || hasOldButNotNewGlamourerData) if (oldHasFiles != newHasFiles)
{ {
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Some new data arrived: NewButNotOldFiles:{hasNewButNotOldFileReplacements}," + logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (File presence changed old={old} new={new}) => {change}",
" OldButNotNewFiles:{hasOldButNotNewFileReplacements}, NewButNotOldGlam:{hasNewButNotOldGlamourerData}, OldButNotNewGlam:{hasOldButNotNewGlamourerData}) => {change}, {change2}", applicationBase, cachedPlayer, objectKind, oldHasFiles, newHasFiles, PlayerChanges.ModFiles);
applicationBase,
cachedPlayer, objectKind, hasNewButNotOldFileReplacements, hasOldButNotNewFileReplacements, hasNewButNotOldGlamourerData, hasOldButNotNewGlamourerData, PlayerChanges.ModFiles, PlayerChanges.Glamourer); set.Add(PlayerChanges.ModFiles);
charaDataToUpdate[objectKind].Add(PlayerChanges.ModFiles); if (objectKind != ObjectKind.Player || forceRedrawOnForcedApply)
charaDataToUpdate[objectKind].Add(PlayerChanges.Glamourer);
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
}
else
{
if (hasNewAndOldFileReplacements)
{ {
var oldList = oldData.FileReplacements[objectKind]; set.Add(PlayerChanges.ForcedRedraw);
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);
}
}
}
} }
}
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); logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (FileReplacements changed or forceApplyMods) => {change}",
if (glamourerDataDifferent || forceApplyCustomization) 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); set.Add(PlayerChanges.ForcedRedraw);
charaDataToUpdate[objectKind].Add(PlayerChanges.Glamourer); }
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); var oldGlamNorm = Norm(oldGlam);
newData.CustomizePlusData.TryGetValue(objectKind, out var newCustomizePlusData); var newGlamNorm = Norm(newGlam);
oldCustomizePlusData ??= string.Empty; if (!string.Equals(oldGlamNorm, newGlamNorm, StringComparison.Ordinal)
newCustomizePlusData ??= string.Empty; || (forceApplyCustomization && HasText(newGlamNorm)))
bool customizeDataDifferent = !string.Equals(oldCustomizePlusData, newCustomizePlusData, StringComparison.Ordinal);
if (customizeDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newCustomizePlusData)))
{ {
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff customize data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Customize); logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Glamourer different) => {change}",
charaDataToUpdate[objectKind].Add(PlayerChanges.Customize); 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); var oldCNorm = Norm(oldC);
if (manipDataDifferent || forceRedrawOnForcedApply) 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); logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Customize+ different) => {change}",
charaDataToUpdate[objectKind].Add(PlayerChanges.ModManip); applicationBase, cachedPlayer, objectKind, PlayerChanges.Customize);
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw); set.Add(PlayerChanges.Customize);
} }
bool heelsOffsetDifferent = !string.Equals(oldData.HeelsData, newData.HeelsData, StringComparison.Ordinal); if (objectKind == ObjectKind.Player)
if (heelsOffsetDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.HeelsData)))
{ {
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff heels data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Heels); var oldManip = Norm(oldData.ManipulationData);
charaDataToUpdate[objectKind].Add(PlayerChanges.Heels); 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 (set.Count > 0)
if (honorificDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.HonorificData))) charaDataToUpdate[objectKind] = set;
{
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);
}
} }
foreach (KeyValuePair<ObjectKind, HashSet<PlayerChanges>> data in charaDataToUpdate.ToList()) foreach (var k in charaDataToUpdate.Keys.ToList())
{ charaDataToUpdate[k] = [.. charaDataToUpdate[k].OrderBy(p => (int)p)];
if (!data.Value.Any()) charaDataToUpdate.Remove(data.Key);
else charaDataToUpdate[data.Key] = [.. data.Value.OrderByDescending(p => (int)p)];
}
return charaDataToUpdate; return charaDataToUpdate;
} }
public static T DeepClone<T>(this T obj) public static T DeepClone<T>(this T obj)
{ {
return JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(obj))!; return JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(obj))!;

View File

@@ -436,11 +436,9 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
Logger.LogDebug("GUID {requestId} on server {uri} for files {files}", Logger.LogDebug("GUID {requestId} on server {uri} for files {files}",
requestId, transfers[0].DownloadUri, string.Join(", ", transfers.Select(c => c.Hash))); requestId, transfers[0].DownloadUri, string.Join(", ", transfers.Select(c => c.Hash)));
// Wait for ready WITHOUT holding a slot
SetStatus(statusKey, DownloadStatus.WaitingForQueue); SetStatus(statusKey, DownloadStatus.WaitingForQueue);
await WaitForDownloadReady(transfers, requestId, ct).ConfigureAwait(false); await WaitForDownloadReady(transfers, requestId, ct).ConfigureAwait(false);
// Hold slot ONLY for the GET
SetStatus(statusKey, DownloadStatus.WaitingForSlot); SetStatus(statusKey, DownloadStatus.WaitingForSlot);
await using ((await AcquireSlotAsync(ct).ConfigureAwait(false)).ConfigureAwait(false)) await using ((await AcquireSlotAsync(ct).ConfigureAwait(false)).ConfigureAwait(false))
{ {
@@ -462,7 +460,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
bool skipDecimation) bool skipDecimation)
{ {
SetStatus(downloadStatusKey, DownloadStatus.Decompressing); SetStatus(downloadStatusKey, DownloadStatus.Decompressing);
MarkTransferredFiles(downloadStatusKey, 1);
var extracted = 0;
try try
{ {
@@ -471,6 +470,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
{ {
while (fileBlockStream.Position < fileBlockStream.Length) while (fileBlockStream.Position < fileBlockStream.Length)
{ {
ct.ThrowIfCancellationRequested();
(string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream); (string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream);
try try
@@ -480,72 +481,69 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
var len = checked((int)fileLengthBytes); var len = checked((int)fileLengthBytes);
if (fileBlockStream.Position + len > fileBlockStream.Length)
throw new EndOfStreamException();
if (!replacementLookup.TryGetValue(fileHash, out var repl)) if (!replacementLookup.TryGetValue(fileHash, out var repl))
{ {
Logger.LogWarning("{dlName}: No replacement mapping for {fileHash}", downloadLabel, fileHash); Logger.LogWarning("{dlName}: No replacement mapping for {fileHash}, skipping {len} bytes",
// still need to skip bytes: downloadLabel, fileHash, len);
var skip = checked((int)fileLengthBytes);
fileBlockStream.Position += skip; fileBlockStream.Seek(len, SeekOrigin.Current);
continue; continue;
} }
var filePath = _fileDbManager.GetCacheFilePath(fileHash, repl.Extension); 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]; var compressed = new byte[len];
await ReadExactlyAsync(fileBlockStream, compressed.AsMemory(0, len), ct).ConfigureAwait(false); 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); MungeBuffer(compressed);
await _decompressGate.WaitAsync(ct).ConfigureAwait(false); await _decompressGate.WaitAsync(ct).ConfigureAwait(false);
byte[] decompressed;
try try
{ {
// offload CPU-intensive decompression to threadpool to free up worker decompressed = await Task.Run(() => LZ4Wrapper.Unwrap(compressed), ct).ConfigureAwait(false);
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);
} }
finally finally
{ {
_decompressGate.Release(); _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) 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) catch (EndOfStreamException)
{ {
@@ -601,11 +599,10 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
_orchestrator.ForbiddenTransfers.Add(new DownloadFileTransfer(dto)); _orchestrator.ForbiddenTransfers.Add(new DownloadFileTransfer(dto));
} }
CurrentDownloads = downloadFileInfoFromService CurrentDownloads = [.. downloadFileInfoFromService
.Distinct() .Distinct()
.Select(d => new DownloadFileTransfer(d)) .Select(d => new DownloadFileTransfer(d))
.Where(d => d.CanBeTransferred) .Where(d => d.CanBeTransferred)];
.ToList();
return CurrentDownloads; return CurrentDownloads;
} }
@@ -1033,48 +1030,58 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
private async Task ProcessDeferredCompressionsAsync(CancellationToken ct) private async Task ProcessDeferredCompressionsAsync(CancellationToken ct)
{ {
if (_deferredCompressionQueue.IsEmpty) if (_deferredCompressionQueue.IsEmpty || !_configService.Current.UseCompactor)
return; 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)) while (_deferredCompressionQueue.TryDequeue(out var filePath))
{ {
if (File.Exists(filePath)) if (!string.IsNullOrWhiteSpace(filePath))
filesToCompress.Add(filePath); filesToCompact.Add(filePath);
} }
if (filesToCompress.Count == 0) if (filesToCompact.Count == 0)
return; 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, await Parallel.ForEachAsync(
new ParallelOptions filesToCompact,
{ new ParallelOptions
MaxDegreeOfParallelism = compressionWorkers, {
CancellationToken = ct MaxDegreeOfParallelism = enqueueWorkers,
CancellationToken = ct
}, },
async (filePath, token) => async (filePath, token) =>
{ {
try try
{ {
token.ThrowIfCancellationRequested();
if (!File.Exists(filePath))
return;
await Task.Yield(); await Task.Yield();
if (_configService.Current.UseCompactor && File.Exists(filePath))
{ _fileCompactor.RequestCompaction(filePath);
var bytes = await File.ReadAllBytesAsync(filePath, token).ConfigureAwait(false);
await _fileCompactor.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false); Logger.LogTrace("Deferred compaction queued: {filePath}", filePath);
Logger.LogTrace("Compressed file: {filePath}", filePath); }
} catch (OperationCanceledException)
{
Logger.LogTrace("Deferred compaction cancelled for file: {filePath}", filePath);
throw;
} }
catch (Exception ex) 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); }).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> private sealed class InlineProgress : IProgress<long>