Compare commits

..

4 Commits

Author SHA1 Message Date
Tsubasahane
d1fafd459b Fix lumina offset for WorldSheet 2026-01-12 13:55:09 +08:00
Tsubasahane
a88677ff66 Merge remote-tracking branch 'origin/2.0.3' into i18n 2026-01-06 10:03:26 +08:00
Tsubasahane
b87837dadc Merge remote-tracking branch 'origin/2.0.3' into i18n 2026-01-03 10:29:25 +08:00
Tsubasahane
f89ea9d879 first step in i18n 2025-12-30 10:20:44 +08:00
35 changed files with 1530 additions and 3330 deletions

View File

@@ -9,8 +9,7 @@ env:
DOTNET_VERSION: |
10.x.x
9.x.x
DOTNET_CLI_TELEMETRY_OPTOUT: true
jobs:
tag-and-release:
runs-on: ubuntu-22.04
@@ -33,14 +32,16 @@ jobs:
- name: Download Dalamud
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
unzip latest.zip -d ~/.xlcore/dalamud/Hooks/dev
unzip latest.zip -d /root/.xlcore/dalamud/Hooks/dev
- name: Lets Build Lightless!
run: |
dotnet publish --configuration Release
mv LightlessSync/bin/x64/Release/LightlessSync/latest.zip LightlessClient.zip
dotnet restore
dotnet build --configuration Release --no-restore
dotnet publish --configuration Release --no-build
- name: Get version
id: package_version
@@ -52,6 +53,19 @@ jobs:
run: |
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)
if: github.ref == 'refs/heads/master'
run: |
@@ -148,7 +162,14 @@ jobs:
echo "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
- 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
env:
RELEASE_ID: ${{ env.RELEASE_ID }}
@@ -156,7 +177,7 @@ jobs:
echo "Uploading to release ID: $RELEASE_ID"
curl --fail-with-body -s -X POST \
-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"
- name: Clone plugin hosting repo
@@ -165,7 +186,7 @@ jobs:
cd LightlessSyncRepo
git clone https://git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessSync.git
env:
GIT_TERMINAL_PROMPT: 0
GIT_TERMINAL_PROMPT: 0
- name: Update plogonmaster.json with version (master)
if: github.ref == 'refs/heads/master'
@@ -261,8 +282,8 @@ jobs:
- name: Commit and push to LightlessSync
run: |
cd LightlessSyncRepo/LightlessSync
git config user.name "Gitea-Automation"
git config user.email "aaa@aaaaaaa.aaa"
git config user.name "github-actions"
git config user.email "github-actions@github.com"
git add .
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

View File

@@ -21,7 +21,6 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
private long _currentFileProgress = 0;
private CancellationTokenSource _scanCancellationTokenSource = new();
private readonly CancellationTokenSource _periodicCalculationTokenSource = new();
private readonly SemaphoreSlim _dbGate = new(1, 1);
public static readonly IImmutableList<string> AllowedFileExtensions = [".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".pbd", ".scd", ".skp", ".shpk", ".kdb"];
private static readonly HashSet<string> AllowedFileExtensionSet = new(AllowedFileExtensions, StringComparer.OrdinalIgnoreCase);
@@ -69,9 +68,6 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
{
Logger.LogInformation("Starting Periodic Storage Directory Calculation Task");
var token = _periodicCalculationTokenSource.Token;
while (IsHalted() && !token.IsCancellationRequested)
await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false);
while (!token.IsCancellationRequested)
{
try
@@ -95,9 +91,6 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public long CurrentFileProgress => _currentFileProgress;
public long FileCacheSize { get; set; }
public long FileCacheDriveFree { get; set; }
private int _haltCount;
private bool IsHalted() => Volatile.Read(ref _haltCount) > 0;
public ConcurrentDictionary<string, int> HaltScanLocks { get; set; } = new(StringComparer.Ordinal);
public bool IsScanRunning => CurrentFileProgress > 0 || TotalFiles > 0;
public long TotalFiles { get; private set; }
@@ -105,36 +98,14 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public void HaltScan(string source)
{
HaltScanLocks.AddOrUpdate(source, 1, (_, v) => v + 1);
Interlocked.Increment(ref _haltCount);
if (!HaltScanLocks.ContainsKey(source)) HaltScanLocks[source] = 0;
HaltScanLocks[source]++;
}
record WatcherChange(WatcherChangeTypes ChangeType, string? OldPath = null);
private readonly record struct CacheEvictionCandidate(string FullPath, long Size, DateTime LastAccessTime);
private readonly object _penumbraGate = new();
private Dictionary<string, WatcherChange> _watcherChanges = new(StringComparer.OrdinalIgnoreCase);
private readonly object _lightlessGate = new();
private Dictionary<string, WatcherChange> _lightlessChanges = new(StringComparer.OrdinalIgnoreCase);
private Dictionary<string, WatcherChange> DrainPenumbraChanges()
{
lock (_penumbraGate)
{
var snapshot = _watcherChanges;
_watcherChanges = new(StringComparer.OrdinalIgnoreCase);
return snapshot;
}
}
private Dictionary<string, WatcherChange> DrainLightlessChanges()
{
lock (_lightlessGate)
{
var snapshot = _lightlessChanges;
_lightlessChanges = new(StringComparer.OrdinalIgnoreCase);
return snapshot;
}
}
private readonly Dictionary<string, WatcherChange> _watcherChanges = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, WatcherChange> _lightlessChanges = new(StringComparer.OrdinalIgnoreCase);
public void StopMonitoring()
{
@@ -197,7 +168,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
if (!HasAllowedExtension(e.FullPath)) return;
lock (_lightlessChanges)
lock (_watcherChanges)
{
_lightlessChanges[e.FullPath] = new(e.ChangeType);
}
@@ -308,58 +279,67 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
private async Task LightlessWatcherExecution()
{
_lightlessFswCts = _lightlessFswCts.CancelRecreate();
_lightlessFswCts = _lightlessFswCts.CancelRecreate();
var token = _lightlessFswCts.Token;
var delay = TimeSpan.FromSeconds(5);
Dictionary<string, WatcherChange> changes;
lock (_lightlessChanges)
changes = _lightlessChanges.ToDictionary(t => t.Key, t => t.Value, StringComparer.Ordinal);
try
{
await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(false);
while (IsHalted() && !token.IsCancellationRequested)
await Task.Delay(250, token).ConfigureAwait(false);
do
{
await Task.Delay(delay, token).ConfigureAwait(false);
} while (HaltScanLocks.Any(f => f.Value > 0));
}
catch (TaskCanceledException) { return; }
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
catch (TaskCanceledException)
{
var deleted = changes.Where(c => c.Value.ChangeType == WatcherChangeTypes.Deleted).Select(c => c.Key);
var renamed = changes.Where(c => c.Value.ChangeType == WatcherChangeTypes.Renamed);
var remaining = changes.Where(c => c.Value.ChangeType != WatcherChangeTypes.Deleted).Select(c => c.Key);
return;
}
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);
}
foreach (var entry in renamed)
foreach (var entry in renamedEntries)
{
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);
}
var allChanges = deleted
.Concat(renamed.Select(c => c.Value.OldPath!))
.Concat(renamed.Select(c => c.Key))
.Concat(remaining)
.Distinct(StringComparer.OrdinalIgnoreCase)
var allChanges = deletedEntries
.Concat(renamedEntries.Select(c => c.Value.OldPath!))
.Concat(renamedEntries.Select(c => c.Key))
.Concat(remainingEntries)
.ToArray();
_ = _fileDbManager.GetFileCachesByPaths(allChanges);
await _fileDbManager.WriteOutFullCsvAsync(token).ConfigureAwait(false);
}
finally
{
_dbGate.Release();
_fileDbManager.WriteOutFullCsv();
}
}
@@ -367,97 +347,77 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
{
_penumbraFswCts = _penumbraFswCts.CancelRecreate();
var token = _penumbraFswCts.Token;
Dictionary<string, WatcherChange> changes;
lock (_watcherChanges)
changes = _watcherChanges.ToDictionary(t => t.Key, t => t.Value, StringComparer.Ordinal);
var delay = TimeSpan.FromSeconds(10);
try
{
await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(false);
while (IsHalted() && !token.IsCancellationRequested)
await Task.Delay(250, token).ConfigureAwait(false);
do
{
await Task.Delay(delay, token).ConfigureAwait(false);
} while (HaltScanLocks.Any(f => f.Value > 0));
}
catch (TaskCanceledException)
{
return;
}
catch (TaskCanceledException) { return; }
var changes = DrainPenumbraChanges();
if (changes.Count > 0)
_ = HandleChangesAsync(changes, token);
lock (_watcherChanges)
{
foreach (var key in changes.Keys)
{
_watcherChanges.Remove(key);
}
}
HandleChanges(changes);
}
public void InvokeScan()
{
TotalFiles = 0;
TotalFilesStorage = 0;
Interlocked.Exchange(ref _currentFileProgress, 0);
_currentFileProgress = 0;
_scanCancellationTokenSource = _scanCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource();
var token = _scanCancellationTokenSource.Token;
_ = Task.Run(async () =>
{
TaskCompletionSource scanTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
try
Logger.LogDebug("Starting Full File Scan");
TotalFiles = 0;
_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...");
await Task.Delay(250, token).ConfigureAwait(false);
_performanceCollector.LogPerformance(this, $"FullFileScan", () => FullFileScan(token));
}
var scanThread = new Thread(() =>
catch (Exception ex)
{
try
{
token.ThrowIfCancellationRequested();
_performanceCollector.LogPerformance(this, $"FullFileScan",
() => FullFileScan(token));
scanTcs.TrySetResult();
}
catch (OperationCanceledException)
{
scanTcs.TrySetCanceled(token);
}
catch (Exception ex)
{
Logger.LogError(ex, "Error during Full File Scan");
scanTcs.TrySetException(ex);
}
})
{
Priority = ThreadPriority.Lowest,
IsBackground = true,
Name = "LightlessSync.FullFileScan"
};
scanThread.Start();
using var _ = token.Register(() => scanTcs.TrySetCanceled(token));
await scanTcs.Task.ConfigureAwait(false);
}
catch (TaskCanceledException)
Logger.LogError(ex, "Error during Full File Scan");
}
})
{
Logger.LogInformation("Full File Scan was canceled.");
}
catch (Exception ex)
Priority = ThreadPriority.Lowest,
IsBackground = true
};
scanThread.Start();
while (scanThread.IsAlive)
{
Logger.LogError(ex, "Unexpected error in InvokeScan task");
}
finally
{
TotalFiles = 0;
TotalFilesStorage = 0;
Interlocked.Exchange(ref _currentFileProgress, 0);
await Task.Delay(250, token).ConfigureAwait(false);
}
TotalFiles = 0;
_currentFileProgress = 0;
}, token);
}
public void RecalculateFileCacheSize(CancellationToken token)
{
if (IsHalted()) return;
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) ||
!Directory.Exists(_configService.Current.CacheFolder))
{
@@ -634,20 +594,10 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public void ResumeScan(string source)
{
int delta = 0;
if (!HaltScanLocks.ContainsKey(source)) HaltScanLocks[source] = 0;
HaltScanLocks.AddOrUpdate(source,
addValueFactory: _ => 0,
updateValueFactory: (_, v) =>
{
ArgumentException.ThrowIfNullOrEmpty(_);
if (v <= 0) return 0;
delta = 1;
return v - 1;
});
if (delta == 1)
Interlocked.Decrement(ref _haltCount);
HaltScanLocks[source]--;
if (HaltScanLocks[source] < 0) HaltScanLocks[source] = 0;
}
protected override void Dispose(bool disposing)
@@ -671,243 +621,235 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
private void FullFileScan(CancellationToken ct)
{
TotalFiles = 1;
_currentFileProgress = 0;
var penumbraDir = _ipcManager.Penumbra.ModDirectory;
var cacheFolder = _configService.Current.CacheFolder;
bool penDirExists = true;
bool cacheDirExists = true;
if (string.IsNullOrEmpty(penumbraDir) || !Directory.Exists(penumbraDir))
{
penDirExists = false;
Logger.LogWarning("Penumbra directory is not set or does not exist.");
return;
}
if (string.IsNullOrEmpty(cacheFolder) || !Directory.Exists(cacheFolder))
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) || !Directory.Exists(_configService.Current.CacheFolder))
{
cacheDirExists = false;
Logger.LogWarning("Lightless Cache directory is not set or does not exist.");
}
if (!penDirExists || !cacheDirExists)
{
return;
}
var prevPriority = Thread.CurrentThread.Priority;
var previousThreadPriority = Thread.CurrentThread.Priority;
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);
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))
try
{
ct.ThrowIfCancellationRequested();
try
{
foreach (var file in Directory.EnumerateFiles(folder, "*.*", SearchOption.AllDirectories))
{
ct.ThrowIfCancellationRequested();
if (!HasAllowedExtension(file)) continue;
if (IsExcludedPenumbraPath(file)) continue;
onDiskPaths.Add(file);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Could not enumerate path {path}", folder);
}
penumbraFiles[folder] =
[
.. Directory.GetFiles(folder, "*.*", SearchOption.AllDirectories)
.AsParallel()
.Where(f => HasAllowedExtension(f)
&& !f.Contains(@"\bg\", StringComparison.OrdinalIgnoreCase)
&& !f.Contains(@"\bgcommon\", StringComparison.OrdinalIgnoreCase)
&& !f.Contains(@"\ui\", StringComparison.OrdinalIgnoreCase)),
];
}
foreach (var file in Directory.EnumerateFiles(cacheFolder, "*.*", SearchOption.TopDirectoryOnly))
catch (Exception ex)
{
ct.ThrowIfCancellationRequested();
var name = Path.GetFileName(file);
var stem = Path.GetFileNameWithoutExtension(file);
if (name.Length == 40 || stem.Length == 40)
onDiskPaths.Add(file);
Logger.LogWarning(ex, "Could not enumerate path {path}", folder);
}
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);
}
Thread.Sleep(50);
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");
return;
}
var threadNr = (int)tcounter!;
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)
{
didMutateDb = true;
_fileDbManager.UpdateHashedFile(entity);
}
foreach (var entity in entitiesToRemove)
{
didMutateDb = true;
_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;
ProcessOne(path);
Interlocked.Increment(ref _currentFileProgress);
Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}",
_fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
return;
}
Logger.LogTrace("Scanner added {count} new files to db", newFiles.Count);
void ProcessOne(string? filePath)
if (!_ipcManager.Penumbra.APIAvailable)
{
if (filePath == null)
return;
if (!_ipcManager.Penumbra.APIAvailable)
{
Logger.LogWarning("Penumbra not available");
return;
}
try
{
var entry = _fileDbManager.CreateFileEntry(filePath);
if (entry == null)
_ = _fileDbManager.CreateCacheEntry(filePath);
}
catch (IOException ioex)
{
Logger.LogDebug(ioex, "File busy or locked: {file}", filePath);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed adding {file}", filePath);
}
Logger.LogWarning("Penumbra not available");
return;
}
Logger.LogDebug("Scan complete");
TotalFiles = 0;
_currentFileProgress = 0;
if (!_configService.Current.InitialScanComplete)
try
{
_configService.Current.InitialScanComplete = true;
_configService.Save();
StartLightlessWatcher(cacheFolder);
StartPenumbraWatcher(penumbraDir);
var entry = _fileDbManager.CreateFileEntry(cachePath);
if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath);
}
catch (IOException ioex)
{
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
}
catch (Exception ex)
{
Logger.LogError(ex, "Error during Full File Scan");
}
finally
{
Thread.CurrentThread.Priority = prevPriority;
_configService.Current.InitialScanComplete = true;
_configService.Save();
StartLightlessWatcher(_configService.Current.CacheFolder);
StartPenumbraWatcher(penumbraDir);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -92,43 +92,25 @@ public sealed class PenumbraCollections : PenumbraBase
_activeTemporaryCollections.TryRemove(collectionId, out _);
}
public async Task SetTemporaryModsAsync(
ILogger logger,
Guid applicationId,
Guid collectionId,
Dictionary<string, string> modPaths,
string scope)
public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths)
{
if (!IsAvailable || collectionId == Guid.Empty)
return;
var modName = $"LightlessChara_Files_{applicationId:N}_{scope}";
var normalized = new Dictionary<string, string>(modPaths.Count, StringComparer.OrdinalIgnoreCase);
foreach (var kvp in modPaths)
{
if (string.IsNullOrWhiteSpace(kvp.Key) || string.IsNullOrWhiteSpace(kvp.Value))
continue;
var gamePath = kvp.Key.Replace('\\', '/').ToLowerInvariant();
normalized[gamePath] = kvp.Value;
return;
}
await DalamudUtil.RunOnFrameworkThread(() =>
{
foreach (var mod in normalized)
logger.LogTrace("[{ApplicationId}] {ModName}: {From} => {To}", applicationId, modName, mod.Key, mod.Value);
foreach (var mod in modPaths)
{
logger.LogTrace("[{ApplicationId}] Change: {From} => {To}", applicationId, mod.Key, mod.Value);
}
var removeResult = _removeTemporaryMod.Invoke(modName, collectionId, 0);
logger.LogTrace("[{ApplicationId}] Removing temp mod {ModName} for {CollectionId}, Success: {Result}",
applicationId, modName, collectionId, removeResult);
var removeResult = _removeTemporaryMod.Invoke("LightlessChara_Files", collectionId, 0);
logger.LogTrace("[{ApplicationId}] Removing temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, removeResult);
if (normalized.Count == 0)
return;
var addResult = _addTemporaryMod.Invoke(modName, collectionId, normalized, string.Empty, 0);
logger.LogTrace("[{ApplicationId}] Setting temp mod {ModName} for {CollectionId}, Success: {Result}",
applicationId, modName, collectionId, addResult);
var addResult = _addTemporaryMod.Invoke("LightlessChara_Files", collectionId, modPaths, string.Empty, 0);
logger.LogTrace("[{ApplicationId}] Setting temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, addResult);
}).ConfigureAwait(false);
}
@@ -189,7 +171,7 @@ public sealed class PenumbraCollections : PenumbraBase
Logger.LogDebug("Cleaning up stale temporary collection {CollectionName} ({CollectionId})", name, collectionId);
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);
return result;
}).ConfigureAwait(false);

View File

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

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

View File

@@ -1,6 +1,6 @@
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using LightlessSync.API.Data.Enum;
using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration;
@@ -9,12 +9,11 @@ using LightlessSync.PlayerData.Data;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.ExceptionServices;
using System.Runtime.InteropServices;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.PlayerData.Factories;
@@ -120,28 +119,45 @@ public class PlayerDataFactory
return null;
}
private static readonly int _characterGameObjectOffset =
(int)Marshal.OffsetOf<Character>(nameof(Character.GameObject));
private static readonly int _gameObjectDrawObjectOffset =
(int)Marshal.OffsetOf<GameObject>(nameof(GameObject.DrawObject));
private async Task<bool> CheckForNullDrawObject(IntPtr playerPointer)
=> await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectSafe(playerPointer))
.ConfigureAwait(false);
=> await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).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;
var drawObjPtrAddress = playerPointer + _characterGameObjectOffset + _gameObjectDrawObjectOffset;
// Read the DrawObject pointer from memory
if (!MemoryProcessProbe.TryReadIntPtr(drawObjPtrAddress, out var drawObj))
if (!IsPointerValid(playerPointer))
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)
@@ -157,7 +173,7 @@ public class PlayerDataFactory
if (_characterBuildCache.TryGetValue(key, out var cached) && IsCacheFresh(cached) && !_characterBuildInflight.ContainsKey(key))
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))
{
@@ -550,20 +566,20 @@ public class PlayerDataFactory
await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
try
{
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
var papPath = cacheEntity?.ResolvedFilepath;
if (!string.IsNullOrEmpty(papPath) && File.Exists(papPath))
try
{
var havokBytes = await Task.Run(() => XivDataAnalyzer.ReadHavokBytesFromPap(papPath), ct)
papIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash, persistToConfig: false), ct)
.ConfigureAwait(false);
if (havokBytes is { Length: > 8 })
{
papIndices = await _dalamudUtil.RunOnFrameworkThread(
() => _modelAnalyzer.ParseHavokBytesOnFrameworkThread(havokBytes, hash, persistToConfig: false))
.ConfigureAwait(false);
}
}
catch (SEHException ex)
{
_logger.LogError(ex, "SEH exception while parsing PAP file (hash={hash}, path={path}). Error code: 0x{code:X}. Skipping this animation.", hash, papPathSummary, ex.ErrorCode);
continue;
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error parsing PAP file (hash={hash}, path={path}). Skipping this animation.", hash, papPathSummary);
continue;
}
}
finally
@@ -574,6 +590,20 @@ public class PlayerDataFactory
if (papIndices == null || papIndices.Count == 0)
continue;
bool hasValidIndices = false;
try
{
hasValidIndices = papIndices.All(k => k.Value != null && k.Value.DefaultIfEmpty().Max() <= 105);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error validating bone indices for PAP (hash={hash}, path={path}). Skipping.", hash, papPathSummary);
continue;
}
if (hasValidIndices)
continue;
if (_logger.IsEnabled(LogLevel.Debug))
{
try
@@ -673,8 +703,8 @@ public class PlayerDataFactory
return new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase).AsReadOnly();
}
var forwardPathsLower = forwardPaths.Length == 0 ? [] : forwardPaths.Select(p => p.ToLowerInvariant()).ToArray();
var reversePathsLower = reversePaths.Length == 0 ? [] : reversePaths.Select(p => p.ToLowerInvariant()).ToArray();
var forwardPathsLower = forwardPaths.Length == 0 ? Array.Empty<string>() : forwardPaths.Select(p => p.ToLowerInvariant()).ToArray();
var reversePathsLower = reversePaths.Length == 0 ? Array.Empty<string>() : reversePaths.Select(p => p.ToLowerInvariant()).ToArray();
Dictionary<string, List<string>> resolvedPaths = new(forwardPaths.Length + reversePaths.Length, StringComparer.Ordinal);
if (handler.ObjectKind != ObjectKind.Player)

View File

@@ -1,68 +1,45 @@
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
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;
namespace LightlessSync.PlayerData.Handlers;
/// <summary>
/// Game object handler for managing game object state and updates
/// </summary>
public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighPriorityMediatorSubscriber
{
private readonly DalamudUtilService _dalamudUtil;
private readonly IObjectTable _objectTable;
private readonly Func<IntPtr> _getAddress;
private readonly bool _isOwnedObject;
private readonly PerformanceCollectorService _performanceCollector;
private readonly Lock _frameworkUpdateGate = new();
private readonly object _frameworkUpdateGate = new();
private bool _frameworkUpdateSubscribed;
private byte _classJob = 0;
private Task? _delayedZoningTask;
private bool _haltProcessing = false;
private CancellationTokenSource _zoningCts = new();
/// <summary>
/// Constructor for GameObjectHandler
/// </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)
public GameObjectHandler(ILogger<GameObjectHandler> logger, PerformanceCollectorService performanceCollector,
LightlessMediator mediator, DalamudUtilService dalamudUtil, ObjectKind objectKind, Func<IntPtr> getAddress, bool ownedObject = true) : base(logger, mediator)
{
_performanceCollector = performanceCollector;
ObjectKind = objectKind;
_dalamudUtil = dalamudUtil;
_objectTable = objectTable;
_getAddress = () =>
{
_dalamudUtil.EnsureIsOnFramework();
return getAddress.Invoke();
};
_isOwnedObject = ownedObject;
Name = string.Empty;
if (ownedObject)
{
Mediator.Subscribe<TransientResourceChangedMessage>(this, msg =>
Mediator.Subscribe<TransientResourceChangedMessage>(this, (msg) =>
{
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<ZoneSwitchStartMessage>(this, _ => ZoneSwitchStart());
Mediator.Subscribe<ZoneSwitchEndMessage>(this, (_) => ZoneSwitchEnd());
Mediator.Subscribe<ZoneSwitchStartMessage>(this, (_) => ZoneSwitchStart());
Mediator.Subscribe<CutsceneStartMessage>(this, _ => _haltProcessing = true);
Mediator.Subscribe<CutsceneEndMessage>(this, _ =>
Mediator.Subscribe<CutsceneStartMessage>(this, (_) =>
{
_haltProcessing = true;
});
Mediator.Subscribe<CutsceneEndMessage>(this, (_) =>
{
_haltProcessing = false;
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));
_dalamudUtil.EnsureIsOnFramework();
CheckAndUpdateObject(allowPublish: true);
_dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult();
}
/// <summary>
/// Draw Condition Enum
/// </summary>
public enum DrawCondition
{
None,
@@ -112,7 +96,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
ModelFilesInSlotLoaded
}
// Properties
public IntPtr Address { get; private set; }
public DrawCondition CurrentDrawCondition { get; set; } = DrawCondition.None;
public byte Gender { get; private set; }
@@ -123,21 +106,18 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
public byte TribeId { get; private set; }
private byte[] CustomizeData { get; set; } = new byte[26];
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>
/// 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)
public async Task ActOnFrameworkAfterEnsureNoDrawAsync(Action<Dalamud.Game.ClientState.Objects.Types.ICharacter> act, CancellationToken token)
{
while (await _dalamudUtil.RunOnFrameworkThread(() =>
{
EnsureLatestObjectState();
if (CurrentDrawCondition != DrawCondition.None) return true;
var gameObj = _dalamudUtil.CreateGameObject(Address);
if (gameObj is ICharacter chara)
if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter 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)
{
if (!string.Equals(Name, name, StringComparison.OrdinalIgnoreCase))
@@ -165,18 +140,11 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
}
}
/// <summary>
/// Gets the game object from the address
/// </summary>
/// <returns>Gane object</returns>
public IGameObject? GetGameObject()
public Dalamud.Game.ClientState.Objects.Types.IGameObject? GetGameObject()
{
return _dalamudUtil.CreateGameObject(Address);
}
/// <summary>
/// Invalidate the object handler
/// </summary>
public void Invalidate()
{
Address = IntPtr.Zero;
@@ -185,62 +153,25 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
_haltProcessing = false;
}
/// <summary>
/// Refresh the object handler state
/// </summary>
public void Refresh()
{
_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()
{
return await _dalamudUtil.RunOnFrameworkThread(IsBeingDrawn).ConfigureAwait(false);
}
/// <summary>
/// Override ToString method for GameObjectHandler
/// </summary>
/// <returns>String</returns>
public override string ToString()
{
var owned = _isOwnedObject ? "Self" : "Other";
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);
/// <summary>
/// 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)
private unsafe void CheckAndUpdateObject(bool allowPublish)
{
var prevAddr = Address;
var prevDrawObj = DrawObjectAddress;
@@ -248,140 +179,127 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
Address = _getAddress();
IGameObject? obj = null;
ICharacter? chara = null;
if (Address != nint.Zero)
if (Address != IntPtr.Zero)
{
// Try get object
obj = TryGetObjectByAddress(Address);
var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address;
DrawObjectAddress = (IntPtr)gameObject->DrawObject;
EntityId = gameObject->EntityId;
if (obj is not null)
{
EntityId = obj.EntityId;
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;
}
var chara = (Character*)Address;
nameString = chara->GameObject.NameString;
if (!string.IsNullOrEmpty(nameString) && !string.Equals(nameString, Name, StringComparison.Ordinal))
Name = nameString;
}
else
{
DrawObjectAddress = nint.Zero;
DrawObjectAddress = IntPtr.Zero;
EntityId = uint.MaxValue;
}
// Update draw condition
CurrentDrawCondition = IsBeingDrawnSafe(obj, chara);
CurrentDrawCondition = IsBeingDrawnUnsafe();
if (_haltProcessing || !allowPublish) return;
// Determine differences
bool drawObjDiff = DrawObjectAddress != prevDrawObj;
bool addrDiff = Address != prevAddr;
// Name change check
bool nameChange = false;
if (nameString is not null)
if (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero)
{
nameChange = !string.Equals(nameString, Name, StringComparison.Ordinal);
if (nameChange) Name = nameString;
}
var chara = (Character*)Address;
var drawObj = (DrawObject*)DrawObjectAddress;
var objType = drawObj->Object.GetObjectType();
var isHuman = objType == ObjectType.CharacterBase
&& ((CharacterBase*)drawObj)->GetModelType() == CharacterBase.ModelType.Human;
// Customize data change check
bool customizeDiff = false;
if (chara is not null)
{
// Class job change check
var classJob = chara.ClassJob.RowId;
if (classJob != _classJob)
nameString ??= ((Character*)Address)->GameObject.NameString;
var nameChange = !string.Equals(nameString, Name, StringComparison.Ordinal);
if (nameChange) Name = nameString;
bool equipDiff = false;
if (isHuman)
{
Logger.LogTrace("[{this}] classjob changed from {old} to {new}", this, _classJob, classJob);
_classJob = (byte)classJob;
Mediator.Publish(new ClassJobChangedMessage(this));
var classJob = chara->CharacterData.ClassJob;
if (classJob != _classJob)
{
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
customizeDiff = CompareAndUpdateCustomizeData(chara.Customize);
// Census update publish
if (_isOwnedObject && ObjectKind == ObjectKind.Player && chara.Customize.Length > (int)CustomizeIndex.Tribe)
if (equipDiff && !_isOwnedObject) // send the message out immediately and cancel out, no reason to continue if not self
{
var gender = chara.Customize[(int)CustomizeIndex.Gender];
var raceId = chara.Customize[(int)CustomizeIndex.Race];
var tribeId = chara.Customize[(int)CustomizeIndex.Tribe];
Logger.LogTrace("[{this}] Changed", this);
return;
}
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));
Gender = gender;
RaceId = raceId;
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 || customizeDiff || nameChange) && _isOwnedObject)
{
Logger.LogDebug("[{this}] Changed, Sending CreateCacheObjectMessage", this);
Mediator.Publish(new CreateCacheForObjectMessage(this));
if ((addrDiff || drawObjDiff || equipDiff || customizeDiff || nameChange) && _isOwnedObject)
{
Logger.LogDebug("[{this}] Changed, Sending CreateCacheObjectMessage", this);
Mediator.Publish(new CreateCacheForObjectMessage(this));
}
}
else if (addrDiff || drawObjDiff)
{
if (Address == nint.Zero)
CurrentDrawCondition = DrawCondition.ObjectZero;
else if (DrawObjectAddress == nint.Zero)
CurrentDrawCondition = DrawCondition.DrawObjectZero;
CurrentDrawCondition = DrawCondition.DrawObjectZero;
Logger.LogTrace("[{this}] Changed", this);
if (_isOwnedObject && ObjectKind != ObjectKind.Player)
{
Mediator.Publish(new ClearCacheForObjectMessage(this));
}
}
}
/// <summary>
/// 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)
private unsafe bool CompareAndUpdateCustomizeData(Span<byte> customizeData)
{
bool hasChanges = false;
// Resize if needed
var len = Math.Min(customizeData.Length, CustomizeData.Length);
for (int i = 0; i < len; i++)
for (int i = 0; i < customizeData.Length; i++)
{
var data = customizeData[i];
if (CustomizeData[i] != data)
@@ -394,9 +312,48 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
return hasChanges;
}
/// <summary>
/// Framework update method
/// </summary>
private unsafe bool CompareAndUpdateEquipByteData(byte* equipSlotData)
{
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()
{
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()
{
EnsureLatestObjectState();
@@ -428,9 +381,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
return CurrentDrawCondition != DrawCondition.None;
}
/// <summary>
/// Ensures the latest object state
/// </summary>
private void EnsureLatestObjectState()
{
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()
{
lock (_frameworkUpdateGate)
@@ -456,9 +403,24 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
}
}
/// <summary>
/// Zone switch end handling
/// </summary>
private unsafe DrawCondition IsBeingDrawnUnsafe()
{
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()
{
if (!_isOwnedObject) return;
@@ -469,7 +431,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
}
catch (ObjectDisposedException)
{
// ignore canelled after disposed
// ignore
}
catch (Exception ex)
{
@@ -477,9 +439,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
}
}
/// <summary>
/// Zone switch start handling
/// </summary>
private void ZoneSwitchStart()
{
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.Enum;
using LightlessSync.API.Data;
namespace LightlessSync.PlayerData.Pairs;
namespace LightlessSync.PlayerData.Pairs;
/// <summary>
/// orchestrates the lifecycle of a paired character
/// </summary>
public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject
{
new string Ident { get; }
bool Initialized { get; }
bool IsVisible { get; }
bool ScheduledForDeletion { get; set; }
CharacterData? LastReceivedCharacterData { get; }
long LastAppliedDataBytes { get; }
new string? PlayerName { get; }
string PlayerNameHash { get; }
uint PlayerCharacterId { get; }
DateTime? LastDataReceivedAt { get; }
DateTime? LastApplyAttemptAt { get; }
DateTime? LastSuccessfulApplyAt { get; }
string? LastFailureReason { get; }
IReadOnlyList<string> LastBlockingConditions { get; }
bool IsApplying { get; }
bool IsDownloading { get; }
int PendingDownloadCount { get; }
int ForbiddenDownloadCount { get; }
bool PendingModReapply { get; }
bool ModApplyDeferred { get; }
int MissingCriticalMods { 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; }
/// <summary>
/// orchestrates the lifecycle of a paired character
/// </summary>
public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject
{
new string Ident { get; }
bool Initialized { get; }
bool IsVisible { get; }
bool ScheduledForDeletion { get; set; }
CharacterData? LastReceivedCharacterData { get; }
long LastAppliedDataBytes { get; }
new string? PlayerName { get; }
string PlayerNameHash { get; }
uint PlayerCharacterId { get; }
DateTime? LastDataReceivedAt { get; }
DateTime? LastApplyAttemptAt { get; }
DateTime? LastSuccessfulApplyAt { get; }
string? LastFailureReason { get; }
IReadOnlyList<string> LastBlockingConditions { get; }
bool IsApplying { get; }
bool IsDownloading { get; }
int PendingDownloadCount { get; }
int ForbiddenDownloadCount { get; }
bool PendingModReapply { get; }
bool ModApplyDeferred { get; }
int MissingCriticalMods { get; }
int MissingNonCriticalMods { get; }
int MissingForbiddenMods { get; }
DateTime? InvisibleSinceUtc { get; }
DateTime? VisibilityEvictionDueAtUtc { get; }
void Initialize();
void ApplyData(CharacterData data);
void ApplyLastReceivedData(bool forced = false);
void HardReapplyLastData();
bool FetchPerformanceMetricsFromCache();
void LoadCachedCharacterData(CharacterData data);
void SetUploading(bool uploading);
void SetPaused(bool paused);
}
void ApplyData(CharacterData data);
void ApplyLastReceivedData(bool forced = false);
bool FetchPerformanceMetricsFromCache();
void LoadCachedCharacterData(CharacterData data);
void SetUploading(bool uploading);
void SetPaused(bool paused);
}

View File

@@ -82,114 +82,60 @@ public class Pair
public void AddContextMenu(IMenuOpenedArgs args)
{
var handler = TryGetHandler();
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;
}
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)
@@ -214,18 +160,6 @@ public class Pair
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)
{
var handler = TryGetHandler();
@@ -310,17 +244,6 @@ public class Pair
handler.ModApplyDeferred,
handler.MissingCriticalMods,
handler.MissingNonCriticalMods,
handler.MissingForbiddenMods,
handler.MinionAddressHex,
handler.MinionObjectIndex,
handler.MinionResolvedAtUtc,
handler.MinionResolveStage,
handler.MinionResolveFailureReason,
handler.MinionPendingRetry,
handler.MinionPendingRetryChanges,
handler.MinionHasAppearanceData,
handler.OwnedPenumbraCollectionId,
handler.NeedsCollectionRebuildDebug);
handler.MissingForbiddenMods);
}
}

View File

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

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

View File

@@ -110,7 +110,6 @@ public sealed class Plugin : IDalamudPlugin
services.AddSingleton(gameGui);
services.AddSingleton(gameInteropProvider);
services.AddSingleton(addonLifecycle);
services.AddSingleton(objectTable);
services.AddSingleton<IUiBuilder>(pluginInterface.UiBuilder);
// Core singletons
@@ -429,7 +428,6 @@ public sealed class Plugin : IDalamudPlugin
return cfg;
});
services.AddSingleton(sp => new ServerConfigService(configDir));
services.AddSingleton(sp => new PenumbraJanitorConfigService(configDir));
services.AddSingleton(sp => new NotesConfigService(configDir));
services.AddSingleton(sp => new PairTagConfigService(configDir));
services.AddSingleton(sp => new SyncshellTagConfigService(configDir));
@@ -443,7 +441,6 @@ public sealed class Plugin : IDalamudPlugin
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<UiThemeConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ChatConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ServerConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<PenumbraJanitorConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<NotesConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<PairTagConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<SyncshellTagConfigService>());

View File

@@ -1,7 +1,6 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
@@ -19,7 +18,7 @@ namespace LightlessSync.Resources {
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Resources {

View File

@@ -14,7 +14,6 @@ using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSu
using IPlayerCharacter = Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
using System.Runtime.InteropServices;
namespace LightlessSync.Services.ActorTracking;
@@ -58,8 +57,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
private bool _hooksActive;
private static readonly TimeSpan SnapshotRefreshInterval = TimeSpan.FromSeconds(1);
private DateTime _nextRefreshAllowed = DateTime.MinValue;
private int _warmStartQueued;
private int _warmStartRan;
public ActorObjectService(
ILogger<ActorObjectService> logger,
@@ -77,6 +74,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
_clientState = clientState;
_condition = condition;
_mediator = mediator;
_mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
{
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 ActorSnapshot Snapshot => Volatile.Read(ref _snapshot);
private GposeSnapshot CurrentGposeSnapshot => Volatile.Read(ref _gposeSnapshot);
@@ -342,8 +341,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
public Task StopAsync(CancellationToken cancellationToken)
{
_warmStartRan = 0;
DisposeHooks();
_activePlayers.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);
}
private unsafe (LightlessObjectKind? OwnedKind, uint OwnerEntityId) DetermineOwnedKind(
GameObject* gameObject,
DalamudObjectKind objectKind,
bool isLocalPlayer)
private unsafe (LightlessObjectKind? OwnedKind, uint OwnerEntityId) DetermineOwnedKind(GameObject* gameObject, DalamudObjectKind objectKind, bool isLocalPlayer)
{
if (gameObject == null)
return (null, 0);
@@ -523,7 +517,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
}
var ownerId = ResolveOwnerId(gameObject);
var localPlayerAddress = _objectTable.LocalPlayer?.Address ?? nint.Zero;
if (localPlayerAddress == nint.Zero)
return (null, ownerId);
@@ -535,7 +528,9 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
if (objectKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
{
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;
return (LightlessObjectKind.MinionOrMount, resolvedOwner);
@@ -545,16 +540,20 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
if (objectKind != DalamudObjectKind.BattleNpc)
return (null, ownerId);
if (ownerId != 0 && ownerId != localEntityId)
if (ownerId != localEntityId)
return (null, ownerId);
var expectedPet = GetPetAddress(localPlayerAddress, localEntityId);
if (expectedPet != nint.Zero && (nint)gameObject == expectedPet)
return (LightlessObjectKind.Pet, ownerId != 0 ? ownerId : localEntityId);
if (expectedPet != nint.Zero
&& (nint)gameObject == expectedPet
&& IsPlayerRelatedOwnedAddress(expectedPet, LightlessObjectKind.Pet))
return (LightlessObjectKind.Pet, ownerId);
var expectedCompanion = GetCompanionAddress(localPlayerAddress, localEntityId);
if (expectedCompanion != nint.Zero && (nint)gameObject == expectedCompanion)
return (LightlessObjectKind.Companion, ownerId != 0 ? ownerId : localEntityId);
if (expectedCompanion != nint.Zero
&& (nint)gameObject == expectedCompanion
&& IsPlayerRelatedOwnedAddress(expectedCompanion, LightlessObjectKind.Companion))
return (LightlessObjectKind.Companion, ownerId);
return (null, ownerId);
}
@@ -582,25 +581,21 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
return nint.Zero;
var playerObject = (GameObject*)localPlayerAddress;
var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1);
if (ownerEntityId == 0)
return nint.Zero;
if (candidateAddress != nint.Zero)
{
var candidate = (GameObject*)candidateAddress;
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
{
var resolvedOwner = ResolveOwnerId(candidate);
if (resolvedOwner == ownerEntityId || resolvedOwner == 0)
if (ResolveOwnerId(candidate) == ownerEntityId)
return candidateAddress;
}
}
if (ownerEntityId == 0)
return nint.Zero;
foreach (var obj in _objectTable)
{
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;
}
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)
{
if (localPlayerAddress == nint.Zero || ownerEntityId == 0)
@@ -1305,19 +1216,21 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
private static unsafe bool IsObjectFullyLoaded(nint address)
{
if (address == nint.Zero) return false;
if (address == nint.Zero)
return false;
var gameObject = (GameObject*)address;
if (gameObject == null) return false;
if (gameObject == null)
return false;
var drawObject = gameObject->DrawObject;
if (drawObject == null) return false;
if (drawObject == null)
return false;
if ((ulong)gameObject->RenderFlags == 2048)
return false;
var characterBase = (CharacterBase*)drawObject;
if (characterBase->HasModelInSlotLoaded != 0)
return false;
@@ -1327,7 +1240,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
return true;
}
[StructLayout(LayoutKind.Auto)]
private readonly record struct LoadState(bool IsValid, bool IsLoaded)
{
public static LoadState Invalid => new(false, false);

View File

@@ -816,12 +816,9 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
{
_logger.LogInformation("Starting DalamudUtilService");
_framework.Update += FrameworkOnUpdate;
_clientState.Login += OnClientLogin;
_clientState.Logout += OnClientLogout;
if (_clientState.IsLoggedIn)
if (IsLoggedIn)
{
OnClientLogin();
_classJobId = _objectTable.LocalPlayer!.ClassJob.RowId;
}
_logger.LogInformation("Started DalamudUtilService");
@@ -834,9 +831,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
Mediator.UnsubscribeAll(this);
_framework.Update -= FrameworkOnUpdate;
_clientState.Login -= OnClientLogin;
_clientState.Logout -= OnClientLogout;
if (_FocusPairIdent.HasValue)
{
if (_framework.IsInFrameworkUpdateThread)
@@ -851,41 +845,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
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(
ILogger logger,
GameObjectHandler handler,
@@ -1313,6 +1272,23 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
if (isNormalFrameworkUpdate)
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
&& _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.NoSavedSettings |
ImGuiWindowFlags.NoNav |
ImGuiWindowFlags.NoFocusOnAppearing |
ImGuiWindowFlags.NoInputs;
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 LocationSharingMessage(UserData User, LocationInfo LocationInfo, DateTimeOffset ExpireAt) : MessageBase;
public record MapChangedMessage(uint MapId) : MessageBase;
public record PenumbraTempCollectionsCleanedMessage : MessageBase;
#pragma warning restore S2094
#pragma warning restore MA0048 // File name must match type name

View File

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

View File

@@ -3,7 +3,6 @@ using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using FFXIVClientStructs.Havok.Common.Serialize.Resource;
using FFXIVClientStructs.Havok.Animation;
using FFXIVClientStructs.Havok.Common.Base.Types;
using FFXIVClientStructs.Havok.Common.Serialize.Resource;
using FFXIVClientStructs.Havok.Common.Serialize.Util;
using LightlessSync.FileCache;
using LightlessSync.Interop.GameModel;
@@ -11,7 +10,6 @@ using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers;
using Microsoft.Extensions.Logging;
using OtterGui.Text.EndObjects;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
@@ -132,136 +130,316 @@ public sealed partial class XivDataAnalyzer
return (output.Count != 0 && output.Values.All(v => v.Count > 0)) ? output : null;
}
public static byte[]? ReadHavokBytesFromPap(string papPath)
public unsafe Dictionary<string, List<ushort>>? GetBoneIndicesFromPap(string hash, bool persistToConfig = true)
{
using var fs = File.Open(papPath, FileMode.Open, FileAccess.Read, FileShare.Read);
if (string.IsNullOrWhiteSpace(hash))
return null;
if (_configService.Current.BonesDictionary.TryGetValue(hash, out var cached) && cached is not null)
return cached;
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
if (cacheEntity == null || string.IsNullOrEmpty(cacheEntity.ResolvedFilepath) || !File.Exists(cacheEntity.ResolvedFilepath))
return null;
using var fs = File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new BinaryReader(fs);
_ = reader.ReadInt32();
_ = reader.ReadInt32();
_ = reader.ReadInt16();
_ = reader.ReadInt16();
var type = reader.ReadByte();
if (type != 0) return null;
_ = reader.ReadByte();
_ = reader.ReadInt32();
var havokPosition = reader.ReadInt32();
var footerPosition = reader.ReadInt32();
if (havokPosition <= 0 || footerPosition <= havokPosition || footerPosition > fs.Length)
return null;
var sizeLong = (long)footerPosition - havokPosition;
if (sizeLong <= 8 || sizeLong > int.MaxValue)
return null;
var size = (int)sizeLong;
fs.Position = havokPosition;
var bytes = reader.ReadBytes(size);
return bytes.Length > 8 ? bytes : null;
}
public unsafe Dictionary<string, List<ushort>>? ParseHavokBytesOnFrameworkThread(
byte[] havokData,
string hash,
bool persistToConfig)
{
var tempSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
var tempHkxPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx");
IntPtr pathAnsi = IntPtr.Zero;
// PAP header (mostly from vfxeditor)
try
{
File.WriteAllBytes(tempHkxPath, havokData);
_ = reader.ReadInt32(); // ignore
_ = reader.ReadInt32(); // ignore
var numAnimations = reader.ReadInt16(); // num animations
var modelId = reader.ReadInt16(); // modelid
pathAnsi = Marshal.StringToHGlobalAnsi(tempHkxPath);
hkSerializeUtil.LoadOptions loadOptions = default;
loadOptions.TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry();
loadOptions.ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry();
loadOptions.Flags = new hkFlags<hkSerializeUtil.LoadOptionBits, int>
if (numAnimations < 0 || numAnimations > 1000)
{
Storage = (int)hkSerializeUtil.LoadOptionBits.Default
};
_logger.LogWarning("PAP file {hash} has invalid animation count {count}, skipping", hash, numAnimations);
return null;
}
hkSerializeUtil.LoadOptions* pOpts = &loadOptions;
var type = reader.ReadByte(); // type
if (type != 0)
return null; // not human
var resource = hkSerializeUtil.LoadFromFile((byte*)pathAnsi, errorResult: null, pOpts);
if (resource == null)
_ = reader.ReadByte(); // variant
_ = reader.ReadInt32(); // ignore
var havokPosition = reader.ReadInt32();
var footerPosition = reader.ReadInt32();
if (havokPosition <= 0 || footerPosition <= havokPosition ||
footerPosition > fs.Length || havokPosition >= fs.Length)
{
_logger.LogWarning("PAP file {hash} has invalid offsets (havok={havok}, footer={footer}, length={length})",
hash, havokPosition, footerPosition, fs.Length);
return null;
}
var havokDataSizeLong = (long)footerPosition - havokPosition;
if (havokDataSizeLong <= 8 || havokDataSizeLong > int.MaxValue)
{
_logger.LogWarning("PAP file {hash} has invalid Havok data size {size}", hash, havokDataSizeLong);
return null;
}
var havokDataSize = (int)havokDataSizeLong;
reader.BaseStream.Position = havokPosition;
var havokData = new byte[havokDataSize];
var bytesRead = reader.Read(havokData, 0, havokDataSize);
if (bytesRead != havokDataSize)
{
_logger.LogWarning("PAP file {hash}: Expected to read {expected} bytes but got {actual}",
hash, havokDataSize, bytesRead);
return null;
}
if (havokData.Length < 8)
return null;
var rootLevelName = @"hkRootLevelContainer"u8;
fixed (byte* n1 = rootLevelName)
var tempSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
var tempFileName = $"lightless_pap_{Guid.NewGuid():N}_{hash.Substring(0, Math.Min(8, hash.Length))}.hkx";
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), tempFileName);
IntPtr tempHavokDataPathAnsi = IntPtr.Zero;
try
{
var container = (hkRootLevelContainer*)resource->GetContentsPointer(
n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry());
if (container == null) return null;
var animationName = @"hkaAnimationContainer"u8;
fixed (byte* n2 = animationName)
var tempDir = Path.GetDirectoryName(tempHavokDataPath);
if (!Directory.Exists(tempDir))
{
var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null);
if (animContainer == null) return null;
_logger.LogWarning("Temp directory {dir} doesn't exist", tempDir);
return null;
}
for (int i = 0; i < animContainer->Bindings.Length; i++)
File.WriteAllBytes(tempHavokDataPath, havokData);
if (!File.Exists(tempHavokDataPath))
{
_logger.LogWarning("Temporary havok file was not created at {path}", tempHavokDataPath);
return null;
}
var writtenFileInfo = new FileInfo(tempHavokDataPath);
if (writtenFileInfo.Length != havokData.Length)
{
_logger.LogWarning("Written temp file size mismatch: expected {expected}, got {actual}",
havokData.Length, writtenFileInfo.Length);
File.Delete(tempHavokDataPath);
return null;
}
tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath);
var loadoptions = stackalloc hkSerializeUtil.LoadOptions[1];
loadoptions->TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry();
loadoptions->ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry();
loadoptions->Flags = new hkFlags<hkSerializeUtil.LoadOptionBits, int>
{
Storage = (int)hkSerializeUtil.LoadOptionBits.Default
};
hkResource* resource = null;
try
{
resource = hkSerializeUtil.LoadFromFile((byte*)tempHavokDataPathAnsi, null, loadoptions);
}
catch (SEHException ex)
{
_logger.LogError(ex, "SEH exception loading Havok file from {path} (hash={hash}). Native error code: 0x{code:X}",
tempHavokDataPath, hash, ex.ErrorCode);
return null;
}
if (resource == null)
{
_logger.LogDebug("Havok resource was null after loading from {path} (hash={hash})", tempHavokDataPath, hash);
return null;
}
if ((nint)resource == nint.Zero || !IsValidPointer((IntPtr)resource))
{
_logger.LogDebug("Havok resource pointer is invalid (hash={hash})", hash);
return null;
}
var rootLevelName = @"hkRootLevelContainer"u8;
fixed (byte* n1 = rootLevelName)
{
var container = (hkRootLevelContainer*)resource->GetContentsPointer(n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry());
if (container == null)
{
var binding = animContainer->Bindings[i].ptr;
if (binding == null) continue;
_logger.LogDebug("hkRootLevelContainer is null (hash={hash})", hash);
return null;
}
var rawSkel = binding->OriginalSkeletonName.String;
var skeletonKey = CanonicalizeSkeletonKey(rawSkel);
if (string.IsNullOrEmpty(skeletonKey)) continue;
if ((nint)container == nint.Zero || !IsValidPointer((IntPtr)container))
{
_logger.LogDebug("hkRootLevelContainer pointer is invalid (hash={hash})", hash);
return null;
}
var boneTransform = binding->TransformTrackToBoneIndices;
if (boneTransform.Length <= 0) continue;
if (!tempSets.TryGetValue(skeletonKey, out var set))
tempSets[skeletonKey] = set = [];
for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++)
var animationName = @"hkaAnimationContainer"u8;
fixed (byte* n2 = animationName)
{
var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null);
if (animContainer == null)
{
var v = boneTransform[boneIdx];
if (v < 0) continue;
set.Add((ushort)v);
_logger.LogDebug("hkaAnimationContainer is null (hash={hash})", hash);
return null;
}
if ((nint)animContainer == nint.Zero || !IsValidPointer((IntPtr)animContainer))
{
_logger.LogDebug("hkaAnimationContainer pointer is invalid (hash={hash})", hash);
return null;
}
if (animContainer->Bindings.Length < 0 || animContainer->Bindings.Length > 10000)
{
_logger.LogDebug("Invalid bindings count {count} (hash={hash})", animContainer->Bindings.Length, hash);
return null;
}
for (int i = 0; i < animContainer->Bindings.Length; i++)
{
var binding = animContainer->Bindings[i].ptr;
if (binding == null)
continue;
if ((nint)binding == nint.Zero || !IsValidPointer((IntPtr)binding))
{
_logger.LogDebug("Skipping invalid binding at index {index} (hash={hash})", i, hash);
continue;
}
var rawSkel = binding->OriginalSkeletonName.String;
var skeletonKey = CanonicalizeSkeletonKey(rawSkel);
if (string.IsNullOrEmpty(skeletonKey))
continue;
var boneTransform = binding->TransformTrackToBoneIndices;
if (boneTransform.Length <= 0 || boneTransform.Length > 10000)
{
_logger.LogDebug("Invalid bone transform length {length} for skeleton {skel} (hash={hash})",
boneTransform.Length, skeletonKey, hash);
continue;
}
if (!tempSets.TryGetValue(skeletonKey, out var set))
{
set = [];
tempSets[skeletonKey] = set;
}
for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++)
{
var v = boneTransform[boneIdx];
if (v < 0 || v > ushort.MaxValue)
continue;
set.Add((ushort)v);
}
}
}
}
}
catch (SEHException ex)
{
_logger.LogError(ex, "SEH exception processing PAP file {hash} from {path}. Error code: 0x{code:X}",
hash, tempHavokDataPath, ex.ErrorCode);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Managed exception loading havok file {hash} from {path}", hash, tempHavokDataPath);
return null;
}
finally
{
if (tempHavokDataPathAnsi != IntPtr.Zero)
Marshal.FreeHGlobal(tempHavokDataPathAnsi);
int retryCount = 3;
while (retryCount > 0 && File.Exists(tempHavokDataPath))
{
try
{
File.Delete(tempHavokDataPath);
break;
}
catch (IOException ex)
{
retryCount--;
if (retryCount == 0)
{
_logger.LogDebug(ex, "Failed to delete temporary havok file after retries: {path}", tempHavokDataPath);
}
else
{
Thread.Sleep(50);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Unexpected error deleting temporary havok file: {path}", tempHavokDataPath);
break;
}
}
}
if (tempSets.Count == 0)
{
_logger.LogDebug("No bone sets found in PAP file (hash={hash})", hash);
return null;
}
var output = new Dictionary<string, List<ushort>>(tempSets.Count, StringComparer.OrdinalIgnoreCase);
foreach (var (key, set) in tempSets)
{
if (set.Count == 0) continue;
var list = set.ToList();
list.Sort();
output[key] = list;
}
if (output.Count == 0)
return null;
_configService.Current.BonesDictionary[hash] = output;
if (persistToConfig)
_configService.Save();
return output;
}
finally
catch (Exception ex)
{
if (pathAnsi != IntPtr.Zero)
Marshal.FreeHGlobal(pathAnsi);
try { if (File.Exists(tempHkxPath)) File.Delete(tempHkxPath); }
catch { /* ignore */ }
_logger.LogError(ex, "Outer exception reading PAP file (hash={hash})", hash);
return null;
}
if (tempSets.Count == 0) return null;
var output = new Dictionary<string, List<ushort>>(tempSets.Count, StringComparer.OrdinalIgnoreCase);
foreach (var (key, set) in tempSets)
{
if (set.Count == 0) continue;
var list = set.ToList();
list.Sort();
output[key] = list;
}
if (output.Count == 0) return null;
_configService.Current.BonesDictionary[hash] = output;
if (persistToConfig) _configService.Save();
return output;
}
private static bool IsValidPointer(IntPtr ptr)
{
if (ptr == IntPtr.Zero)
return false;
try
{
_ = Marshal.ReadByte(ptr);
return true;
}
catch
{
return false;
}
}
public static string CanonicalizeSkeletonKey(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
@@ -339,56 +517,41 @@ public sealed partial class XivDataAnalyzer
if (mode == AnimationValidationMode.Unsafe)
return true;
var papByBucket = new Dictionary<string, List<ushort>>(StringComparer.OrdinalIgnoreCase);
var papBuckets = papBoneIndices.Keys
.Select(CanonicalizeSkeletonKey)
.Where(k => !string.IsNullOrEmpty(k))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var (rawKey, list) in papBoneIndices)
{
var key = CanonicalizeSkeletonKey(rawKey);
if (string.IsNullOrEmpty(key))
continue;
if (string.Equals(key, "skeleton", StringComparison.OrdinalIgnoreCase))
key = "__any__";
if (!papByBucket.TryGetValue(key, out var acc))
papByBucket[key] = acc = [];
if (list is { Count: > 0 })
acc.AddRange(list);
}
foreach (var k in papByBucket.Keys.ToList())
papByBucket[k] = papByBucket[k].Distinct().ToList();
if (papByBucket.Count == 0)
if (papBuckets.Count == 0)
{
reason = "No skeleton bucket bindings found in the PAP";
return false;
}
static bool AllIndicesOk(
HashSet<ushort> available,
List<ushort> indices,
bool papLikelyOneBased,
bool allowOneBasedShift,
bool allowNeighborTolerance,
out ushort missing)
if (mode == AnimationValidationMode.Safe)
{
foreach (var idx in indices)
{
if (!ContainsIndexCompat(available, idx, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance))
{
missing = idx;
return false;
}
}
if (papBuckets.Any(b => localBoneSets.ContainsKey(b)))
return true;
missing = 0;
return true;
reason = $"No matching skeleton bucket between PAP [{string.Join(", ", papBuckets)}] and local [{string.Join(", ", localBoneSets.Keys.Order())}].";
return false;
}
foreach (var (bucket, indices) in papByBucket)
foreach (var bucket in papBuckets)
{
if (!localBoneSets.TryGetValue(bucket, out var available))
{
reason = $"Missing skeleton bucket '{bucket}' on local actor.";
return false;
}
var indices = papBoneIndices
.Where(kvp => string.Equals(CanonicalizeSkeletonKey(kvp.Key), bucket, StringComparison.OrdinalIgnoreCase))
.SelectMany(kvp => kvp.Value ?? Enumerable.Empty<ushort>())
.Distinct()
.ToList();
if (indices.Count == 0)
continue;
@@ -402,32 +565,14 @@ public sealed partial class XivDataAnalyzer
}
bool papLikelyOneBased = allowOneBasedShift && (min == 1) && has1 && !has0;
if (string.Equals(bucket, "__any__", StringComparison.OrdinalIgnoreCase))
foreach (var idx in indices)
{
foreach (var (lk, ls) in localBoneSets)
if (!ContainsIndexCompat(available, idx, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance))
{
if (AllIndicesOk(ls, indices, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance, out _))
goto nextBucket;
reason = $"No compatible local skeleton for PAP '{bucket}': missing bone index {idx}.";
return false;
}
reason = $"No compatible local skeleton bucket for generic PAP skeleton '{bucket}'. Local buckets: {string.Join(", ", localBoneSets.Keys)}";
return false;
}
if (!localBoneSets.TryGetValue(bucket, out var available))
{
reason = $"Missing skeleton bucket '{bucket}' on local actor.";
return false;
}
if (!AllIndicesOk(available, indices, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance, out var missing))
{
reason = $"No compatible local skeleton for PAP '{bucket}': missing bone index {missing}.";
return false;
}
nextBucket:
;
}
return true;

View File

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

View File

@@ -497,7 +497,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase
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))
continue;
@@ -759,22 +759,29 @@ public class LightFinderUI : WindowMediatorSubscriberBase
var scale = ImGuiHelpers.GlobalScale;
// if not already open
if (!ImGui.IsPopupOpen("JoinSyncshellModal"))
ImGui.OpenPopup("JoinSyncshellModal");
Vector2 windowPos = ImGui.GetWindowPos();
Vector2 windowSize = ImGui.GetWindowSize();
float modalWidth = Math.Min(420f * scale, windowSize.X - 40f * scale);
float modalHeight = 295f * scale;
Vector2 childPos = new Vector2(
(windowSize.X - modalWidth) * 0.5f,
(windowSize.Y - modalHeight) * 0.5f
);
ImGui.SetCursorPos(childPos);
ImGui.SetNextWindowPos(new Vector2(
windowPos.X + (windowSize.X - modalWidth) * 0.5f,
windowPos.Y + (windowSize.Y - modalHeight) * 0.5f
), ImGuiCond.Always);
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 var childBg = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetStyle().Colors[(int)ImGuiCol.WindowBg]);
using var rounding = ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 8f * scale);
using var borderSize = ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, 2f * scale);
using var padding = ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(16f * scale, 16f * scale));
using ImRaii.Color modalBorder = ImRaii.PushColor(ImGuiCol.Border, UIColors.Get("LightlessPurple").WithAlpha(0.5f));
using ImRaii.Style rounding = ImRaii.PushStyle(ImGuiStyleVar.WindowRounding, 8f * scale);
using ImRaii.Style borderSize = ImRaii.PushStyle(ImGuiStyleVar.WindowBorderSize, 2f * scale);
using ImRaii.Style 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;
@@ -836,7 +843,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase
_joinDto = null;
_joinInfo = null;
ImGui.CloseCurrentPopup();
}
}
@@ -851,13 +858,20 @@ public class LightFinderUI : WindowMediatorSubscriberBase
{
_joinDto = 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)
@@ -1566,20 +1580,11 @@ public class LightFinderUI : WindowMediatorSubscriberBase
if (previousGid != null)
{
try
var newIndex = _nearbySyncshells.FindIndex(s => string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal));
if (newIndex >= 0)
{
var nearbySyncshellsSnapshot = _nearbySyncshells.ToArray();
var newIndex = Array.FindIndex(nearbySyncshellsSnapshot,
s => string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal));
if (newIndex >= 0)
{
_selectedNearbyIndex = newIndex;
return;
}
}
catch
{
ClearSelection();
_selectedNearbyIndex = newIndex;
return;
}
}
@@ -1621,18 +1626,9 @@ public class LightFinderUI : WindowMediatorSubscriberBase
private string? GetSelectedGid()
{
try
{
var index = _selectedNearbyIndex;
var list = _nearbySyncshells.ToArray();
if (index < 0 || index >= list.Length)
return null;
return list[index].Group.GID;
}
catch
{
if (_selectedNearbyIndex < 0 || _selectedNearbyIndex >= _nearbySyncshells.Count)
return null;
}
return _nearbySyncshells[_selectedNearbyIndex].Group.GID;
}
#endregion

View File

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

View File

@@ -1688,46 +1688,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
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.TextUnformatted("Syncshell Memberships");
if (snapshot.PairsWithGroups.TryGetValue(pair, out var groups) && groups.Count > 0)
@@ -3289,16 +3249,16 @@ public class SettingsUi : WindowMediatorSubscriberBase
var labels = new[]
{
"Unsafe (Off)",
"Safe (Race Check)",
"Safest (Race + Bones Check)",
"Unsafe",
"Safe (Race)",
"Safest (Race + Bones)",
};
var tooltips = new[]
{
"No validation. Fastest, but may allow incompatible animations.",
"Validates skeleton race + modded skeleton check. Will be safer to use but will block some animations",
"Requires matching skeleton race + bone compatibility. Will block alot, not recommended.",
"No validation. Fastest, but may allow incompatible animations (riskier).",
"Validates skeleton race + modded skeleton check (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.PlayerData.Pairs;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace LightlessSync.Utils;
@@ -54,168 +56,164 @@ public static class VariousExtensions
return new CancellationTokenSource();
}
public static Dictionary<ObjectKind, HashSet<PlayerChanges>> CheckUpdatedData(
this CharacterData newData,
Guid applicationBase,
CharacterData? oldData,
ILogger logger,
IPairPerformanceSubject cachedPlayer,
bool forceApplyCustomization,
bool forceApplyMods,
public static Dictionary<ObjectKind, HashSet<PlayerChanges>> CheckUpdatedData(this CharacterData newData, Guid applicationBase,
CharacterData? oldData, ILogger logger, IPairPerformanceSubject cachedPlayer, bool forceApplyCustomization, bool forceApplyMods,
bool suppressForcedRedrawOnForcedModApply = false)
{
oldData ??= new();
static bool HasFiles(List<FileReplacementData>? list) => list is { Count: > 0 };
static bool HasText(string? s) => !string.IsNullOrEmpty(s);
static string Norm(string? s) => s ?? string.Empty;
var forceRedrawOnForcedApply = forceApplyMods && !suppressForcedRedrawOnForcedModApply;
var charaDataToUpdate = new Dictionary<ObjectKind, HashSet<PlayerChanges>>();
foreach (ObjectKind objectKind in Enum.GetValues<ObjectKind>())
{
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);
newData.FileReplacements.TryGetValue(objectKind, out var newFileRepls);
bool hasNewButNotOldFileReplacements = newFileReplacements != null && existingFileReplacements == null;
bool hasOldButNotNewFileReplacements = existingFileReplacements != null && newFileReplacements == null;
oldData.GlamourerData.TryGetValue(objectKind, out var oldGlam);
newData.GlamourerData.TryGetValue(objectKind, out var newGlam);
bool hasNewButNotOldGlamourerData = newGlamourerData != null && existingGlamourerData == null;
bool hasOldButNotNewGlamourerData = existingGlamourerData != null && newGlamourerData == null;
var oldHasFiles = HasFiles(oldFileRepls);
var newHasFiles = HasFiles(newFileRepls);
bool hasNewAndOldFileReplacements = newFileReplacements != null && existingFileReplacements != null;
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}",
applicationBase, cachedPlayer, objectKind, oldHasFiles, newHasFiles, PlayerChanges.ModFiles);
set.Add(PlayerChanges.ModFiles);
if (objectKind != ObjectKind.Player || forceRedrawOnForcedApply)
{
set.Add(PlayerChanges.ForcedRedraw);
}
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Some new data arrived: NewButNotOldFiles:{hasNewButNotOldFileReplacements}," +
" OldButNotNewFiles:{hasOldButNotNewFileReplacements}, NewButNotOldGlam:{hasNewButNotOldGlamourerData}, OldButNotNewGlam:{hasOldButNotNewGlamourerData}) => {change}, {change2}",
applicationBase,
cachedPlayer, objectKind, hasNewButNotOldFileReplacements, hasOldButNotNewFileReplacements, hasNewButNotOldGlamourerData, hasOldButNotNewGlamourerData, PlayerChanges.ModFiles, PlayerChanges.Glamourer);
charaDataToUpdate[objectKind].Add(PlayerChanges.ModFiles);
charaDataToUpdate[objectKind].Add(PlayerChanges.Glamourer);
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
}
else if (newHasFiles)
else
{
var listsAreEqual = oldFileRepls!.SequenceEqual(newFileRepls!, PlayerData.Data.FileReplacementDataComparer.Instance);
if (!listsAreEqual || forceApplyMods)
if (hasNewAndOldFileReplacements)
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (FileReplacements changed or forceApplyMods) => {change}",
applicationBase, cachedPlayer, objectKind, PlayerChanges.ModFiles);
set.Add(PlayerChanges.ModFiles);
if (objectKind != ObjectKind.Player || forceRedrawOnForcedApply)
var oldList = oldData.FileReplacements[objectKind];
var newList = newData.FileReplacements[objectKind];
var listsAreEqual = oldList.SequenceEqual(newList, PlayerData.Data.FileReplacementDataComparer.Instance);
if (!listsAreEqual || forceApplyMods)
{
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)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var existingHair = oldFileRepls.Where(g => g.GamePaths.Any(p => p.Contains("/hair/", StringComparison.OrdinalIgnoreCase)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var existingTail = oldFileRepls.Where(g => g.GamePaths.Any(p => p.Contains("/tail/", StringComparison.OrdinalIgnoreCase)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var newFace = newFileRepls!.Where(g => g.GamePaths.Any(p => p.Contains("/face/", StringComparison.OrdinalIgnoreCase)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var newHair = newFileRepls.Where(g => g.GamePaths.Any(p => p.Contains("/hair/", StringComparison.OrdinalIgnoreCase)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var newTail = newFileRepls.Where(g => g.GamePaths.Any(p => p.Contains("/tail/", StringComparison.OrdinalIgnoreCase)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var existingTransients = oldFileRepls.Where(g => g.GamePaths.Any(p => !p.EndsWith("mdl", StringComparison.OrdinalIgnoreCase)
&& !p.EndsWith("tex", StringComparison.OrdinalIgnoreCase)
&& !p.EndsWith("mtrl", StringComparison.OrdinalIgnoreCase)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var newTransients = newFileRepls.Where(g => g.GamePaths.Any(p => !p.EndsWith("mdl", StringComparison.OrdinalIgnoreCase)
&& !p.EndsWith("tex", StringComparison.OrdinalIgnoreCase)
&& !p.EndsWith("mtrl", StringComparison.OrdinalIgnoreCase)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var differentFace = !existingFace.SequenceEqual(newFace, PlayerData.Data.FileReplacementDataComparer.Instance);
var differentHair = !existingHair.SequenceEqual(newHair, PlayerData.Data.FileReplacementDataComparer.Instance);
var differentTail = !existingTail.SequenceEqual(newTail, PlayerData.Data.FileReplacementDataComparer.Instance);
var differentTransients = !existingTransients.SequenceEqual(newTransients, PlayerData.Data.FileReplacementDataComparer.Instance);
if (differentFace || differentHair || differentTail || differentTransients)
set.Add(PlayerChanges.ForcedRedraw);
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Glamourer different) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Glamourer);
charaDataToUpdate[objectKind].Add(PlayerChanges.Glamourer);
}
}
}
var oldGlamNorm = Norm(oldGlam);
var newGlamNorm = Norm(newGlam);
oldData.CustomizePlusData.TryGetValue(objectKind, out var oldCustomizePlusData);
newData.CustomizePlusData.TryGetValue(objectKind, out var newCustomizePlusData);
if (!string.Equals(oldGlamNorm, newGlamNorm, StringComparison.Ordinal)
|| (forceApplyCustomization && HasText(newGlamNorm)))
oldCustomizePlusData ??= string.Empty;
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}",
applicationBase, cachedPlayer, objectKind, PlayerChanges.Glamourer);
set.Add(PlayerChanges.Glamourer);
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff customize data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Customize);
charaDataToUpdate[objectKind].Add(PlayerChanges.Customize);
}
oldData.CustomizePlusData.TryGetValue(objectKind, out var oldC);
newData.CustomizePlusData.TryGetValue(objectKind, out var newC);
if (objectKind != ObjectKind.Player) continue;
var oldCNorm = Norm(oldC);
var newCNorm = Norm(newC);
if (!string.Equals(oldCNorm, newCNorm, StringComparison.Ordinal)
|| (forceApplyCustomization && HasText(newCNorm)))
bool manipDataDifferent = !string.Equals(oldData.ManipulationData, newData.ManipulationData, StringComparison.Ordinal);
if (manipDataDifferent || forceRedrawOnForcedApply)
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Customize+ different) => {change}",
applicationBase, cachedPlayer, objectKind, PlayerChanges.Customize);
set.Add(PlayerChanges.Customize);
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff manip data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModManip);
charaDataToUpdate[objectKind].Add(PlayerChanges.ModManip);
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
}
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);
var newManip = Norm(newData.ManipulationData);
if (!string.Equals(oldManip, newManip, StringComparison.Ordinal) || forceRedrawOnForcedApply)
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Manip different) => {change}",
applicationBase, cachedPlayer, objectKind, PlayerChanges.ModManip);
set.Add(PlayerChanges.ModManip);
set.Add(PlayerChanges.ForcedRedraw);
}
if (!string.Equals(Norm(oldData.HeelsData), Norm(newData.HeelsData), StringComparison.Ordinal)
|| (forceApplyCustomization && HasText(newData.HeelsData)))
set.Add(PlayerChanges.Heels);
if (!string.Equals(Norm(oldData.HonorificData), Norm(newData.HonorificData), StringComparison.Ordinal)
|| (forceApplyCustomization && HasText(newData.HonorificData)))
set.Add(PlayerChanges.Honorific);
if (!string.Equals(Norm(oldData.MoodlesData), Norm(newData.MoodlesData), StringComparison.Ordinal)
|| (forceApplyCustomization && HasText(newData.MoodlesData)))
set.Add(PlayerChanges.Moodles);
if (!string.Equals(Norm(oldData.PetNamesData), Norm(newData.PetNamesData), StringComparison.Ordinal)
|| (forceApplyCustomization && HasText(newData.PetNamesData)))
set.Add(PlayerChanges.PetNames);
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff heels data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Heels);
charaDataToUpdate[objectKind].Add(PlayerChanges.Heels);
}
if (set.Count > 0)
charaDataToUpdate[objectKind] = set;
bool honorificDataDifferent = !string.Equals(oldData.HonorificData, newData.HonorificData, StringComparison.Ordinal);
if (honorificDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.HonorificData)))
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff honorific data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Honorific);
charaDataToUpdate[objectKind].Add(PlayerChanges.Honorific);
}
bool moodlesDataDifferent = !string.Equals(oldData.MoodlesData, newData.MoodlesData, StringComparison.Ordinal);
if (moodlesDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.MoodlesData)))
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff moodles data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Moodles);
charaDataToUpdate[objectKind].Add(PlayerChanges.Moodles);
}
bool petNamesDataDifferent = !string.Equals(oldData.PetNamesData, newData.PetNamesData, StringComparison.Ordinal);
if (petNamesDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.PetNamesData)))
{
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff petnames data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.PetNames);
charaDataToUpdate[objectKind].Add(PlayerChanges.PetNames);
}
}
foreach (var k in charaDataToUpdate.Keys.ToList())
charaDataToUpdate[k] = [.. charaDataToUpdate[k].OrderBy(p => (int)p)];
foreach (KeyValuePair<ObjectKind, HashSet<PlayerChanges>> data in charaDataToUpdate.ToList())
{
if (!data.Value.Any()) charaDataToUpdate.Remove(data.Key);
else charaDataToUpdate[data.Key] = [.. data.Value.OrderByDescending(p => (int)p)];
}
return charaDataToUpdate;
}
public static T DeepClone<T>(this T 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}",
requestId, transfers[0].DownloadUri, string.Join(", ", transfers.Select(c => c.Hash)));
// Wait for ready WITHOUT holding a slot
SetStatus(statusKey, DownloadStatus.WaitingForQueue);
await WaitForDownloadReady(transfers, requestId, ct).ConfigureAwait(false);
// Hold slot ONLY for the GET
SetStatus(statusKey, DownloadStatus.WaitingForSlot);
await using ((await AcquireSlotAsync(ct).ConfigureAwait(false)).ConfigureAwait(false))
{
@@ -460,8 +462,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
bool skipDecimation)
{
SetStatus(downloadStatusKey, DownloadStatus.Decompressing);
var extracted = 0;
MarkTransferredFiles(downloadStatusKey, 1);
try
{
@@ -470,8 +471,6 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
{
while (fileBlockStream.Position < fileBlockStream.Length)
{
ct.ThrowIfCancellationRequested();
(string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream);
try
@@ -481,69 +480,72 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
var len = checked((int)fileLengthBytes);
if (fileBlockStream.Position + len > fileBlockStream.Length)
throw new EndOfStreamException();
if (!replacementLookup.TryGetValue(fileHash, out var repl))
{
Logger.LogWarning("{dlName}: No replacement mapping for {fileHash}, skipping {len} bytes",
downloadLabel, fileHash, len);
fileBlockStream.Seek(len, SeekOrigin.Current);
Logger.LogWarning("{dlName}: No replacement mapping for {fileHash}", downloadLabel, fileHash);
// still need to skip bytes:
var skip = checked((int)fileLengthBytes);
fileBlockStream.Position += skip;
continue;
}
var filePath = _fileDbManager.GetCacheFilePath(fileHash, repl.Extension);
Logger.LogTrace("{dlName}: Extracting {fileHash}:{len} => {dest}",
downloadLabel, fileHash, len, filePath);
Logger.LogTrace("{dlName}: Decompressing {file}:{len} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath);
var compressed = new byte[len];
await ReadExactlyAsync(fileBlockStream, compressed.AsMemory(0, len), ct).ConfigureAwait(false);
MungeBuffer(compressed);
await _decompressGate.WaitAsync(ct).ConfigureAwait(false);
byte[] decompressed;
try
{
decompressed = await Task.Run(() => LZ4Wrapper.Unwrap(compressed), ct).ConfigureAwait(false);
}
finally
{
_decompressGate.Release();
}
var decompressed = LZ4Wrapper.Unwrap(compressed);
if (rawSizeLookup.TryGetValue(fileHash, out var expectedRawSize)
&& expectedRawSize > 0
&& decompressed.LongLength != expectedRawSize)
{
Logger.LogWarning(
"{dlName}: Size mismatch for {fileHash} (expected {expected}, got {actual}). Treating as corrupt.",
downloadLabel, fileHash, expectedRawSize, decompressed.LongLength);
try { if (File.Exists(filePath)) File.Delete(filePath); } catch { /* ignore */ }
await _fileCompactor.WriteAllBytesAsync(filePath, Array.Empty<byte>(), ct).ConfigureAwait(false);
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation);
continue;
}
await _fileCompactor.WriteAllBytesAsync(filePath, decompressed, ct, enqueueCompaction: false).ConfigureAwait(false);
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation);
MungeBuffer(compressed);
extracted++;
MarkTransferredFiles(downloadStatusKey, extracted);
await _decompressGate.WaitAsync(ct).ConfigureAwait(false);
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)
{
Logger.LogWarning("{dlName}: Block ended mid-entry while extracting {fileHash}", downloadLabel, fileHash);
break;
Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", downloadLabel, fileHash);
}
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)
{
@@ -599,10 +601,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
_orchestrator.ForbiddenTransfers.Add(new DownloadFileTransfer(dto));
}
CurrentDownloads = [.. downloadFileInfoFromService
CurrentDownloads = downloadFileInfoFromService
.Distinct()
.Select(d => new DownloadFileTransfer(d))
.Where(d => d.CanBeTransferred)];
.Where(d => d.CanBeTransferred)
.ToList();
return CurrentDownloads;
}
@@ -1030,58 +1033,48 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
private async Task ProcessDeferredCompressionsAsync(CancellationToken ct)
{
if (_deferredCompressionQueue.IsEmpty || !_configService.Current.UseCompactor)
if (_deferredCompressionQueue.IsEmpty)
return;
// Drain queue into a unique set (same file can be enqueued multiple times)
var filesToCompact = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var filesToCompress = new List<string>();
while (_deferredCompressionQueue.TryDequeue(out var filePath))
{
if (!string.IsNullOrWhiteSpace(filePath))
filesToCompact.Add(filePath);
if (File.Exists(filePath))
filesToCompress.Add(filePath);
}
if (filesToCompact.Count == 0)
if (filesToCompress.Count == 0)
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(
filesToCompact,
new ParallelOptions
{
MaxDegreeOfParallelism = enqueueWorkers,
CancellationToken = ct
await Parallel.ForEachAsync(filesToCompress,
new ParallelOptions
{
MaxDegreeOfParallelism = compressionWorkers,
CancellationToken = ct
},
async (filePath, token) =>
{
try
{
token.ThrowIfCancellationRequested();
if (!File.Exists(filePath))
return;
try
{
await Task.Yield();
_fileCompactor.RequestCompaction(filePath);
Logger.LogTrace("Deferred compaction queued: {filePath}", filePath);
}
catch (OperationCanceledException)
{
Logger.LogTrace("Deferred compaction cancelled for file: {filePath}", filePath);
throw;
if (_configService.Current.UseCompactor && File.Exists(filePath))
{
var bytes = await File.ReadAllBytesAsync(filePath, token).ConfigureAwait(false);
await _fileCompactor.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false);
Logger.LogTrace("Compressed file: {filePath}", filePath);
}
}
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);
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>