Compare commits

..

13 Commits

Author SHA1 Message Date
cake
3654365f2a bump version
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m6s
2026-01-06 14:45:23 +01:00
cake
9b256dd185 Merge branch '2.0.3' into dev 2026-01-06 14:45:02 +01:00
defnotken
223ade39cb another push
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m13s
2026-01-05 20:48:24 -06:00
defnotken
5aca9e70b2 Merge branch '2.0.3' into dev 2026-01-05 20:47:38 -06:00
defnotken
92772cf334 dev push
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m12s
2026-01-05 20:21:26 -06:00
defnotken
0395e81a9f Merge branch '2.0.3' into dev 2026-01-05 20:17:12 -06:00
defnotken
7734a7bf7e dev build
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m8s
2026-01-05 17:42:21 -06:00
defnotken
db2d19bb1e Merge branch '2.0.3' into dev 2026-01-05 17:41:48 -06:00
defnotken
ab305a249c more checks
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m7s
2026-01-05 15:48:54 -06:00
defnotken
9d104a9dd8 Merge branch '2.0.3' into dev 2026-01-05 15:42:15 -06:00
defnotken
bcd3bd5ca2 add more checks
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m8s
2026-01-05 15:08:26 -06:00
defnotken
c1829a9837 Merge branch '2.0.3' into dev
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m14s
2026-01-05 14:48:47 -06:00
defnotken
cca23f6e05 Building Dev
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m27s
2026-01-05 10:50:25 -06:00
33 changed files with 1176 additions and 3093 deletions

View File

@@ -9,7 +9,6 @@ env:
DOTNET_VERSION: | DOTNET_VERSION: |
10.x.x 10.x.x
9.x.x 9.x.x
DOTNET_CLI_TELEMETRY_OPTOUT: true
jobs: jobs:
tag-and-release: tag-and-release:
@@ -33,14 +32,16 @@ jobs:
- name: Download Dalamud - name: Download Dalamud
run: | run: |
mkdir -p ~/.xlcore/dalamud/Hooks/dev cd /
mkdir -p root/.xlcore/dalamud/Hooks/dev
curl -O https://goatcorp.github.io/dalamud-distrib/stg/latest.zip curl -O https://goatcorp.github.io/dalamud-distrib/stg/latest.zip
unzip latest.zip -d ~/.xlcore/dalamud/Hooks/dev unzip latest.zip -d /root/.xlcore/dalamud/Hooks/dev
- name: Lets Build Lightless! - name: Lets Build Lightless!
run: | run: |
dotnet publish --configuration Release dotnet restore
mv LightlessSync/bin/x64/Release/LightlessSync/latest.zip LightlessClient.zip dotnet build --configuration Release --no-restore
dotnet publish --configuration Release --no-build
- name: Get version - name: Get version
id: package_version id: package_version
@@ -52,6 +53,19 @@ jobs:
run: | run: |
echo "Version: ${{ steps.package_version.outputs.version }}" echo "Version: ${{ steps.package_version.outputs.version }}"
- name: Prepare Lightless Client
run: |
PUBLISH_PATH="/workspace/Lightless-Sync/LightlessClient/LightlessSync/bin/x64/Release/publish/"
if [ -d "$PUBLISH_PATH" ]; then
rm -rf "$PUBLISH_PATH"
echo "Removed $PUBLISH_PATH"
else
echo "$PUBLISH_PATH does not exist, nothing to remove."
fi
mkdir -p output
(cd /workspace/Lightless-Sync/LightlessClient/LightlessSync/bin/x64/Release/ && zip -r $OLDPWD/output/LightlessClient.zip *)
- name: Create Git tag if not exists (master) - name: Create Git tag if not exists (master)
if: github.ref == 'refs/heads/master' if: github.ref == 'refs/heads/master'
run: | run: |
@@ -149,6 +163,13 @@ jobs:
echo "release_id=$release_id" >> $GITHUB_OUTPUT || echo "::set-output name=release_id::$release_id" echo "release_id=$release_id" >> $GITHUB_OUTPUT || echo "::set-output name=release_id::$release_id"
echo "RELEASE_ID=$release_id" >> $GITHUB_ENV echo "RELEASE_ID=$release_id" >> $GITHUB_ENV
- name: Check asset exists
run: |
if [ ! -f output/LightlessClient.zip ]; then
echo "output/LightlessClient.zip does not exist!"
exit 1
fi
- name: Upload Assets to release - name: Upload Assets to release
env: env:
RELEASE_ID: ${{ env.RELEASE_ID }} RELEASE_ID: ${{ env.RELEASE_ID }}
@@ -156,7 +177,7 @@ jobs:
echo "Uploading to release ID: $RELEASE_ID" echo "Uploading to release ID: $RELEASE_ID"
curl --fail-with-body -s -X POST \ curl --fail-with-body -s -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \ -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@LightlessClient.zip" \ -F "attachment=@output/LightlessClient.zip" \
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets" "https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets"
- name: Clone plugin hosting repo - name: Clone plugin hosting repo
@@ -165,7 +186,7 @@ jobs:
cd LightlessSyncRepo cd LightlessSyncRepo
git clone https://git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessSync.git git clone https://git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessSync.git
env: env:
GIT_TERMINAL_PROMPT: 0 GIT_TERMINAL_PROMPT: 0
- name: Update plogonmaster.json with version (master) - name: Update plogonmaster.json with version (master)
if: github.ref == 'refs/heads/master' if: github.ref == 'refs/heads/master'
@@ -261,8 +282,8 @@ jobs:
- name: Commit and push to LightlessSync - name: Commit and push to LightlessSync
run: | run: |
cd LightlessSyncRepo/LightlessSync cd LightlessSyncRepo/LightlessSync
git config user.name "Gitea-Automation" git config user.name "github-actions"
git config user.email "aaa@aaaaaaa.aaa" git config user.email "github-actions@github.com"
git add . git add .
git diff-index --quiet HEAD || git commit -m "Update ${{ env.PLUGIN_NAME }} to ${{ steps.package_version.outputs.version }}" git diff-index --quiet HEAD || git commit -m "Update ${{ env.PLUGIN_NAME }} to ${{ steps.package_version.outputs.version }}"
git push https://x-access-token:${{ secrets.AUTOMATION_TOKEN }}@git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessSync.git HEAD:main git push https://x-access-token:${{ secrets.AUTOMATION_TOKEN }}@git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessSync.git HEAD:main

View File

@@ -21,7 +21,6 @@ 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);
@@ -69,9 +68,6 @@ 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
@@ -95,9 +91,6 @@ 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; }
@@ -105,36 +98,14 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public void HaltScan(string source) public void HaltScan(string source)
{ {
HaltScanLocks.AddOrUpdate(source, 1, (_, v) => v + 1); if (!HaltScanLocks.ContainsKey(source)) HaltScanLocks[source] = 0;
Interlocked.Increment(ref _haltCount); HaltScanLocks[source]++;
} }
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 object _penumbraGate = new(); private readonly Dictionary<string, WatcherChange> _watcherChanges = new(StringComparer.OrdinalIgnoreCase);
private Dictionary<string, WatcherChange> _watcherChanges = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, WatcherChange> _lightlessChanges = 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()
{ {
@@ -197,7 +168,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
if (!HasAllowedExtension(e.FullPath)) return; if (!HasAllowedExtension(e.FullPath)) return;
lock (_lightlessChanges) lock (_watcherChanges)
{ {
_lightlessChanges[e.FullPath] = new(e.ChangeType); _lightlessChanges[e.FullPath] = new(e.ChangeType);
} }
@@ -308,58 +279,67 @@ 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
{ {
await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(false); do
while (IsHalted() && !token.IsCancellationRequested) {
await Task.Delay(250, token).ConfigureAwait(false); await Task.Delay(delay, token).ConfigureAwait(false);
} while (HaltScanLocks.Any(f => f.Value > 0));
} }
catch (TaskCanceledException) { return; } catch (TaskCanceledException)
var changes = DrainLightlessChanges();
if (changes.Count > 0)
_ = HandleChangesAsync(changes, token);
}
private async Task HandleChangesAsync(Dictionary<string, WatcherChange> changes, CancellationToken token)
{
await _dbGate.WaitAsync(token).ConfigureAwait(false);
try
{ {
var deleted = changes.Where(c => c.Value.ChangeType == WatcherChangeTypes.Deleted).Select(c => c.Key); return;
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 deleted) lock (_lightlessChanges)
{
foreach (var key in changes.Keys)
{
_lightlessChanges.Remove(key);
}
}
HandleChanges(changes);
}
private void HandleChanges(Dictionary<string, WatcherChange> changes)
{
lock (_fileDbManager)
{
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);
foreach (var entry in deletedEntries)
{ {
Logger.LogDebug("FSW Change: Deletion - {val}", entry); Logger.LogDebug("FSW Change: Deletion - {val}", entry);
} }
foreach (var entry in renamed) foreach (var entry in renamedEntries)
{ {
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 remaining) foreach (var entry in remainingEntries)
{ {
Logger.LogDebug("FSW Change: Creation or Change - {val}", entry); Logger.LogDebug("FSW Change: Creation or Change - {val}", entry);
} }
var allChanges = deleted var allChanges = deletedEntries
.Concat(renamed.Select(c => c.Value.OldPath!)) .Concat(renamedEntries.Select(c => c.Value.OldPath!))
.Concat(renamed.Select(c => c.Key)) .Concat(renamedEntries.Select(c => c.Key))
.Concat(remaining) .Concat(remainingEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray(); .ToArray();
_ = _fileDbManager.GetFileCachesByPaths(allChanges); _ = _fileDbManager.GetFileCachesByPaths(allChanges);
await _fileDbManager.WriteOutFullCsvAsync(token).ConfigureAwait(false);
} _fileDbManager.WriteOutFullCsv();
finally
{
_dbGate.Release();
} }
} }
@@ -367,97 +347,77 @@ 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
{ {
await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(false); do
while (IsHalted() && !token.IsCancellationRequested) {
await Task.Delay(250, token).ConfigureAwait(false); await Task.Delay(delay, token).ConfigureAwait(false);
} while (HaltScanLocks.Any(f => f.Value > 0));
}
catch (TaskCanceledException)
{
return;
} }
catch (TaskCanceledException) { return; }
var changes = DrainPenumbraChanges(); lock (_watcherChanges)
if (changes.Count > 0) {
_ = HandleChangesAsync(changes, token); foreach (var key in changes.Keys)
{
_watcherChanges.Remove(key);
}
}
HandleChanges(changes);
} }
public void InvokeScan() public void InvokeScan()
{ {
TotalFiles = 0; TotalFiles = 0;
TotalFilesStorage = 0; _currentFileProgress = 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 () =>
{ {
TaskCompletionSource scanTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); Logger.LogDebug("Starting Full File Scan");
TotalFiles = 0;
try _currentFileProgress = 0;
while (_dalamudUtil.IsOnFrameworkThread)
{ {
Logger.LogDebug("Starting Full File Scan"); Logger.LogWarning("Scanner is on framework, waiting for leaving thread before continuing");
await Task.Delay(250, token).ConfigureAwait(false);
}
while (IsHalted() && !token.IsCancellationRequested) Thread scanThread = new(() =>
{
try
{ {
Logger.LogDebug("Scan is halted, waiting..."); _performanceCollector.LogPerformance(this, $"FullFileScan", () => FullFileScan(token));
await Task.Delay(250, token).ConfigureAwait(false);
} }
catch (Exception ex)
var scanThread = new Thread(() =>
{ {
try Logger.LogError(ex, "Error during Full File Scan");
{ }
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."); Priority = ThreadPriority.Lowest,
} IsBackground = true
catch (Exception ex) };
scanThread.Start();
while (scanThread.IsAlive)
{ {
Logger.LogError(ex, "Unexpected error in InvokeScan task"); await Task.Delay(250, token).ConfigureAwait(false);
}
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))
{ {
@@ -634,20 +594,10 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public void ResumeScan(string source) public void ResumeScan(string source)
{ {
int delta = 0; if (!HaltScanLocks.ContainsKey(source)) HaltScanLocks[source] = 0;
HaltScanLocks.AddOrUpdate(source, HaltScanLocks[source]--;
addValueFactory: _ => 0, if (HaltScanLocks[source] < 0) HaltScanLocks[source] = 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)
@@ -671,243 +621,235 @@ 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;
var cacheFolder = _configService.Current.CacheFolder; bool penDirExists = true;
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 prevPriority = Thread.CurrentThread.Priority; var previousThreadPriority = 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);
try Dictionary<string, string[]> penumbraFiles = new(StringComparer.Ordinal);
foreach (var folder in Directory.EnumerateDirectories(penumbraDir!))
{ {
Logger.LogDebug("Getting files from {penumbra} and {storage}", penumbraDir, cacheFolder); try
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))
{ {
ct.ThrowIfCancellationRequested(); penumbraFiles[folder] =
[
try .. Directory.GetFiles(folder, "*.*", SearchOption.AllDirectories)
{ .AsParallel()
foreach (var file in Directory.EnumerateFiles(folder, "*.*", SearchOption.AllDirectories)) .Where(f => HasAllowedExtension(f)
{ && !f.Contains(@"\bg\", StringComparison.OrdinalIgnoreCase)
ct.ThrowIfCancellationRequested(); && !f.Contains(@"\bgcommon\", StringComparison.OrdinalIgnoreCase)
&& !f.Contains(@"\ui\", StringComparison.OrdinalIgnoreCase)),
if (!HasAllowedExtension(file)) continue; ];
if (IsExcludedPenumbraPath(file)) continue;
onDiskPaths.Add(file);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Could not enumerate path {path}", folder);
}
} }
catch (Exception ex)
foreach (var file in Directory.EnumerateFiles(cacheFolder, "*.*", SearchOption.TopDirectoryOnly))
{ {
ct.ThrowIfCancellationRequested(); Logger.LogWarning(ex, "Could not enumerate path {path}", folder);
var name = Path.GetFileName(file);
var stem = Path.GetFileNameWithoutExtension(file);
if (name.Length == 40 || stem.Length == 40)
onDiskPaths.Add(file);
} }
Thread.Sleep(50);
var threadCount = Math.Clamp((int)(Environment.ProcessorCount / 2.0f), 2, 8);
var fileCacheList = _fileDbManager.GetAllFileCaches();
var fileCaches = new ConcurrentQueue<FileCacheEntity>(fileCacheList);
TotalFilesStorage = fileCaches.Count;
TotalFiles = onDiskPaths.Count + TotalFilesStorage;
var validOrPresentInDb = new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
var entitiesToUpdate = new ConcurrentBag<FileCacheEntity>();
var entitiesToRemove = new ConcurrentBag<FileCacheEntity>();
if (!_ipcManager.Penumbra.APIAvailable)
{
Logger.LogWarning("Penumbra not available");
return;
}
Thread[] workerThreads = new Thread[threadCount];
for (int i = 0; i < threadCount; i++)
{
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);
}
while (!ct.IsCancellationRequested && workerThreads.Any(t => t.IsAlive))
{
ct.WaitHandle.WaitOne(250);
}
if (ct.IsCancellationRequested) return; if (ct.IsCancellationRequested) return;
}
Logger.LogTrace("Scanner validated existing db files"); 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 (!_ipcManager.Penumbra.APIAvailable) 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) =>
{ {
Logger.LogWarning("Penumbra not available"); var threadNr = (int)tcounter!;
return; Logger.LogTrace("Spawning Worker Thread {i}", threadNr);
} while (!ct.IsCancellationRequested && fileCaches.TryDequeue(out var workload))
{
try
{
if (ct.IsCancellationRequested) return;
var didMutateDb = false; if (!_ipcManager.Penumbra.APIAvailable)
{
Logger.LogWarning("Penumbra not available");
return;
}
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); }
}
}
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) foreach (var entity in entitiesToUpdate)
{ {
didMutateDb = true;
_fileDbManager.UpdateHashedFile(entity); _fileDbManager.UpdateHashedFile(entity);
} }
foreach (var entity in entitiesToRemove) foreach (var entity in entitiesToRemove)
{ {
didMutateDb = true;
_fileDbManager.RemoveHashedFile(entity.Hash, entity.PrefixedFilePath); _fileDbManager.RemoveHashedFile(entity.Hash, entity.PrefixedFilePath);
} }
if (didMutateDb) _fileDbManager.WriteOutFullCsv();
_fileDbManager.WriteOutFullCsv(); }
if (ct.IsCancellationRequested) return; Logger.LogTrace("Scanner validated existing db files");
var newFiles = onDiskPaths.Where(p => !validOrPresentInDb.ContainsKey(p)).ToList(); if (!_ipcManager.Penumbra.APIAvailable)
{
Logger.LogWarning("Penumbra not available");
return;
}
foreach (var path in newFiles) 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)
{ {
if (ct.IsCancellationRequested) break; Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}",
ProcessOne(path); _fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
Interlocked.Increment(ref _currentFileProgress); return;
} }
Logger.LogTrace("Scanner added {count} new files to db", newFiles.Count); if (!_ipcManager.Penumbra.APIAvailable)
void ProcessOne(string? filePath)
{ {
if (filePath == null) Logger.LogWarning("Penumbra not available");
return; 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"); try
TotalFiles = 0;
_currentFileProgress = 0;
if (!_configService.Current.InitialScanComplete)
{ {
_configService.Current.InitialScanComplete = true; var entry = _fileDbManager.CreateFileEntry(cachePath);
_configService.Save(); if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath);
}
StartLightlessWatcher(cacheFolder); catch (IOException ioex)
StartPenumbraWatcher(penumbraDir); {
Logger.LogDebug(ioex, "File busy or locked: {file}", cachePath);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed adding {file}", cachePath);
} }
} }
catch (OperationCanceledException)
Logger.LogDebug("Scan complete");
TotalFiles = 0;
_currentFileProgress = 0;
entitiesToRemove.Clear();
allScannedFiles.Clear();
if (!_configService.Current.InitialScanComplete)
{ {
// normal cancellation _configService.Current.InitialScanComplete = true;
} _configService.Save();
catch (Exception ex) StartLightlessWatcher(_configService.Current.CacheFolder);
{ 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, bool enqueueCompaction = true) public async Task WriteAllBytesAsync(string filePath, byte[] bytes, CancellationToken token)
{ {
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,12 +221,6 @@ 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 = handler.Address; var address = (nint)handler.Address;
if (address != nint.Zero) if (address != nint.Zero)
{ {
tempMap[address] = handler; tempMap[address] = handler;

View File

@@ -81,10 +81,7 @@ 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, "Player"); => _collections.SetTemporaryModsAsync(logger, applicationId, collectionId, modPaths);
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,43 +92,25 @@ public sealed class PenumbraCollections : PenumbraBase
_activeTemporaryCollections.TryRemove(collectionId, out _); _activeTemporaryCollections.TryRemove(collectionId, out _);
} }
public async Task SetTemporaryModsAsync( public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths)
ILogger logger,
Guid applicationId,
Guid collectionId,
Dictionary<string, string> modPaths,
string scope)
{ {
if (!IsAvailable || collectionId == Guid.Empty) 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)) return;
continue;
var gamePath = kvp.Key.Replace('\\', '/').ToLowerInvariant();
normalized[gamePath] = kvp.Value;
} }
await DalamudUtil.RunOnFrameworkThread(() => await DalamudUtil.RunOnFrameworkThread(() =>
{ {
foreach (var mod in normalized) foreach (var mod in modPaths)
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(modName, collectionId, 0); var removeResult = _removeTemporaryMod.Invoke("LightlessChara_Files", collectionId, 0);
logger.LogTrace("[{ApplicationId}] Removing temp mod {ModName} for {CollectionId}, Success: {Result}", logger.LogTrace("[{ApplicationId}] Removing temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, removeResult);
applicationId, modName, collectionId, removeResult);
if (normalized.Count == 0) var addResult = _addTemporaryMod.Invoke("LightlessChara_Files", collectionId, modPaths, string.Empty, 0);
return; logger.LogTrace("[{ApplicationId}] Setting temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, addResult);
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);
} }
@@ -189,7 +171,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 = _removeTemporaryCollection.Invoke(collectionId); var result = (PenumbraApiEc)_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,8 +158,9 @@ 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 AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Unsafe; public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
public bool AnimationAllowOneBasedShift { get; set; } = false; public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Safe;
public bool AnimationAllowOneBasedShift { get; set; } = true;
public bool AnimationAllowNeighborIndexTolerance { get; set; } = false; public bool AnimationAllowNeighborIndexTolerance { get; set; } = false;
} }

View File

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

View File

@@ -1,14 +0,0 @@
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

@@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<Authors></Authors> <Authors></Authors>
<Company></Company> <Company></Company>
<Version>2.0.3</Version> <Version>2.0.2.76</Version>
<Description></Description> <Description></Description>
<Copyright></Copyright> <Copyright></Copyright>
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl> <PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>

View File

@@ -1,5 +1,4 @@
using Dalamud.Plugin.Services; using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Enum;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
@@ -12,7 +11,6 @@ public class GameObjectHandlerFactory
{ {
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly IObjectTable _objectTable;
private readonly LightlessMediator _lightlessMediator; private readonly LightlessMediator _lightlessMediator;
private readonly PerformanceCollectorService _performanceCollectorService; private readonly PerformanceCollectorService _performanceCollectorService;
@@ -20,14 +18,12 @@ public class GameObjectHandlerFactory
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
PerformanceCollectorService performanceCollectorService, PerformanceCollectorService performanceCollectorService,
LightlessMediator lightlessMediator, LightlessMediator lightlessMediator,
IServiceProvider serviceProvider, IServiceProvider serviceProvider)
IObjectTable objectTable)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_performanceCollectorService = performanceCollectorService; _performanceCollectorService = performanceCollectorService;
_lightlessMediator = lightlessMediator; _lightlessMediator = lightlessMediator;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_objectTable = objectTable;
} }
public async Task<GameObjectHandler> Create(ObjectKind objectKind, Func<nint> getAddressFunc, bool isWatched = false) public async Task<GameObjectHandler> Create(ObjectKind objectKind, Func<nint> getAddressFunc, bool isWatched = false)
@@ -40,7 +36,6 @@ public class GameObjectHandlerFactory
dalamudUtilService, dalamudUtilService,
objectKind, objectKind,
getAddressFunc, getAddressFunc,
_objectTable,
isWatched)).ConfigureAwait(false); isWatched)).ConfigureAwait(false);
} }
} }

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 FFXIVClientStructs.FFXIV.Client.Game.Object; using LightlessSync.API.Data.Enum;
using LightlessSync.FileCache; using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc; using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
@@ -9,12 +9,11 @@ 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;
@@ -120,28 +119,45 @@ 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(() => CheckForNullDrawObjectSafe(playerPointer)) => await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
.ConfigureAwait(false);
private static bool CheckForNullDrawObjectSafe(nint playerPointer) private unsafe static bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
{ {
if (playerPointer == nint.Zero) if (playerPointer == IntPtr.Zero)
return true; return true;
var drawObjPtrAddress = playerPointer + _characterGameObjectOffset + _gameObjectDrawObjectOffset; if (!IsPointerValid(playerPointer))
// Read the DrawObject pointer from memory
if (!MemoryProcessProbe.TryReadIntPtr(drawObjPtrAddress, out var drawObj))
return true; return true;
return drawObj == nint.Zero; 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;
}
} }
private static bool IsCacheFresh(CacheEntry entry) private static bool IsCacheFresh(CacheEntry entry)
@@ -157,7 +173,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, valueFactory: k => BuildAndCacheAsync(obj, k)); var buildTask = _characterBuildInflight.GetOrAdd(key, _ => BuildAndCacheAsync(obj, key));
if (_characterBuildCache.TryGetValue(key, out cached)) if (_characterBuildCache.TryGetValue(key, out cached))
{ {

View File

@@ -1,68 +1,45 @@
using Dalamud.Game.ClientState.Objects.Enums; using FFXIVClientStructs.FFXIV.Client.Game.Character;
using Dalamud.Game.ClientState.Objects.Types; using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using Dalamud.Plugin.Services;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Runtime.CompilerServices;
using static FFXIVClientStructs.FFXIV.Client.Game.Character.DrawDataContainer;
using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.PlayerData.Handlers; namespace LightlessSync.PlayerData.Handlers;
/// <summary>
/// Game object handler for managing game object state and updates
/// </summary>
public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighPriorityMediatorSubscriber public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighPriorityMediatorSubscriber
{ {
private readonly DalamudUtilService _dalamudUtil; private readonly DalamudUtilService _dalamudUtil;
private readonly IObjectTable _objectTable;
private readonly Func<IntPtr> _getAddress; private readonly Func<IntPtr> _getAddress;
private readonly bool _isOwnedObject; private readonly bool _isOwnedObject;
private readonly PerformanceCollectorService _performanceCollector; private readonly PerformanceCollectorService _performanceCollector;
private readonly Lock _frameworkUpdateGate = new(); private readonly object _frameworkUpdateGate = new();
private bool _frameworkUpdateSubscribed; private bool _frameworkUpdateSubscribed;
private byte _classJob = 0; private byte _classJob = 0;
private Task? _delayedZoningTask; private Task? _delayedZoningTask;
private bool _haltProcessing = false; private bool _haltProcessing = false;
private CancellationTokenSource _zoningCts = new(); private CancellationTokenSource _zoningCts = new();
/// <summary> public GameObjectHandler(ILogger<GameObjectHandler> logger, PerformanceCollectorService performanceCollector,
/// Constructor for GameObjectHandler LightlessMediator mediator, DalamudUtilService dalamudUtil, ObjectKind objectKind, Func<IntPtr> getAddress, bool ownedObject = true) : base(logger, mediator)
/// </summary>
/// <param name="logger">Logger</param>
/// <param name="performanceCollector">Performance Collector</param>
/// <param name="mediator">Lightless Mediator</param>
/// <param name="dalamudUtil">Dalamud Utilties Service</param>
/// <param name="objectKind">Object kind of Object</param>
/// <param name="getAddress">Get Adress</param>
/// <param name="objectTable">Object table of Dalamud</param>
/// <param name="ownedObject">Object is owned by user</param>
public GameObjectHandler(
ILogger<GameObjectHandler> logger,
PerformanceCollectorService performanceCollector,
LightlessMediator mediator,
DalamudUtilService dalamudUtil,
ObjectKind objectKind,
Func<IntPtr> getAddress,
IObjectTable objectTable,
bool ownedObject = true) : base(logger, mediator)
{ {
_performanceCollector = performanceCollector; _performanceCollector = performanceCollector;
ObjectKind = objectKind; ObjectKind = objectKind;
_dalamudUtil = dalamudUtil; _dalamudUtil = dalamudUtil;
_objectTable = objectTable;
_getAddress = () => _getAddress = () =>
{ {
_dalamudUtil.EnsureIsOnFramework(); _dalamudUtil.EnsureIsOnFramework();
return getAddress.Invoke(); return getAddress.Invoke();
}; };
_isOwnedObject = ownedObject; _isOwnedObject = ownedObject;
Name = string.Empty; Name = string.Empty;
if (ownedObject) if (ownedObject)
{ {
Mediator.Subscribe<TransientResourceChangedMessage>(this, msg => Mediator.Subscribe<TransientResourceChangedMessage>(this, (msg) =>
{ {
if (_delayedZoningTask?.IsCompleted ?? true) if (_delayedZoningTask?.IsCompleted ?? true)
{ {
@@ -72,36 +49,43 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
}); });
} }
EnableFrameworkUpdates(); if (_isOwnedObject)
{
EnableFrameworkUpdates();
}
Mediator.Subscribe<ZoneSwitchEndMessage>(this, _ => ZoneSwitchEnd()); Mediator.Subscribe<ZoneSwitchEndMessage>(this, (_) => ZoneSwitchEnd());
Mediator.Subscribe<ZoneSwitchStartMessage>(this, _ => ZoneSwitchStart()); Mediator.Subscribe<ZoneSwitchStartMessage>(this, (_) => ZoneSwitchStart());
Mediator.Subscribe<CutsceneStartMessage>(this, _ => _haltProcessing = true); Mediator.Subscribe<CutsceneStartMessage>(this, (_) =>
Mediator.Subscribe<CutsceneEndMessage>(this, _ => {
_haltProcessing = true;
});
Mediator.Subscribe<CutsceneEndMessage>(this, (_) =>
{ {
_haltProcessing = false; _haltProcessing = false;
ZoneSwitchEnd(); ZoneSwitchEnd();
}); });
Mediator.Subscribe<PenumbraStartRedrawMessage>(this, (msg) =>
Mediator.Subscribe<PenumbraStartRedrawMessage>(this, msg =>
{ {
if (msg.Address == Address) _haltProcessing = true; if (msg.Address == Address)
{
_haltProcessing = true;
}
}); });
Mediator.Subscribe<PenumbraEndRedrawMessage>(this, msg => Mediator.Subscribe<PenumbraEndRedrawMessage>(this, (msg) =>
{ {
if (msg.Address == Address) _haltProcessing = false; if (msg.Address == Address)
{
_haltProcessing = false;
}
}); });
Mediator.Publish(new GameObjectHandlerCreatedMessage(this, _isOwnedObject)); Mediator.Publish(new GameObjectHandlerCreatedMessage(this, _isOwnedObject));
_dalamudUtil.EnsureIsOnFramework(); _dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult();
CheckAndUpdateObject(allowPublish: true);
} }
/// <summary>
/// Draw Condition Enum
/// </summary>
public enum DrawCondition public enum DrawCondition
{ {
None, None,
@@ -112,7 +96,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
ModelFilesInSlotLoaded ModelFilesInSlotLoaded
} }
// Properties
public IntPtr Address { get; private set; } public IntPtr Address { get; private set; }
public DrawCondition CurrentDrawCondition { get; set; } = DrawCondition.None; public DrawCondition CurrentDrawCondition { get; set; } = DrawCondition.None;
public byte Gender { get; private set; } public byte Gender { get; private set; }
@@ -123,21 +106,18 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
public byte TribeId { get; private set; } public byte TribeId { get; private set; }
private byte[] CustomizeData { get; set; } = new byte[26]; private byte[] CustomizeData { get; set; } = new byte[26];
private IntPtr DrawObjectAddress { get; set; } private IntPtr DrawObjectAddress { get; set; }
private byte[] EquipSlotData { get; set; } = new byte[40];
private ushort[] MainHandData { get; set; } = new ushort[3];
private ushort[] OffHandData { get; set; } = new ushort[3];
/// <summary> public async Task ActOnFrameworkAfterEnsureNoDrawAsync(Action<Dalamud.Game.ClientState.Objects.Types.ICharacter> act, CancellationToken token)
/// Act on framework thread after ensuring no draw condition
/// </summary>
/// <param name="act">Action of Character</param>
/// <param name="token">Cancellation Token</param>
/// <returns>Task Completion</returns>
public async Task ActOnFrameworkAfterEnsureNoDrawAsync(Action<ICharacter> act, CancellationToken token)
{ {
while (await _dalamudUtil.RunOnFrameworkThread(() => while (await _dalamudUtil.RunOnFrameworkThread(() =>
{ {
EnsureLatestObjectState(); EnsureLatestObjectState();
if (CurrentDrawCondition != DrawCondition.None) return true; if (CurrentDrawCondition != DrawCondition.None) return true;
var gameObj = _dalamudUtil.CreateGameObject(Address); var gameObj = _dalamudUtil.CreateGameObject(Address);
if (gameObj is ICharacter chara) if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter chara)
{ {
act.Invoke(chara); act.Invoke(chara);
} }
@@ -148,11 +128,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
} }
} }
/// <summary>
/// Compare Name And Throw if not equal
/// </summary>
/// <param name="name">Name that will be compared to Object Handler.</param>
/// <exception cref="InvalidOperationException">Not equal if thrown</exception>
public void CompareNameAndThrow(string name) public void CompareNameAndThrow(string name)
{ {
if (!string.Equals(Name, name, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(Name, name, StringComparison.OrdinalIgnoreCase))
@@ -165,18 +140,11 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
} }
} }
/// <summary> public Dalamud.Game.ClientState.Objects.Types.IGameObject? GetGameObject()
/// Gets the game object from the address
/// </summary>
/// <returns>Gane object</returns>
public IGameObject? GetGameObject()
{ {
return _dalamudUtil.CreateGameObject(Address); return _dalamudUtil.CreateGameObject(Address);
} }
/// <summary>
/// Invalidate the object handler
/// </summary>
public void Invalidate() public void Invalidate()
{ {
Address = IntPtr.Zero; Address = IntPtr.Zero;
@@ -185,62 +153,25 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
_haltProcessing = false; _haltProcessing = false;
} }
/// <summary>
/// Refresh the object handler state
/// </summary>
public void Refresh() public void Refresh()
{ {
_dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult(); _dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult();
} }
/// <summary>
/// Is Being Drawn Run On Framework Asyncronously
/// </summary>
/// <returns>Object is being run in framework</returns>
public async Task<bool> IsBeingDrawnRunOnFrameworkAsync() public async Task<bool> IsBeingDrawnRunOnFrameworkAsync()
{ {
return await _dalamudUtil.RunOnFrameworkThread(IsBeingDrawn).ConfigureAwait(false); return await _dalamudUtil.RunOnFrameworkThread(IsBeingDrawn).ConfigureAwait(false);
} }
/// <summary>
/// Override ToString method for GameObjectHandler
/// </summary>
/// <returns>String</returns>
public override string ToString() public override string ToString()
{ {
var owned = _isOwnedObject ? "Self" : "Other"; var owned = _isOwnedObject ? "Self" : "Other";
return $"{owned}/{ObjectKind}:{Name} ({Address:X},{DrawObjectAddress:X})"; return $"{owned}/{ObjectKind}:{Name} ({Address:X},{DrawObjectAddress:X})";
} }
/// <summary>
/// Try Get Object By Address from Object Table
/// </summary>
/// <param name="address">Object address</param>
/// <returns>Game Object of adress</returns>
private IGameObject? TryGetObjectByAddress(nint address)
{
if (address == nint.Zero) return null;
// Search object table
foreach (var obj in _objectTable)
{
if (obj is null) continue;
if (obj.Address == address)
return obj;
}
return null;
}
/// <summary>
/// Checks and updates the object state
/// </summary>
private void CheckAndUpdateObject() => CheckAndUpdateObject(allowPublish: true); private void CheckAndUpdateObject() => CheckAndUpdateObject(allowPublish: true);
/// <summary> private unsafe void CheckAndUpdateObject(bool allowPublish)
/// Checks and updates the object state with option to allow publish
/// </summary>
/// <param name="allowPublish">Allows to publish the object</param>
private void CheckAndUpdateObject(bool allowPublish)
{ {
var prevAddr = Address; var prevAddr = Address;
var prevDrawObj = DrawObjectAddress; var prevDrawObj = DrawObjectAddress;
@@ -248,140 +179,127 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
Address = _getAddress(); Address = _getAddress();
IGameObject? obj = null; if (Address != IntPtr.Zero)
ICharacter? chara = null;
if (Address != nint.Zero)
{ {
// Try get object var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address;
obj = TryGetObjectByAddress(Address); DrawObjectAddress = (IntPtr)gameObject->DrawObject;
EntityId = gameObject->EntityId;
if (obj is not null) var chara = (Character*)Address;
{ nameString = chara->GameObject.NameString;
EntityId = obj.EntityId; if (!string.IsNullOrEmpty(nameString) && !string.Equals(nameString, Name, StringComparison.Ordinal))
Name = nameString;
DrawObjectAddress = Address;
// Name update
nameString = obj.Name.TextValue ?? string.Empty;
if (!string.IsNullOrEmpty(nameString) && !string.Equals(nameString, Name, StringComparison.Ordinal))
Name = nameString;
chara = obj as ICharacter;
}
else
{
DrawObjectAddress = nint.Zero;
EntityId = uint.MaxValue;
}
} }
else else
{ {
DrawObjectAddress = nint.Zero; DrawObjectAddress = IntPtr.Zero;
EntityId = uint.MaxValue; EntityId = uint.MaxValue;
} }
// Update draw condition CurrentDrawCondition = IsBeingDrawnUnsafe();
CurrentDrawCondition = IsBeingDrawnSafe(obj, chara);
if (_haltProcessing || !allowPublish) return; if (_haltProcessing || !allowPublish) return;
// Determine differences
bool drawObjDiff = DrawObjectAddress != prevDrawObj; bool drawObjDiff = DrawObjectAddress != prevDrawObj;
bool addrDiff = Address != prevAddr; bool addrDiff = Address != prevAddr;
// Name change check if (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero)
bool nameChange = false;
if (nameString is not null)
{ {
nameChange = !string.Equals(nameString, Name, StringComparison.Ordinal); var chara = (Character*)Address;
if (nameChange) Name = nameString; var drawObj = (DrawObject*)DrawObjectAddress;
} var objType = drawObj->Object.GetObjectType();
var isHuman = objType == ObjectType.CharacterBase
&& ((CharacterBase*)drawObj)->GetModelType() == CharacterBase.ModelType.Human;
// Customize data change check nameString ??= ((Character*)Address)->GameObject.NameString;
bool customizeDiff = false; var nameChange = !string.Equals(nameString, Name, StringComparison.Ordinal);
if (chara is not null) if (nameChange) Name = nameString;
{
// Class job change check bool equipDiff = false;
var classJob = chara.ClassJob.RowId;
if (classJob != _classJob) if (isHuman)
{ {
Logger.LogTrace("[{this}] classjob changed from {old} to {new}", this, _classJob, classJob); var classJob = chara->CharacterData.ClassJob;
_classJob = (byte)classJob; if (classJob != _classJob)
Mediator.Publish(new ClassJobChangedMessage(this)); {
Logger.LogTrace("[{this}] classjob changed from {old} to {new}", this, _classJob, classJob);
_classJob = classJob;
Mediator.Publish(new ClassJobChangedMessage(this));
}
equipDiff = CompareAndUpdateEquipByteData((byte*)&((Human*)drawObj)->Head);
ref var mh = ref chara->DrawData.Weapon(WeaponSlot.MainHand);
ref var oh = ref chara->DrawData.Weapon(WeaponSlot.OffHand);
equipDiff |= CompareAndUpdateMainHand((Weapon*)mh.DrawObject);
equipDiff |= CompareAndUpdateOffHand((Weapon*)oh.DrawObject);
if (equipDiff)
Logger.LogTrace("Checking [{this}] equip data as human from draw obj, result: {diff}", this, equipDiff);
}
else
{
equipDiff = CompareAndUpdateEquipByteData((byte*)Unsafe.AsPointer(ref chara->DrawData.EquipmentModelIds[0]));
if (equipDiff)
Logger.LogTrace("Checking [{this}] equip data from game obj, result: {diff}", this, equipDiff);
} }
// Customize data comparison if (equipDiff && !_isOwnedObject) // send the message out immediately and cancel out, no reason to continue if not self
customizeDiff = CompareAndUpdateCustomizeData(chara.Customize);
// Census update publish
if (_isOwnedObject && ObjectKind == ObjectKind.Player && chara.Customize.Length > (int)CustomizeIndex.Tribe)
{ {
var gender = chara.Customize[(int)CustomizeIndex.Gender]; Logger.LogTrace("[{this}] Changed", this);
var raceId = chara.Customize[(int)CustomizeIndex.Race]; return;
var tribeId = chara.Customize[(int)CustomizeIndex.Tribe]; }
if (gender != Gender || raceId != RaceId || tribeId != TribeId) bool customizeDiff = false;
if (isHuman)
{
var gender = ((Human*)drawObj)->Customize.Sex;
var raceId = ((Human*)drawObj)->Customize.Race;
var tribeId = ((Human*)drawObj)->Customize.Tribe;
if (_isOwnedObject && ObjectKind == ObjectKind.Player
&& (gender != Gender || raceId != RaceId || tribeId != TribeId))
{ {
Mediator.Publish(new CensusUpdateMessage(gender, raceId, tribeId)); Mediator.Publish(new CensusUpdateMessage(gender, raceId, tribeId));
Gender = gender; Gender = gender;
RaceId = raceId; RaceId = raceId;
TribeId = tribeId; TribeId = tribeId;
} }
customizeDiff = CompareAndUpdateCustomizeData(((Human*)drawObj)->Customize.Data);
if (customizeDiff)
Logger.LogTrace("Checking [{this}] customize data as human from draw obj, result: {diff}", this, customizeDiff);
}
else
{
customizeDiff = CompareAndUpdateCustomizeData(chara->DrawData.CustomizeData.Data);
if (customizeDiff)
Logger.LogTrace("Checking [{this}] customize data from game obj, result: {diff}", this, equipDiff);
} }
}
if ((addrDiff || drawObjDiff || equipDiff || customizeDiff || nameChange) && _isOwnedObject)
if ((addrDiff || drawObjDiff || customizeDiff || nameChange) && _isOwnedObject) {
{ Logger.LogDebug("[{this}] Changed, Sending CreateCacheObjectMessage", this);
Logger.LogDebug("[{this}] Changed, Sending CreateCacheObjectMessage", this); Mediator.Publish(new CreateCacheForObjectMessage(this));
Mediator.Publish(new CreateCacheForObjectMessage(this)); }
} }
else if (addrDiff || drawObjDiff) else if (addrDiff || drawObjDiff)
{ {
if (Address == nint.Zero) CurrentDrawCondition = DrawCondition.DrawObjectZero;
CurrentDrawCondition = DrawCondition.ObjectZero;
else if (DrawObjectAddress == nint.Zero)
CurrentDrawCondition = DrawCondition.DrawObjectZero;
Logger.LogTrace("[{this}] Changed", this); Logger.LogTrace("[{this}] Changed", this);
if (_isOwnedObject && ObjectKind != ObjectKind.Player) if (_isOwnedObject && ObjectKind != ObjectKind.Player)
{
Mediator.Publish(new ClearCacheForObjectMessage(this)); Mediator.Publish(new ClearCacheForObjectMessage(this));
}
} }
} }
/// <summary> private unsafe bool CompareAndUpdateCustomizeData(Span<byte> customizeData)
/// Is object being drawn safe check
/// </summary>
/// <param name="obj">Object thats being checked</param>
/// <param name="chara">Character of the object</param>
/// <returns>Draw Condition of character</returns>
private DrawCondition IsBeingDrawnSafe(IGameObject? obj, ICharacter? chara)
{
// Object zero check
if (Address == nint.Zero) return DrawCondition.ObjectZero;
if (obj is null) return DrawCondition.DrawObjectZero;
// Draw Object check
if (chara is not null && (chara.Customize is null || chara.Customize.Length == 0))
return DrawCondition.DrawObjectZero;
return DrawCondition.None;
}
/// <summary>
/// Compare and update customize data of character
/// </summary>
/// <param name="customizeData">Customize+ data of object</param>
/// <returns>Successfully applied or not</returns>
private bool CompareAndUpdateCustomizeData(ReadOnlySpan<byte> customizeData)
{ {
bool hasChanges = false; bool hasChanges = false;
// Resize if needed for (int i = 0; i < customizeData.Length; i++)
var len = Math.Min(customizeData.Length, CustomizeData.Length);
for (int i = 0; i < len; i++)
{ {
var data = customizeData[i]; var data = customizeData[i];
if (CustomizeData[i] != data) if (CustomizeData[i] != data)
@@ -394,9 +312,48 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
return hasChanges; return hasChanges;
} }
/// <summary> private unsafe bool CompareAndUpdateEquipByteData(byte* equipSlotData)
/// Framework update method {
/// </summary> bool hasChanges = false;
for (int i = 0; i < EquipSlotData.Length; i++)
{
var data = equipSlotData[i];
if (EquipSlotData[i] != data)
{
EquipSlotData[i] = data;
hasChanges = true;
}
}
return hasChanges;
}
private unsafe bool CompareAndUpdateMainHand(Weapon* weapon)
{
if ((nint)weapon == nint.Zero) return false;
bool hasChanges = false;
hasChanges |= weapon->ModelSetId != MainHandData[0];
MainHandData[0] = weapon->ModelSetId;
hasChanges |= weapon->Variant != MainHandData[1];
MainHandData[1] = weapon->Variant;
hasChanges |= weapon->SecondaryId != MainHandData[2];
MainHandData[2] = weapon->SecondaryId;
return hasChanges;
}
private unsafe bool CompareAndUpdateOffHand(Weapon* weapon)
{
if ((nint)weapon == nint.Zero) return false;
bool hasChanges = false;
hasChanges |= weapon->ModelSetId != OffHandData[0];
OffHandData[0] = weapon->ModelSetId;
hasChanges |= weapon->Variant != OffHandData[1];
OffHandData[1] = weapon->Variant;
hasChanges |= weapon->SecondaryId != OffHandData[2];
OffHandData[2] = weapon->SecondaryId;
return hasChanges;
}
private void FrameworkUpdate() private void FrameworkUpdate()
{ {
try try
@@ -410,10 +367,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
} }
} }
/// <summary>
/// Is object being drawn check
/// </summary>
/// <returns>Is being drawn</returns>
private bool IsBeingDrawn() private bool IsBeingDrawn()
{ {
EnsureLatestObjectState(); EnsureLatestObjectState();
@@ -428,9 +381,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
return CurrentDrawCondition != DrawCondition.None; return CurrentDrawCondition != DrawCondition.None;
} }
/// <summary>
/// Ensures the latest object state
/// </summary>
private void EnsureLatestObjectState() private void EnsureLatestObjectState()
{ {
if (_haltProcessing || !_frameworkUpdateSubscribed) if (_haltProcessing || !_frameworkUpdateSubscribed)
@@ -439,9 +389,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
} }
} }
/// <summary>
/// Enables framework updates for the object handler
/// </summary>
private void EnableFrameworkUpdates() private void EnableFrameworkUpdates()
{ {
lock (_frameworkUpdateGate) lock (_frameworkUpdateGate)
@@ -456,9 +403,24 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
} }
} }
/// <summary> private unsafe DrawCondition IsBeingDrawnUnsafe()
/// Zone switch end handling {
/// </summary> if (Address == IntPtr.Zero) return DrawCondition.ObjectZero;
if (DrawObjectAddress == IntPtr.Zero) return DrawCondition.DrawObjectZero;
var visibilityFlags = ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address)->RenderFlags;
if (visibilityFlags != VisibilityFlags.None) return DrawCondition.RenderFlags;
if (ObjectKind == ObjectKind.Player)
{
var modelInSlotLoaded = (((CharacterBase*)DrawObjectAddress)->HasModelInSlotLoaded != 0);
if (modelInSlotLoaded) return DrawCondition.ModelInSlotLoaded;
var modelFilesInSlotLoaded = (((CharacterBase*)DrawObjectAddress)->HasModelFilesInSlotLoaded != 0);
if (modelFilesInSlotLoaded) return DrawCondition.ModelFilesInSlotLoaded;
}
return DrawCondition.None;
}
private void ZoneSwitchEnd() private void ZoneSwitchEnd()
{ {
if (!_isOwnedObject) return; if (!_isOwnedObject) return;
@@ -469,7 +431,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
} }
catch (ObjectDisposedException) catch (ObjectDisposedException)
{ {
// ignore canelled after disposed // ignore
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -477,9 +439,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
} }
} }
/// <summary>
/// Zone switch start handling
/// </summary>
private void ZoneSwitchStart() private void ZoneSwitchStart()
{ {
if (!_isOwnedObject) return; if (!_isOwnedObject) return;

View File

@@ -1,461 +0,0 @@
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin.Services;
using LightlessSync.API.Data;
using LightlessSync.Interop.Ipc;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using Microsoft.Extensions.Logging;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.PlayerData.Handlers;
/// <summary>
/// Owned object handler for applying changes to owned objects.
/// </summary>
internal sealed class OwnedObjectHandler
{
// Debug information for owned object resolution
internal readonly record struct OwnedResolveDebug(
DateTime? ResolvedAtUtc,
nint Address,
ushort? ObjectIndex,
string Stage,
string? FailureReason)
{
public string? AddressHex => Address == nint.Zero ? null : $"0x{Address:X}";
public static OwnedResolveDebug Empty => new(null, nint.Zero, null, string.Empty, null);
}
private OwnedResolveDebug _minionResolveDebug = OwnedResolveDebug.Empty;
public OwnedResolveDebug MinionResolveDebug => _minionResolveDebug;
// Dependencies
private readonly ILogger _logger;
private readonly DalamudUtilService _dalamudUtil;
private readonly GameObjectHandlerFactory _handlerFactory;
private readonly IpcManager _ipc;
private readonly ActorObjectService _actorObjectService;
private readonly IObjectTable _objectTable;
// Timeouts for fully loaded checks
private const int _fullyLoadedTimeoutMsPlayer = 30000;
private const int _fullyLoadedTimeoutMsOther = 5000;
public OwnedObjectHandler(
ILogger logger,
DalamudUtilService dalamudUtil,
GameObjectHandlerFactory handlerFactory,
IpcManager ipc,
ActorObjectService actorObjectService,
IObjectTable objectTable)
{
_logger = logger;
_dalamudUtil = dalamudUtil;
_handlerFactory = handlerFactory;
_ipc = ipc;
_actorObjectService = actorObjectService;
_objectTable = objectTable;
}
/// <summary>
/// Applies the specified changes to the owned object of the given kind.
/// </summary>
/// <param name="applicationId">Application ID of the Character Object</param>
/// <param name="kind">Object Kind of the given object</param>
/// <param name="changes">Changes of the object</param>
/// <param name="data">Data of the object</param>
/// <param name="playerHandler">Owner of the object</param>
/// <param name="penumbraCollection">Collection if needed</param>
/// <param name="customizeIds">Customizing identications for the object</param>
/// <param name="token">Cancellation Token</param>
/// <returns>Successfully applied or not</returns>
public async Task<bool> ApplyAsync(
Guid applicationId,
ObjectKind kind,
HashSet<PlayerChanges> changes,
CharacterData data,
GameObjectHandler playerHandler,
Guid penumbraCollection,
Dictionary<ObjectKind, Guid?> customizeIds,
CancellationToken token)
{
// Validate player handler
if (playerHandler.Address == nint.Zero)
return false;
// Create handler for owned object
var handler = await CreateHandlerAsync(kind, playerHandler, token).ConfigureAwait(false);
if (handler is null || handler.Address == nint.Zero)
return false;
try
{
token.ThrowIfCancellationRequested();
// Determine if we have file replacements for this kind
bool hasFileReplacements =
kind != ObjectKind.Player
&& data.FileReplacements.TryGetValue(kind, out var repls)
&& repls is { Count: > 0 };
// Determine if we should assign a Penumbra collection
bool shouldAssignCollection =
kind != ObjectKind.Player
&& hasFileReplacements
&& penumbraCollection != Guid.Empty
&& _ipc.Penumbra.APIAvailable;
// Determine if only IPC-only changes are being made for player
bool isPlayerIpcOnly =
kind == ObjectKind.Player
&& changes.Count > 0
&& changes.All(c => c is PlayerChanges.Honorific
or PlayerChanges.Moodles
or PlayerChanges.PetNames
or PlayerChanges.Heels);
// Wait for drawing to complete
await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false);
// Determine timeouts
var drawTimeoutMs = handler.ObjectKind == ObjectKind.Player ? 30000 : 5000;
var fullyLoadedTimeoutMs = handler.ObjectKind == ObjectKind.Player ? _fullyLoadedTimeoutMsPlayer : _fullyLoadedTimeoutMsOther;
// Wait for drawing to complete
await _dalamudUtil
.WaitWhileCharacterIsDrawing(_logger, handler, applicationId, drawTimeoutMs, token)
.ConfigureAwait(false);
if (handler.Address != nint.Zero)
{
// Wait for fully loaded
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();
// Assign Penumbra collection if needed
if (shouldAssignCollection)
{
// Get object index
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;
}
// Assign collection
await _ipc.Penumbra
.AssignTemporaryCollectionAsync(_logger, penumbraCollection, objIndex.Value)
.ConfigureAwait(false);
}
var tasks = new List<Task>();
// Apply each change
foreach (var change in changes.OrderBy(c => (int)c))
{
token.ThrowIfCancellationRequested();
// Handle each change type
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;
}
}
// Await all tasks for change applications
if (tasks.Count > 0)
await Task.WhenAll(tasks).ConfigureAwait(false);
token.ThrowIfCancellationRequested();
// Determine if redraw is needed
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)
);
// Skip redraw for player if only IPC-only changes were made
if (isPlayerIpcOnly)
needsRedraw = false;
// Perform redraw if needed
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();
}
}
/// <summary>
/// Creates a GameObjectHandler for the owned object of the specified kind.
/// </summary>
/// <param name="kind">Object kind of the handler</param>
/// <param name="playerHandler">Owner of the given object</param>
/// <param name="token">Cancellation Token</param>
/// <returns>Handler for the GameObject with the handler</returns>
private async Task<GameObjectHandler?> CreateHandlerAsync(ObjectKind kind, GameObjectHandler playerHandler, CancellationToken token)
{
token.ThrowIfCancellationRequested();
// Debug info setter
void SetMinionDebug(string stage, string? failure, nint addr = default, ushort? objIndex = null)
{
if (kind != ObjectKind.MinionOrMount)
return;
_minionResolveDebug = new OwnedResolveDebug(
DateTime.UtcNow,
addr,
objIndex,
stage,
failure);
}
// Direct return for player
if (kind == ObjectKind.Player)
return playerHandler;
// First, try direct retrieval via Dalamud API
var playerPtr = playerHandler.Address;
if (playerPtr == nint.Zero)
{
SetMinionDebug("player_ptr_zero", "playerHandler.Address == 0");
return null;
}
// Try direct retrieval
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 that fails, scan the object table for owned objects
var stage = ownedPtr != nint.Zero ? "direct" : "direct_miss";
// Owner ID based scan
if (ownedPtr == nint.Zero)
{
token.ThrowIfCancellationRequested();
// Get owner entity ID
var ownerEntityId = playerHandler.EntityId;
if (ownerEntityId == 0 || ownerEntityId == uint.MaxValue)
{
// Read unsafe
ownerEntityId = await _dalamudUtil
.RunOnFrameworkThread(() => ReadEntityIdSafe(playerHandler))
.ConfigureAwait(false);
}
if (ownerEntityId != 0 && ownerEntityId != uint.MaxValue)
{
// Scan for owned object
ownedPtr = await _dalamudUtil
.RunOnFrameworkThread(() => FindOwnedByOwnerIdSafe(kind, ownerEntityId))
.ConfigureAwait(false);
stage = ownedPtr != nint.Zero ? "owner_scan" : "owner_scan_miss";
}
else
{
stage = "owner_id_unavailable";
}
}
if (ownedPtr == nint.Zero)
{
SetMinionDebug(stage, "ownedPtr == 0");
return null;
}
token.ThrowIfCancellationRequested();
// Create handler
var handler = await _handlerFactory.Create(kind, () => ownedPtr, isWatched: false).ConfigureAwait(false);
if (handler is null || handler.Address == nint.Zero)
{
SetMinionDebug(stage, "handlerFactory returned null/zero", ownedPtr);
return null;
}
// Get object index for debug
ushort? objIndex = await _dalamudUtil.RunOnFrameworkThread(() => handler.GetGameObject()?.ObjectIndex)
.ConfigureAwait(false);
SetMinionDebug(stage, failure: null, handler.Address, objIndex);
return handler;
}
/// <summary>
/// Entity ID reader with safety checks.
/// </summary>
/// <param name="playerHandler">Handler of the Object</param>
/// <returns>Entity Id</returns>
private static uint ReadEntityIdSafe(GameObjectHandler playerHandler) => playerHandler.GetGameObject()?.EntityId ?? 0;
/// <summary>
/// Finds an owned object by scanning the object table for the specified owner entity ID.
/// </summary>
/// <param name="kind">Object kind to find of owned object</param>
/// <param name="ownerEntityId">Owner Id</param>
/// <returns>Object Id</returns>
private nint FindOwnedByOwnerIdSafe(ObjectKind kind, uint ownerEntityId)
{
// Validate owner ID
if (ownerEntityId == 0 || ownerEntityId == uint.MaxValue)
return nint.Zero;
// Scan object table
foreach (var obj in _objectTable)
{
// Validate object
if (obj is null || obj.Address == nint.Zero)
continue;
// Check owner ID match
if (obj.OwnerId != ownerEntityId)
continue;
// Check kind match
if (!IsOwnedKindMatch(obj, kind))
continue;
return obj.Address;
}
return nint.Zero;
}
/// <summary>
/// Determines if the given object matches the specified owned kind.
/// </summary>
/// <param name="obj">Game Object</param>
/// <param name="kind">Object Kind</param>
/// <returns></returns>
private static bool IsOwnedKindMatch(IGameObject obj, ObjectKind kind) => kind switch
{
// Match minion or mount
ObjectKind.MinionOrMount =>
obj.ObjectKind is DalamudObjectKind.MountType
or DalamudObjectKind.Companion,
// Match pet
ObjectKind.Pet =>
obj.ObjectKind == DalamudObjectKind.BattleNpc
&& obj is IBattleNpc bnPet
&& bnPet.BattleNpcKind == BattleNpcSubKind.Pet,
// Match companion
ObjectKind.Companion =>
obj.ObjectKind == DalamudObjectKind.BattleNpc
&& obj is IBattleNpc bnBuddy
&& bnBuddy.BattleNpcKind == BattleNpcSubKind.Chocobo,
_ => false
};
/// <summary>
/// Applies Customize Plus data to the specified object.
/// </summary>
/// <param name="address">Object Address</param>
/// <param name="customizeData">Data of the Customize+ that has to be applied</param>
/// <param name="kind">Object Kind</param>
/// <param name="customizeIds">Customize+ Ids</param>
/// <returns>Task</returns>
private async Task ApplyCustomizeAsync(nint address, string customizeData, ObjectKind kind, Dictionary<ObjectKind, Guid?> customizeIds)
{
customizeIds[kind] = await _ipc.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false);
}
/// <summary>
/// Reverts Customize Plus changes for the specified object.
/// </summary>
/// <param name="customizeId">Customize+ Id</param>
/// <param name="kind">Object Id</param>
/// <param name="customizeIds">List of Customize+ ids</param>
/// <returns></returns>
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);
}
}

View File

@@ -1,69 +1,43 @@
using LightlessSync.API.Data; using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
namespace LightlessSync.PlayerData.Pairs; namespace LightlessSync.PlayerData.Pairs;
/// <summary> /// <summary>
/// orchestrates the lifecycle of a paired character /// orchestrates the lifecycle of a paired character
/// </summary> /// </summary>
public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject
{ {
new string Ident { get; } new string Ident { get; }
bool Initialized { get; } bool Initialized { get; }
bool IsVisible { get; } bool IsVisible { get; }
bool ScheduledForDeletion { get; set; } bool ScheduledForDeletion { get; set; }
CharacterData? LastReceivedCharacterData { get; } CharacterData? LastReceivedCharacterData { get; }
long LastAppliedDataBytes { get; } long LastAppliedDataBytes { get; }
new string? PlayerName { get; } new string? PlayerName { get; }
string PlayerNameHash { get; } string PlayerNameHash { get; }
uint PlayerCharacterId { get; } uint PlayerCharacterId { get; }
DateTime? LastDataReceivedAt { get; }
DateTime? LastDataReceivedAt { get; } DateTime? LastApplyAttemptAt { get; }
DateTime? LastApplyAttemptAt { get; } DateTime? LastSuccessfulApplyAt { get; }
DateTime? LastSuccessfulApplyAt { get; } string? LastFailureReason { get; }
IReadOnlyList<string> LastBlockingConditions { get; }
string? LastFailureReason { get; } bool IsApplying { get; }
IReadOnlyList<string> LastBlockingConditions { get; } bool IsDownloading { get; }
int PendingDownloadCount { get; }
bool IsApplying { get; } int ForbiddenDownloadCount { get; }
bool IsDownloading { get; } bool PendingModReapply { get; }
int PendingDownloadCount { get; } bool ModApplyDeferred { get; }
int ForbiddenDownloadCount { get; } int MissingCriticalMods { get; }
int MissingNonCriticalMods { get; }
bool PendingModReapply { get; } int MissingForbiddenMods { get; }
bool ModApplyDeferred { get; } DateTime? InvisibleSinceUtc { get; }
int MissingCriticalMods { get; } DateTime? VisibilityEvictionDueAtUtc { get; }
int MissingNonCriticalMods { get; }
int MissingForbiddenMods { get; }
DateTime? InvisibleSinceUtc { get; }
DateTime? VisibilityEvictionDueAtUtc { get; }
string? MinionAddressHex { get; }
ushort? MinionObjectIndex { get; }
DateTime? MinionResolvedAtUtc { get; }
string? MinionResolveStage { get; }
string? MinionResolveFailureReason { get; }
bool MinionPendingRetry { get; }
IReadOnlyList<string> MinionPendingRetryChanges { get; }
bool MinionHasAppearanceData { get; }
Guid OwnedPenumbraCollectionId { get; }
bool NeedsCollectionRebuildDebug { get; }
uint MinionOrMountCharacterId { get; }
uint PetCharacterId { get; }
uint CompanionCharacterId { get; }
void Initialize(); void Initialize();
void ApplyData(CharacterData data); void ApplyData(CharacterData data);
void ApplyLastReceivedData(bool forced = false); void ApplyLastReceivedData(bool forced = false);
void HardReapplyLastData(); bool FetchPerformanceMetricsFromCache();
bool FetchPerformanceMetricsFromCache(); void LoadCachedCharacterData(CharacterData data);
void LoadCachedCharacterData(CharacterData data); void SetUploading(bool uploading);
void SetUploading(bool uploading); void SetPaused(bool paused);
void SetPaused(bool paused); }
}

View File

@@ -82,114 +82,60 @@ public class Pair
public void AddContextMenu(IMenuOpenedArgs args) public void AddContextMenu(IMenuOpenedArgs args)
{ {
var handler = TryGetHandler(); var handler = TryGetHandler();
if (handler is null) if (handler is null)
return;
if (args.Target is not MenuTargetDefault target)
return;
var obj = target.TargetObject;
if (obj is null)
return;
var eid = obj.EntityId;
var isPlayerTarget = eid != 0 && eid != uint.MaxValue && eid == handler.PlayerCharacterId;
if (!(isPlayerTarget))
return;
if (isPlayerTarget)
{ {
if (!IsPaused)
{
UiSharedService.AddContextMenuItem(
args,
name: "Open Profile",
prefixChar: 'L',
colorMenuItem: _lightlessPrefixColor,
onClick: () =>
{
_mediator.Publish(new ProfileOpenStandaloneMessage(this));
return Task.CompletedTask;
});
UiSharedService.AddContextMenuItem(
args,
name: "(Soft) - Reapply last data",
prefixChar: 'L',
colorMenuItem: _lightlessPrefixColor,
onClick: () =>
{
ApplyLastReceivedData(forced: true);
return Task.CompletedTask;
});
UiSharedService.AddContextMenuItem(
args,
name: "(Hard) - Reapply last data",
prefixChar: 'L',
colorMenuItem: _lightlessPrefixColor,
onClick: () =>
{
HardApplyLastReceivedData();
return Task.CompletedTask;
});
}
UiSharedService.AddContextMenuItem(
args,
name: "Change Permissions",
prefixChar: 'L',
colorMenuItem: _lightlessPrefixColor,
onClick: () =>
{
_mediator.Publish(new OpenPermissionWindow(this));
return Task.CompletedTask;
});
if (IsPaused)
{
UiSharedService.AddContextMenuItem(
args,
name: "Toggle Unpause State",
prefixChar: 'L',
colorMenuItem: _lightlessPrefixColor,
onClick: () =>
{
_ = _apiController.Value.UnpauseAsync(UserData);
return Task.CompletedTask;
});
}
else
{
UiSharedService.AddContextMenuItem(
args,
name: "Toggle Pause State",
prefixChar: 'L',
colorMenuItem: _lightlessPrefixColor,
onClick: () =>
{
_ = _apiController.Value.PauseAsync(UserData);
return Task.CompletedTask;
});
}
UiSharedService.AddContextMenuItem(
args,
name: "Cycle Pause State",
prefixChar: 'L',
colorMenuItem: _lightlessPrefixColor,
onClick: () =>
{
TriggerCyclePause();
return Task.CompletedTask;
});
return; return;
} }
if (args.Target is not MenuTargetDefault target || target.TargetObjectId != handler.PlayerCharacterId)
{
return;
}
if (!IsPaused)
{
UiSharedService.AddContextMenuItem(args, name: "Open Profile", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
{
_mediator.Publish(new ProfileOpenStandaloneMessage(this));
return Task.CompletedTask;
});
UiSharedService.AddContextMenuItem(args, name: "Reapply last data", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
{
ApplyLastReceivedData(forced: true);
return Task.CompletedTask;
});
}
UiSharedService.AddContextMenuItem(args, name: "Change Permissions", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
{
_mediator.Publish(new OpenPermissionWindow(this));
return Task.CompletedTask;
});
if (IsPaused)
{
UiSharedService.AddContextMenuItem(args, name: "Toggle Unpause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
{
_ = _apiController.Value.UnpauseAsync(UserData);
return Task.CompletedTask;
});
}
else
{
UiSharedService.AddContextMenuItem(args, name: "Toggle Pause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
{
_ = _apiController.Value.PauseAsync(UserData);
return Task.CompletedTask;
});
}
UiSharedService.AddContextMenuItem(args, name: "Cycle Pause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
{
TriggerCyclePause();
return Task.CompletedTask;
});
} }
public void ApplyData(OnlineUserCharaDataDto data) public void ApplyData(OnlineUserCharaDataDto data)
@@ -214,18 +160,6 @@ public class Pair
handler.ApplyLastReceivedData(forced); handler.ApplyLastReceivedData(forced);
} }
public void HardApplyLastReceivedData()
{
var handler = TryGetHandler();
if (handler is null)
{
_logger.LogTrace("ApplyLastReceivedData skipped for {Uid}: handler missing.", UserData.UID);
return;
}
handler.HardReapplyLastData();
}
public void CreateCachedPlayer(OnlineUserIdentDto? dto = null) public void CreateCachedPlayer(OnlineUserIdentDto? dto = null)
{ {
var handler = TryGetHandler(); var handler = TryGetHandler();
@@ -310,17 +244,6 @@ public class Pair
handler.ModApplyDeferred, handler.ModApplyDeferred,
handler.MissingCriticalMods, handler.MissingCriticalMods,
handler.MissingNonCriticalMods, handler.MissingNonCriticalMods,
handler.MissingForbiddenMods, handler.MissingForbiddenMods);
handler.MinionAddressHex,
handler.MinionObjectIndex,
handler.MinionResolvedAtUtc,
handler.MinionResolveStage,
handler.MinionResolveFailureReason,
handler.MinionPendingRetry,
handler.MinionPendingRetryChanges,
handler.MinionHasAppearanceData,
handler.OwnedPenumbraCollectionId,
handler.NeedsCollectionRebuildDebug);
} }
} }

View File

@@ -21,50 +21,28 @@ public sealed record PairDebugInfo(
bool ModApplyDeferred, bool ModApplyDeferred,
int MissingCriticalMods, int MissingCriticalMods,
int MissingNonCriticalMods, int MissingNonCriticalMods,
int MissingForbiddenMods, int MissingForbiddenMods)
string? MinionAddressHex,
ushort? MinionObjectIndex,
DateTime? MinionResolvedAtUtc,
string? MinionResolveStage,
string? MinionResolveFailureReason,
bool MinionPendingRetry,
IReadOnlyList<string> MinionPendingRetryChanges,
bool MinionHasAppearanceData,
Guid OwnedPenumbraCollectionId,
bool NeedsCollectionRebuild)
{ {
public static PairDebugInfo Empty { get; } = new( public static PairDebugInfo Empty { get; } = new(
HasHandler: false, false,
HandlerInitialized: false, false,
HandlerVisible: false, false,
HandlerScheduledForDeletion: false, false,
LastDataReceivedAt: null, null,
LastApplyAttemptAt: null, null,
LastSuccessfulApplyAt: null, null,
InvisibleSinceUtc: null, null,
VisibilityEvictionDueAtUtc: null, null,
VisibilityEvictionRemainingSeconds: null, null,
LastFailureReason: null, null,
BlockingConditions: [], Array.Empty<string>(),
IsApplying: false, false,
IsDownloading: false, false,
PendingDownloadCount: 0, 0,
ForbiddenDownloadCount: 0, 0,
PendingModReapply: false, false,
ModApplyDeferred: false, false,
MissingCriticalMods: 0, 0,
MissingNonCriticalMods: 0, 0,
MissingForbiddenMods: 0, 0);
MinionAddressHex: null,
MinionObjectIndex: null,
MinionResolvedAtUtc: null,
MinionResolveStage: null,
MinionResolveFailureReason: null,
MinionPendingRetry: false,
MinionPendingRetryChanges: [],
MinionHasAppearanceData: false,
OwnedPenumbraCollectionId: Guid.Empty,
NeedsCollectionRebuild: false);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -40,7 +40,6 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
private readonly LightlessConfigService _configService; private readonly LightlessConfigService _configService;
private readonly XivDataAnalyzer _modelAnalyzer; private readonly XivDataAnalyzer _modelAnalyzer;
private readonly IFramework _framework; private readonly IFramework _framework;
private readonly IObjectTable _objectTable;
public PairHandlerAdapterFactory( public PairHandlerAdapterFactory(
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
@@ -64,8 +63,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
PairPerformanceMetricsCache pairPerformanceMetricsCache, PairPerformanceMetricsCache pairPerformanceMetricsCache,
PenumbraTempCollectionJanitor tempCollectionJanitor, PenumbraTempCollectionJanitor tempCollectionJanitor,
XivDataAnalyzer modelAnalyzer, XivDataAnalyzer modelAnalyzer,
LightlessConfigService configService, LightlessConfigService configService)
IObjectTable objectTable)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_mediator = mediator; _mediator = mediator;
@@ -89,7 +87,6 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
_tempCollectionJanitor = tempCollectionJanitor; _tempCollectionJanitor = tempCollectionJanitor;
_modelAnalyzer = modelAnalyzer; _modelAnalyzer = modelAnalyzer;
_configService = configService; _configService = configService;
_objectTable = objectTable;
} }
public IPairHandlerAdapter Create(string ident) public IPairHandlerAdapter Create(string ident)
@@ -108,7 +105,6 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
_pluginWarningNotificationManager, _pluginWarningNotificationManager,
dalamudUtilService, dalamudUtilService,
_framework, _framework,
_objectTable,
actorObjectService, actorObjectService,
_lifetime, _lifetime,
_fileCacheManager, _fileCacheManager,

View File

@@ -110,7 +110,6 @@ public sealed class Plugin : IDalamudPlugin
services.AddSingleton(gameGui); services.AddSingleton(gameGui);
services.AddSingleton(gameInteropProvider); services.AddSingleton(gameInteropProvider);
services.AddSingleton(addonLifecycle); services.AddSingleton(addonLifecycle);
services.AddSingleton(objectTable);
services.AddSingleton<IUiBuilder>(pluginInterface.UiBuilder); services.AddSingleton<IUiBuilder>(pluginInterface.UiBuilder);
// Core singletons // Core singletons
@@ -429,7 +428,6 @@ 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));
@@ -443,7 +441,6 @@ 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,7 +14,6 @@ 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;
@@ -58,8 +57,6 @@ 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,
@@ -77,6 +74,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
_clientState = clientState; _clientState = clientState;
_condition = condition; _condition = condition;
_mediator = mediator; _mediator = mediator;
_mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) => _mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
{ {
if (!msg.OwnedObject) return; if (!msg.OwnedObject) return;
@@ -98,6 +96,7 @@ 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);
@@ -342,8 +341,6 @@ 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();
@@ -508,10 +505,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
return new ActorDescriptor(name, hashedCid, address, objectIndex, isLocal, isInGpose, objectKind, ownedKind, ownerEntityId); return new ActorDescriptor(name, hashedCid, address, objectIndex, isLocal, isInGpose, objectKind, ownedKind, ownerEntityId);
} }
private unsafe (LightlessObjectKind? OwnedKind, uint OwnerEntityId) DetermineOwnedKind( private unsafe (LightlessObjectKind? OwnedKind, uint OwnerEntityId) DetermineOwnedKind(GameObject* gameObject, DalamudObjectKind objectKind, bool isLocalPlayer)
GameObject* gameObject,
DalamudObjectKind objectKind,
bool isLocalPlayer)
{ {
if (gameObject == null) if (gameObject == null)
return (null, 0); return (null, 0);
@@ -523,7 +517,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
} }
var ownerId = ResolveOwnerId(gameObject); var ownerId = ResolveOwnerId(gameObject);
var localPlayerAddress = _objectTable.LocalPlayer?.Address ?? nint.Zero; var localPlayerAddress = _objectTable.LocalPlayer?.Address ?? nint.Zero;
if (localPlayerAddress == nint.Zero) if (localPlayerAddress == nint.Zero)
return (null, ownerId); return (null, ownerId);
@@ -535,7 +528,9 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
if (objectKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion) if (objectKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
{ {
var expectedMinionOrMount = GetMinionOrMountAddress(localPlayerAddress, localEntityId); var expectedMinionOrMount = GetMinionOrMountAddress(localPlayerAddress, localEntityId);
if (expectedMinionOrMount != nint.Zero && (nint)gameObject == expectedMinionOrMount) if (expectedMinionOrMount != nint.Zero
&& (nint)gameObject == expectedMinionOrMount
&& IsPlayerRelatedOwnedAddress(expectedMinionOrMount, LightlessObjectKind.MinionOrMount))
{ {
var resolvedOwner = ownerId != 0 ? ownerId : localEntityId; var resolvedOwner = ownerId != 0 ? ownerId : localEntityId;
return (LightlessObjectKind.MinionOrMount, resolvedOwner); return (LightlessObjectKind.MinionOrMount, resolvedOwner);
@@ -545,16 +540,20 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
if (objectKind != DalamudObjectKind.BattleNpc) if (objectKind != DalamudObjectKind.BattleNpc)
return (null, ownerId); return (null, ownerId);
if (ownerId != 0 && ownerId != localEntityId) if (ownerId != localEntityId)
return (null, ownerId); return (null, ownerId);
var expectedPet = GetPetAddress(localPlayerAddress, localEntityId); var expectedPet = GetPetAddress(localPlayerAddress, localEntityId);
if (expectedPet != nint.Zero && (nint)gameObject == expectedPet) if (expectedPet != nint.Zero
return (LightlessObjectKind.Pet, ownerId != 0 ? ownerId : localEntityId); && (nint)gameObject == expectedPet
&& IsPlayerRelatedOwnedAddress(expectedPet, LightlessObjectKind.Pet))
return (LightlessObjectKind.Pet, ownerId);
var expectedCompanion = GetCompanionAddress(localPlayerAddress, localEntityId); var expectedCompanion = GetCompanionAddress(localPlayerAddress, localEntityId);
if (expectedCompanion != nint.Zero && (nint)gameObject == expectedCompanion) if (expectedCompanion != nint.Zero
return (LightlessObjectKind.Companion, ownerId != 0 ? ownerId : localEntityId); && (nint)gameObject == expectedCompanion
&& IsPlayerRelatedOwnedAddress(expectedCompanion, LightlessObjectKind.Companion))
return (LightlessObjectKind.Companion, ownerId);
return (null, ownerId); return (null, ownerId);
} }
@@ -582,25 +581,21 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
return nint.Zero; return nint.Zero;
var playerObject = (GameObject*)localPlayerAddress; var playerObject = (GameObject*)localPlayerAddress;
var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1); var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1);
if (ownerEntityId == 0)
return nint.Zero;
if (candidateAddress != nint.Zero) if (candidateAddress != nint.Zero)
{ {
var candidate = (GameObject*)candidateAddress; var candidate = (GameObject*)candidateAddress;
var candidateKind = (DalamudObjectKind)candidate->ObjectKind; var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion) if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
{ {
var resolvedOwner = ResolveOwnerId(candidate); if (ResolveOwnerId(candidate) == ownerEntityId)
if (resolvedOwner == ownerEntityId || resolvedOwner == 0)
return candidateAddress; return candidateAddress;
} }
} }
if (ownerEntityId == 0)
return nint.Zero;
foreach (var obj in _objectTable) foreach (var obj in _objectTable)
{ {
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress) if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
@@ -617,90 +612,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
return nint.Zero; return nint.Zero;
} }
public unsafe bool TryFindOwnedObject(uint ownerEntityId, LightlessObjectKind kind, out nint address)
{
address = nint.Zero;
if (ownerEntityId == 0) return false;
foreach (var addr in EnumerateActiveCharacterAddresses())
{
if (addr == nint.Zero) continue;
var go = (GameObject*)addr;
var ok = (DalamudObjectKind)go->ObjectKind;
switch (kind)
{
case LightlessObjectKind.MinionOrMount:
if (ok is DalamudObjectKind.MountType or DalamudObjectKind.Companion
&& ResolveOwnerId(go) == ownerEntityId)
{
address = addr;
return true;
}
break;
case LightlessObjectKind.Pet:
if (ok == DalamudObjectKind.BattleNpc
&& go->BattleNpcSubKind == BattleNpcSubKind.Pet
&& ResolveOwnerId(go) == ownerEntityId)
{
address = addr;
return true;
}
break;
case LightlessObjectKind.Companion:
if (ok == DalamudObjectKind.BattleNpc
&& go->BattleNpcSubKind == BattleNpcSubKind.Buddy
&& ResolveOwnerId(go) == ownerEntityId)
{
address = addr;
return true;
}
break;
}
}
return false;
}
public unsafe IReadOnlyList<nint> GetMinionOrMountCandidates(uint ownerEntityId, ushort preferredPlayerIndex)
{
var results = new List<(nint Ptr, int Score)>(4);
var manager = GameObjectManager.Instance();
if (manager == null || ownerEntityId == 0)
return Array.Empty<nint>();
const int objectLimit = 200;
for (var i = 0; i < objectLimit; i++)
{
var obj = manager->Objects.IndexSorted[i].Value;
if (obj == null)
continue;
var kind = (DalamudObjectKind)obj->ObjectKind;
if (kind is not (DalamudObjectKind.MountType or DalamudObjectKind.Companion))
continue;
var owner = ResolveOwnerId(obj);
if (owner != ownerEntityId)
continue;
var idx = obj->ObjectIndex;
var score = Math.Abs(idx - (preferredPlayerIndex + 1));
if (obj->DrawObject == null) score += 50;
results.Add(((nint)obj, score));
}
return results
.OrderBy(r => r.Score)
.Select(r => r.Ptr)
.ToArray();
}
private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId) private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId)
{ {
if (localPlayerAddress == nint.Zero || ownerEntityId == 0) if (localPlayerAddress == nint.Zero || ownerEntityId == 0)
@@ -1305,19 +1216,21 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
private static unsafe bool IsObjectFullyLoaded(nint address) private static unsafe bool IsObjectFullyLoaded(nint address)
{ {
if (address == nint.Zero) return false; if (address == nint.Zero)
return false;
var gameObject = (GameObject*)address; var gameObject = (GameObject*)address;
if (gameObject == null) return false; if (gameObject == null)
return false;
var drawObject = gameObject->DrawObject; var drawObject = gameObject->DrawObject;
if (drawObject == null) return false; if (drawObject == null)
return false;
if ((ulong)gameObject->RenderFlags == 2048) if ((ulong)gameObject->RenderFlags == 2048)
return false; return false;
var characterBase = (CharacterBase*)drawObject; var characterBase = (CharacterBase*)drawObject;
if (characterBase->HasModelInSlotLoaded != 0) if (characterBase->HasModelInSlotLoaded != 0)
return false; return false;
@@ -1327,7 +1240,6 @@ 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

@@ -93,7 +93,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
{ {
return gameData.GetExcelSheet<Lumina.Excel.Sheets.World>(clientLanguage)! return gameData.GetExcelSheet<Lumina.Excel.Sheets.World>(clientLanguage)!
.Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0]) .Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0])
|| w is { RowId: > 1000, UserType: 101 or 201 })) || w is { RowId: > 1000, Region: 101 or 201 }))
.ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString()); .ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString());
}); });
JobData = new(() => JobData = new(() =>
@@ -816,12 +816,9 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
{ {
_logger.LogInformation("Starting DalamudUtilService"); _logger.LogInformation("Starting DalamudUtilService");
_framework.Update += FrameworkOnUpdate; _framework.Update += FrameworkOnUpdate;
_clientState.Login += OnClientLogin; if (IsLoggedIn)
_clientState.Logout += OnClientLogout;
if (_clientState.IsLoggedIn)
{ {
OnClientLogin(); _classJobId = _objectTable.LocalPlayer!.ClassJob.RowId;
} }
_logger.LogInformation("Started DalamudUtilService"); _logger.LogInformation("Started DalamudUtilService");
@@ -834,9 +831,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
Mediator.UnsubscribeAll(this); Mediator.UnsubscribeAll(this);
_framework.Update -= FrameworkOnUpdate; _framework.Update -= FrameworkOnUpdate;
_clientState.Login -= OnClientLogin;
_clientState.Logout -= OnClientLogout;
if (_FocusPairIdent.HasValue) if (_FocusPairIdent.HasValue)
{ {
if (_framework.IsInFrameworkUpdateThread) if (_framework.IsInFrameworkUpdateThread)
@@ -851,41 +845,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return Task.CompletedTask; return Task.CompletedTask;
} }
private void OnClientLogin()
{
if (IsLoggedIn)
return;
_ = RunOnFrameworkThread(() =>
{
if (IsLoggedIn)
return;
var localPlayer = _objectTable.LocalPlayer;
IsLoggedIn = true;
_lastZone = _clientState.TerritoryType;
if (localPlayer != null)
{
_lastWorldId = (ushort)localPlayer.CurrentWorld.RowId;
_classJobId = localPlayer.ClassJob.RowId;
}
_cid = RebuildCID();
Mediator.Publish(new DalamudLoginMessage());
});
}
private void OnClientLogout(int type, int code)
{
if (!IsLoggedIn)
return;
_ = RunOnFrameworkThread(() =>
{
if (!IsLoggedIn)
return;
IsLoggedIn = false;
_lastWorldId = 0;
Mediator.Publish(new DalamudLogoutMessage());
});
}
public async Task WaitWhileCharacterIsDrawing( public async Task WaitWhileCharacterIsDrawing(
ILogger logger, ILogger logger,
GameObjectHandler handler, GameObjectHandler handler,
@@ -1313,6 +1272,23 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
if (isNormalFrameworkUpdate) if (isNormalFrameworkUpdate)
return; return;
if (localPlayer != null && !IsLoggedIn)
{
_logger.LogDebug("Logged in");
IsLoggedIn = true;
_lastZone = _clientState.TerritoryType;
_lastWorldId = (ushort)localPlayer.CurrentWorld.RowId;
_cid = RebuildCID();
Mediator.Publish(new DalamudLoginMessage());
}
else if (localPlayer == null && IsLoggedIn)
{
_logger.LogDebug("Logged out");
IsLoggedIn = false;
_lastWorldId = 0;
Mediator.Publish(new DalamudLogoutMessage());
}
if (_gameConfig != null if (_gameConfig != null
&& _gameConfig.TryGet(Dalamud.Game.Config.SystemConfigOption.LodType_DX11, out bool lodEnabled)) && _gameConfig.TryGet(Dalamud.Game.Config.SystemConfigOption.LodType_DX11, out bool lodEnabled))
{ {

View File

@@ -68,7 +68,6 @@ 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,6 +138,5 @@ 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 PenumbraJanitorConfigService _config; private readonly LightlessConfigService _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,
PenumbraJanitorConfigService config) : base(logger, mediator) LightlessConfigService config) : base(logger, mediator)
{ {
_ipc = ipc; _ipc = ipc;
_config = config; _config = config;
@@ -67,8 +67,5 @@ 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)
{ {
await _cancellationTokenSource.CancelAsync().ConfigureAwait(false); _cancellationTokenSource.Cancel();
if (_dalamudUtilService.IsOnFrameworkThread) if (_dalamudUtilService.IsOnFrameworkThread)
{ {

View File

@@ -497,7 +497,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase
var cardData = new List<(GroupJoinDto Shell, string BroadcasterName, bool IsOwnBroadcast)>(); var cardData = new List<(GroupJoinDto Shell, string BroadcasterName, bool IsOwnBroadcast)>();
foreach (var shell in _nearbySyncshells.ToArray()) foreach (var shell in _nearbySyncshells)
{ {
if (shell?.Group == null || string.IsNullOrEmpty(shell.Group.GID)) if (shell?.Group == null || string.IsNullOrEmpty(shell.Group.GID))
continue; continue;
@@ -759,22 +759,29 @@ public class LightFinderUI : WindowMediatorSubscriberBase
var scale = ImGuiHelpers.GlobalScale; var scale = ImGuiHelpers.GlobalScale;
// if not already open
if (!ImGui.IsPopupOpen("JoinSyncshellModal"))
ImGui.OpenPopup("JoinSyncshellModal");
Vector2 windowPos = ImGui.GetWindowPos();
Vector2 windowSize = ImGui.GetWindowSize(); Vector2 windowSize = ImGui.GetWindowSize();
float modalWidth = Math.Min(420f * scale, windowSize.X - 40f * scale); float modalWidth = Math.Min(420f * scale, windowSize.X - 40f * scale);
float modalHeight = 295f * scale; float modalHeight = 295f * scale;
Vector2 childPos = new Vector2( ImGui.SetNextWindowPos(new Vector2(
(windowSize.X - modalWidth) * 0.5f, windowPos.X + (windowSize.X - modalWidth) * 0.5f,
(windowSize.Y - modalHeight) * 0.5f windowPos.Y + (windowSize.Y - modalHeight) * 0.5f
); ), ImGuiCond.Always);
ImGui.SetCursorPos(childPos); ImGui.SetNextWindowSize(new Vector2(modalWidth, modalHeight));
ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * ImGuiHelpers.GlobalScale);
ImGui.PushStyleColor(ImGuiCol.Border, Vector4.Zero);
using var modalBorder = ImRaii.PushColor(ImGuiCol.Border, UIColors.Get("LightlessPurple").WithAlpha(0.5f)); using ImRaii.Color modalBorder = ImRaii.PushColor(ImGuiCol.Border, UIColors.Get("LightlessPurple").WithAlpha(0.5f));
using var childBg = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetStyle().Colors[(int)ImGuiCol.WindowBg]); using ImRaii.Style rounding = ImRaii.PushStyle(ImGuiStyleVar.WindowRounding, 8f * scale);
using var rounding = ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 8f * scale); using ImRaii.Style borderSize = ImRaii.PushStyle(ImGuiStyleVar.WindowBorderSize, 2f * scale);
using var borderSize = ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, 2f * scale); using ImRaii.Style padding = ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(16f * scale, 16f * scale));
using var padding = ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(16f * scale, 16f * scale));
if (ImGui.BeginChild("JoinSyncshellOverlay", new Vector2(modalWidth, modalHeight), true, ImGuiWindowFlags.NoScrollbar)) ImGuiWindowFlags flags = ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoScrollbar;
if (ImGui.BeginPopupModal("JoinSyncshellModal", ref _joinModalOpen, flags))
{ {
float contentWidth = ImGui.GetContentRegionAvail().X; float contentWidth = ImGui.GetContentRegionAvail().X;
@@ -836,7 +843,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase
_joinDto = null; _joinDto = null;
_joinInfo = null; _joinInfo = null;
ImGui.CloseCurrentPopup();
} }
} }
@@ -851,13 +858,20 @@ public class LightFinderUI : WindowMediatorSubscriberBase
{ {
_joinDto = null; _joinDto = null;
_joinInfo = null; _joinInfo = null;
ImGui.CloseCurrentPopup();
} }
} }
} }
// Handle modal close via the bool ref
if (!_joinModalOpen)
{
_joinDto = null;
_joinInfo = null;
}
ImGui.EndPopup();
} }
ImGui.EndChild();
} }
private void DrawPermissionToggleRow(string label, FontAwesomeIcon icon, bool suggested, bool current, Action<bool> apply, float contentWidth) private void DrawPermissionToggleRow(string label, FontAwesomeIcon icon, bool suggested, bool current, Action<bool> apply, float contentWidth)
@@ -1566,20 +1580,11 @@ public class LightFinderUI : WindowMediatorSubscriberBase
if (previousGid != null) if (previousGid != null)
{ {
try var newIndex = _nearbySyncshells.FindIndex(s => string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal));
if (newIndex >= 0)
{ {
var nearbySyncshellsSnapshot = _nearbySyncshells.ToArray(); _selectedNearbyIndex = newIndex;
var newIndex = Array.FindIndex(nearbySyncshellsSnapshot, return;
s => string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal));
if (newIndex >= 0)
{
_selectedNearbyIndex = newIndex;
return;
}
}
catch
{
ClearSelection();
} }
} }
@@ -1621,18 +1626,9 @@ public class LightFinderUI : WindowMediatorSubscriberBase
private string? GetSelectedGid() private string? GetSelectedGid()
{ {
try if (_selectedNearbyIndex < 0 || _selectedNearbyIndex >= _nearbySyncshells.Count)
{
var index = _selectedNearbyIndex;
var list = _nearbySyncshells.ToArray();
if (index < 0 || index >= list.Length)
return null;
return list[index].Group.GID;
}
catch
{
return null; return null;
} return _nearbySyncshells[_selectedNearbyIndex].Group.GID;
} }
#endregion #endregion

View File

@@ -47,7 +47,6 @@ 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

@@ -1688,46 +1688,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.EndTable(); ImGui.EndTable();
} }
ImGui.Separator();
ImGui.TextUnformatted("Owned / Minion-Mount");
if (ImGui.BeginTable("##pairDebugOwnedMinion", 2, ImGuiTableFlags.SizingStretchProp))
{
DrawPairPropertyRow("Owned Temp Collection", debugInfo.OwnedPenumbraCollectionId == Guid.Empty
? "n/a"
: debugInfo.OwnedPenumbraCollectionId.ToString());
DrawPairPropertyRow("Needs Collection Rebuild", FormatBool(debugInfo.NeedsCollectionRebuild));
DrawPairPropertyRow("Minion Ptr", string.IsNullOrEmpty(debugInfo.MinionAddressHex)
? "n/a"
: debugInfo.MinionAddressHex);
DrawPairPropertyRow("Minion ObjectIndex", debugInfo.MinionObjectIndex.HasValue
? debugInfo.MinionObjectIndex.Value.ToString(CultureInfo.InvariantCulture)
: "n/a");
DrawPairPropertyRow("Minion Resolved At", FormatTimestamp(debugInfo.MinionResolvedAtUtc));
DrawPairPropertyRow("Minion Resolve Stage", string.IsNullOrEmpty(debugInfo.MinionResolveStage)
? "n/a"
: debugInfo.MinionResolveStage);
DrawPairPropertyRow("Minion Resolve Failure", string.IsNullOrEmpty(debugInfo.MinionResolveFailureReason)
? "n/a"
: debugInfo.MinionResolveFailureReason);
DrawPairPropertyRow("Minion Pending Retry", FormatBool(debugInfo.MinionPendingRetry));
var retryChanges = debugInfo.MinionPendingRetryChanges is { Count: > 0 }
? string.Join(", ", debugInfo.MinionPendingRetryChanges)
: "n/a";
DrawPairPropertyRow("Minion Pending Changes", retryChanges);
DrawPairPropertyRow("Minion Has Appearance Data", FormatBool(debugInfo.MinionHasAppearanceData));
ImGui.EndTable();
}
ImGui.Separator(); ImGui.Separator();
ImGui.TextUnformatted("Syncshell Memberships"); ImGui.TextUnformatted("Syncshell Memberships");
if (snapshot.PairsWithGroups.TryGetValue(pair, out var groups) && groups.Count > 0) if (snapshot.PairsWithGroups.TryGetValue(pair, out var groups) && groups.Count > 0)
@@ -3289,16 +3249,16 @@ public class SettingsUi : WindowMediatorSubscriberBase
var labels = new[] var labels = new[]
{ {
"Unsafe (Off)", "Unsafe",
"Safe (Race Check)", "Safe (Race)",
"Safest (Race + Bones Check)", "Safest (Race + Bones)",
}; };
var tooltips = new[] var tooltips = new[]
{ {
"No validation. Fastest, but may allow incompatible animations.", "No validation. Fastest, but may allow incompatible animations (riskier).",
"Validates skeleton race + modded skeleton check. Will be safer to use but will block some animations", "Validates skeleton race + modded skeleton check (recommended).",
"Requires matching skeleton race + bone compatibility. Will block alot, not recommended.", "Requires matching skeleton race + bone compatibility (strictest).",
}; };

View File

@@ -1,41 +0,0 @@
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,6 +3,8 @@ 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;
@@ -54,168 +56,164 @@ public static class VariousExtensions
return new CancellationTokenSource(); return new CancellationTokenSource();
} }
public static Dictionary<ObjectKind, HashSet<PlayerChanges>> CheckUpdatedData( public static Dictionary<ObjectKind, HashSet<PlayerChanges>> CheckUpdatedData(this CharacterData newData, Guid applicationBase,
this CharacterData newData, CharacterData? oldData, ILogger logger, IPairPerformanceSubject cachedPlayer, bool forceApplyCustomization, bool forceApplyMods,
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>())
{ {
var set = new HashSet<PlayerChanges>(); 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);
oldData.FileReplacements.TryGetValue(objectKind, out var oldFileRepls); bool hasNewButNotOldFileReplacements = newFileReplacements != null && existingFileReplacements == null;
newData.FileReplacements.TryGetValue(objectKind, out var newFileRepls); bool hasOldButNotNewFileReplacements = existingFileReplacements != null && newFileReplacements == null;
oldData.GlamourerData.TryGetValue(objectKind, out var oldGlam); bool hasNewButNotOldGlamourerData = newGlamourerData != null && existingGlamourerData == null;
newData.GlamourerData.TryGetValue(objectKind, out var newGlam); bool hasOldButNotNewGlamourerData = existingGlamourerData != null && newGlamourerData == null;
var oldHasFiles = HasFiles(oldFileRepls); bool hasNewAndOldFileReplacements = newFileReplacements != null && existingFileReplacements != null;
var newHasFiles = HasFiles(newFileRepls); bool hasNewAndOldGlamourerData = newGlamourerData != null && existingGlamourerData != null;
var forceRedrawOnForcedApply = forceApplyMods && !suppressForcedRedrawOnForcedModApply;
if (oldHasFiles != newHasFiles) if (hasNewButNotOldFileReplacements || hasOldButNotNewFileReplacements || hasNewButNotOldGlamourerData || hasOldButNotNewGlamourerData)
{ {
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (File presence changed old={old} new={new}) => {change}", logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Some new data arrived: NewButNotOldFiles:{hasNewButNotOldFileReplacements}," +
applicationBase, cachedPlayer, objectKind, oldHasFiles, newHasFiles, PlayerChanges.ModFiles); " OldButNotNewFiles:{hasOldButNotNewFileReplacements}, NewButNotOldGlam:{hasNewButNotOldGlamourerData}, OldButNotNewGlam:{hasOldButNotNewGlamourerData}) => {change}, {change2}",
applicationBase,
set.Add(PlayerChanges.ModFiles); cachedPlayer, objectKind, hasNewButNotOldFileReplacements, hasOldButNotNewFileReplacements, hasNewButNotOldGlamourerData, hasOldButNotNewGlamourerData, PlayerChanges.ModFiles, PlayerChanges.Glamourer);
if (objectKind != ObjectKind.Player || forceRedrawOnForcedApply) charaDataToUpdate[objectKind].Add(PlayerChanges.ModFiles);
{ charaDataToUpdate[objectKind].Add(PlayerChanges.Glamourer);
set.Add(PlayerChanges.ForcedRedraw); charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
}
} }
else if (newHasFiles) else
{ {
var listsAreEqual = oldFileRepls!.SequenceEqual(newFileRepls!, PlayerData.Data.FileReplacementDataComparer.Instance); if (hasNewAndOldFileReplacements)
if (!listsAreEqual || forceApplyMods)
{ {
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (FileReplacements changed or forceApplyMods) => {change}", var oldList = oldData.FileReplacements[objectKind];
applicationBase, cachedPlayer, objectKind, PlayerChanges.ModFiles); var newList = newData.FileReplacements[objectKind];
var listsAreEqual = oldList.SequenceEqual(newList, PlayerData.Data.FileReplacementDataComparer.Instance);
set.Add(PlayerChanges.ModFiles); if (!listsAreEqual || forceApplyMods)
if (objectKind != ObjectKind.Player || forceRedrawOnForcedApply)
{ {
set.Add(PlayerChanges.ForcedRedraw); 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 (hasNewAndOldGlamourerData)
{
bool glamourerDataDifferent = !string.Equals(oldData.GlamourerData[objectKind], newData.GlamourerData[objectKind], StringComparison.Ordinal);
if (glamourerDataDifferent || forceApplyCustomization)
{ {
var existingFace = oldFileRepls.Where(g => g.GamePaths.Any(p => p.Contains("/face/", StringComparison.OrdinalIgnoreCase))) logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Glamourer different) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Glamourer);
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList(); charaDataToUpdate[objectKind].Add(PlayerChanges.Glamourer);
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);
} }
} }
} }
var oldGlamNorm = Norm(oldGlam); oldData.CustomizePlusData.TryGetValue(objectKind, out var oldCustomizePlusData);
var newGlamNorm = Norm(newGlam); newData.CustomizePlusData.TryGetValue(objectKind, out var newCustomizePlusData);
if (!string.Equals(oldGlamNorm, newGlamNorm, StringComparison.Ordinal) oldCustomizePlusData ??= string.Empty;
|| (forceApplyCustomization && HasText(newGlamNorm))) newCustomizePlusData ??= string.Empty;
bool customizeDataDifferent = !string.Equals(oldCustomizePlusData, newCustomizePlusData, StringComparison.Ordinal);
if (customizeDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newCustomizePlusData)))
{ {
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Glamourer different) => {change}", logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff customize data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Customize);
applicationBase, cachedPlayer, objectKind, PlayerChanges.Glamourer); charaDataToUpdate[objectKind].Add(PlayerChanges.Customize);
set.Add(PlayerChanges.Glamourer);
} }
oldData.CustomizePlusData.TryGetValue(objectKind, out var oldC); if (objectKind != ObjectKind.Player) continue;
newData.CustomizePlusData.TryGetValue(objectKind, out var newC);
var oldCNorm = Norm(oldC); bool manipDataDifferent = !string.Equals(oldData.ManipulationData, newData.ManipulationData, StringComparison.Ordinal);
var newCNorm = Norm(newC); if (manipDataDifferent || forceRedrawOnForcedApply)
if (!string.Equals(oldCNorm, newCNorm, StringComparison.Ordinal)
|| (forceApplyCustomization && HasText(newCNorm)))
{ {
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Customize+ different) => {change}", logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff manip data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModManip);
applicationBase, cachedPlayer, objectKind, PlayerChanges.Customize); charaDataToUpdate[objectKind].Add(PlayerChanges.ModManip);
set.Add(PlayerChanges.Customize); charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
} }
if (objectKind == ObjectKind.Player) bool heelsOffsetDifferent = !string.Equals(oldData.HeelsData, newData.HeelsData, StringComparison.Ordinal);
if (heelsOffsetDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.HeelsData)))
{ {
var oldManip = Norm(oldData.ManipulationData); logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff heels data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Heels);
var newManip = Norm(newData.ManipulationData); charaDataToUpdate[objectKind].Add(PlayerChanges.Heels);
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);
} }
if (set.Count > 0) bool honorificDataDifferent = !string.Equals(oldData.HonorificData, newData.HonorificData, StringComparison.Ordinal);
charaDataToUpdate[objectKind] = set; 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);
}
} }
foreach (var k in charaDataToUpdate.Keys.ToList()) foreach (KeyValuePair<ObjectKind, HashSet<PlayerChanges>> data in charaDataToUpdate.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,9 +436,11 @@ 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))
{ {
@@ -460,8 +462,7 @@ 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
{ {
@@ -470,8 +471,6 @@ 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
@@ -481,69 +480,72 @@ 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}, skipping {len} bytes", Logger.LogWarning("{dlName}: No replacement mapping for {fileHash}", downloadLabel, fileHash);
downloadLabel, fileHash, len); // still need to skip bytes:
var skip = checked((int)fileLengthBytes);
fileBlockStream.Seek(len, SeekOrigin.Current); fileBlockStream.Position += skip;
continue; continue;
} }
var filePath = _fileDbManager.GetCacheFilePath(fileHash, repl.Extension); var filePath = _fileDbManager.GetCacheFilePath(fileHash, repl.Extension);
Logger.LogTrace("{dlName}: Extracting {fileHash}:{len} => {dest}", Logger.LogTrace("{dlName}: Decompressing {file}:{len} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath);
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); MungeBuffer(compressed);
var decompressed = LZ4Wrapper.Unwrap(compressed);
await _decompressGate.WaitAsync(ct).ConfigureAwait(false);
byte[] decompressed;
try
{
decompressed = await Task.Run(() => LZ4Wrapper.Unwrap(compressed), ct).ConfigureAwait(false);
}
finally
{
_decompressGate.Release();
}
if (rawSizeLookup.TryGetValue(fileHash, out var expectedRawSize) if (rawSizeLookup.TryGetValue(fileHash, out var expectedRawSize)
&& expectedRawSize > 0 && expectedRawSize > 0
&& decompressed.LongLength != expectedRawSize) && decompressed.LongLength != expectedRawSize)
{ {
Logger.LogWarning( await _fileCompactor.WriteAllBytesAsync(filePath, Array.Empty<byte>(), ct).ConfigureAwait(false);
"{dlName}: Size mismatch for {fileHash} (expected {expected}, got {actual}). Treating as corrupt.", PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation);
downloadLabel, fileHash, expectedRawSize, decompressed.LongLength);
try { if (File.Exists(filePath)) File.Delete(filePath); } catch { /* ignore */ }
continue; continue;
} }
await _fileCompactor.WriteAllBytesAsync(filePath, decompressed, ct, enqueueCompaction: false).ConfigureAwait(false); MungeBuffer(compressed);
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation);
extracted++; await _decompressGate.WaitAsync(ct).ConfigureAwait(false);
MarkTransferredFiles(downloadStatusKey, extracted); 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);
}
finally
{
_decompressGate.Release();
}
} }
catch (EndOfStreamException) catch (EndOfStreamException)
{ {
Logger.LogWarning("{dlName}: Block ended mid-entry while extracting {fileHash}", downloadLabel, fileHash); Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", downloadLabel, fileHash);
break;
} }
catch (Exception ex) catch (Exception e)
{ {
Logger.LogWarning(ex, "{dlName}: Error extracting {fileHash} from block", downloadLabel, fileHash); Logger.LogWarning(e, "{dlName}: Error during decompression", downloadLabel);
} }
} }
SetStatus(downloadStatusKey, DownloadStatus.Completed);
} }
SetStatus(downloadStatusKey, DownloadStatus.Completed);
} }
catch (EndOfStreamException) catch (EndOfStreamException)
{ {
@@ -599,10 +601,11 @@ 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;
} }
@@ -1030,58 +1033,48 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
private async Task ProcessDeferredCompressionsAsync(CancellationToken ct) private async Task ProcessDeferredCompressionsAsync(CancellationToken ct)
{ {
if (_deferredCompressionQueue.IsEmpty || !_configService.Current.UseCompactor) if (_deferredCompressionQueue.IsEmpty)
return; return;
// Drain queue into a unique set (same file can be enqueued multiple times) var filesToCompress = new List<string>();
var filesToCompact = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (_deferredCompressionQueue.TryDequeue(out var filePath)) while (_deferredCompressionQueue.TryDequeue(out var filePath))
{ {
if (!string.IsNullOrWhiteSpace(filePath)) if (File.Exists(filePath))
filesToCompact.Add(filePath); filesToCompress.Add(filePath);
} }
if (filesToCompact.Count == 0) if (filesToCompress.Count == 0)
return; return;
Logger.LogDebug("Starting deferred compaction of {count} files", filesToCompact.Count); Logger.LogDebug("Starting deferred compression of {count} files", filesToCompress.Count);
var enqueueWorkers = Math.Clamp(Environment.ProcessorCount / 4, 1, 2); var compressionWorkers = Math.Clamp(Environment.ProcessorCount / 4, 2, 4);
await Parallel.ForEachAsync( await Parallel.ForEachAsync(filesToCompress,
filesToCompact,
new ParallelOptions new ParallelOptions
{ {
MaxDegreeOfParallelism = enqueueWorkers, MaxDegreeOfParallelism = compressionWorkers,
CancellationToken = ct 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);
Logger.LogTrace("Deferred compaction queued: {filePath}", filePath); await _fileCompactor.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false);
} 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 queue deferred compaction for file: {filePath}", filePath); Logger.LogWarning(ex, "Failed to compress file: {filePath}", filePath);
} }
}).ConfigureAwait(false); }).ConfigureAwait(false);
Logger.LogDebug("Completed queuing deferred compaction of {count} files", filesToCompact.Count); Logger.LogDebug("Completed deferred compression of {count} files", filesToCompress.Count);
} }
private sealed class InlineProgress : IProgress<long> private sealed class InlineProgress : IProgress<long>