Compare commits
29 Commits
merge-abel
...
collection
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
699535b68b | ||
|
|
828705cbfb | ||
|
|
d8b4122ec3 | ||
|
|
f6a5c85c2d | ||
|
|
9fcbd68ca2 | ||
|
|
73dee6d9a5 | ||
|
|
4502cadaeb | ||
| 7f33b6a4ce | |||
| 61f584f059 | |||
| 95d286f990 | |||
|
|
42d6a19db1 | ||
|
|
05f7d256d7 | ||
|
|
058ba504cb | ||
|
|
19966f3828 | ||
|
|
d8b9e9cf19 | ||
|
|
ad34d88336 | ||
| 9167bb1afd | |||
|
|
5161c6bad3 | ||
|
|
ce28799db3 | ||
|
|
9b9010ab8e | ||
|
|
032201ed9e | ||
|
|
775b128cf3 | ||
|
|
4bb8db8c03 | ||
|
|
f307c65c66 | ||
|
|
4eec363cd2 | ||
|
|
d00df84ed6 | ||
|
|
9048b3bd87 | ||
|
|
a2ed9f8d2b | ||
| 8e08da7471 |
@@ -9,7 +9,8 @@ env:
|
|||||||
DOTNET_VERSION: |
|
DOTNET_VERSION: |
|
||||||
10.x.x
|
10.x.x
|
||||||
9.x.x
|
9.x.x
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tag-and-release:
|
tag-and-release:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
@@ -32,16 +33,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Download Dalamud
|
- name: Download Dalamud
|
||||||
run: |
|
run: |
|
||||||
cd /
|
mkdir -p ~/.xlcore/dalamud/Hooks/dev
|
||||||
mkdir -p root/.xlcore/dalamud/Hooks/dev
|
|
||||||
curl -O https://goatcorp.github.io/dalamud-distrib/stg/latest.zip
|
curl -O https://goatcorp.github.io/dalamud-distrib/stg/latest.zip
|
||||||
unzip latest.zip -d /root/.xlcore/dalamud/Hooks/dev
|
unzip latest.zip -d ~/.xlcore/dalamud/Hooks/dev
|
||||||
|
|
||||||
- name: Lets Build Lightless!
|
- name: Lets Build Lightless!
|
||||||
run: |
|
run: |
|
||||||
dotnet restore
|
dotnet publish --configuration Release
|
||||||
dotnet build --configuration Release --no-restore
|
mv LightlessSync/bin/x64/Release/LightlessSync/latest.zip LightlessClient.zip
|
||||||
dotnet publish --configuration Release --no-build
|
|
||||||
|
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: package_version
|
id: package_version
|
||||||
@@ -53,19 +52,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "Version: ${{ steps.package_version.outputs.version }}"
|
echo "Version: ${{ steps.package_version.outputs.version }}"
|
||||||
|
|
||||||
- name: Prepare Lightless Client
|
|
||||||
run: |
|
|
||||||
PUBLISH_PATH="/workspace/Lightless-Sync/LightlessClient/LightlessSync/bin/x64/Release/publish/"
|
|
||||||
if [ -d "$PUBLISH_PATH" ]; then
|
|
||||||
rm -rf "$PUBLISH_PATH"
|
|
||||||
echo "Removed $PUBLISH_PATH"
|
|
||||||
else
|
|
||||||
echo "$PUBLISH_PATH does not exist, nothing to remove."
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p output
|
|
||||||
(cd /workspace/Lightless-Sync/LightlessClient/LightlessSync/bin/x64/Release/ && zip -r $OLDPWD/output/LightlessClient.zip *)
|
|
||||||
|
|
||||||
- name: Create Git tag if not exists (master)
|
- name: Create Git tag if not exists (master)
|
||||||
if: github.ref == 'refs/heads/master'
|
if: github.ref == 'refs/heads/master'
|
||||||
run: |
|
run: |
|
||||||
@@ -162,14 +148,7 @@ jobs:
|
|||||||
echo "release_id=$release_id"
|
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_OUTPUT || echo "::set-output name=release_id::$release_id"
|
||||||
echo "RELEASE_ID=$release_id" >> $GITHUB_ENV
|
echo "RELEASE_ID=$release_id" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Check asset exists
|
|
||||||
run: |
|
|
||||||
if [ ! -f output/LightlessClient.zip ]; then
|
|
||||||
echo "output/LightlessClient.zip does not exist!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload Assets to release
|
- name: Upload Assets to release
|
||||||
env:
|
env:
|
||||||
RELEASE_ID: ${{ env.RELEASE_ID }}
|
RELEASE_ID: ${{ env.RELEASE_ID }}
|
||||||
@@ -177,7 +156,7 @@ jobs:
|
|||||||
echo "Uploading to release ID: $RELEASE_ID"
|
echo "Uploading to release ID: $RELEASE_ID"
|
||||||
curl --fail-with-body -s -X POST \
|
curl --fail-with-body -s -X POST \
|
||||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||||
-F "attachment=@output/LightlessClient.zip" \
|
-F "attachment=@LightlessClient.zip" \
|
||||||
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets"
|
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets"
|
||||||
|
|
||||||
- name: Clone plugin hosting repo
|
- name: Clone plugin hosting repo
|
||||||
@@ -186,7 +165,7 @@ jobs:
|
|||||||
cd LightlessSyncRepo
|
cd LightlessSyncRepo
|
||||||
git clone https://git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessSync.git
|
git clone https://git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessSync.git
|
||||||
env:
|
env:
|
||||||
GIT_TERMINAL_PROMPT: 0
|
GIT_TERMINAL_PROMPT: 0
|
||||||
|
|
||||||
- name: Update plogonmaster.json with version (master)
|
- name: Update plogonmaster.json with version (master)
|
||||||
if: github.ref == 'refs/heads/master'
|
if: github.ref == 'refs/heads/master'
|
||||||
@@ -282,8 +261,8 @@ jobs:
|
|||||||
- name: Commit and push to LightlessSync
|
- name: Commit and push to LightlessSync
|
||||||
run: |
|
run: |
|
||||||
cd LightlessSyncRepo/LightlessSync
|
cd LightlessSyncRepo/LightlessSync
|
||||||
git config user.name "github-actions"
|
git config user.name "Gitea-Automation"
|
||||||
git config user.email "github-actions@github.com"
|
git config user.email "aaa@aaaaaaa.aaa"
|
||||||
git add .
|
git add .
|
||||||
git diff-index --quiet HEAD || git commit -m "Update ${{ env.PLUGIN_NAME }} to ${{ steps.package_version.outputs.version }}"
|
git diff-index --quiet HEAD || git commit -m "Update ${{ env.PLUGIN_NAME }} to ${{ steps.package_version.outputs.version }}"
|
||||||
git push https://x-access-token:${{ secrets.AUTOMATION_TOKEN }}@git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessSync.git HEAD:main
|
git push https://x-access-token:${{ secrets.AUTOMATION_TOKEN }}@git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessSync.git HEAD:main
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
|||||||
private long _currentFileProgress = 0;
|
private long _currentFileProgress = 0;
|
||||||
private CancellationTokenSource _scanCancellationTokenSource = new();
|
private CancellationTokenSource _scanCancellationTokenSource = new();
|
||||||
private readonly CancellationTokenSource _periodicCalculationTokenSource = new();
|
private readonly CancellationTokenSource _periodicCalculationTokenSource = new();
|
||||||
|
private readonly SemaphoreSlim _dbGate = new(1, 1);
|
||||||
public static readonly IImmutableList<string> AllowedFileExtensions = [".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".pbd", ".scd", ".skp", ".shpk", ".kdb"];
|
public static readonly IImmutableList<string> AllowedFileExtensions = [".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".pbd", ".scd", ".skp", ".shpk", ".kdb"];
|
||||||
private static readonly HashSet<string> AllowedFileExtensionSet = new(AllowedFileExtensions, StringComparer.OrdinalIgnoreCase);
|
private static readonly HashSet<string> AllowedFileExtensionSet = new(AllowedFileExtensions, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
@@ -68,6 +69,9 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
Logger.LogInformation("Starting Periodic Storage Directory Calculation Task");
|
Logger.LogInformation("Starting Periodic Storage Directory Calculation Task");
|
||||||
var token = _periodicCalculationTokenSource.Token;
|
var token = _periodicCalculationTokenSource.Token;
|
||||||
|
while (IsHalted() && !token.IsCancellationRequested)
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false);
|
||||||
|
|
||||||
while (!token.IsCancellationRequested)
|
while (!token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -91,6 +95,9 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
|||||||
public long CurrentFileProgress => _currentFileProgress;
|
public long CurrentFileProgress => _currentFileProgress;
|
||||||
public long FileCacheSize { get; set; }
|
public long FileCacheSize { get; set; }
|
||||||
public long FileCacheDriveFree { get; set; }
|
public long FileCacheDriveFree { get; set; }
|
||||||
|
|
||||||
|
private int _haltCount;
|
||||||
|
private bool IsHalted() => Volatile.Read(ref _haltCount) > 0;
|
||||||
public ConcurrentDictionary<string, int> HaltScanLocks { get; set; } = new(StringComparer.Ordinal);
|
public ConcurrentDictionary<string, int> HaltScanLocks { get; set; } = new(StringComparer.Ordinal);
|
||||||
public bool IsScanRunning => CurrentFileProgress > 0 || TotalFiles > 0;
|
public bool IsScanRunning => CurrentFileProgress > 0 || TotalFiles > 0;
|
||||||
public long TotalFiles { get; private set; }
|
public long TotalFiles { get; private set; }
|
||||||
@@ -98,14 +105,36 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
public void HaltScan(string source)
|
public void HaltScan(string source)
|
||||||
{
|
{
|
||||||
if (!HaltScanLocks.ContainsKey(source)) HaltScanLocks[source] = 0;
|
HaltScanLocks.AddOrUpdate(source, 1, (_, v) => v + 1);
|
||||||
HaltScanLocks[source]++;
|
Interlocked.Increment(ref _haltCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
record WatcherChange(WatcherChangeTypes ChangeType, string? OldPath = null);
|
record WatcherChange(WatcherChangeTypes ChangeType, string? OldPath = null);
|
||||||
private readonly record struct CacheEvictionCandidate(string FullPath, long Size, DateTime LastAccessTime);
|
private readonly record struct CacheEvictionCandidate(string FullPath, long Size, DateTime LastAccessTime);
|
||||||
private readonly Dictionary<string, WatcherChange> _watcherChanges = new(StringComparer.OrdinalIgnoreCase);
|
private readonly object _penumbraGate = new();
|
||||||
private readonly Dictionary<string, WatcherChange> _lightlessChanges = new(StringComparer.OrdinalIgnoreCase);
|
private Dictionary<string, WatcherChange> _watcherChanges = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private readonly object _lightlessGate = new();
|
||||||
|
private Dictionary<string, WatcherChange> _lightlessChanges = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private Dictionary<string, WatcherChange> DrainPenumbraChanges()
|
||||||
|
{
|
||||||
|
lock (_penumbraGate)
|
||||||
|
{
|
||||||
|
var snapshot = _watcherChanges;
|
||||||
|
_watcherChanges = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, WatcherChange> DrainLightlessChanges()
|
||||||
|
{
|
||||||
|
lock (_lightlessGate)
|
||||||
|
{
|
||||||
|
var snapshot = _lightlessChanges;
|
||||||
|
_lightlessChanges = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void StopMonitoring()
|
public void StopMonitoring()
|
||||||
{
|
{
|
||||||
@@ -168,7 +197,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
if (!HasAllowedExtension(e.FullPath)) return;
|
if (!HasAllowedExtension(e.FullPath)) return;
|
||||||
|
|
||||||
lock (_watcherChanges)
|
lock (_lightlessChanges)
|
||||||
{
|
{
|
||||||
_lightlessChanges[e.FullPath] = new(e.ChangeType);
|
_lightlessChanges[e.FullPath] = new(e.ChangeType);
|
||||||
}
|
}
|
||||||
@@ -279,67 +308,58 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
private async Task LightlessWatcherExecution()
|
private async Task LightlessWatcherExecution()
|
||||||
{
|
{
|
||||||
_lightlessFswCts = _lightlessFswCts.CancelRecreate();
|
_lightlessFswCts = _lightlessFswCts.CancelRecreate();
|
||||||
var token = _lightlessFswCts.Token;
|
var token = _lightlessFswCts.Token;
|
||||||
var delay = TimeSpan.FromSeconds(5);
|
|
||||||
Dictionary<string, WatcherChange> changes;
|
|
||||||
lock (_lightlessChanges)
|
|
||||||
changes = _lightlessChanges.ToDictionary(t => t.Key, t => t.Value, StringComparer.Ordinal);
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
do
|
await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(false);
|
||||||
{
|
while (IsHalted() && !token.IsCancellationRequested)
|
||||||
await Task.Delay(delay, token).ConfigureAwait(false);
|
await Task.Delay(250, token).ConfigureAwait(false);
|
||||||
} while (HaltScanLocks.Any(f => f.Value > 0));
|
|
||||||
}
|
|
||||||
catch (TaskCanceledException)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
catch (TaskCanceledException) { return; }
|
||||||
|
|
||||||
lock (_lightlessChanges)
|
var changes = DrainLightlessChanges();
|
||||||
{
|
if (changes.Count > 0)
|
||||||
foreach (var key in changes.Keys)
|
_ = HandleChangesAsync(changes, token);
|
||||||
{
|
|
||||||
_lightlessChanges.Remove(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HandleChanges(changes);
|
|
||||||
}
|
}
|
||||||
|
private async Task HandleChangesAsync(Dictionary<string, WatcherChange> changes, CancellationToken token)
|
||||||
private void HandleChanges(Dictionary<string, WatcherChange> changes)
|
|
||||||
{
|
{
|
||||||
lock (_fileDbManager)
|
await _dbGate.WaitAsync(token).ConfigureAwait(false);
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var deletedEntries = changes.Where(c => c.Value.ChangeType == WatcherChangeTypes.Deleted).Select(c => c.Key);
|
var deleted = changes.Where(c => c.Value.ChangeType == WatcherChangeTypes.Deleted).Select(c => c.Key);
|
||||||
var renamedEntries = changes.Where(c => c.Value.ChangeType == WatcherChangeTypes.Renamed);
|
var renamed = changes.Where(c => c.Value.ChangeType == WatcherChangeTypes.Renamed);
|
||||||
var remainingEntries = changes.Where(c => c.Value.ChangeType != WatcherChangeTypes.Deleted).Select(c => c.Key);
|
var remaining = changes.Where(c => c.Value.ChangeType != WatcherChangeTypes.Deleted).Select(c => c.Key);
|
||||||
|
|
||||||
foreach (var entry in deletedEntries)
|
foreach (var entry in deleted)
|
||||||
{
|
{
|
||||||
Logger.LogDebug("FSW Change: Deletion - {val}", entry);
|
Logger.LogDebug("FSW Change: Deletion - {val}", entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var entry in renamedEntries)
|
foreach (var entry in renamed)
|
||||||
{
|
{
|
||||||
Logger.LogDebug("FSW Change: Renamed - {oldVal} => {val}", entry.Value.OldPath, entry.Key);
|
Logger.LogDebug("FSW Change: Renamed - {oldVal} => {val}", entry.Value.OldPath, entry.Key);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var entry in remainingEntries)
|
foreach (var entry in remaining)
|
||||||
{
|
{
|
||||||
Logger.LogDebug("FSW Change: Creation or Change - {val}", entry);
|
Logger.LogDebug("FSW Change: Creation or Change - {val}", entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
var allChanges = deletedEntries
|
var allChanges = deleted
|
||||||
.Concat(renamedEntries.Select(c => c.Value.OldPath!))
|
.Concat(renamed.Select(c => c.Value.OldPath!))
|
||||||
.Concat(renamedEntries.Select(c => c.Key))
|
.Concat(renamed.Select(c => c.Key))
|
||||||
.Concat(remainingEntries)
|
.Concat(remaining)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
_ = _fileDbManager.GetFileCachesByPaths(allChanges);
|
_ = _fileDbManager.GetFileCachesByPaths(allChanges);
|
||||||
|
await _fileDbManager.WriteOutFullCsvAsync(token).ConfigureAwait(false);
|
||||||
_fileDbManager.WriteOutFullCsv();
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_dbGate.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,77 +367,97 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
_penumbraFswCts = _penumbraFswCts.CancelRecreate();
|
_penumbraFswCts = _penumbraFswCts.CancelRecreate();
|
||||||
var token = _penumbraFswCts.Token;
|
var token = _penumbraFswCts.Token;
|
||||||
Dictionary<string, WatcherChange> changes;
|
|
||||||
lock (_watcherChanges)
|
|
||||||
changes = _watcherChanges.ToDictionary(t => t.Key, t => t.Value, StringComparer.Ordinal);
|
|
||||||
var delay = TimeSpan.FromSeconds(10);
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
do
|
await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(false);
|
||||||
{
|
while (IsHalted() && !token.IsCancellationRequested)
|
||||||
await Task.Delay(delay, token).ConfigureAwait(false);
|
await Task.Delay(250, token).ConfigureAwait(false);
|
||||||
} while (HaltScanLocks.Any(f => f.Value > 0));
|
|
||||||
}
|
|
||||||
catch (TaskCanceledException)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
catch (TaskCanceledException) { return; }
|
||||||
|
|
||||||
lock (_watcherChanges)
|
var changes = DrainPenumbraChanges();
|
||||||
{
|
if (changes.Count > 0)
|
||||||
foreach (var key in changes.Keys)
|
_ = HandleChangesAsync(changes, token);
|
||||||
{
|
|
||||||
_watcherChanges.Remove(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HandleChanges(changes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void InvokeScan()
|
public void InvokeScan()
|
||||||
{
|
{
|
||||||
TotalFiles = 0;
|
TotalFiles = 0;
|
||||||
_currentFileProgress = 0;
|
TotalFilesStorage = 0;
|
||||||
|
Interlocked.Exchange(ref _currentFileProgress, 0);
|
||||||
|
|
||||||
_scanCancellationTokenSource = _scanCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource();
|
_scanCancellationTokenSource = _scanCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource();
|
||||||
var token = _scanCancellationTokenSource.Token;
|
var token = _scanCancellationTokenSource.Token;
|
||||||
|
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
Logger.LogDebug("Starting Full File Scan");
|
TaskCompletionSource scanTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
TotalFiles = 0;
|
|
||||||
_currentFileProgress = 0;
|
|
||||||
while (_dalamudUtil.IsOnFrameworkThread)
|
|
||||||
{
|
|
||||||
Logger.LogWarning("Scanner is on framework, waiting for leaving thread before continuing");
|
|
||||||
await Task.Delay(250, token).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Thread scanThread = new(() =>
|
try
|
||||||
{
|
{
|
||||||
try
|
Logger.LogDebug("Starting Full File Scan");
|
||||||
|
|
||||||
|
while (IsHalted() && !token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
_performanceCollector.LogPerformance(this, $"FullFileScan", () => FullFileScan(token));
|
Logger.LogDebug("Scan is halted, waiting...");
|
||||||
|
await Task.Delay(250, token).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
|
var scanThread = new Thread(() =>
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, "Error during Full File Scan");
|
try
|
||||||
}
|
{
|
||||||
})
|
token.ThrowIfCancellationRequested();
|
||||||
{
|
|
||||||
Priority = ThreadPriority.Lowest,
|
_performanceCollector.LogPerformance(this, $"FullFileScan",
|
||||||
IsBackground = true
|
() => FullFileScan(token));
|
||||||
};
|
|
||||||
scanThread.Start();
|
scanTcs.TrySetResult();
|
||||||
while (scanThread.IsAlive)
|
}
|
||||||
{
|
catch (OperationCanceledException)
|
||||||
await Task.Delay(250, token).ConfigureAwait(false);
|
{
|
||||||
|
scanTcs.TrySetCanceled(token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Error during Full File Scan");
|
||||||
|
scanTcs.TrySetException(ex);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
{
|
||||||
|
Priority = ThreadPriority.Lowest,
|
||||||
|
IsBackground = true,
|
||||||
|
Name = "LightlessSync.FullFileScan"
|
||||||
|
};
|
||||||
|
|
||||||
|
scanThread.Start();
|
||||||
|
|
||||||
|
using var _ = token.Register(() => scanTcs.TrySetCanceled(token));
|
||||||
|
|
||||||
|
await scanTcs.Task.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
Logger.LogInformation("Full File Scan was canceled.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Unexpected error in InvokeScan task");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TotalFiles = 0;
|
||||||
|
TotalFilesStorage = 0;
|
||||||
|
Interlocked.Exchange(ref _currentFileProgress, 0);
|
||||||
}
|
}
|
||||||
TotalFiles = 0;
|
|
||||||
_currentFileProgress = 0;
|
|
||||||
}, token);
|
}, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RecalculateFileCacheSize(CancellationToken token)
|
public void RecalculateFileCacheSize(CancellationToken token)
|
||||||
{
|
{
|
||||||
|
if (IsHalted()) return;
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) ||
|
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) ||
|
||||||
!Directory.Exists(_configService.Current.CacheFolder))
|
!Directory.Exists(_configService.Current.CacheFolder))
|
||||||
{
|
{
|
||||||
@@ -594,10 +634,20 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
public void ResumeScan(string source)
|
public void ResumeScan(string source)
|
||||||
{
|
{
|
||||||
if (!HaltScanLocks.ContainsKey(source)) HaltScanLocks[source] = 0;
|
int delta = 0;
|
||||||
|
|
||||||
HaltScanLocks[source]--;
|
HaltScanLocks.AddOrUpdate(source,
|
||||||
if (HaltScanLocks[source] < 0) HaltScanLocks[source] = 0;
|
addValueFactory: _ => 0,
|
||||||
|
updateValueFactory: (_, v) =>
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(_);
|
||||||
|
if (v <= 0) return 0;
|
||||||
|
delta = 1;
|
||||||
|
return v - 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (delta == 1)
|
||||||
|
Interlocked.Decrement(ref _haltCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
@@ -621,201 +671,81 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
|||||||
private void FullFileScan(CancellationToken ct)
|
private void FullFileScan(CancellationToken ct)
|
||||||
{
|
{
|
||||||
TotalFiles = 1;
|
TotalFiles = 1;
|
||||||
|
_currentFileProgress = 0;
|
||||||
|
|
||||||
var penumbraDir = _ipcManager.Penumbra.ModDirectory;
|
var penumbraDir = _ipcManager.Penumbra.ModDirectory;
|
||||||
bool penDirExists = true;
|
var cacheFolder = _configService.Current.CacheFolder;
|
||||||
bool cacheDirExists = true;
|
|
||||||
if (string.IsNullOrEmpty(penumbraDir) || !Directory.Exists(penumbraDir))
|
if (string.IsNullOrEmpty(penumbraDir) || !Directory.Exists(penumbraDir))
|
||||||
{
|
{
|
||||||
penDirExists = false;
|
|
||||||
Logger.LogWarning("Penumbra directory is not set or does not exist.");
|
Logger.LogWarning("Penumbra directory is not set or does not exist.");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) || !Directory.Exists(_configService.Current.CacheFolder))
|
|
||||||
|
if (string.IsNullOrEmpty(cacheFolder) || !Directory.Exists(cacheFolder))
|
||||||
{
|
{
|
||||||
cacheDirExists = false;
|
|
||||||
Logger.LogWarning("Lightless Cache directory is not set or does not exist.");
|
Logger.LogWarning("Lightless Cache directory is not set or does not exist.");
|
||||||
}
|
|
||||||
if (!penDirExists || !cacheDirExists)
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var previousThreadPriority = Thread.CurrentThread.Priority;
|
var prevPriority = Thread.CurrentThread.Priority;
|
||||||
Thread.CurrentThread.Priority = ThreadPriority.Lowest;
|
Thread.CurrentThread.Priority = ThreadPriority.Lowest;
|
||||||
Logger.LogDebug("Getting files from {penumbra} and {storage}", penumbraDir, _configService.Current.CacheFolder);
|
|
||||||
|
|
||||||
Dictionary<string, string[]> penumbraFiles = new(StringComparer.Ordinal);
|
try
|
||||||
foreach (var folder in Directory.EnumerateDirectories(penumbraDir!))
|
|
||||||
{
|
{
|
||||||
try
|
Logger.LogDebug("Getting files from {penumbra} and {storage}", penumbraDir, cacheFolder);
|
||||||
|
|
||||||
|
var onDiskPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
static bool IsExcludedPenumbraPath(string path)
|
||||||
|
=> path.Contains(@"\bg\", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| path.Contains(@"\bgcommon\", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| path.Contains(@"\ui\", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var folder in Directory.EnumerateDirectories(penumbraDir))
|
||||||
{
|
{
|
||||||
penumbraFiles[folder] =
|
ct.ThrowIfCancellationRequested();
|
||||||
[
|
|
||||||
.. Directory.GetFiles(folder, "*.*", SearchOption.AllDirectories)
|
|
||||||
.AsParallel()
|
|
||||||
.Where(f => HasAllowedExtension(f)
|
|
||||||
&& !f.Contains(@"\bg\", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& !f.Contains(@"\bgcommon\", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& !f.Contains(@"\ui\", StringComparison.OrdinalIgnoreCase)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogWarning(ex, "Could not enumerate path {path}", folder);
|
|
||||||
}
|
|
||||||
Thread.Sleep(50);
|
|
||||||
if (ct.IsCancellationRequested) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var allCacheFiles = Directory.GetFiles(_configService.Current.CacheFolder, "*.*", SearchOption.TopDirectoryOnly)
|
try
|
||||||
.AsParallel()
|
|
||||||
.Where(f =>
|
|
||||||
{
|
|
||||||
var val = f.Split('\\')[^1];
|
|
||||||
return val.Length == 40 || (val.Split('.').FirstOrDefault()?.Length ?? 0) == 40;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ct.IsCancellationRequested) return;
|
|
||||||
|
|
||||||
var allScannedFiles = (penumbraFiles.SelectMany(k => k.Value))
|
|
||||||
.Concat(allCacheFiles)
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToDictionary(t => t.ToLowerInvariant(), t => false, StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
TotalFiles = allScannedFiles.Count;
|
|
||||||
Thread.CurrentThread.Priority = previousThreadPriority;
|
|
||||||
|
|
||||||
Thread.Sleep(TimeSpan.FromSeconds(2));
|
|
||||||
|
|
||||||
if (ct.IsCancellationRequested) return;
|
|
||||||
|
|
||||||
// scan files from database
|
|
||||||
var threadCount = Math.Clamp((int)(Environment.ProcessorCount / 2.0f), 2, 8);
|
|
||||||
|
|
||||||
List<FileCacheEntity> entitiesToRemove = [];
|
|
||||||
List<FileCacheEntity> entitiesToUpdate = [];
|
|
||||||
Lock sync = new();
|
|
||||||
Thread[] workerThreads = new Thread[threadCount];
|
|
||||||
|
|
||||||
ConcurrentQueue<FileCacheEntity> fileCaches = new(_fileDbManager.GetAllFileCaches());
|
|
||||||
|
|
||||||
TotalFilesStorage = fileCaches.Count;
|
|
||||||
|
|
||||||
for (int i = 0; i < threadCount; i++)
|
|
||||||
{
|
|
||||||
Logger.LogTrace("Creating Thread {i}", i);
|
|
||||||
workerThreads[i] = new((tcounter) =>
|
|
||||||
{
|
|
||||||
var threadNr = (int)tcounter!;
|
|
||||||
Logger.LogTrace("Spawning Worker Thread {i}", threadNr);
|
|
||||||
while (!ct.IsCancellationRequested && fileCaches.TryDequeue(out var workload))
|
|
||||||
{
|
{
|
||||||
try
|
foreach (var file in Directory.EnumerateFiles(folder, "*.*", SearchOption.AllDirectories))
|
||||||
{
|
{
|
||||||
if (ct.IsCancellationRequested) return;
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
if (!_ipcManager.Penumbra.APIAvailable)
|
if (!HasAllowedExtension(file)) continue;
|
||||||
{
|
if (IsExcludedPenumbraPath(file)) continue;
|
||||||
Logger.LogWarning("Penumbra not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var validatedCacheResult = _fileDbManager.ValidateFileCacheEntity(workload);
|
onDiskPaths.Add(file);
|
||||||
if (validatedCacheResult.State != FileState.RequireDeletion)
|
|
||||||
{
|
|
||||||
lock (sync) { allScannedFiles[validatedCacheResult.FileCache.ResolvedFilepath] = true; }
|
|
||||||
}
|
|
||||||
if (validatedCacheResult.State == FileState.RequireUpdate)
|
|
||||||
{
|
|
||||||
Logger.LogTrace("To update: {path}", validatedCacheResult.FileCache.ResolvedFilepath);
|
|
||||||
lock (sync) { entitiesToUpdate.Add(validatedCacheResult.FileCache); }
|
|
||||||
}
|
|
||||||
else if (validatedCacheResult.State == FileState.RequireDeletion)
|
|
||||||
{
|
|
||||||
Logger.LogTrace("To delete: {path}", validatedCacheResult.FileCache.ResolvedFilepath);
|
|
||||||
lock (sync) { entitiesToRemove.Add(validatedCacheResult.FileCache); }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
if (workload != null)
|
|
||||||
{
|
|
||||||
Logger.LogWarning(ex, "Failed validating {path}", workload.ResolvedFilepath);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Logger.LogWarning(ex, "Failed validating unknown workload");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Interlocked.Increment(ref _currentFileProgress);
|
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
Logger.LogTrace("Ending Worker Thread {i}", threadNr);
|
{
|
||||||
})
|
Logger.LogWarning(ex, "Could not enumerate path {path}", folder);
|
||||||
{
|
}
|
||||||
Priority = ThreadPriority.Lowest,
|
|
||||||
IsBackground = true
|
|
||||||
};
|
|
||||||
workerThreads[i].Start(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
while (!ct.IsCancellationRequested && workerThreads.Any(u => u.IsAlive))
|
|
||||||
{
|
|
||||||
Thread.Sleep(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ct.IsCancellationRequested) return;
|
|
||||||
|
|
||||||
Logger.LogTrace("Threads exited");
|
|
||||||
|
|
||||||
if (!_ipcManager.Penumbra.APIAvailable)
|
|
||||||
{
|
|
||||||
Logger.LogWarning("Penumbra not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entitiesToUpdate.Count != 0 || entitiesToRemove.Count != 0)
|
|
||||||
{
|
|
||||||
foreach (var entity in entitiesToUpdate)
|
|
||||||
{
|
|
||||||
_fileDbManager.UpdateHashedFile(entity);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var entity in entitiesToRemove)
|
foreach (var file in Directory.EnumerateFiles(cacheFolder, "*.*", SearchOption.TopDirectoryOnly))
|
||||||
{
|
{
|
||||||
_fileDbManager.RemoveHashedFile(entity.Hash, entity.PrefixedFilePath);
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var name = Path.GetFileName(file);
|
||||||
|
var stem = Path.GetFileNameWithoutExtension(file);
|
||||||
|
|
||||||
|
if (name.Length == 40 || stem.Length == 40)
|
||||||
|
onDiskPaths.Add(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
_fileDbManager.WriteOutFullCsv();
|
var threadCount = Math.Clamp((int)(Environment.ProcessorCount / 2.0f), 2, 8);
|
||||||
}
|
|
||||||
|
|
||||||
Logger.LogTrace("Scanner validated existing db files");
|
var fileCacheList = _fileDbManager.GetAllFileCaches();
|
||||||
|
var fileCaches = new ConcurrentQueue<FileCacheEntity>(fileCacheList);
|
||||||
|
|
||||||
if (!_ipcManager.Penumbra.APIAvailable)
|
TotalFilesStorage = fileCaches.Count;
|
||||||
{
|
TotalFiles = onDiskPaths.Count + TotalFilesStorage;
|
||||||
Logger.LogWarning("Penumbra not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ct.IsCancellationRequested) return;
|
var validOrPresentInDb = new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var entitiesToUpdate = new ConcurrentBag<FileCacheEntity>();
|
||||||
var newFiles = allScannedFiles.Where(c => !c.Value).Select(c => c.Key).ToList();
|
var entitiesToRemove = new ConcurrentBag<FileCacheEntity>();
|
||||||
foreach (var cachePath in newFiles)
|
|
||||||
{
|
|
||||||
if (ct.IsCancellationRequested) break;
|
|
||||||
ProcessOne(cachePath);
|
|
||||||
Interlocked.Increment(ref _currentFileProgress);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.LogTrace("Scanner added {count} new files to db", newFiles.Count);
|
|
||||||
|
|
||||||
void ProcessOne(string? cachePath)
|
|
||||||
{
|
|
||||||
if (_fileDbManager == null || _ipcManager?.Penumbra == null || cachePath == null)
|
|
||||||
{
|
|
||||||
Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}",
|
|
||||||
_fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_ipcManager.Penumbra.APIAvailable)
|
if (!_ipcManager.Penumbra.APIAvailable)
|
||||||
{
|
{
|
||||||
@@ -823,33 +753,161 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
Thread[] workerThreads = new Thread[threadCount];
|
||||||
|
for (int i = 0; i < threadCount; i++)
|
||||||
{
|
{
|
||||||
var entry = _fileDbManager.CreateFileEntry(cachePath);
|
workerThreads[i] = new Thread(tcounter =>
|
||||||
if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath);
|
{
|
||||||
|
var threadNr = (int)tcounter!;
|
||||||
|
Logger.LogTrace("Spawning Worker Thread {i}", threadNr);
|
||||||
|
|
||||||
|
while (!ct.IsCancellationRequested && fileCaches.TryDequeue(out var workload))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
if (!_ipcManager.Penumbra.APIAvailable)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var validated = _fileDbManager.ValidateFileCacheEntity(workload);
|
||||||
|
|
||||||
|
if (validated.State != FileState.RequireDeletion)
|
||||||
|
{
|
||||||
|
validOrPresentInDb.TryAdd(validated.FileCache.ResolvedFilepath, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validated.State == FileState.RequireUpdate)
|
||||||
|
{
|
||||||
|
Logger.LogTrace("To update: {path}", validated.FileCache.ResolvedFilepath);
|
||||||
|
entitiesToUpdate.Add(validated.FileCache);
|
||||||
|
}
|
||||||
|
else if (validated.State == FileState.RequireDeletion)
|
||||||
|
{
|
||||||
|
Logger.LogTrace("To delete: {path}", validated.FileCache.ResolvedFilepath);
|
||||||
|
entitiesToRemove.Add(validated.FileCache);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (workload != null)
|
||||||
|
Logger.LogWarning(ex, "Failed validating {path}", workload.ResolvedFilepath);
|
||||||
|
else
|
||||||
|
Logger.LogWarning(ex, "Failed validating unknown workload");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _currentFileProgress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.LogTrace("Ending Worker Thread {i}", threadNr);
|
||||||
|
})
|
||||||
|
{
|
||||||
|
Priority = ThreadPriority.Lowest,
|
||||||
|
IsBackground = true
|
||||||
|
};
|
||||||
|
|
||||||
|
workerThreads[i].Start(i);
|
||||||
}
|
}
|
||||||
catch (IOException ioex)
|
|
||||||
|
while (!ct.IsCancellationRequested && workerThreads.Any(t => t.IsAlive))
|
||||||
{
|
{
|
||||||
Logger.LogDebug(ioex, "File busy or locked: {file}", cachePath);
|
ct.WaitHandle.WaitOne(250);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
|
if (ct.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
Logger.LogTrace("Scanner validated existing db files");
|
||||||
|
|
||||||
|
if (!_ipcManager.Penumbra.APIAvailable)
|
||||||
{
|
{
|
||||||
Logger.LogWarning(ex, "Failed adding {file}", cachePath);
|
Logger.LogWarning("Penumbra not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var didMutateDb = false;
|
||||||
|
|
||||||
|
foreach (var entity in entitiesToUpdate)
|
||||||
|
{
|
||||||
|
didMutateDb = true;
|
||||||
|
_fileDbManager.UpdateHashedFile(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var entity in entitiesToRemove)
|
||||||
|
{
|
||||||
|
didMutateDb = true;
|
||||||
|
_fileDbManager.RemoveHashedFile(entity.Hash, entity.PrefixedFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (didMutateDb)
|
||||||
|
_fileDbManager.WriteOutFullCsv();
|
||||||
|
|
||||||
|
if (ct.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
var newFiles = onDiskPaths.Where(p => !validOrPresentInDb.ContainsKey(p)).ToList();
|
||||||
|
|
||||||
|
foreach (var path in newFiles)
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested) break;
|
||||||
|
ProcessOne(path);
|
||||||
|
Interlocked.Increment(ref _currentFileProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.LogTrace("Scanner added {count} new files to db", newFiles.Count);
|
||||||
|
|
||||||
|
void ProcessOne(string? filePath)
|
||||||
|
{
|
||||||
|
if (filePath == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!_ipcManager.Penumbra.APIAvailable)
|
||||||
|
{
|
||||||
|
Logger.LogWarning("Penumbra not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entry = _fileDbManager.CreateFileEntry(filePath);
|
||||||
|
if (entry == null)
|
||||||
|
_ = _fileDbManager.CreateCacheEntry(filePath);
|
||||||
|
}
|
||||||
|
catch (IOException ioex)
|
||||||
|
{
|
||||||
|
Logger.LogDebug(ioex, "File busy or locked: {file}", filePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "Failed adding {file}", filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.LogDebug("Scan complete");
|
||||||
|
|
||||||
|
TotalFiles = 0;
|
||||||
|
_currentFileProgress = 0;
|
||||||
|
|
||||||
|
if (!_configService.Current.InitialScanComplete)
|
||||||
|
{
|
||||||
|
_configService.Current.InitialScanComplete = true;
|
||||||
|
_configService.Save();
|
||||||
|
|
||||||
|
StartLightlessWatcher(cacheFolder);
|
||||||
|
StartPenumbraWatcher(penumbraDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
Logger.LogDebug("Scan complete");
|
|
||||||
TotalFiles = 0;
|
|
||||||
_currentFileProgress = 0;
|
|
||||||
entitiesToRemove.Clear();
|
|
||||||
allScannedFiles.Clear();
|
|
||||||
|
|
||||||
if (!_configService.Current.InitialScanComplete)
|
|
||||||
{
|
{
|
||||||
_configService.Current.InitialScanComplete = true;
|
// normal cancellation
|
||||||
_configService.Save();
|
}
|
||||||
StartLightlessWatcher(_configService.Current.CacheFolder);
|
catch (Exception ex)
|
||||||
StartPenumbraWatcher(penumbraDir);
|
{
|
||||||
|
Logger.LogError(ex, "Error during Full File Scan");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Thread.CurrentThread.Priority = prevPriority;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +213,7 @@ public sealed partial class FileCompactor : IDisposable
|
|||||||
/// <param name="bytes">Bytes that have to be written</param>
|
/// <param name="bytes">Bytes that have to be written</param>
|
||||||
/// <param name="token">Cancellation Token for interupts</param>
|
/// <param name="token">Cancellation Token for interupts</param>
|
||||||
/// <returns>Writing Task</returns>
|
/// <returns>Writing Task</returns>
|
||||||
public async Task WriteAllBytesAsync(string filePath, byte[] bytes, CancellationToken token)
|
public async Task WriteAllBytesAsync(string filePath, byte[] bytes, CancellationToken token, bool enqueueCompaction = true)
|
||||||
{
|
{
|
||||||
var dir = Path.GetDirectoryName(filePath);
|
var dir = Path.GetDirectoryName(filePath);
|
||||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||||
@@ -221,6 +221,12 @@ public sealed partial class FileCompactor : IDisposable
|
|||||||
|
|
||||||
await File.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false);
|
await File.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (enqueueCompaction && _lightlessConfigService.Current.UseCompactor)
|
||||||
|
EnqueueCompaction(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RequestCompaction(string filePath)
|
||||||
|
{
|
||||||
if (_lightlessConfigService.Current.UseCompactor)
|
if (_lightlessConfigService.Current.UseCompactor)
|
||||||
EnqueueCompaction(filePath);
|
EnqueueCompaction(filePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
foreach (var handler in _playerRelatedPointers)
|
foreach (var handler in _playerRelatedPointers)
|
||||||
{
|
{
|
||||||
var address = (nint)handler.Address;
|
var address = handler.Address;
|
||||||
if (address != nint.Zero)
|
if (address != nint.Zero)
|
||||||
{
|
{
|
||||||
tempMap[address] = handler;
|
tempMap[address] = handler;
|
||||||
|
|||||||
@@ -81,7 +81,10 @@ public sealed class IpcCallerPenumbra : IpcServiceBase
|
|||||||
=> _collections.RemoveTemporaryCollectionAsync(logger, applicationId, collectionId);
|
=> _collections.RemoveTemporaryCollectionAsync(logger, applicationId, collectionId);
|
||||||
|
|
||||||
public Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths)
|
public Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths)
|
||||||
=> _collections.SetTemporaryModsAsync(logger, applicationId, collectionId, modPaths);
|
=> _collections.SetTemporaryModsAsync(logger, applicationId, collectionId, modPaths, "Player");
|
||||||
|
|
||||||
|
public Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths, string scope)
|
||||||
|
=> _collections.SetTemporaryModsAsync(logger, applicationId, collectionId, modPaths, scope);
|
||||||
|
|
||||||
public Task SetManipulationDataAsync(ILogger logger, Guid applicationId, Guid collectionId, string manipulationData)
|
public Task SetManipulationDataAsync(ILogger logger, Guid applicationId, Guid collectionId, string manipulationData)
|
||||||
=> _collections.SetManipulationDataAsync(logger, applicationId, collectionId, manipulationData);
|
=> _collections.SetManipulationDataAsync(logger, applicationId, collectionId, manipulationData);
|
||||||
|
|||||||
@@ -92,25 +92,43 @@ public sealed class PenumbraCollections : PenumbraBase
|
|||||||
_activeTemporaryCollections.TryRemove(collectionId, out _);
|
_activeTemporaryCollections.TryRemove(collectionId, out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths)
|
public async Task SetTemporaryModsAsync(
|
||||||
|
ILogger logger,
|
||||||
|
Guid applicationId,
|
||||||
|
Guid collectionId,
|
||||||
|
Dictionary<string, string> modPaths,
|
||||||
|
string scope)
|
||||||
{
|
{
|
||||||
if (!IsAvailable || collectionId == Guid.Empty)
|
if (!IsAvailable || collectionId == Guid.Empty)
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
var modName = $"LightlessChara_Files_{applicationId:N}_{scope}";
|
||||||
|
|
||||||
|
var normalized = new Dictionary<string, string>(modPaths.Count, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var kvp in modPaths)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(kvp.Key) || string.IsNullOrWhiteSpace(kvp.Value))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var gamePath = kvp.Key.Replace('\\', '/').ToLowerInvariant();
|
||||||
|
normalized[gamePath] = kvp.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
await DalamudUtil.RunOnFrameworkThread(() =>
|
await DalamudUtil.RunOnFrameworkThread(() =>
|
||||||
{
|
{
|
||||||
foreach (var mod in modPaths)
|
foreach (var mod in normalized)
|
||||||
{
|
logger.LogTrace("[{ApplicationId}] {ModName}: {From} => {To}", applicationId, modName, mod.Key, mod.Value);
|
||||||
logger.LogTrace("[{ApplicationId}] Change: {From} => {To}", applicationId, mod.Key, mod.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
var removeResult = _removeTemporaryMod.Invoke("LightlessChara_Files", collectionId, 0);
|
var removeResult = _removeTemporaryMod.Invoke(modName, collectionId, 0);
|
||||||
logger.LogTrace("[{ApplicationId}] Removing temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, removeResult);
|
logger.LogTrace("[{ApplicationId}] Removing temp mod {ModName} for {CollectionId}, Success: {Result}",
|
||||||
|
applicationId, modName, collectionId, removeResult);
|
||||||
|
|
||||||
var addResult = _addTemporaryMod.Invoke("LightlessChara_Files", collectionId, modPaths, string.Empty, 0);
|
if (normalized.Count == 0)
|
||||||
logger.LogTrace("[{ApplicationId}] Setting temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, addResult);
|
return;
|
||||||
|
|
||||||
|
var addResult = _addTemporaryMod.Invoke(modName, collectionId, normalized, string.Empty, 0);
|
||||||
|
logger.LogTrace("[{ApplicationId}] Setting temp mod {ModName} for {CollectionId}, Success: {Result}",
|
||||||
|
applicationId, modName, collectionId, addResult);
|
||||||
}).ConfigureAwait(false);
|
}).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +189,7 @@ public sealed class PenumbraCollections : PenumbraBase
|
|||||||
Logger.LogDebug("Cleaning up stale temporary collection {CollectionName} ({CollectionId})", name, collectionId);
|
Logger.LogDebug("Cleaning up stale temporary collection {CollectionName} ({CollectionId})", name, collectionId);
|
||||||
var deleteResult = await DalamudUtil.RunOnFrameworkThread(() =>
|
var deleteResult = await DalamudUtil.RunOnFrameworkThread(() =>
|
||||||
{
|
{
|
||||||
var result = (PenumbraApiEc)_removeTemporaryCollection.Invoke(collectionId);
|
var result = _removeTemporaryCollection.Invoke(collectionId);
|
||||||
Logger.LogTrace("Cleanup RemoveTemporaryCollection result for {CollectionName} ({CollectionId}): {Result}", name, collectionId, result);
|
Logger.LogTrace("Cleanup RemoveTemporaryCollection result for {CollectionName} ({CollectionId}): {Result}", name, collectionId, result);
|
||||||
return result;
|
return result;
|
||||||
}).ConfigureAwait(false);
|
}).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public sealed class ChatConfig : ILightlessConfiguration
|
|||||||
public bool ShowRulesOverlayOnOpen { get; set; } = true;
|
public bool ShowRulesOverlayOnOpen { get; set; } = true;
|
||||||
public bool ShowMessageTimestamps { get; set; } = true;
|
public bool ShowMessageTimestamps { get; set; } = true;
|
||||||
public bool ShowNotesInSyncshellChat { get; set; } = true;
|
public bool ShowNotesInSyncshellChat { get; set; } = true;
|
||||||
|
public bool EnableAnimatedEmotes { get; set; } = true;
|
||||||
public float ChatWindowOpacity { get; set; } = .97f;
|
public float ChatWindowOpacity { get; set; } = .97f;
|
||||||
public bool FadeWhenUnfocused { get; set; } = false;
|
public bool FadeWhenUnfocused { get; set; } = false;
|
||||||
public float UnfocusedWindowOpacity { get; set; } = 0.6f;
|
public float UnfocusedWindowOpacity { get; set; } = 0.6f;
|
||||||
|
|||||||
@@ -158,9 +158,8 @@ public class LightlessConfig : ILightlessConfiguration
|
|||||||
public string? SelectedFinderSyncshell { get; set; } = null;
|
public string? SelectedFinderSyncshell { get; set; } = null;
|
||||||
public string LastSeenVersion { get; set; } = string.Empty;
|
public string LastSeenVersion { get; set; } = string.Empty;
|
||||||
public bool EnableParticleEffects { get; set; } = true;
|
public bool EnableParticleEffects { get; set; } = true;
|
||||||
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
|
public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Unsafe;
|
||||||
public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Safe;
|
public bool AnimationAllowOneBasedShift { get; set; } = false;
|
||||||
public bool AnimationAllowOneBasedShift { get; set; } = true;
|
|
||||||
|
|
||||||
public bool AnimationAllowNeighborIndexTolerance { get; set; } = false;
|
public bool AnimationAllowNeighborIndexTolerance { get; set; } = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace LightlessSync.LightlessConfiguration.Configurations;
|
||||||
|
|
||||||
|
public class PenumbraJanitorConfig : ILightlessConfiguration
|
||||||
|
{
|
||||||
|
public int Version { get; set; } = 0;
|
||||||
|
|
||||||
|
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using LightlessSync.LightlessConfiguration.Configurations;
|
||||||
|
|
||||||
|
namespace LightlessSync.LightlessConfiguration;
|
||||||
|
|
||||||
|
public class PenumbraJanitorConfigService : ConfigurationServiceBase<PenumbraJanitorConfig>
|
||||||
|
{
|
||||||
|
public const string ConfigName = "penumbra-collections.json";
|
||||||
|
|
||||||
|
public PenumbraJanitorConfigService(string configDir) : base(configDir)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ConfigurationName => ConfigName;
|
||||||
|
}
|
||||||
@@ -24,6 +24,15 @@
|
|||||||
<Compile Remove="PlayerData\Export\**" />
|
<Compile Remove="PlayerData\Export\**" />
|
||||||
<EmbeddedResource Remove="PlayerData\Export\**" />
|
<EmbeddedResource Remove="PlayerData\Export\**" />
|
||||||
<None Remove="PlayerData\Export\**" />
|
<None Remove="PlayerData\Export\**" />
|
||||||
|
<EmbeddedResource Update="Resources\Resources.resx">
|
||||||
|
<Generator>PublicResXFileCodeGenerator</Generator>
|
||||||
|
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||||
|
</EmbeddedResource>
|
||||||
|
<Compile Update="Resources\Resources.Designer.cs">
|
||||||
|
<DesignTime>True</DesignTime>
|
||||||
|
<AutoGen>True</AutoGen>
|
||||||
|
<DependentUpon>Resources.resx</DependentUpon>
|
||||||
|
</Compile>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -68,8 +77,6 @@
|
|||||||
</None>
|
</None>
|
||||||
<EmbeddedResource Include="Changelog\changelog.yaml" />
|
<EmbeddedResource Include="Changelog\changelog.yaml" />
|
||||||
<EmbeddedResource Include="Changelog\credits.yaml" />
|
<EmbeddedResource Include="Changelog\credits.yaml" />
|
||||||
<EmbeddedResource Include="Localization\de.json" />
|
|
||||||
<EmbeddedResource Include="Localization\fr.json" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
3
LightlessSync/LightlessSync.csproj.DotSettings
Normal file
3
LightlessSync/LightlessSync.csproj.DotSettings
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
|
<s:String x:Key="/Default/CodeEditing/Localization/Localizable/@EntryValue">Yes</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeEditing/Localization/LocalizableInspector/@EntryValue">Pessimistic</s:String></wpf:ResourceDictionary>
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
using CheapLoc;
|
|
||||||
|
|
||||||
namespace LightlessSync.Localization;
|
|
||||||
|
|
||||||
public static class Strings
|
|
||||||
{
|
|
||||||
public static ToSStrings ToS { get; set; } = new();
|
|
||||||
|
|
||||||
public class ToSStrings
|
|
||||||
{
|
|
||||||
public readonly string AgreeLabel = Loc.Localize("AgreeLabel", "I agree");
|
|
||||||
public readonly string AgreementLabel = Loc.Localize("AgreementLabel", "Agreement of Usage of Service");
|
|
||||||
public readonly string ButtonWillBeAvailableIn = Loc.Localize("ButtonWillBeAvailableIn", "'I agree' button will be available in");
|
|
||||||
public readonly string LanguageLabel = Loc.Localize("LanguageLabel", "Language");
|
|
||||||
|
|
||||||
public readonly string Paragraph1 = Loc.Localize("Paragraph1",
|
|
||||||
"All of the mod files currently active on your character as well as your current character state will be uploaded to the service you registered yourself at automatically. " +
|
|
||||||
"The plugin will exclusively upload the necessary mod files and not the whole mod.");
|
|
||||||
|
|
||||||
public readonly string Paragraph2 = Loc.Localize("Paragraph2",
|
|
||||||
"If you are on a data capped internet connection, higher fees due to data usage depending on the amount of downloaded and uploaded mod files might occur. " +
|
|
||||||
"Mod files will be compressed on up- and download to save on bandwidth usage. Due to varying up- and download speeds, changes in characters might not be visible immediately. " +
|
|
||||||
"Files present on the service that already represent your active mod files will not be uploaded again.");
|
|
||||||
|
|
||||||
public readonly string Paragraph3 = Loc.Localize("Paragraph3",
|
|
||||||
"The mod files you are uploading are confidential and will not be distributed to parties other than the ones who are requesting the exact same mod files. " +
|
|
||||||
"Please think about who you are going to pair since it is unavoidable that they will receive and locally cache the necessary mod files that you have currently in use. " +
|
|
||||||
"Locally cached mod files will have arbitrary file names to discourage attempts at replicating the original mod.");
|
|
||||||
|
|
||||||
public readonly string Paragraph4 = Loc.Localize("Paragraph4",
|
|
||||||
"The plugin creator tried their best to keep you secure. However, there is no guarantee for 100% security. Do not blindly pair your client with everyone.");
|
|
||||||
|
|
||||||
public readonly string Paragraph5 = Loc.Localize("Paragraph5",
|
|
||||||
"Mod files that are saved on the service will remain on the service as long as there are requests for the files from clients. " +
|
|
||||||
"After a period of not being used, the mod files will be automatically deleted. " +
|
|
||||||
"You will also be able to wipe all the files you have personally uploaded on request. " +
|
|
||||||
"The service holds no information about which mod files belong to which mod.");
|
|
||||||
|
|
||||||
public readonly string Paragraph6 = Loc.Localize("Paragraph6",
|
|
||||||
"This service is provided as-is. In case of abuse join the Lightless Sync Discord.");
|
|
||||||
|
|
||||||
public readonly string ReadLabel = Loc.Localize("ReadLabel", "READ THIS CAREFULLY");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"LanguageLabel": {
|
|
||||||
"message": "Language",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"AgreementLabel": {
|
|
||||||
"message": "Nutzungsbedingungen",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"ReadLabel": {
|
|
||||||
"message": "BITTE LIES DIES SORGFÄLTIG",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"Paragraph1": {
|
|
||||||
"message": "Alle Moddateien, die aktuell auf deinem Charakter aktiv sind und dein Charakterzustand werden automatisch zu dem Service, an dem du dich registriert hast, hochgeladen. Das Plugin wird ausschließlich die nötigen Moddateien hochladen und nicht die gesamte Modifikation.",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"Paragraph2": {
|
|
||||||
"message": "Falls du mit einer getakteten Internetverbindung verbunden bist, können durch den Datentransfer von Hoch- und Runtergeladenen Moddateien höhere Kosten entstehen. Moddateien werden beim Hoch- und Runterladen komprimiert um Bandbreite zu sparen. Durch unterschiedliche Hoch- und Runterladgeschwindigkeiten ist es möglich, dass Änderungen an Charakteren nicht sofort sichtbar sind. Dateien die bereits auf dem Service existieren, werden nicht nochmals hochgeladen.",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"Paragraph3": {
|
|
||||||
"message": "Die Moddateien die du hochlädst sind vertraulich und werden nicht mit anderen Nutzern geteilt, die nicht die exakt selben Dateien anfordern. Bitte überlege dir sorgfältig mit wem du deinen Identifikationscode teilst, da es unvermeidlich ist, dass die andere Person deine Moddateien erhält und lokal zwischenspeichert. Lokal zwischengespeicherte Dateien haben willkürrliche Namen um vor Versuchen abzuschrecken die originalen Moddateien aus diesen wiederherzustellen.",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"Paragraph4": {
|
|
||||||
"message": "Der Ersteller des Plugins hat sein Bestes getan, um deine Sicherheit zu gewährleisten. Es gibt jedoch keine Garantie für 100%ige Sicherheit. Teile deinen Identifikationscode nicht blind mit jedem.",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"Paragraph5": {
|
|
||||||
"message": "Moddateien, die auf dem Service gespeichert sind, verbleiben auf dem Service, solange es Anforderungen für diese Dateien gibt. Nach einer Zeitspanne in der die Dateien nicht verwendet wurden, werden diese automatisch gelöscht. Du hast auch die Möglichkeit manuell alle Dateien auf dem Service zu löschen. Der Service hat keine Informationen welche Moddateien zu welcher Modifikation gehören.",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"Paragraph6": {
|
|
||||||
"message": "Dieser Dienst wird ohne Gewähr angeboten. Im Falle eines Missbrauchs tretet dem Lightless Sync Discord bei.",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"AgreeLabel": {
|
|
||||||
"message": "Ich Stimme zu",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"ButtonWillBeAvailableIn": {
|
|
||||||
"message": "\"Ich stimme zu\" Knopf verfügbar in",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"LanguageLabel": {
|
|
||||||
"message": "Language",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"AgreementLabel": {
|
|
||||||
"message": "Conditions d'Utilisation",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"ReadLabel": {
|
|
||||||
"message": "LISEZ CES INFORMATIONS ATTENTIVEMENT",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"Paragraph1": {
|
|
||||||
"message": "Tous les fichiers moddés actuellement en cours d'utilisation ainsi que le statut actuel de votre personnage vont être mix en ligne via le service sur lequel vous vous êtes automatiquement enregistré. Seuls les fichiers nécessaires seront téléversés par le plugin et non pas le mod en entier.",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"Paragraph2": {
|
|
||||||
"message": "Si le débit de votre connexion internet est limité, le téléchargement et téléversement d'un grand nombre de fichiers peut entraîner des coûts supplémentaires. Les fichiers seront compressés au chargement et versement pour réduire l'impact sur votre bande passants. Selon la rapidité de vos téléchargements et téléversements, les changements ne seront peut-être pas visibles instantanément sur les personnages. Les fichiers déja présents sur le service qui correspondent à ceux de vos mods en cours d'utilisation ne seront pas remis en ligne.",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"Paragraph3": {
|
|
||||||
"message": "Les fichiers que vous allez partager sont confidentiels et ne seront envoyés qu'aux utilisateurs qui feront une requête exacte de ceux-çi. Nous vous demandons de (re)considérer qui sera synchronisé avec vous, puisqu'ils recevront et stockeront inévitablement en local les fichiers nécéssaires utilisés à cet instant. Les noms des fichiers stockés localement sont changés de manière arbitraire afin de décourager toute tentative de réplication des originaux.",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"Paragraph4": {
|
|
||||||
"message": "Le créateur de ce plugin a tenté de sécuriser l'application du mieux possible. Cependant, il ne peut pas garantir une protection 100% infaillible. Pour votre sécurité, ne vous synchronisez pas aveuglément et avec n'importe qui.",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"Paragraph5": {
|
|
||||||
"message": "Les fichiers sauvegardés sur le service resteront en ligne tant que des utilisateurs en feront usage. Ils seront effacés automatiquement après une certaine période d'inactivité. Vous pouvez également demander l'effacement de tous les fichiers que vous avez mis en ligne vous-même. Le service en soi ne contient aucune information pouvant identifier quel fichier appartient à quel mod.",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"Paragraph6": {
|
|
||||||
"message": "Ce service et ses composants vous sont fournis en l'état. En cas d'abus rejoindre le serveur Discord Lightless Sync.",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"AgreeLabel": {
|
|
||||||
"message": "J'accept",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
},
|
|
||||||
"ButtonWillBeAvailableIn": {
|
|
||||||
"message": "Bouton \"J'accept\" disposible dans",
|
|
||||||
"description": "ToSStrings..ctor"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using LightlessSync.API.Data.Enum;
|
using Dalamud.Plugin.Services;
|
||||||
|
using LightlessSync.API.Data.Enum;
|
||||||
using LightlessSync.PlayerData.Handlers;
|
using LightlessSync.PlayerData.Handlers;
|
||||||
using LightlessSync.Services;
|
using LightlessSync.Services;
|
||||||
using LightlessSync.Services.Mediator;
|
using LightlessSync.Services.Mediator;
|
||||||
@@ -11,6 +12,7 @@ public class GameObjectHandlerFactory
|
|||||||
{
|
{
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
|
private readonly IObjectTable _objectTable;
|
||||||
private readonly LightlessMediator _lightlessMediator;
|
private readonly LightlessMediator _lightlessMediator;
|
||||||
private readonly PerformanceCollectorService _performanceCollectorService;
|
private readonly PerformanceCollectorService _performanceCollectorService;
|
||||||
|
|
||||||
@@ -18,12 +20,14 @@ public class GameObjectHandlerFactory
|
|||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
PerformanceCollectorService performanceCollectorService,
|
PerformanceCollectorService performanceCollectorService,
|
||||||
LightlessMediator lightlessMediator,
|
LightlessMediator lightlessMediator,
|
||||||
IServiceProvider serviceProvider)
|
IServiceProvider serviceProvider,
|
||||||
|
IObjectTable objectTable)
|
||||||
{
|
{
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
_performanceCollectorService = performanceCollectorService;
|
_performanceCollectorService = performanceCollectorService;
|
||||||
_lightlessMediator = lightlessMediator;
|
_lightlessMediator = lightlessMediator;
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
|
_objectTable = objectTable;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<GameObjectHandler> Create(ObjectKind objectKind, Func<nint> getAddressFunc, bool isWatched = false)
|
public async Task<GameObjectHandler> Create(ObjectKind objectKind, Func<nint> getAddressFunc, bool isWatched = false)
|
||||||
@@ -36,6 +40,7 @@ public class GameObjectHandlerFactory
|
|||||||
dalamudUtilService,
|
dalamudUtilService,
|
||||||
objectKind,
|
objectKind,
|
||||||
getAddressFunc,
|
getAddressFunc,
|
||||||
|
_objectTable,
|
||||||
isWatched)).ConfigureAwait(false);
|
isWatched)).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using LightlessSync.API.Data.Enum;
|
using LightlessSync.API.Data.Enum;
|
||||||
using LightlessSync.API.Dto.User;
|
using LightlessSync.API.Dto.User;
|
||||||
using LightlessSync.PlayerData.Pairs;
|
using LightlessSync.PlayerData.Pairs;
|
||||||
using LightlessSync.Services.Mediator;
|
using LightlessSync.Services.Mediator;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
using Dalamud.Utility;
|
||||||
using LightlessSync.API.Data.Enum;
|
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.Game.Object;
|
||||||
using LightlessSync.FileCache;
|
using LightlessSync.FileCache;
|
||||||
using LightlessSync.Interop.Ipc;
|
using LightlessSync.Interop.Ipc;
|
||||||
using LightlessSync.LightlessConfiguration;
|
using LightlessSync.LightlessConfiguration;
|
||||||
@@ -8,9 +9,12 @@ using LightlessSync.PlayerData.Data;
|
|||||||
using LightlessSync.PlayerData.Handlers;
|
using LightlessSync.PlayerData.Handlers;
|
||||||
using LightlessSync.Services;
|
using LightlessSync.Services;
|
||||||
using LightlessSync.Services.Mediator;
|
using LightlessSync.Services.Mediator;
|
||||||
|
using LightlessSync.Utils;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
||||||
|
|
||||||
namespace LightlessSync.PlayerData.Factories;
|
namespace LightlessSync.PlayerData.Factories;
|
||||||
|
|
||||||
@@ -116,29 +120,28 @@ public class PlayerDataFactory
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly int _characterGameObjectOffset =
|
||||||
|
(int)Marshal.OffsetOf<Character>(nameof(Character.GameObject));
|
||||||
|
|
||||||
|
private static readonly int _gameObjectDrawObjectOffset =
|
||||||
|
(int)Marshal.OffsetOf<GameObject>(nameof(GameObject.DrawObject));
|
||||||
|
|
||||||
private async Task<bool> CheckForNullDrawObject(IntPtr playerPointer)
|
private async Task<bool> CheckForNullDrawObject(IntPtr playerPointer)
|
||||||
=> await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
|
=> await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectSafe(playerPointer))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
private unsafe static bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
|
private static bool CheckForNullDrawObjectSafe(nint playerPointer)
|
||||||
{
|
{
|
||||||
if (playerPointer == IntPtr.Zero)
|
if (playerPointer == nint.Zero)
|
||||||
return true;
|
return true;
|
||||||
try
|
|
||||||
{
|
|
||||||
var character = (Character*)playerPointer;
|
|
||||||
if (character == null)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
var gameObject = &character->GameObject;
|
var drawObjPtrAddress = playerPointer + _characterGameObjectOffset + _gameObjectDrawObjectOffset;
|
||||||
if (gameObject == null)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
return gameObject->DrawObject == null;
|
// Read the DrawObject pointer from memory
|
||||||
}
|
if (!MemoryProcessProbe.TryReadIntPtr(drawObjPtrAddress, out var drawObj))
|
||||||
catch (AccessViolationException)
|
|
||||||
{
|
|
||||||
return true;
|
return true;
|
||||||
}
|
|
||||||
|
return drawObj == nint.Zero;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsCacheFresh(CacheEntry entry)
|
private static bool IsCacheFresh(CacheEntry entry)
|
||||||
@@ -154,7 +157,7 @@ public class PlayerDataFactory
|
|||||||
if (_characterBuildCache.TryGetValue(key, out var cached) && IsCacheFresh(cached) && !_characterBuildInflight.ContainsKey(key))
|
if (_characterBuildCache.TryGetValue(key, out var cached) && IsCacheFresh(cached) && !_characterBuildInflight.ContainsKey(key))
|
||||||
return cached.Fragment;
|
return cached.Fragment;
|
||||||
|
|
||||||
var buildTask = _characterBuildInflight.GetOrAdd(key, _ => BuildAndCacheAsync(obj, key));
|
var buildTask = _characterBuildInflight.GetOrAdd(key, valueFactory: k => BuildAndCacheAsync(obj, k));
|
||||||
|
|
||||||
if (_characterBuildCache.TryGetValue(key, out cached))
|
if (_characterBuildCache.TryGetValue(key, out cached))
|
||||||
{
|
{
|
||||||
@@ -537,13 +540,31 @@ public class PlayerDataFactory
|
|||||||
|
|
||||||
var hash = g.Key;
|
var hash = g.Key;
|
||||||
|
|
||||||
|
var resolvedPath = g.Select(f => f.ResolvedPath).Distinct(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var papPathSummary = string.Join(", ", resolvedPath);
|
||||||
|
if (papPathSummary.IsNullOrEmpty())
|
||||||
|
papPathSummary = "<unknown pap path>";
|
||||||
|
|
||||||
Dictionary<string, List<ushort>>? papIndices = null;
|
Dictionary<string, List<ushort>>? papIndices = null;
|
||||||
|
|
||||||
await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
|
await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
papIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash), ct)
|
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
|
||||||
.ConfigureAwait(false);
|
var papPath = cacheEntity?.ResolvedFilepath;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(papPath) && File.Exists(papPath))
|
||||||
|
{
|
||||||
|
var havokBytes = await Task.Run(() => XivDataAnalyzer.ReadHavokBytesFromPap(papPath), ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (havokBytes is { Length: > 8 })
|
||||||
|
{
|
||||||
|
papIndices = await _dalamudUtil.RunOnFrameworkThread(
|
||||||
|
() => _modelAnalyzer.ParseHavokBytesOnFrameworkThread(havokBytes, hash, persistToConfig: false))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -553,43 +574,61 @@ public class PlayerDataFactory
|
|||||||
if (papIndices == null || papIndices.Count == 0)
|
if (papIndices == null || papIndices.Count == 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (_logger.IsEnabled(LogLevel.Debug))
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
{
|
{
|
||||||
var papBuckets = papIndices
|
try
|
||||||
.Select(kvp => new
|
{
|
||||||
{
|
var papBuckets = papIndices
|
||||||
Raw = kvp.Key,
|
.Where(kvp => kvp.Value is { Count: > 0 })
|
||||||
Key = XivDataAnalyzer.CanonicalizeSkeletonKey(kvp.Key),
|
.Select(kvp => new
|
||||||
Indices = kvp.Value
|
{
|
||||||
})
|
Raw = kvp.Key,
|
||||||
.Where(x => x.Indices is { Count: > 0 })
|
Key = XivDataAnalyzer.CanonicalizeSkeletonKey(kvp.Key),
|
||||||
.GroupBy(x => string.IsNullOrEmpty(x.Key) ? x.Raw : x.Key!, StringComparer.OrdinalIgnoreCase)
|
Indices = kvp.Value
|
||||||
.Select(grp =>
|
})
|
||||||
{
|
.Where(x => x.Indices is { Count: > 0 })
|
||||||
var all = grp.SelectMany(v => v.Indices).ToList();
|
.GroupBy(x => string.IsNullOrEmpty(x.Key) ? x.Raw : x.Key!, StringComparer.OrdinalIgnoreCase)
|
||||||
var min = all.Count > 0 ? all.Min() : 0;
|
.Select(grp =>
|
||||||
var max = all.Count > 0 ? all.Max() : 0;
|
{
|
||||||
var raws = string.Join(',', grp.Select(v => v.Raw).Distinct(StringComparer.OrdinalIgnoreCase));
|
var all = grp.SelectMany(v => v.Indices).ToList();
|
||||||
return $"{grp.Key}(min={min},max={max},raw=[{raws}])";
|
var min = all.Count > 0 ? all.Min() : 0;
|
||||||
})
|
var max = all.Count > 0 ? all.Max() : 0;
|
||||||
.ToList();
|
var raws = string.Join(',', grp.Select(v => v.Raw).Distinct(StringComparer.OrdinalIgnoreCase));
|
||||||
|
return $"{grp.Key}(min={min},max={max},raw=[{raws}])";
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
_logger.LogDebug("SEND pap buckets for hash={hash}: {b}",
|
_logger.LogDebug("SEND pap buckets for hash={hash}: {b}",
|
||||||
hash,
|
hash,
|
||||||
string.Join(" | ", papBuckets));
|
string.Join(" | ", papBuckets));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Error logging PAP bucket details for hash={hash}", hash);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason))
|
bool isCompatible = false;
|
||||||
|
string reason = string.Empty;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
isCompatible = XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out reason);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Error checking PAP compatibility for hash={hash}, path={path}. Treating as incompatible.", hash, papPathSummary);
|
||||||
|
reason = $"Exception during compatibility check: {ex.Message}";
|
||||||
|
isCompatible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCompatible)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
noValidationFailed++;
|
noValidationFailed++;
|
||||||
|
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"Animation PAP hash {hash} is not compatible with local skeletons; dropping all mappings for this hash. Reason: {reason}",
|
"Animation PAP is not compatible with local skeletons; dropping mappings for {papPath}. Reason: {reason}",
|
||||||
hash,
|
papPathSummary,
|
||||||
reason);
|
reason);
|
||||||
|
|
||||||
var removedGamePaths = fragment.FileReplacements
|
var removedGamePaths = fragment.FileReplacements
|
||||||
@@ -634,8 +673,8 @@ public class PlayerDataFactory
|
|||||||
return new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase).AsReadOnly();
|
return new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase).AsReadOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
var forwardPathsLower = forwardPaths.Length == 0 ? Array.Empty<string>() : forwardPaths.Select(p => p.ToLowerInvariant()).ToArray();
|
var forwardPathsLower = forwardPaths.Length == 0 ? [] : forwardPaths.Select(p => p.ToLowerInvariant()).ToArray();
|
||||||
var reversePathsLower = reversePaths.Length == 0 ? Array.Empty<string>() : reversePaths.Select(p => p.ToLowerInvariant()).ToArray();
|
var reversePathsLower = reversePaths.Length == 0 ? [] : reversePaths.Select(p => p.ToLowerInvariant()).ToArray();
|
||||||
|
|
||||||
Dictionary<string, List<string>> resolvedPaths = new(forwardPaths.Length + reversePaths.Length, StringComparer.Ordinal);
|
Dictionary<string, List<string>> resolvedPaths = new(forwardPaths.Length + reversePaths.Length, StringComparer.Ordinal);
|
||||||
if (handler.ObjectKind != ObjectKind.Player)
|
if (handler.ObjectKind != ObjectKind.Player)
|
||||||
@@ -669,7 +708,7 @@ public class PlayerDataFactory
|
|||||||
list.Add(forwardPaths[i].ToLowerInvariant());
|
list.Add(forwardPaths[i].ToLowerInvariant());
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
|
resolvedPaths[filePath] = [forwardPathsLower[i]];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +1,68 @@
|
|||||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
using Dalamud.Game.ClientState.Objects.Enums;
|
||||||
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
using Dalamud.Game.ClientState.Objects.Types;
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
using LightlessSync.Services;
|
using LightlessSync.Services;
|
||||||
using LightlessSync.Services.Mediator;
|
using LightlessSync.Services.Mediator;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using static FFXIVClientStructs.FFXIV.Client.Game.Character.DrawDataContainer;
|
|
||||||
using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags;
|
|
||||||
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
||||||
|
|
||||||
namespace LightlessSync.PlayerData.Handlers;
|
namespace LightlessSync.PlayerData.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Game object handler for managing game object state and updates
|
||||||
|
/// </summary>
|
||||||
public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighPriorityMediatorSubscriber
|
public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighPriorityMediatorSubscriber
|
||||||
{
|
{
|
||||||
private readonly DalamudUtilService _dalamudUtil;
|
private readonly DalamudUtilService _dalamudUtil;
|
||||||
|
private readonly IObjectTable _objectTable;
|
||||||
private readonly Func<IntPtr> _getAddress;
|
private readonly Func<IntPtr> _getAddress;
|
||||||
private readonly bool _isOwnedObject;
|
private readonly bool _isOwnedObject;
|
||||||
private readonly PerformanceCollectorService _performanceCollector;
|
private readonly PerformanceCollectorService _performanceCollector;
|
||||||
private readonly object _frameworkUpdateGate = new();
|
private readonly Lock _frameworkUpdateGate = new();
|
||||||
private bool _frameworkUpdateSubscribed;
|
private bool _frameworkUpdateSubscribed;
|
||||||
private byte _classJob = 0;
|
private byte _classJob = 0;
|
||||||
private Task? _delayedZoningTask;
|
private Task? _delayedZoningTask;
|
||||||
private bool _haltProcessing = false;
|
private bool _haltProcessing = false;
|
||||||
private CancellationTokenSource _zoningCts = new();
|
private CancellationTokenSource _zoningCts = new();
|
||||||
|
|
||||||
public GameObjectHandler(ILogger<GameObjectHandler> logger, PerformanceCollectorService performanceCollector,
|
/// <summary>
|
||||||
LightlessMediator mediator, DalamudUtilService dalamudUtil, ObjectKind objectKind, Func<IntPtr> getAddress, bool ownedObject = true) : base(logger, mediator)
|
/// 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)
|
||||||
{
|
{
|
||||||
_performanceCollector = performanceCollector;
|
_performanceCollector = performanceCollector;
|
||||||
ObjectKind = objectKind;
|
ObjectKind = objectKind;
|
||||||
_dalamudUtil = dalamudUtil;
|
_dalamudUtil = dalamudUtil;
|
||||||
|
_objectTable = objectTable;
|
||||||
|
|
||||||
_getAddress = () =>
|
_getAddress = () =>
|
||||||
{
|
{
|
||||||
_dalamudUtil.EnsureIsOnFramework();
|
_dalamudUtil.EnsureIsOnFramework();
|
||||||
return getAddress.Invoke();
|
return getAddress.Invoke();
|
||||||
};
|
};
|
||||||
|
|
||||||
_isOwnedObject = ownedObject;
|
_isOwnedObject = ownedObject;
|
||||||
Name = string.Empty;
|
Name = string.Empty;
|
||||||
|
|
||||||
if (ownedObject)
|
if (ownedObject)
|
||||||
{
|
{
|
||||||
Mediator.Subscribe<TransientResourceChangedMessage>(this, (msg) =>
|
Mediator.Subscribe<TransientResourceChangedMessage>(this, msg =>
|
||||||
{
|
{
|
||||||
if (_delayedZoningTask?.IsCompleted ?? true)
|
if (_delayedZoningTask?.IsCompleted ?? true)
|
||||||
{
|
{
|
||||||
@@ -49,44 +72,36 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_isOwnedObject)
|
EnableFrameworkUpdates();
|
||||||
{
|
|
||||||
EnableFrameworkUpdates();
|
|
||||||
}
|
|
||||||
|
|
||||||
Mediator.Subscribe<ZoneSwitchEndMessage>(this, (_) => ZoneSwitchEnd());
|
Mediator.Subscribe<ZoneSwitchEndMessage>(this, _ => ZoneSwitchEnd());
|
||||||
Mediator.Subscribe<ZoneSwitchStartMessage>(this, (_) => ZoneSwitchStart());
|
Mediator.Subscribe<ZoneSwitchStartMessage>(this, _ => ZoneSwitchStart());
|
||||||
|
|
||||||
Mediator.Subscribe<CutsceneStartMessage>(this, (_) =>
|
Mediator.Subscribe<CutsceneStartMessage>(this, _ => _haltProcessing = true);
|
||||||
{
|
Mediator.Subscribe<CutsceneEndMessage>(this, _ =>
|
||||||
_haltProcessing = true;
|
|
||||||
});
|
|
||||||
Mediator.Subscribe<CutsceneEndMessage>(this, (_) =>
|
|
||||||
{
|
{
|
||||||
_haltProcessing = false;
|
_haltProcessing = false;
|
||||||
ZoneSwitchEnd();
|
ZoneSwitchEnd();
|
||||||
});
|
});
|
||||||
Mediator.Subscribe<PenumbraStartRedrawMessage>(this, (msg) =>
|
|
||||||
|
Mediator.Subscribe<PenumbraStartRedrawMessage>(this, msg =>
|
||||||
{
|
{
|
||||||
if (msg.Address == Address)
|
if (msg.Address == Address) _haltProcessing = true;
|
||||||
{
|
|
||||||
_haltProcessing = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
Mediator.Subscribe<PenumbraEndRedrawMessage>(this, (msg) =>
|
Mediator.Subscribe<PenumbraEndRedrawMessage>(this, msg =>
|
||||||
{
|
{
|
||||||
if (msg.Address == Address)
|
if (msg.Address == Address) _haltProcessing = false;
|
||||||
{
|
|
||||||
_haltProcessing = false;
|
|
||||||
Refresh();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Mediator.Publish(new GameObjectHandlerCreatedMessage(this, _isOwnedObject));
|
Mediator.Publish(new GameObjectHandlerCreatedMessage(this, _isOwnedObject));
|
||||||
|
|
||||||
_dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult();
|
_dalamudUtil.EnsureIsOnFramework();
|
||||||
|
CheckAndUpdateObject(allowPublish: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Draw Condition Enum
|
||||||
|
/// </summary>
|
||||||
public enum DrawCondition
|
public enum DrawCondition
|
||||||
{
|
{
|
||||||
None,
|
None,
|
||||||
@@ -97,6 +112,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
|||||||
ModelFilesInSlotLoaded
|
ModelFilesInSlotLoaded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Properties
|
||||||
public IntPtr Address { get; private set; }
|
public IntPtr Address { get; private set; }
|
||||||
public DrawCondition CurrentDrawCondition { get; set; } = DrawCondition.None;
|
public DrawCondition CurrentDrawCondition { get; set; } = DrawCondition.None;
|
||||||
public byte Gender { get; private set; }
|
public byte Gender { get; private set; }
|
||||||
@@ -107,28 +123,36 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
|||||||
public byte TribeId { get; private set; }
|
public byte TribeId { get; private set; }
|
||||||
private byte[] CustomizeData { get; set; } = new byte[26];
|
private byte[] CustomizeData { get; set; } = new byte[26];
|
||||||
private IntPtr DrawObjectAddress { get; set; }
|
private IntPtr DrawObjectAddress { get; set; }
|
||||||
private byte[] EquipSlotData { get; set; } = new byte[40];
|
|
||||||
private ushort[] MainHandData { get; set; } = new ushort[3];
|
|
||||||
private ushort[] OffHandData { get; set; } = new ushort[3];
|
|
||||||
|
|
||||||
public async Task ActOnFrameworkAfterEnsureNoDrawAsync(Action<Dalamud.Game.ClientState.Objects.Types.ICharacter> act, CancellationToken token)
|
/// <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)
|
||||||
{
|
{
|
||||||
while (await _dalamudUtil.RunOnFrameworkThread(() =>
|
while (await _dalamudUtil.RunOnFrameworkThread(() =>
|
||||||
{
|
{
|
||||||
EnsureLatestObjectState();
|
EnsureLatestObjectState();
|
||||||
if (CurrentDrawCondition != DrawCondition.None) return true;
|
if (CurrentDrawCondition != DrawCondition.None) return true;
|
||||||
var gameObj = _dalamudUtil.CreateGameObject(Address);
|
var gameObj = _dalamudUtil.CreateGameObject(Address);
|
||||||
if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter chara)
|
if (gameObj is ICharacter chara)
|
||||||
{
|
{
|
||||||
act.Invoke(chara);
|
act.Invoke(chara);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}).ConfigureAwait(false))
|
}).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
await Task.Delay(250, token).ConfigureAwait(false);
|
await Task.Delay(250, token).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compare Name And Throw if not equal
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">Name that will be compared to Object Handler.</param>
|
||||||
|
/// <exception cref="InvalidOperationException">Not equal if thrown</exception>
|
||||||
public void CompareNameAndThrow(string name)
|
public void CompareNameAndThrow(string name)
|
||||||
{
|
{
|
||||||
if (!string.Equals(Name, name, StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(Name, name, StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -141,11 +165,18 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Dalamud.Game.ClientState.Objects.Types.IGameObject? GetGameObject()
|
/// <summary>
|
||||||
|
/// Gets the game object from the address
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Gane object</returns>
|
||||||
|
public IGameObject? GetGameObject()
|
||||||
{
|
{
|
||||||
return _dalamudUtil.CreateGameObject(Address);
|
return _dalamudUtil.CreateGameObject(Address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidate the object handler
|
||||||
|
/// </summary>
|
||||||
public void Invalidate()
|
public void Invalidate()
|
||||||
{
|
{
|
||||||
Address = IntPtr.Zero;
|
Address = IntPtr.Zero;
|
||||||
@@ -154,159 +185,203 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
|||||||
_haltProcessing = false;
|
_haltProcessing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refresh the object handler state
|
||||||
|
/// </summary>
|
||||||
public void Refresh()
|
public void Refresh()
|
||||||
{
|
{
|
||||||
_dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult();
|
_dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Is Being Drawn Run On Framework Asyncronously
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Object is being run in framework</returns>
|
||||||
public async Task<bool> IsBeingDrawnRunOnFrameworkAsync()
|
public async Task<bool> IsBeingDrawnRunOnFrameworkAsync()
|
||||||
{
|
{
|
||||||
return await _dalamudUtil.RunOnFrameworkThread(IsBeingDrawn).ConfigureAwait(false);
|
return await _dalamudUtil.RunOnFrameworkThread(IsBeingDrawn).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Override ToString method for GameObjectHandler
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>String</returns>
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
{
|
{
|
||||||
var owned = _isOwnedObject ? "Self" : "Other";
|
var owned = _isOwnedObject ? "Self" : "Other";
|
||||||
return $"{owned}/{ObjectKind}:{Name} ({Address:X},{DrawObjectAddress:X})";
|
return $"{owned}/{ObjectKind}:{Name} ({Address:X},{DrawObjectAddress:X})";
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
/// <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)
|
||||||
{
|
{
|
||||||
base.Dispose(disposing);
|
if (address == nint.Zero) return null;
|
||||||
|
|
||||||
Mediator.Publish(new GameObjectHandlerDestroyedMessage(this, _isOwnedObject));
|
// Search object table
|
||||||
|
foreach (var obj in _objectTable)
|
||||||
|
{
|
||||||
|
if (obj is null) continue;
|
||||||
|
if (obj.Address == address)
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks and updates the object state
|
||||||
|
/// </summary>
|
||||||
private void CheckAndUpdateObject() => CheckAndUpdateObject(allowPublish: true);
|
private void CheckAndUpdateObject() => CheckAndUpdateObject(allowPublish: true);
|
||||||
|
|
||||||
private unsafe void CheckAndUpdateObject(bool 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)
|
||||||
{
|
{
|
||||||
var prevAddr = Address;
|
var prevAddr = Address;
|
||||||
var prevDrawObj = DrawObjectAddress;
|
var prevDrawObj = DrawObjectAddress;
|
||||||
|
string? nameString = null;
|
||||||
|
|
||||||
Address = _getAddress();
|
Address = _getAddress();
|
||||||
|
|
||||||
if (Address != IntPtr.Zero)
|
IGameObject? obj = null;
|
||||||
|
ICharacter? chara = null;
|
||||||
|
|
||||||
|
if (Address != nint.Zero)
|
||||||
{
|
{
|
||||||
var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address;
|
// Try get object
|
||||||
DrawObjectAddress = (IntPtr)gameObject->DrawObject;
|
obj = TryGetObjectByAddress(Address);
|
||||||
EntityId = gameObject->EntityId;
|
|
||||||
|
|
||||||
var chara = (Character*)Address;
|
if (obj is not null)
|
||||||
var newName = chara->GameObject.NameString;
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(newName) && !string.Equals(newName, Name, StringComparison.Ordinal))
|
|
||||||
Name = newName;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
DrawObjectAddress = IntPtr.Zero;
|
|
||||||
EntityId = uint.MaxValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
CurrentDrawCondition = IsBeingDrawnUnsafe();
|
|
||||||
|
|
||||||
if (_haltProcessing || !allowPublish) return;
|
|
||||||
|
|
||||||
bool drawObjDiff = DrawObjectAddress != prevDrawObj;
|
|
||||||
bool addrDiff = Address != prevAddr;
|
|
||||||
|
|
||||||
if (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero)
|
|
||||||
{
|
|
||||||
var chara = (Character*)Address;
|
|
||||||
var name = chara->GameObject.NameString;
|
|
||||||
bool nameChange = !string.Equals(name, Name, StringComparison.Ordinal);
|
|
||||||
if (nameChange)
|
|
||||||
{
|
{
|
||||||
Name = name;
|
EntityId = obj.EntityId;
|
||||||
}
|
|
||||||
bool equipDiff = false;
|
|
||||||
|
|
||||||
if (((DrawObject*)DrawObjectAddress)->Object.GetObjectType() == ObjectType.CharacterBase
|
DrawObjectAddress = Address;
|
||||||
&& ((CharacterBase*)DrawObjectAddress)->GetModelType() == CharacterBase.ModelType.Human)
|
|
||||||
{
|
|
||||||
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*)DrawObjectAddress)->Head);
|
// Name update
|
||||||
|
nameString = obj.Name.TextValue ?? string.Empty;
|
||||||
|
if (!string.IsNullOrEmpty(nameString) && !string.Equals(nameString, Name, StringComparison.Ordinal))
|
||||||
|
Name = nameString;
|
||||||
|
|
||||||
ref var mh = ref chara->DrawData.Weapon(WeaponSlot.MainHand);
|
chara = obj as ICharacter;
|
||||||
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
|
else
|
||||||
{
|
{
|
||||||
equipDiff = CompareAndUpdateEquipByteData((byte*)Unsafe.AsPointer(ref chara->DrawData.EquipmentModelIds[0]));
|
DrawObjectAddress = nint.Zero;
|
||||||
if (equipDiff)
|
EntityId = uint.MaxValue;
|
||||||
Logger.LogTrace("Checking [{this}] equip data from game obj, result: {diff}", this, equipDiff);
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DrawObjectAddress = nint.Zero;
|
||||||
|
EntityId = uint.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update draw condition
|
||||||
|
CurrentDrawCondition = IsBeingDrawnSafe(obj, chara);
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
nameChange = !string.Equals(nameString, Name, StringComparison.Ordinal);
|
||||||
|
if (nameChange) Name = nameString;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customize data change check
|
||||||
|
bool customizeDiff = false;
|
||||||
|
if (chara is not null)
|
||||||
|
{
|
||||||
|
// Class job change check
|
||||||
|
var classJob = chara.ClassJob.RowId;
|
||||||
|
if (classJob != _classJob)
|
||||||
|
{
|
||||||
|
Logger.LogTrace("[{this}] classjob changed from {old} to {new}", this, _classJob, classJob);
|
||||||
|
_classJob = (byte)classJob;
|
||||||
|
Mediator.Publish(new ClassJobChangedMessage(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (equipDiff && !_isOwnedObject) // send the message out immediately and cancel out, no reason to continue if not self
|
// Customize data comparison
|
||||||
|
customizeDiff = CompareAndUpdateCustomizeData(chara.Customize);
|
||||||
|
|
||||||
|
// Census update publish
|
||||||
|
if (_isOwnedObject && ObjectKind == ObjectKind.Player && chara.Customize.Length > (int)CustomizeIndex.Tribe)
|
||||||
{
|
{
|
||||||
Logger.LogTrace("[{this}] Changed", this);
|
var gender = chara.Customize[(int)CustomizeIndex.Gender];
|
||||||
return;
|
var raceId = chara.Customize[(int)CustomizeIndex.Race];
|
||||||
}
|
var tribeId = chara.Customize[(int)CustomizeIndex.Tribe];
|
||||||
|
|
||||||
bool customizeDiff = false;
|
if (gender != Gender || raceId != RaceId || tribeId != TribeId)
|
||||||
|
|
||||||
if (((DrawObject*)DrawObjectAddress)->Object.GetObjectType() == ObjectType.CharacterBase
|
|
||||||
&& ((CharacterBase*)DrawObjectAddress)->GetModelType() == CharacterBase.ModelType.Human)
|
|
||||||
{
|
|
||||||
var gender = ((Human*)DrawObjectAddress)->Customize.Sex;
|
|
||||||
var raceId = ((Human*)DrawObjectAddress)->Customize.Race;
|
|
||||||
var tribeId = ((Human*)DrawObjectAddress)->Customize.Tribe;
|
|
||||||
|
|
||||||
if (_isOwnedObject && ObjectKind == ObjectKind.Player
|
|
||||||
&& (gender != Gender || raceId != RaceId || tribeId != TribeId))
|
|
||||||
{
|
{
|
||||||
Mediator.Publish(new CensusUpdateMessage(gender, raceId, tribeId));
|
Mediator.Publish(new CensusUpdateMessage(gender, raceId, tribeId));
|
||||||
Gender = gender;
|
Gender = gender;
|
||||||
RaceId = raceId;
|
RaceId = raceId;
|
||||||
TribeId = tribeId;
|
TribeId = tribeId;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
customizeDiff = CompareAndUpdateCustomizeData(((Human*)DrawObjectAddress)->Customize.Data);
|
|
||||||
if (customizeDiff)
|
|
||||||
Logger.LogTrace("Checking [{this}] customize data as human from draw obj, result: {diff}", this, customizeDiff);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
customizeDiff = CompareAndUpdateCustomizeData(chara->DrawData.CustomizeData.Data);
|
|
||||||
if (customizeDiff)
|
|
||||||
Logger.LogTrace("Checking [{this}] customize data from game obj, result: {diff}", this, equipDiff);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((addrDiff || drawObjDiff || equipDiff || customizeDiff || nameChange) && _isOwnedObject)
|
if ((addrDiff || drawObjDiff || customizeDiff || nameChange) && _isOwnedObject)
|
||||||
{
|
{
|
||||||
Logger.LogDebug("[{this}] Changed, Sending CreateCacheObjectMessage", this);
|
Logger.LogDebug("[{this}] Changed, Sending CreateCacheObjectMessage", this);
|
||||||
Mediator.Publish(new CreateCacheForObjectMessage(this));
|
Mediator.Publish(new CreateCacheForObjectMessage(this));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else if (addrDiff || drawObjDiff)
|
else if (addrDiff || drawObjDiff)
|
||||||
{
|
{
|
||||||
CurrentDrawCondition = DrawCondition.DrawObjectZero;
|
if (Address == nint.Zero)
|
||||||
|
CurrentDrawCondition = DrawCondition.ObjectZero;
|
||||||
|
else if (DrawObjectAddress == nint.Zero)
|
||||||
|
CurrentDrawCondition = DrawCondition.DrawObjectZero;
|
||||||
|
|
||||||
Logger.LogTrace("[{this}] Changed", this);
|
Logger.LogTrace("[{this}] Changed", this);
|
||||||
|
|
||||||
if (_isOwnedObject && ObjectKind != ObjectKind.Player)
|
if (_isOwnedObject && ObjectKind != ObjectKind.Player)
|
||||||
{
|
|
||||||
Mediator.Publish(new ClearCacheForObjectMessage(this));
|
Mediator.Publish(new ClearCacheForObjectMessage(this));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private unsafe bool CompareAndUpdateCustomizeData(Span<byte> customizeData)
|
/// <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)
|
||||||
{
|
{
|
||||||
bool hasChanges = false;
|
bool hasChanges = false;
|
||||||
|
|
||||||
for (int i = 0; i < customizeData.Length; i++)
|
// Resize if needed
|
||||||
|
var len = Math.Min(customizeData.Length, CustomizeData.Length);
|
||||||
|
for (int i = 0; i < len; i++)
|
||||||
{
|
{
|
||||||
var data = customizeData[i];
|
var data = customizeData[i];
|
||||||
if (CustomizeData[i] != data)
|
if (CustomizeData[i] != data)
|
||||||
@@ -319,48 +394,9 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
|||||||
return hasChanges;
|
return hasChanges;
|
||||||
}
|
}
|
||||||
|
|
||||||
private unsafe bool CompareAndUpdateEquipByteData(byte* equipSlotData)
|
/// <summary>
|
||||||
{
|
/// Framework update method
|
||||||
bool hasChanges = false;
|
/// </summary>
|
||||||
for (int i = 0; i < EquipSlotData.Length; i++)
|
|
||||||
{
|
|
||||||
var data = equipSlotData[i];
|
|
||||||
if (EquipSlotData[i] != data)
|
|
||||||
{
|
|
||||||
EquipSlotData[i] = data;
|
|
||||||
hasChanges = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasChanges;
|
|
||||||
}
|
|
||||||
|
|
||||||
private unsafe bool CompareAndUpdateMainHand(Weapon* weapon)
|
|
||||||
{
|
|
||||||
if ((nint)weapon == nint.Zero) return false;
|
|
||||||
bool hasChanges = false;
|
|
||||||
hasChanges |= weapon->ModelSetId != MainHandData[0];
|
|
||||||
MainHandData[0] = weapon->ModelSetId;
|
|
||||||
hasChanges |= weapon->Variant != MainHandData[1];
|
|
||||||
MainHandData[1] = weapon->Variant;
|
|
||||||
hasChanges |= weapon->SecondaryId != MainHandData[2];
|
|
||||||
MainHandData[2] = weapon->SecondaryId;
|
|
||||||
return hasChanges;
|
|
||||||
}
|
|
||||||
|
|
||||||
private unsafe bool CompareAndUpdateOffHand(Weapon* weapon)
|
|
||||||
{
|
|
||||||
if ((nint)weapon == nint.Zero) return false;
|
|
||||||
bool hasChanges = false;
|
|
||||||
hasChanges |= weapon->ModelSetId != OffHandData[0];
|
|
||||||
OffHandData[0] = weapon->ModelSetId;
|
|
||||||
hasChanges |= weapon->Variant != OffHandData[1];
|
|
||||||
OffHandData[1] = weapon->Variant;
|
|
||||||
hasChanges |= weapon->SecondaryId != OffHandData[2];
|
|
||||||
OffHandData[2] = weapon->SecondaryId;
|
|
||||||
return hasChanges;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void FrameworkUpdate()
|
private void FrameworkUpdate()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -374,6 +410,10 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Is object being drawn check
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Is being drawn</returns>
|
||||||
private bool IsBeingDrawn()
|
private bool IsBeingDrawn()
|
||||||
{
|
{
|
||||||
EnsureLatestObjectState();
|
EnsureLatestObjectState();
|
||||||
@@ -388,6 +428,9 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
|||||||
return CurrentDrawCondition != DrawCondition.None;
|
return CurrentDrawCondition != DrawCondition.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures the latest object state
|
||||||
|
/// </summary>
|
||||||
private void EnsureLatestObjectState()
|
private void EnsureLatestObjectState()
|
||||||
{
|
{
|
||||||
if (_haltProcessing || !_frameworkUpdateSubscribed)
|
if (_haltProcessing || !_frameworkUpdateSubscribed)
|
||||||
@@ -396,6 +439,9 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enables framework updates for the object handler
|
||||||
|
/// </summary>
|
||||||
private void EnableFrameworkUpdates()
|
private void EnableFrameworkUpdates()
|
||||||
{
|
{
|
||||||
lock (_frameworkUpdateGate)
|
lock (_frameworkUpdateGate)
|
||||||
@@ -410,24 +456,9 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private unsafe DrawCondition IsBeingDrawnUnsafe()
|
/// <summary>
|
||||||
{
|
/// Zone switch end handling
|
||||||
if (Address == IntPtr.Zero) return DrawCondition.ObjectZero;
|
/// </summary>
|
||||||
if (DrawObjectAddress == IntPtr.Zero) return DrawCondition.DrawObjectZero;
|
|
||||||
var visibilityFlags = ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address)->RenderFlags;
|
|
||||||
if (visibilityFlags != VisibilityFlags.None) return DrawCondition.RenderFlags;
|
|
||||||
|
|
||||||
if (ObjectKind == ObjectKind.Player)
|
|
||||||
{
|
|
||||||
var modelInSlotLoaded = (((CharacterBase*)DrawObjectAddress)->HasModelInSlotLoaded != 0);
|
|
||||||
if (modelInSlotLoaded) return DrawCondition.ModelInSlotLoaded;
|
|
||||||
var modelFilesInSlotLoaded = (((CharacterBase*)DrawObjectAddress)->HasModelFilesInSlotLoaded != 0);
|
|
||||||
if (modelFilesInSlotLoaded) return DrawCondition.ModelFilesInSlotLoaded;
|
|
||||||
}
|
|
||||||
|
|
||||||
return DrawCondition.None;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ZoneSwitchEnd()
|
private void ZoneSwitchEnd()
|
||||||
{
|
{
|
||||||
if (!_isOwnedObject) return;
|
if (!_isOwnedObject) return;
|
||||||
@@ -438,7 +469,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
|||||||
}
|
}
|
||||||
catch (ObjectDisposedException)
|
catch (ObjectDisposedException)
|
||||||
{
|
{
|
||||||
// ignore
|
// ignore canelled after disposed
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -446,6 +477,9 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zone switch start handling
|
||||||
|
/// </summary>
|
||||||
private void ZoneSwitchStart()
|
private void ZoneSwitchStart()
|
||||||
{
|
{
|
||||||
if (!_isOwnedObject) return;
|
if (!_isOwnedObject) return;
|
||||||
|
|||||||
461
LightlessSync/PlayerData/Handlers/OwnedObjectHandler.cs
Normal file
461
LightlessSync/PlayerData/Handlers/OwnedObjectHandler.cs
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,43 +1,69 @@
|
|||||||
using LightlessSync.API.Data;
|
using LightlessSync.API.Data;
|
||||||
|
using LightlessSync.API.Data.Enum;
|
||||||
|
|
||||||
namespace LightlessSync.PlayerData.Pairs;
|
namespace LightlessSync.PlayerData.Pairs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// orchestrates the lifecycle of a paired character
|
/// orchestrates the lifecycle of a paired character
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject
|
public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject
|
||||||
{
|
{
|
||||||
new string Ident { get; }
|
new string Ident { get; }
|
||||||
bool Initialized { get; }
|
bool Initialized { get; }
|
||||||
bool IsVisible { get; }
|
bool IsVisible { get; }
|
||||||
bool ScheduledForDeletion { get; set; }
|
bool ScheduledForDeletion { get; set; }
|
||||||
CharacterData? LastReceivedCharacterData { get; }
|
CharacterData? LastReceivedCharacterData { get; }
|
||||||
long LastAppliedDataBytes { get; }
|
long LastAppliedDataBytes { get; }
|
||||||
new string? PlayerName { get; }
|
new string? PlayerName { get; }
|
||||||
string PlayerNameHash { get; }
|
string PlayerNameHash { get; }
|
||||||
uint PlayerCharacterId { get; }
|
uint PlayerCharacterId { get; }
|
||||||
DateTime? LastDataReceivedAt { get; }
|
|
||||||
DateTime? LastApplyAttemptAt { get; }
|
DateTime? LastDataReceivedAt { get; }
|
||||||
DateTime? LastSuccessfulApplyAt { get; }
|
DateTime? LastApplyAttemptAt { get; }
|
||||||
string? LastFailureReason { get; }
|
DateTime? LastSuccessfulApplyAt { get; }
|
||||||
IReadOnlyList<string> LastBlockingConditions { get; }
|
|
||||||
bool IsApplying { get; }
|
string? LastFailureReason { get; }
|
||||||
bool IsDownloading { get; }
|
IReadOnlyList<string> LastBlockingConditions { get; }
|
||||||
int PendingDownloadCount { get; }
|
|
||||||
int ForbiddenDownloadCount { get; }
|
bool IsApplying { get; }
|
||||||
bool PendingModReapply { get; }
|
bool IsDownloading { get; }
|
||||||
bool ModApplyDeferred { get; }
|
int PendingDownloadCount { get; }
|
||||||
int MissingCriticalMods { get; }
|
int ForbiddenDownloadCount { get; }
|
||||||
int MissingNonCriticalMods { get; }
|
|
||||||
int MissingForbiddenMods { get; }
|
bool PendingModReapply { get; }
|
||||||
DateTime? InvisibleSinceUtc { get; }
|
bool ModApplyDeferred { get; }
|
||||||
DateTime? VisibilityEvictionDueAtUtc { 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; }
|
||||||
|
|
||||||
void Initialize();
|
void Initialize();
|
||||||
void ApplyData(CharacterData data);
|
void ApplyData(CharacterData data);
|
||||||
void ApplyLastReceivedData(bool forced = false);
|
void ApplyLastReceivedData(bool forced = false);
|
||||||
bool FetchPerformanceMetricsFromCache();
|
void HardReapplyLastData();
|
||||||
void LoadCachedCharacterData(CharacterData data);
|
bool FetchPerformanceMetricsFromCache();
|
||||||
void SetUploading(bool uploading);
|
void LoadCachedCharacterData(CharacterData data);
|
||||||
void SetPaused(bool paused);
|
void SetUploading(bool uploading);
|
||||||
}
|
void SetPaused(bool paused);
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,60 +82,114 @@ public class Pair
|
|||||||
|
|
||||||
public void AddContextMenu(IMenuOpenedArgs args)
|
public void AddContextMenu(IMenuOpenedArgs args)
|
||||||
{
|
{
|
||||||
|
|
||||||
var handler = TryGetHandler();
|
var handler = TryGetHandler();
|
||||||
if (handler is null)
|
if (handler is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (args.Target is not MenuTargetDefault target)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var obj = target.TargetObject;
|
||||||
|
if (obj is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var eid = obj.EntityId;
|
||||||
|
|
||||||
|
var isPlayerTarget = eid != 0 && eid != uint.MaxValue && eid == handler.PlayerCharacterId;
|
||||||
|
|
||||||
|
if (!(isPlayerTarget))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (isPlayerTarget)
|
||||||
{
|
{
|
||||||
|
if (!IsPaused)
|
||||||
|
{
|
||||||
|
UiSharedService.AddContextMenuItem(
|
||||||
|
args,
|
||||||
|
name: "Open Profile",
|
||||||
|
prefixChar: 'L',
|
||||||
|
colorMenuItem: _lightlessPrefixColor,
|
||||||
|
onClick: () =>
|
||||||
|
{
|
||||||
|
_mediator.Publish(new ProfileOpenStandaloneMessage(this));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
UiSharedService.AddContextMenuItem(
|
||||||
|
args,
|
||||||
|
name: "(Soft) - Reapply last data",
|
||||||
|
prefixChar: 'L',
|
||||||
|
colorMenuItem: _lightlessPrefixColor,
|
||||||
|
onClick: () =>
|
||||||
|
{
|
||||||
|
ApplyLastReceivedData(forced: true);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
UiSharedService.AddContextMenuItem(
|
||||||
|
args,
|
||||||
|
name: "(Hard) - Reapply last data",
|
||||||
|
prefixChar: 'L',
|
||||||
|
colorMenuItem: _lightlessPrefixColor,
|
||||||
|
onClick: () =>
|
||||||
|
{
|
||||||
|
HardApplyLastReceivedData();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
UiSharedService.AddContextMenuItem(
|
||||||
|
args,
|
||||||
|
name: "Change Permissions",
|
||||||
|
prefixChar: 'L',
|
||||||
|
colorMenuItem: _lightlessPrefixColor,
|
||||||
|
onClick: () =>
|
||||||
|
{
|
||||||
|
_mediator.Publish(new OpenPermissionWindow(this));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (IsPaused)
|
||||||
|
{
|
||||||
|
UiSharedService.AddContextMenuItem(
|
||||||
|
args,
|
||||||
|
name: "Toggle Unpause State",
|
||||||
|
prefixChar: 'L',
|
||||||
|
colorMenuItem: _lightlessPrefixColor,
|
||||||
|
onClick: () =>
|
||||||
|
{
|
||||||
|
_ = _apiController.Value.UnpauseAsync(UserData);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
UiSharedService.AddContextMenuItem(
|
||||||
|
args,
|
||||||
|
name: "Toggle Pause State",
|
||||||
|
prefixChar: 'L',
|
||||||
|
colorMenuItem: _lightlessPrefixColor,
|
||||||
|
onClick: () =>
|
||||||
|
{
|
||||||
|
_ = _apiController.Value.PauseAsync(UserData);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
UiSharedService.AddContextMenuItem(
|
||||||
|
args,
|
||||||
|
name: "Cycle Pause State",
|
||||||
|
prefixChar: 'L',
|
||||||
|
colorMenuItem: _lightlessPrefixColor,
|
||||||
|
onClick: () =>
|
||||||
|
{
|
||||||
|
TriggerCyclePause();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.Target is not MenuTargetDefault target || target.TargetObjectId != handler.PlayerCharacterId)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!IsPaused)
|
|
||||||
{
|
|
||||||
UiSharedService.AddContextMenuItem(args, name: "Open Profile", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
|
|
||||||
{
|
|
||||||
_mediator.Publish(new ProfileOpenStandaloneMessage(this));
|
|
||||||
return Task.CompletedTask;
|
|
||||||
});
|
|
||||||
|
|
||||||
UiSharedService.AddContextMenuItem(args, name: "Reapply last data", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
|
|
||||||
{
|
|
||||||
ApplyLastReceivedData(forced: true);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
UiSharedService.AddContextMenuItem(args, name: "Change Permissions", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
|
|
||||||
{
|
|
||||||
_mediator.Publish(new OpenPermissionWindow(this));
|
|
||||||
return Task.CompletedTask;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (IsPaused)
|
|
||||||
{
|
|
||||||
UiSharedService.AddContextMenuItem(args, name: "Toggle Unpause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
|
|
||||||
{
|
|
||||||
_ = _apiController.Value.UnpauseAsync(UserData);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
UiSharedService.AddContextMenuItem(args, name: "Toggle Pause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
|
|
||||||
{
|
|
||||||
_ = _apiController.Value.PauseAsync(UserData);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
UiSharedService.AddContextMenuItem(args, name: "Cycle Pause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
|
|
||||||
{
|
|
||||||
TriggerCyclePause();
|
|
||||||
return Task.CompletedTask;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ApplyData(OnlineUserCharaDataDto data)
|
public void ApplyData(OnlineUserCharaDataDto data)
|
||||||
@@ -160,6 +214,18 @@ public class Pair
|
|||||||
handler.ApplyLastReceivedData(forced);
|
handler.ApplyLastReceivedData(forced);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void HardApplyLastReceivedData()
|
||||||
|
{
|
||||||
|
var handler = TryGetHandler();
|
||||||
|
if (handler is null)
|
||||||
|
{
|
||||||
|
_logger.LogTrace("ApplyLastReceivedData skipped for {Uid}: handler missing.", UserData.UID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.HardReapplyLastData();
|
||||||
|
}
|
||||||
|
|
||||||
public void CreateCachedPlayer(OnlineUserIdentDto? dto = null)
|
public void CreateCachedPlayer(OnlineUserIdentDto? dto = null)
|
||||||
{
|
{
|
||||||
var handler = TryGetHandler();
|
var handler = TryGetHandler();
|
||||||
@@ -244,6 +310,17 @@ public class Pair
|
|||||||
handler.ModApplyDeferred,
|
handler.ModApplyDeferred,
|
||||||
handler.MissingCriticalMods,
|
handler.MissingCriticalMods,
|
||||||
handler.MissingNonCriticalMods,
|
handler.MissingNonCriticalMods,
|
||||||
handler.MissingForbiddenMods);
|
handler.MissingForbiddenMods,
|
||||||
|
|
||||||
|
handler.MinionAddressHex,
|
||||||
|
handler.MinionObjectIndex,
|
||||||
|
handler.MinionResolvedAtUtc,
|
||||||
|
handler.MinionResolveStage,
|
||||||
|
handler.MinionResolveFailureReason,
|
||||||
|
handler.MinionPendingRetry,
|
||||||
|
handler.MinionPendingRetryChanges,
|
||||||
|
handler.MinionHasAppearanceData,
|
||||||
|
handler.OwnedPenumbraCollectionId,
|
||||||
|
handler.NeedsCollectionRebuildDebug);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,28 +21,50 @@ public sealed record PairDebugInfo(
|
|||||||
bool ModApplyDeferred,
|
bool ModApplyDeferred,
|
||||||
int MissingCriticalMods,
|
int MissingCriticalMods,
|
||||||
int MissingNonCriticalMods,
|
int MissingNonCriticalMods,
|
||||||
int MissingForbiddenMods)
|
int MissingForbiddenMods,
|
||||||
|
|
||||||
|
string? MinionAddressHex,
|
||||||
|
ushort? MinionObjectIndex,
|
||||||
|
DateTime? MinionResolvedAtUtc,
|
||||||
|
string? MinionResolveStage,
|
||||||
|
string? MinionResolveFailureReason,
|
||||||
|
bool MinionPendingRetry,
|
||||||
|
IReadOnlyList<string> MinionPendingRetryChanges,
|
||||||
|
bool MinionHasAppearanceData,
|
||||||
|
Guid OwnedPenumbraCollectionId,
|
||||||
|
bool NeedsCollectionRebuild)
|
||||||
{
|
{
|
||||||
public static PairDebugInfo Empty { get; } = new(
|
public static PairDebugInfo Empty { get; } = new(
|
||||||
false,
|
HasHandler: false,
|
||||||
false,
|
HandlerInitialized: false,
|
||||||
false,
|
HandlerVisible: false,
|
||||||
false,
|
HandlerScheduledForDeletion: false,
|
||||||
null,
|
LastDataReceivedAt: null,
|
||||||
null,
|
LastApplyAttemptAt: null,
|
||||||
null,
|
LastSuccessfulApplyAt: null,
|
||||||
null,
|
InvisibleSinceUtc: null,
|
||||||
null,
|
VisibilityEvictionDueAtUtc: null,
|
||||||
null,
|
VisibilityEvictionRemainingSeconds: null,
|
||||||
null,
|
LastFailureReason: null,
|
||||||
Array.Empty<string>(),
|
BlockingConditions: [],
|
||||||
false,
|
IsApplying: false,
|
||||||
false,
|
IsDownloading: false,
|
||||||
0,
|
PendingDownloadCount: 0,
|
||||||
0,
|
ForbiddenDownloadCount: 0,
|
||||||
false,
|
PendingModReapply: false,
|
||||||
false,
|
ModApplyDeferred: false,
|
||||||
0,
|
MissingCriticalMods: 0,
|
||||||
0,
|
MissingNonCriticalMods: 0,
|
||||||
0);
|
MissingForbiddenMods: 0,
|
||||||
|
|
||||||
|
MinionAddressHex: null,
|
||||||
|
MinionObjectIndex: null,
|
||||||
|
MinionResolvedAtUtc: null,
|
||||||
|
MinionResolveStage: null,
|
||||||
|
MinionResolveFailureReason: null,
|
||||||
|
MinionPendingRetry: false,
|
||||||
|
MinionPendingRetryChanges: [],
|
||||||
|
MinionHasAppearanceData: false,
|
||||||
|
OwnedPenumbraCollectionId: Guid.Empty,
|
||||||
|
NeedsCollectionRebuild: false);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
|||||||
private readonly LightlessConfigService _configService;
|
private readonly LightlessConfigService _configService;
|
||||||
private readonly XivDataAnalyzer _modelAnalyzer;
|
private readonly XivDataAnalyzer _modelAnalyzer;
|
||||||
private readonly IFramework _framework;
|
private readonly IFramework _framework;
|
||||||
|
private readonly IObjectTable _objectTable;
|
||||||
|
|
||||||
public PairHandlerAdapterFactory(
|
public PairHandlerAdapterFactory(
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
@@ -63,7 +64,8 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
|||||||
PairPerformanceMetricsCache pairPerformanceMetricsCache,
|
PairPerformanceMetricsCache pairPerformanceMetricsCache,
|
||||||
PenumbraTempCollectionJanitor tempCollectionJanitor,
|
PenumbraTempCollectionJanitor tempCollectionJanitor,
|
||||||
XivDataAnalyzer modelAnalyzer,
|
XivDataAnalyzer modelAnalyzer,
|
||||||
LightlessConfigService configService)
|
LightlessConfigService configService,
|
||||||
|
IObjectTable objectTable)
|
||||||
{
|
{
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
_mediator = mediator;
|
_mediator = mediator;
|
||||||
@@ -87,6 +89,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
|||||||
_tempCollectionJanitor = tempCollectionJanitor;
|
_tempCollectionJanitor = tempCollectionJanitor;
|
||||||
_modelAnalyzer = modelAnalyzer;
|
_modelAnalyzer = modelAnalyzer;
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
|
_objectTable = objectTable;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IPairHandlerAdapter Create(string ident)
|
public IPairHandlerAdapter Create(string ident)
|
||||||
@@ -105,6 +108,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
|||||||
_pluginWarningNotificationManager,
|
_pluginWarningNotificationManager,
|
||||||
dalamudUtilService,
|
dalamudUtilService,
|
||||||
_framework,
|
_framework,
|
||||||
|
_objectTable,
|
||||||
actorObjectService,
|
actorObjectService,
|
||||||
_lifetime,
|
_lifetime,
|
||||||
_fileCacheManager,
|
_fileCacheManager,
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
services.AddSingleton(gameGui);
|
services.AddSingleton(gameGui);
|
||||||
services.AddSingleton(gameInteropProvider);
|
services.AddSingleton(gameInteropProvider);
|
||||||
services.AddSingleton(addonLifecycle);
|
services.AddSingleton(addonLifecycle);
|
||||||
|
services.AddSingleton(objectTable);
|
||||||
services.AddSingleton<IUiBuilder>(pluginInterface.UiBuilder);
|
services.AddSingleton<IUiBuilder>(pluginInterface.UiBuilder);
|
||||||
|
|
||||||
// Core singletons
|
// Core singletons
|
||||||
@@ -428,6 +429,7 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
return cfg;
|
return cfg;
|
||||||
});
|
});
|
||||||
services.AddSingleton(sp => new ServerConfigService(configDir));
|
services.AddSingleton(sp => new ServerConfigService(configDir));
|
||||||
|
services.AddSingleton(sp => new PenumbraJanitorConfigService(configDir));
|
||||||
services.AddSingleton(sp => new NotesConfigService(configDir));
|
services.AddSingleton(sp => new NotesConfigService(configDir));
|
||||||
services.AddSingleton(sp => new PairTagConfigService(configDir));
|
services.AddSingleton(sp => new PairTagConfigService(configDir));
|
||||||
services.AddSingleton(sp => new SyncshellTagConfigService(configDir));
|
services.AddSingleton(sp => new SyncshellTagConfigService(configDir));
|
||||||
@@ -441,6 +443,7 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<UiThemeConfigService>());
|
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<UiThemeConfigService>());
|
||||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ChatConfigService>());
|
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ChatConfigService>());
|
||||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ServerConfigService>());
|
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ServerConfigService>());
|
||||||
|
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<PenumbraJanitorConfigService>());
|
||||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<NotesConfigService>());
|
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<NotesConfigService>());
|
||||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<PairTagConfigService>());
|
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<PairTagConfigService>());
|
||||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<SyncshellTagConfigService>());
|
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<SyncshellTagConfigService>());
|
||||||
|
|||||||
9
LightlessSync/Resources/LocalizationExtention.cs
Normal file
9
LightlessSync/Resources/LocalizationExtention.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace LightlessSync.Resources;
|
||||||
|
|
||||||
|
public static class LocalizationExtensions
|
||||||
|
{
|
||||||
|
public static string F(this string mask, params object[] args)
|
||||||
|
{
|
||||||
|
return string.Format(mask, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
LightlessSync/Resources/Resources.Designer.cs
generated
Normal file
171
LightlessSync/Resources/Resources.Designer.cs
generated
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// <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.
|
||||||
|
// </auto-generated>
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
namespace LightlessSync.Resources {
|
||||||
|
using System;
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A strongly-typed resource class, for looking up localized strings, etc.
|
||||||
|
/// </summary>
|
||||||
|
// This class was auto-generated by the StronglyTypedResourceBuilder
|
||||||
|
// 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.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||||
|
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||||
|
public class Resources {
|
||||||
|
|
||||||
|
private static global::System.Resources.ResourceManager resourceMan;
|
||||||
|
|
||||||
|
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||||
|
|
||||||
|
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||||
|
internal Resources() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the cached ResourceManager instance used by this class.
|
||||||
|
/// </summary>
|
||||||
|
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||||
|
public static global::System.Resources.ResourceManager ResourceManager {
|
||||||
|
get {
|
||||||
|
if (object.ReferenceEquals(resourceMan, null)) {
|
||||||
|
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("LightlessSync.Resources.Resources", typeof(Resources).Assembly);
|
||||||
|
resourceMan = temp;
|
||||||
|
}
|
||||||
|
return resourceMan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overrides the current thread's CurrentUICulture property for all
|
||||||
|
/// resource lookups using this strongly typed resource class.
|
||||||
|
/// </summary>
|
||||||
|
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||||
|
public static global::System.Globalization.CultureInfo Culture {
|
||||||
|
get {
|
||||||
|
return resourceCulture;
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
resourceCulture = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to I agree.
|
||||||
|
/// </summary>
|
||||||
|
public static string ToSStrings_AgreeLabel {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ToSStrings_AgreeLabel", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Agreement of Usage of Service.
|
||||||
|
/// </summary>
|
||||||
|
public static string ToSStrings_AgreementLabel {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ToSStrings_AgreementLabel", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to 'I agree' button will be available in.
|
||||||
|
/// </summary>
|
||||||
|
public static string ToSStrings_ButtonWillBeAvailableIn {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ToSStrings_ButtonWillBeAvailableIn", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Language.
|
||||||
|
/// </summary>
|
||||||
|
public static string ToSStrings_LanguageLabel {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ToSStrings_LanguageLabel", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to All of the mod files currently active on your character as well as your current character state will be uploaded to the service you registered yourself at automatically. The plugin will exclusively upload the necessary mod files and not the whole mod..
|
||||||
|
/// </summary>
|
||||||
|
public static string ToSStrings_Paragraph1 {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ToSStrings_Paragraph1", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to If you are on a data capped internet connection, higher fees due to data usage depending on the amount of downloaded and uploaded mod files might occur. Mod files will be compressed on up- and download to save on bandwidth usage. Due to varying up- and download speeds, changes in characters might not be visible immediately. Files present on the service that already represent your active mod files will not be uploaded again..
|
||||||
|
/// </summary>
|
||||||
|
public static string ToSStrings_Paragraph2 {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ToSStrings_Paragraph2", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to The mod files you are uploading are confidential and will not be distributed to parties other than the ones who are requesting the exact same mod files. Please think about who you are going to pair since it is unavoidable that they will receive and locally cache the necessary mod files that you have currently in use. Locally cached mod files will have arbitrary file names to discourage attempts at replicating the original mod..
|
||||||
|
/// </summary>
|
||||||
|
public static string ToSStrings_Paragraph3 {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ToSStrings_Paragraph3", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to The plugin creator tried their best to keep you secure. However, there is no guarantee for 100% security. Do not blindly pair your client with everyone..
|
||||||
|
/// </summary>
|
||||||
|
public static string ToSStrings_Paragraph4 {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ToSStrings_Paragraph4", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Mod files that are saved on the service will remain on the service as long as there are requests for the files from clients. After a period of not being used, the mod files will be automatically deleted. You will also be able to wipe all the files you have personally uploaded on request. The service holds no information about which mod files belong to which mod..
|
||||||
|
/// </summary>
|
||||||
|
public static string ToSStrings_Paragraph5 {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ToSStrings_Paragraph5", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to This service is provided as-is. In case of abuse join the Lightless Sync Discord..
|
||||||
|
/// </summary>
|
||||||
|
public static string ToSStrings_Paragraph6 {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ToSStrings_Paragraph6", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to READ THIS CAREFULLY.
|
||||||
|
/// </summary>
|
||||||
|
public static string ToSStrings_ReadLabel {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ToSStrings_ReadLabel", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized string similar to Users Online.
|
||||||
|
/// </summary>
|
||||||
|
public static string Users_Online {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("Users_Online", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
LightlessSync/Resources/Resources.de.resx
Normal file
47
LightlessSync/Resources/Resources.de.resx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<root>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>1.3</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<data name="ToSStrings_LanguageLabel" xml:space="preserve">
|
||||||
|
<value>Language</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_AgreementLabel" xml:space="preserve">
|
||||||
|
<value>Nutzungsbedingungen</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_ReadLabel" xml:space="preserve">
|
||||||
|
<value>BITTE LIES DIES SORGFÄLTIG</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph1" xml:space="preserve">
|
||||||
|
<value>Alle Moddateien, die aktuell auf deinem Charakter aktiv sind und dein Charakterzustand werden automatisch zu dem Service, an dem du dich registriert hast, hochgeladen. Das Plugin wird ausschließlich die nötigen Moddateien hochladen und nicht die gesamte Modifikation.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph2" xml:space="preserve">
|
||||||
|
<value>Falls du mit einer getakteten Internetverbindung verbunden bist, können durch den Datentransfer von Hoch- und Runtergeladenen Moddateien höhere Kosten entstehen. Moddateien werden beim Hoch- und Runterladen komprimiert um Bandbreite zu sparen. Durch unterschiedliche Hoch- und Runterladgeschwindigkeiten ist es möglich, dass Änderungen an Charakteren nicht sofort sichtbar sind. Dateien die bereits auf dem Service existieren, werden nicht nochmals hochgeladen.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph3" xml:space="preserve">
|
||||||
|
<value>Die Moddateien die du hochlädst sind vertraulich und werden nicht mit anderen Nutzern geteilt, die nicht die exakt selben Dateien anfordern. Bitte überlege dir sorgfältig mit wem du deinen Identifikationscode teilst, da es unvermeidlich ist, dass die andere Person deine Moddateien erhält und lokal zwischenspeichert. Lokal zwischengespeicherte Dateien haben willkürrliche Namen um vor Versuchen abzuschrecken die originalen Moddateien aus diesen wiederherzustellen.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph4" xml:space="preserve">
|
||||||
|
<value>Der Ersteller des Plugins hat sein Bestes getan, um deine Sicherheit zu gewährleisten. Es gibt jedoch keine Garantie für 100%ige Sicherheit. Teile deinen Identifikationscode nicht blind mit jedem.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph5" xml:space="preserve">
|
||||||
|
<value>Moddateien, die auf dem Service gespeichert sind, verbleiben auf dem Service, solange es Anforderungen für diese Dateien gibt. Nach einer Zeitspanne in der die Dateien nicht verwendet wurden, werden diese automatisch gelöscht. Du hast auch die Möglichkeit manuell alle Dateien auf dem Service zu löschen. Der Service hat keine Informationen welche Moddateien zu welcher Modifikation gehören.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph6" xml:space="preserve">
|
||||||
|
<value>Dieser Dienst wird ohne Gewähr angeboten. Im Falle eines Missbrauchs tretet dem Lightless Sync Discord bei.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_AgreeLabel" xml:space="preserve">
|
||||||
|
<value>Ich Stimme zu</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_ButtonWillBeAvailableIn" xml:space="preserve">
|
||||||
|
<value>"Ich stimme zu" Knopf verfügbar in</value>
|
||||||
|
</data>
|
||||||
|
</root>
|
||||||
47
LightlessSync/Resources/Resources.fr.resx
Normal file
47
LightlessSync/Resources/Resources.fr.resx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<root>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>1.3</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<data name="ToSStrings_LanguageLabel" xml:space="preserve">
|
||||||
|
<value>Language</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_AgreementLabel" xml:space="preserve">
|
||||||
|
<value>Conditions d'Utilisation</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_ReadLabel" xml:space="preserve">
|
||||||
|
<value>LISEZ CES INFORMATIONS ATTENTIVEMENT</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph1" xml:space="preserve">
|
||||||
|
<value>Tous les fichiers moddés actuellement en cours d'utilisation ainsi que le statut actuel de votre personnage vont être mix en ligne via le service sur lequel vous vous êtes automatiquement enregistré. Seuls les fichiers nécessaires seront téléversés par le plugin et non pas le mod en entier.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph2" xml:space="preserve">
|
||||||
|
<value>Si le débit de votre connexion internet est limité, le téléchargement et téléversement d'un grand nombre de fichiers peut entraîner des coûts supplémentaires. Les fichiers seront compressés au chargement et versement pour réduire l'impact sur votre bande passants. Selon la rapidité de vos téléchargements et téléversements, les changements ne seront peut-être pas visibles instantanément sur les personnages. Les fichiers déja présents sur le service qui correspondent à ceux de vos mods en cours d'utilisation ne seront pas remis en ligne.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph3" xml:space="preserve">
|
||||||
|
<value>Les fichiers que vous allez partager sont confidentiels et ne seront envoyés qu'aux utilisateurs qui feront une requête exacte de ceux-çi. Nous vous demandons de (re)considérer qui sera synchronisé avec vous, puisqu'ils recevront et stockeront inévitablement en local les fichiers nécéssaires utilisés à cet instant. Les noms des fichiers stockés localement sont changés de manière arbitraire afin de décourager toute tentative de réplication des originaux.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph4" xml:space="preserve">
|
||||||
|
<value>Le créateur de ce plugin a tenté de sécuriser l'application du mieux possible. Cependant, il ne peut pas garantir une protection 100% infaillible. Pour votre sécurité, ne vous synchronisez pas aveuglément et avec n'importe qui.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph5" xml:space="preserve">
|
||||||
|
<value>Les fichiers sauvegardés sur le service resteront en ligne tant que des utilisateurs en feront usage. Ils seront effacés automatiquement après une certaine période d'inactivité. Vous pouvez également demander l'effacement de tous les fichiers que vous avez mis en ligne vous-même. Le service en soi ne contient aucune information pouvant identifier quel fichier appartient à quel mod.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph6" xml:space="preserve">
|
||||||
|
<value>Ce service et ses composants vous sont fournis en l'état. En cas d'abus rejoindre le serveur Discord Lightless Sync.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_AgreeLabel" xml:space="preserve">
|
||||||
|
<value>J'accept</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_ButtonWillBeAvailableIn" xml:space="preserve">
|
||||||
|
<value>Bouton "J'accept" disposible dans</value>
|
||||||
|
</data>
|
||||||
|
</root>
|
||||||
57
LightlessSync/Resources/Resources.resx
Normal file
57
LightlessSync/Resources/Resources.resx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<root>
|
||||||
|
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||||
|
<xsd:element name="root" msdata:IsDataSet="true">
|
||||||
|
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:schema>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>1.3</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<data name="ToSStrings_AgreeLabel" xml:space="preserve">
|
||||||
|
<value>I agree</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_AgreementLabel" xml:space="preserve">
|
||||||
|
<value>Agreement of Usage of Service</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_ButtonWillBeAvailableIn" xml:space="preserve">
|
||||||
|
<value>'I agree' button will be available in</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph1" xml:space="preserve">
|
||||||
|
<value>All of the mod files currently active on your character as well as your current character state will be uploaded to the service you registered yourself at automatically. The plugin will exclusively upload the necessary mod files and not the whole mod.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph2" xml:space="preserve">
|
||||||
|
<value>If you are on a data capped internet connection, higher fees due to data usage depending on the amount of downloaded and uploaded mod files might occur. Mod files will be compressed on up- and download to save on bandwidth usage. Due to varying up- and download speeds, changes in characters might not be visible immediately. Files present on the service that already represent your active mod files will not be uploaded again.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph3" xml:space="preserve">
|
||||||
|
<value>The mod files you are uploading are confidential and will not be distributed to parties other than the ones who are requesting the exact same mod files. Please think about who you are going to pair since it is unavoidable that they will receive and locally cache the necessary mod files that you have currently in use. Locally cached mod files will have arbitrary file names to discourage attempts at replicating the original mod.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph4" xml:space="preserve">
|
||||||
|
<value>The plugin creator tried their best to keep you secure. However, there is no guarantee for 100% security. Do not blindly pair your client with everyone.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph5" xml:space="preserve">
|
||||||
|
<value>Mod files that are saved on the service will remain on the service as long as there are requests for the files from clients. After a period of not being used, the mod files will be automatically deleted. You will also be able to wipe all the files you have personally uploaded on request. The service holds no information about which mod files belong to which mod.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_Paragraph6" xml:space="preserve">
|
||||||
|
<value>This service is provided as-is. In case of abuse join the Lightless Sync Discord.</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_ReadLabel" xml:space="preserve">
|
||||||
|
<value>READ THIS CAREFULLY</value>
|
||||||
|
</data>
|
||||||
|
<data name="ToSStrings_LanguageLabel" xml:space="preserve">
|
||||||
|
<value>Language</value>
|
||||||
|
</data>
|
||||||
|
<data name="Users_Online" xml:space="preserve">
|
||||||
|
<value>Users Online</value>
|
||||||
|
</data>
|
||||||
|
</root>
|
||||||
20
LightlessSync/Resources/Resources.zh.resx
Normal file
20
LightlessSync/Resources/Resources.zh.resx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<root>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>1.3</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<data name="ToSStrings_LanguageLabel" xml:space="preserve">
|
||||||
|
<value>语言</value>
|
||||||
|
</data>
|
||||||
|
<data name="Users_Online" xml:space="preserve">
|
||||||
|
<value>用户在线</value>
|
||||||
|
</data>
|
||||||
|
</root>
|
||||||
@@ -14,6 +14,7 @@ using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSu
|
|||||||
using IPlayerCharacter = Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter;
|
using IPlayerCharacter = Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter;
|
||||||
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
||||||
using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace LightlessSync.Services.ActorTracking;
|
namespace LightlessSync.Services.ActorTracking;
|
||||||
|
|
||||||
@@ -57,6 +58,8 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
|||||||
private bool _hooksActive;
|
private bool _hooksActive;
|
||||||
private static readonly TimeSpan SnapshotRefreshInterval = TimeSpan.FromSeconds(1);
|
private static readonly TimeSpan SnapshotRefreshInterval = TimeSpan.FromSeconds(1);
|
||||||
private DateTime _nextRefreshAllowed = DateTime.MinValue;
|
private DateTime _nextRefreshAllowed = DateTime.MinValue;
|
||||||
|
private int _warmStartQueued;
|
||||||
|
private int _warmStartRan;
|
||||||
|
|
||||||
public ActorObjectService(
|
public ActorObjectService(
|
||||||
ILogger<ActorObjectService> logger,
|
ILogger<ActorObjectService> logger,
|
||||||
@@ -74,7 +77,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
|||||||
_clientState = clientState;
|
_clientState = clientState;
|
||||||
_condition = condition;
|
_condition = condition;
|
||||||
_mediator = mediator;
|
_mediator = mediator;
|
||||||
|
|
||||||
_mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
|
_mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
|
||||||
{
|
{
|
||||||
if (!msg.OwnedObject) return;
|
if (!msg.OwnedObject) return;
|
||||||
@@ -96,7 +98,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
|||||||
}
|
}
|
||||||
|
|
||||||
private bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51];
|
private bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51];
|
||||||
|
|
||||||
private ActorSnapshot Snapshot => Volatile.Read(ref _snapshot);
|
private ActorSnapshot Snapshot => Volatile.Read(ref _snapshot);
|
||||||
private GposeSnapshot CurrentGposeSnapshot => Volatile.Read(ref _gposeSnapshot);
|
private GposeSnapshot CurrentGposeSnapshot => Volatile.Read(ref _gposeSnapshot);
|
||||||
|
|
||||||
@@ -341,6 +342,8 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
|||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
_warmStartRan = 0;
|
||||||
|
|
||||||
DisposeHooks();
|
DisposeHooks();
|
||||||
_activePlayers.Clear();
|
_activePlayers.Clear();
|
||||||
_gposePlayers.Clear();
|
_gposePlayers.Clear();
|
||||||
@@ -505,7 +508,10 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
|||||||
return new ActorDescriptor(name, hashedCid, address, objectIndex, isLocal, isInGpose, objectKind, ownedKind, ownerEntityId);
|
return new ActorDescriptor(name, hashedCid, address, objectIndex, isLocal, isInGpose, objectKind, ownedKind, ownerEntityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private unsafe (LightlessObjectKind? OwnedKind, uint OwnerEntityId) DetermineOwnedKind(GameObject* gameObject, DalamudObjectKind objectKind, bool isLocalPlayer)
|
private unsafe (LightlessObjectKind? OwnedKind, uint OwnerEntityId) DetermineOwnedKind(
|
||||||
|
GameObject* gameObject,
|
||||||
|
DalamudObjectKind objectKind,
|
||||||
|
bool isLocalPlayer)
|
||||||
{
|
{
|
||||||
if (gameObject == null)
|
if (gameObject == null)
|
||||||
return (null, 0);
|
return (null, 0);
|
||||||
@@ -517,6 +523,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
|||||||
}
|
}
|
||||||
|
|
||||||
var ownerId = ResolveOwnerId(gameObject);
|
var ownerId = ResolveOwnerId(gameObject);
|
||||||
|
|
||||||
var localPlayerAddress = _objectTable.LocalPlayer?.Address ?? nint.Zero;
|
var localPlayerAddress = _objectTable.LocalPlayer?.Address ?? nint.Zero;
|
||||||
if (localPlayerAddress == nint.Zero)
|
if (localPlayerAddress == nint.Zero)
|
||||||
return (null, ownerId);
|
return (null, ownerId);
|
||||||
@@ -528,9 +535,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
|||||||
if (objectKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
|
if (objectKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
|
||||||
{
|
{
|
||||||
var expectedMinionOrMount = GetMinionOrMountAddress(localPlayerAddress, localEntityId);
|
var expectedMinionOrMount = GetMinionOrMountAddress(localPlayerAddress, localEntityId);
|
||||||
if (expectedMinionOrMount != nint.Zero
|
if (expectedMinionOrMount != nint.Zero && (nint)gameObject == expectedMinionOrMount)
|
||||||
&& (nint)gameObject == expectedMinionOrMount
|
|
||||||
&& IsPlayerRelatedOwnedAddress(expectedMinionOrMount, LightlessObjectKind.MinionOrMount))
|
|
||||||
{
|
{
|
||||||
var resolvedOwner = ownerId != 0 ? ownerId : localEntityId;
|
var resolvedOwner = ownerId != 0 ? ownerId : localEntityId;
|
||||||
return (LightlessObjectKind.MinionOrMount, resolvedOwner);
|
return (LightlessObjectKind.MinionOrMount, resolvedOwner);
|
||||||
@@ -540,20 +545,16 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
|||||||
if (objectKind != DalamudObjectKind.BattleNpc)
|
if (objectKind != DalamudObjectKind.BattleNpc)
|
||||||
return (null, ownerId);
|
return (null, ownerId);
|
||||||
|
|
||||||
if (ownerId != localEntityId)
|
if (ownerId != 0 && ownerId != localEntityId)
|
||||||
return (null, ownerId);
|
return (null, ownerId);
|
||||||
|
|
||||||
var expectedPet = GetPetAddress(localPlayerAddress, localEntityId);
|
var expectedPet = GetPetAddress(localPlayerAddress, localEntityId);
|
||||||
if (expectedPet != nint.Zero
|
if (expectedPet != nint.Zero && (nint)gameObject == expectedPet)
|
||||||
&& (nint)gameObject == expectedPet
|
return (LightlessObjectKind.Pet, ownerId != 0 ? ownerId : localEntityId);
|
||||||
&& IsPlayerRelatedOwnedAddress(expectedPet, LightlessObjectKind.Pet))
|
|
||||||
return (LightlessObjectKind.Pet, ownerId);
|
|
||||||
|
|
||||||
var expectedCompanion = GetCompanionAddress(localPlayerAddress, localEntityId);
|
var expectedCompanion = GetCompanionAddress(localPlayerAddress, localEntityId);
|
||||||
if (expectedCompanion != nint.Zero
|
if (expectedCompanion != nint.Zero && (nint)gameObject == expectedCompanion)
|
||||||
&& (nint)gameObject == expectedCompanion
|
return (LightlessObjectKind.Companion, ownerId != 0 ? ownerId : localEntityId);
|
||||||
&& IsPlayerRelatedOwnedAddress(expectedCompanion, LightlessObjectKind.Companion))
|
|
||||||
return (LightlessObjectKind.Companion, ownerId);
|
|
||||||
|
|
||||||
return (null, ownerId);
|
return (null, ownerId);
|
||||||
}
|
}
|
||||||
@@ -581,21 +582,25 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
|||||||
return nint.Zero;
|
return nint.Zero;
|
||||||
|
|
||||||
var playerObject = (GameObject*)localPlayerAddress;
|
var playerObject = (GameObject*)localPlayerAddress;
|
||||||
var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1);
|
|
||||||
if (ownerEntityId == 0)
|
|
||||||
return nint.Zero;
|
|
||||||
|
|
||||||
|
var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1);
|
||||||
if (candidateAddress != nint.Zero)
|
if (candidateAddress != nint.Zero)
|
||||||
{
|
{
|
||||||
var candidate = (GameObject*)candidateAddress;
|
var candidate = (GameObject*)candidateAddress;
|
||||||
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
|
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
|
||||||
|
|
||||||
if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
|
if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
|
||||||
{
|
{
|
||||||
if (ResolveOwnerId(candidate) == ownerEntityId)
|
var resolvedOwner = ResolveOwnerId(candidate);
|
||||||
|
|
||||||
|
if (resolvedOwner == ownerEntityId || resolvedOwner == 0)
|
||||||
return candidateAddress;
|
return candidateAddress;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ownerEntityId == 0)
|
||||||
|
return nint.Zero;
|
||||||
|
|
||||||
foreach (var obj in _objectTable)
|
foreach (var obj in _objectTable)
|
||||||
{
|
{
|
||||||
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
|
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
|
||||||
@@ -612,6 +617,90 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
|||||||
return nint.Zero;
|
return nint.Zero;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public unsafe bool TryFindOwnedObject(uint ownerEntityId, LightlessObjectKind kind, out nint address)
|
||||||
|
{
|
||||||
|
address = nint.Zero;
|
||||||
|
if (ownerEntityId == 0) return false;
|
||||||
|
|
||||||
|
foreach (var addr in EnumerateActiveCharacterAddresses())
|
||||||
|
{
|
||||||
|
if (addr == nint.Zero) continue;
|
||||||
|
|
||||||
|
var go = (GameObject*)addr;
|
||||||
|
var ok = (DalamudObjectKind)go->ObjectKind;
|
||||||
|
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case LightlessObjectKind.MinionOrMount:
|
||||||
|
if (ok is DalamudObjectKind.MountType or DalamudObjectKind.Companion
|
||||||
|
&& ResolveOwnerId(go) == ownerEntityId)
|
||||||
|
{
|
||||||
|
address = addr;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case LightlessObjectKind.Pet:
|
||||||
|
if (ok == DalamudObjectKind.BattleNpc
|
||||||
|
&& go->BattleNpcSubKind == BattleNpcSubKind.Pet
|
||||||
|
&& ResolveOwnerId(go) == ownerEntityId)
|
||||||
|
{
|
||||||
|
address = addr;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case LightlessObjectKind.Companion:
|
||||||
|
if (ok == DalamudObjectKind.BattleNpc
|
||||||
|
&& go->BattleNpcSubKind == BattleNpcSubKind.Buddy
|
||||||
|
&& ResolveOwnerId(go) == ownerEntityId)
|
||||||
|
{
|
||||||
|
address = addr;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe IReadOnlyList<nint> GetMinionOrMountCandidates(uint ownerEntityId, ushort preferredPlayerIndex)
|
||||||
|
{
|
||||||
|
var results = new List<(nint Ptr, int Score)>(4);
|
||||||
|
|
||||||
|
var manager = GameObjectManager.Instance();
|
||||||
|
if (manager == null || ownerEntityId == 0)
|
||||||
|
return Array.Empty<nint>();
|
||||||
|
|
||||||
|
const int objectLimit = 200;
|
||||||
|
for (var i = 0; i < objectLimit; i++)
|
||||||
|
{
|
||||||
|
var obj = manager->Objects.IndexSorted[i].Value;
|
||||||
|
if (obj == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var kind = (DalamudObjectKind)obj->ObjectKind;
|
||||||
|
if (kind is not (DalamudObjectKind.MountType or DalamudObjectKind.Companion))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var owner = ResolveOwnerId(obj);
|
||||||
|
if (owner != ownerEntityId)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var idx = obj->ObjectIndex;
|
||||||
|
var score = Math.Abs(idx - (preferredPlayerIndex + 1));
|
||||||
|
if (obj->DrawObject == null) score += 50;
|
||||||
|
|
||||||
|
results.Add(((nint)obj, score));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
.OrderBy(r => r.Score)
|
||||||
|
.Select(r => r.Ptr)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId)
|
private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId)
|
||||||
{
|
{
|
||||||
if (localPlayerAddress == nint.Zero || ownerEntityId == 0)
|
if (localPlayerAddress == nint.Zero || ownerEntityId == 0)
|
||||||
@@ -1216,21 +1305,19 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
|||||||
|
|
||||||
private static unsafe bool IsObjectFullyLoaded(nint address)
|
private static unsafe bool IsObjectFullyLoaded(nint address)
|
||||||
{
|
{
|
||||||
if (address == nint.Zero)
|
if (address == nint.Zero) return false;
|
||||||
return false;
|
|
||||||
|
|
||||||
var gameObject = (GameObject*)address;
|
var gameObject = (GameObject*)address;
|
||||||
if (gameObject == null)
|
if (gameObject == null) return false;
|
||||||
return false;
|
|
||||||
|
|
||||||
var drawObject = gameObject->DrawObject;
|
var drawObject = gameObject->DrawObject;
|
||||||
if (drawObject == null)
|
if (drawObject == null) return false;
|
||||||
return false;
|
|
||||||
|
|
||||||
if ((ulong)gameObject->RenderFlags == 2048)
|
if ((ulong)gameObject->RenderFlags == 2048)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var characterBase = (CharacterBase*)drawObject;
|
var characterBase = (CharacterBase*)drawObject;
|
||||||
|
|
||||||
if (characterBase->HasModelInSlotLoaded != 0)
|
if (characterBase->HasModelInSlotLoaded != 0)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -1240,6 +1327,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Auto)]
|
||||||
private readonly record struct LoadState(bool IsValid, bool IsLoaded)
|
private readonly record struct LoadState(bool IsValid, bool IsLoaded)
|
||||||
{
|
{
|
||||||
public static LoadState Invalid => new(false, false);
|
public static LoadState Invalid => new(false, false);
|
||||||
|
|||||||
@@ -1,29 +1,41 @@
|
|||||||
using Dalamud.Interface.Textures.TextureWraps;
|
using Dalamud.Interface.Textures.TextureWraps;
|
||||||
|
using LightlessSync.LightlessConfiguration;
|
||||||
using LightlessSync.UI;
|
using LightlessSync.UI;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.Formats.Gif;
|
||||||
|
using SixLabors.ImageSharp.Formats.Webp;
|
||||||
|
using SixLabors.ImageSharp.Metadata;
|
||||||
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
|
||||||
namespace LightlessSync.Services.Chat;
|
namespace LightlessSync.Services.Chat;
|
||||||
|
|
||||||
public sealed class ChatEmoteService : IDisposable
|
public sealed class ChatEmoteService : IDisposable
|
||||||
{
|
{
|
||||||
private const string GlobalEmoteSetUrl = "https://7tv.io/v3/emote-sets/global";
|
private const string GlobalEmoteSetUrl = "https://7tv.io/v3/emote-sets/global";
|
||||||
|
private const int DefaultFrameDelayMs = 100;
|
||||||
|
private const int MinFrameDelayMs = 20;
|
||||||
|
|
||||||
private readonly ILogger<ChatEmoteService> _logger;
|
private readonly ILogger<ChatEmoteService> _logger;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly UiSharedService _uiSharedService;
|
private readonly UiSharedService _uiSharedService;
|
||||||
|
private readonly ChatConfigService _chatConfigService;
|
||||||
private readonly ConcurrentDictionary<string, EmoteEntry> _emotes = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, EmoteEntry> _emotes = new(StringComparer.Ordinal);
|
||||||
private readonly SemaphoreSlim _downloadGate = new(3, 3);
|
private readonly SemaphoreSlim _downloadGate = new(3, 3);
|
||||||
|
|
||||||
private readonly object _loadLock = new();
|
private readonly object _loadLock = new();
|
||||||
private Task? _loadTask;
|
private Task? _loadTask;
|
||||||
|
|
||||||
public ChatEmoteService(ILogger<ChatEmoteService> logger, HttpClient httpClient, UiSharedService uiSharedService)
|
public ChatEmoteService(ILogger<ChatEmoteService> logger, HttpClient httpClient, UiSharedService uiSharedService, ChatConfigService chatConfigService)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
_uiSharedService = uiSharedService;
|
_uiSharedService = uiSharedService;
|
||||||
|
_chatConfigService = chatConfigService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void EnsureGlobalEmotesLoaded()
|
public void EnsureGlobalEmotesLoaded()
|
||||||
@@ -62,13 +74,17 @@ public sealed class ChatEmoteService : IDisposable
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.Texture is not null)
|
var allowAnimation = _chatConfigService.Current.EnableAnimatedEmotes;
|
||||||
|
if (entry.TryGetTexture(allowAnimation, out texture))
|
||||||
{
|
{
|
||||||
texture = entry.Texture;
|
if (allowAnimation && entry.NeedsAnimationLoad && !entry.HasAttemptedAnimation)
|
||||||
|
{
|
||||||
|
entry.EnsureLoading(allowAnimation, QueueEmoteDownload, allowWhenStaticLoaded: true);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.EnsureLoading(QueueEmoteDownload);
|
entry.EnsureLoading(allowAnimation, QueueEmoteDownload);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +92,7 @@ public sealed class ChatEmoteService : IDisposable
|
|||||||
{
|
{
|
||||||
foreach (var entry in _emotes.Values)
|
foreach (var entry in _emotes.Values)
|
||||||
{
|
{
|
||||||
entry.Texture?.Dispose();
|
entry.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
_downloadGate.Dispose();
|
_downloadGate.Dispose();
|
||||||
@@ -108,13 +124,13 @@ public sealed class ChatEmoteService : IDisposable
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var url = TryBuildEmoteUrl(emoteElement);
|
var source = TryBuildEmoteSource(emoteElement);
|
||||||
if (string.IsNullOrWhiteSpace(url))
|
if (source is null || (!source.Value.HasStatic && !source.Value.HasAnimation))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
_emotes.TryAdd(name, new EmoteEntry(url));
|
_emotes.TryAdd(name, new EmoteEntry(name, source.Value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -123,7 +139,7 @@ public sealed class ChatEmoteService : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? TryBuildEmoteUrl(JsonElement emoteElement)
|
private static EmoteSource? TryBuildEmoteSource(JsonElement emoteElement)
|
||||||
{
|
{
|
||||||
if (!emoteElement.TryGetProperty("data", out var dataElement))
|
if (!emoteElement.TryGetProperty("data", out var dataElement))
|
||||||
{
|
{
|
||||||
@@ -156,29 +172,38 @@ public sealed class ChatEmoteService : IDisposable
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileName = PickBestStaticFile(filesElement);
|
var files = ReadEmoteFiles(filesElement);
|
||||||
if (string.IsNullOrWhiteSpace(fileName))
|
if (files.Count == 0)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseUrl.TrimEnd('/') + "/" + fileName;
|
var animatedFile = PickBestAnimatedFile(files);
|
||||||
|
var animatedUrl = animatedFile is null ? null : BuildEmoteUrl(baseUrl, animatedFile.Value.Name);
|
||||||
|
|
||||||
|
var staticName = animatedFile?.StaticName;
|
||||||
|
if (string.IsNullOrWhiteSpace(staticName))
|
||||||
|
{
|
||||||
|
staticName = PickBestStaticFileName(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
var staticUrl = string.IsNullOrWhiteSpace(staticName) ? null : BuildEmoteUrl(baseUrl, staticName);
|
||||||
|
if (string.IsNullOrWhiteSpace(animatedUrl) && string.IsNullOrWhiteSpace(staticUrl))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new EmoteSource(staticUrl, animatedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? PickBestStaticFile(JsonElement filesElement)
|
private static string BuildEmoteUrl(string baseUrl, string fileName)
|
||||||
{
|
=> baseUrl.TrimEnd('/') + "/" + fileName;
|
||||||
string? png1x = null;
|
|
||||||
string? webp1x = null;
|
|
||||||
string? pngFallback = null;
|
|
||||||
string? webpFallback = null;
|
|
||||||
|
|
||||||
|
private static List<EmoteFile> ReadEmoteFiles(JsonElement filesElement)
|
||||||
|
{
|
||||||
|
var files = new List<EmoteFile>();
|
||||||
foreach (var file in filesElement.EnumerateArray())
|
foreach (var file in filesElement.EnumerateArray())
|
||||||
{
|
{
|
||||||
if (file.TryGetProperty("static", out var staticElement) && staticElement.ValueKind == JsonValueKind.False)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!file.TryGetProperty("name", out var nameElement))
|
if (!file.TryGetProperty("name", out var nameElement))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -190,6 +215,88 @@ public sealed class ChatEmoteService : IDisposable
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string? staticName = null;
|
||||||
|
if (file.TryGetProperty("static_name", out var staticNameElement) && staticNameElement.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
staticName = staticNameElement.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
var frameCount = 1;
|
||||||
|
if (file.TryGetProperty("frame_count", out var frameCountElement) && frameCountElement.ValueKind == JsonValueKind.Number)
|
||||||
|
{
|
||||||
|
frameCountElement.TryGetInt32(out frameCount);
|
||||||
|
frameCount = Math.Max(frameCount, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
string? format = null;
|
||||||
|
if (file.TryGetProperty("format", out var formatElement) && formatElement.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
format = formatElement.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
files.Add(new EmoteFile(name, staticName, frameCount, format));
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EmoteFile? PickBestAnimatedFile(IReadOnlyList<EmoteFile> files)
|
||||||
|
{
|
||||||
|
EmoteFile? webp1x = null;
|
||||||
|
EmoteFile? gif1x = null;
|
||||||
|
EmoteFile? webpFallback = null;
|
||||||
|
EmoteFile? gifFallback = null;
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
if (file.FrameCount <= 1 || !IsAnimatedFormatSupported(file))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.Name.Equals("1x.webp", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
webp1x = file;
|
||||||
|
}
|
||||||
|
else if (file.Name.Equals("1x.gif", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
gif1x = file;
|
||||||
|
}
|
||||||
|
else if (file.Name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) && webpFallback is null)
|
||||||
|
{
|
||||||
|
webpFallback = file;
|
||||||
|
}
|
||||||
|
else if (file.Name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) && gifFallback is null)
|
||||||
|
{
|
||||||
|
gifFallback = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return webp1x ?? gif1x ?? webpFallback ?? gifFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? PickBestStaticFileName(IReadOnlyList<EmoteFile> files)
|
||||||
|
{
|
||||||
|
string? png1x = null;
|
||||||
|
string? webp1x = null;
|
||||||
|
string? gif1x = null;
|
||||||
|
string? pngFallback = null;
|
||||||
|
string? webpFallback = null;
|
||||||
|
string? gifFallback = null;
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
if (file.FrameCount > 1)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = file.StaticName ?? file.Name;
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (name.Equals("1x.png", StringComparison.OrdinalIgnoreCase))
|
if (name.Equals("1x.png", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
png1x = name;
|
png1x = name;
|
||||||
@@ -198,6 +305,10 @@ public sealed class ChatEmoteService : IDisposable
|
|||||||
{
|
{
|
||||||
webp1x = name;
|
webp1x = name;
|
||||||
}
|
}
|
||||||
|
else if (name.Equals("1x.gif", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
gif1x = name;
|
||||||
|
}
|
||||||
else if (name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && pngFallback is null)
|
else if (name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && pngFallback is null)
|
||||||
{
|
{
|
||||||
pngFallback = name;
|
pngFallback = name;
|
||||||
@@ -206,25 +317,80 @@ public sealed class ChatEmoteService : IDisposable
|
|||||||
{
|
{
|
||||||
webpFallback = name;
|
webpFallback = name;
|
||||||
}
|
}
|
||||||
|
else if (name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) && gifFallback is null)
|
||||||
|
{
|
||||||
|
gifFallback = name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return png1x ?? webp1x ?? pngFallback ?? webpFallback;
|
return png1x ?? webp1x ?? gif1x ?? pngFallback ?? webpFallback ?? gifFallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void QueueEmoteDownload(EmoteEntry entry)
|
private static bool IsAnimatedFormatSupported(EmoteFile file)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(file.Format))
|
||||||
|
{
|
||||||
|
return file.Format.Equals("WEBP", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| file.Format.Equals("GIF", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
return file.Name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| file.Name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct EmoteSource(string? StaticUrl, string? AnimatedUrl)
|
||||||
|
{
|
||||||
|
public bool HasStatic => !string.IsNullOrWhiteSpace(StaticUrl);
|
||||||
|
public bool HasAnimation => !string.IsNullOrWhiteSpace(AnimatedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct EmoteFile(string Name, string? StaticName, int FrameCount, string? Format);
|
||||||
|
|
||||||
|
private void QueueEmoteDownload(EmoteEntry entry, bool allowAnimation)
|
||||||
{
|
{
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
await _downloadGate.WaitAsync().ConfigureAwait(false);
|
await _downloadGate.WaitAsync().ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var data = await _httpClient.GetByteArrayAsync(entry.Url).ConfigureAwait(false);
|
if (allowAnimation)
|
||||||
var texture = _uiSharedService.LoadImage(data);
|
{
|
||||||
entry.SetTexture(texture);
|
if (entry.HasAnimatedSource)
|
||||||
|
{
|
||||||
|
entry.MarkAnimationAttempted();
|
||||||
|
if (await TryLoadAnimatedEmoteAsync(entry).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.HasStaticSource && !entry.HasStaticTexture && await TryLoadStaticEmoteAsync(entry).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (entry.HasStaticSource && await TryLoadStaticEmoteAsync(entry).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.HasAnimatedSource)
|
||||||
|
{
|
||||||
|
entry.MarkAnimationAttempted();
|
||||||
|
if (await TryLoadAnimatedEmoteAsync(entry).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.MarkFailed();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to load 7TV emote {Url}", entry.Url);
|
_logger.LogDebug(ex, "Failed to load 7TV emote {Emote}", entry.Code);
|
||||||
entry.MarkFailed();
|
entry.MarkFailed();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -234,21 +400,334 @@ public sealed class ChatEmoteService : IDisposable
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class EmoteEntry
|
private async Task<bool> TryLoadAnimatedEmoteAsync(EmoteEntry entry)
|
||||||
{
|
{
|
||||||
private int _loadingState;
|
if (string.IsNullOrWhiteSpace(entry.AnimatedUrl))
|
||||||
|
|
||||||
public EmoteEntry(string url)
|
|
||||||
{
|
{
|
||||||
Url = url;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Url { get; }
|
try
|
||||||
public IDalamudTextureWrap? Texture { get; private set; }
|
|
||||||
|
|
||||||
public void EnsureLoading(Action<EmoteEntry> queueDownload)
|
|
||||||
{
|
{
|
||||||
if (Texture is not null)
|
var data = await _httpClient.GetByteArrayAsync(entry.AnimatedUrl).ConfigureAwait(false);
|
||||||
|
var isWebp = entry.AnimatedUrl.EndsWith(".webp", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (!TryDecodeAnimation(data, isWebp, out var animation))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.SetAnimation(animation);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to decode animated 7TV emote {Emote}", entry.Code);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TryLoadStaticEmoteAsync(EmoteEntry entry)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(entry.StaticUrl))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = await _httpClient.GetByteArrayAsync(entry.StaticUrl).ConfigureAwait(false);
|
||||||
|
var texture = _uiSharedService.LoadImage(data);
|
||||||
|
entry.SetStaticTexture(texture);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to decode static 7TV emote {Emote}", entry.Code);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryDecodeAnimation(byte[] data, bool isWebp, out EmoteAnimation? animation)
|
||||||
|
{
|
||||||
|
animation = null;
|
||||||
|
List<EmoteFrame>? frames = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Image<Rgba32> image;
|
||||||
|
if (isWebp)
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream(data);
|
||||||
|
image = WebpDecoder.Instance.Decode<Rgba32>(
|
||||||
|
new WebpDecoderOptions { BackgroundColorHandling = BackgroundColorHandling.Ignore },
|
||||||
|
stream);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
image = Image.Load<Rgba32>(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (image)
|
||||||
|
{
|
||||||
|
if (image.Frames.Count <= 1)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var composite = new Image<Rgba32>(image.Width, image.Height, Color.Transparent);
|
||||||
|
Image<Rgba32>? restoreCanvas = null;
|
||||||
|
GifDisposalMethod? pendingGifDisposal = null;
|
||||||
|
WebpDisposalMethod? pendingWebpDisposal = null;
|
||||||
|
|
||||||
|
frames = new List<EmoteFrame>(image.Frames.Count);
|
||||||
|
for (var i = 0; i < image.Frames.Count; i++)
|
||||||
|
{
|
||||||
|
var frameMetadata = image.Frames[i].Metadata;
|
||||||
|
var delayMs = GetFrameDelayMs(frameMetadata);
|
||||||
|
|
||||||
|
ApplyDisposal(composite, ref restoreCanvas, pendingGifDisposal, pendingWebpDisposal);
|
||||||
|
|
||||||
|
GifDisposalMethod? currentGifDisposal = null;
|
||||||
|
WebpDisposalMethod? currentWebpDisposal = null;
|
||||||
|
var blendMethod = WebpBlendMethod.Over;
|
||||||
|
|
||||||
|
if (isWebp)
|
||||||
|
{
|
||||||
|
if (frameMetadata.TryGetWebpFrameMetadata(out var webpMetadata))
|
||||||
|
{
|
||||||
|
currentWebpDisposal = webpMetadata.DisposalMethod;
|
||||||
|
blendMethod = webpMetadata.BlendMethod;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (frameMetadata.TryGetGifMetadata(out var gifMetadata))
|
||||||
|
{
|
||||||
|
currentGifDisposal = gifMetadata.DisposalMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentGifDisposal == GifDisposalMethod.RestoreToPrevious)
|
||||||
|
{
|
||||||
|
restoreCanvas?.Dispose();
|
||||||
|
restoreCanvas = composite.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var frameImage = image.Frames.CloneFrame(i);
|
||||||
|
var alphaMode = blendMethod == WebpBlendMethod.Source
|
||||||
|
? PixelAlphaCompositionMode.Src
|
||||||
|
: PixelAlphaCompositionMode.SrcOver;
|
||||||
|
composite.Mutate(ctx => ctx.DrawImage(frameImage, PixelColorBlendingMode.Normal, alphaMode, 1f));
|
||||||
|
|
||||||
|
using var renderedFrame = composite.Clone();
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
renderedFrame.SaveAsPng(ms);
|
||||||
|
|
||||||
|
var texture = _uiSharedService.LoadImage(ms.ToArray());
|
||||||
|
frames.Add(new EmoteFrame(texture, delayMs));
|
||||||
|
|
||||||
|
pendingGifDisposal = currentGifDisposal;
|
||||||
|
pendingWebpDisposal = currentWebpDisposal;
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreCanvas?.Dispose();
|
||||||
|
|
||||||
|
animation = new EmoteAnimation(frames);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (frames is not null)
|
||||||
|
{
|
||||||
|
foreach (var frame in frames)
|
||||||
|
{
|
||||||
|
frame.Texture.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetFrameDelayMs(ImageFrameMetadata metadata)
|
||||||
|
{
|
||||||
|
if (metadata.TryGetGifMetadata(out var gifMetadata))
|
||||||
|
{
|
||||||
|
var delayMs = (long)gifMetadata.FrameDelay * 10L;
|
||||||
|
return NormalizeFrameDelayMs(delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.TryGetWebpFrameMetadata(out var webpMetadata))
|
||||||
|
{
|
||||||
|
return NormalizeFrameDelayMs(webpMetadata.FrameDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DefaultFrameDelayMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int NormalizeFrameDelayMs(long delayMs)
|
||||||
|
{
|
||||||
|
if (delayMs <= 0)
|
||||||
|
{
|
||||||
|
return DefaultFrameDelayMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
var clamped = delayMs > int.MaxValue ? int.MaxValue : (int)delayMs;
|
||||||
|
return Math.Max(clamped, MinFrameDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyDisposal(
|
||||||
|
Image<Rgba32> composite,
|
||||||
|
ref Image<Rgba32>? restoreCanvas,
|
||||||
|
GifDisposalMethod? gifDisposal,
|
||||||
|
WebpDisposalMethod? webpDisposal)
|
||||||
|
{
|
||||||
|
if (gifDisposal is not null)
|
||||||
|
{
|
||||||
|
switch (gifDisposal)
|
||||||
|
{
|
||||||
|
case GifDisposalMethod.RestoreToBackground:
|
||||||
|
composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent));
|
||||||
|
break;
|
||||||
|
case GifDisposalMethod.RestoreToPrevious:
|
||||||
|
if (restoreCanvas is not null)
|
||||||
|
{
|
||||||
|
composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent));
|
||||||
|
var restoreSnapshot = restoreCanvas;
|
||||||
|
composite.Mutate(ctx => ctx.DrawImage(restoreSnapshot, PixelColorBlendingMode.Normal, PixelAlphaCompositionMode.Src, 1f));
|
||||||
|
restoreCanvas.Dispose();
|
||||||
|
restoreCanvas = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (webpDisposal == WebpDisposalMethod.RestoreToBackground)
|
||||||
|
{
|
||||||
|
composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class EmoteAnimation : IDisposable
|
||||||
|
{
|
||||||
|
private readonly EmoteFrame[] _frames;
|
||||||
|
private readonly int _durationMs;
|
||||||
|
private readonly long _startTimestamp;
|
||||||
|
|
||||||
|
public EmoteAnimation(IReadOnlyList<EmoteFrame> frames)
|
||||||
|
{
|
||||||
|
_frames = frames.ToArray();
|
||||||
|
_durationMs = Math.Max(1, frames.Sum(frame => frame.DurationMs));
|
||||||
|
_startTimestamp = Stopwatch.GetTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDalamudTextureWrap? GetCurrentFrame()
|
||||||
|
{
|
||||||
|
if (_frames.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_frames.Length == 1)
|
||||||
|
{
|
||||||
|
return _frames[0].Texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
var elapsedTicks = Stopwatch.GetTimestamp() - _startTimestamp;
|
||||||
|
var elapsedMs = (elapsedTicks * 1000L) / Stopwatch.Frequency;
|
||||||
|
var targetMs = (int)(elapsedMs % _durationMs);
|
||||||
|
var accumulated = 0;
|
||||||
|
|
||||||
|
foreach (var frame in _frames)
|
||||||
|
{
|
||||||
|
accumulated += frame.DurationMs;
|
||||||
|
if (targetMs < accumulated)
|
||||||
|
{
|
||||||
|
return frame.Texture;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _frames[^1].Texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDalamudTextureWrap? GetStaticFrame()
|
||||||
|
{
|
||||||
|
if (_frames.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _frames[0].Texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var frame in _frames)
|
||||||
|
{
|
||||||
|
frame.Texture.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct EmoteFrame(IDalamudTextureWrap Texture, int DurationMs);
|
||||||
|
|
||||||
|
private sealed class EmoteEntry : IDisposable
|
||||||
|
{
|
||||||
|
private int _loadingState;
|
||||||
|
private int _animationAttempted;
|
||||||
|
private IDalamudTextureWrap? _staticTexture;
|
||||||
|
private EmoteAnimation? _animation;
|
||||||
|
|
||||||
|
public EmoteEntry(string code, EmoteSource source)
|
||||||
|
{
|
||||||
|
Code = code;
|
||||||
|
StaticUrl = source.StaticUrl;
|
||||||
|
AnimatedUrl = source.AnimatedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Code { get; }
|
||||||
|
public string? StaticUrl { get; }
|
||||||
|
public string? AnimatedUrl { get; }
|
||||||
|
public bool HasStaticSource => !string.IsNullOrWhiteSpace(StaticUrl);
|
||||||
|
public bool HasAnimatedSource => !string.IsNullOrWhiteSpace(AnimatedUrl);
|
||||||
|
public bool HasStaticTexture => _staticTexture is not null;
|
||||||
|
public bool HasAttemptedAnimation => Interlocked.CompareExchange(ref _animationAttempted, 0, 0) != 0;
|
||||||
|
public bool NeedsAnimationLoad => _animation is null && HasAnimatedSource;
|
||||||
|
|
||||||
|
public void MarkAnimationAttempted()
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _animationAttempted, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetTexture(bool allowAnimation, out IDalamudTextureWrap? texture)
|
||||||
|
{
|
||||||
|
if (allowAnimation && _animation is not null)
|
||||||
|
{
|
||||||
|
texture = _animation.GetCurrentFrame();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_staticTexture is not null)
|
||||||
|
{
|
||||||
|
texture = _staticTexture;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowAnimation && _animation is not null)
|
||||||
|
{
|
||||||
|
texture = _animation.GetStaticFrame();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
texture = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EnsureLoading(bool allowAnimation, Action<EmoteEntry, bool> queueDownload, bool allowWhenStaticLoaded = false)
|
||||||
|
{
|
||||||
|
if (_animation is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowWhenStaticLoaded && _staticTexture is not null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -258,12 +737,22 @@ public sealed class ChatEmoteService : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
queueDownload(this);
|
queueDownload(this, allowAnimation);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetTexture(IDalamudTextureWrap texture)
|
public void SetAnimation(EmoteAnimation animation)
|
||||||
{
|
{
|
||||||
Texture = texture;
|
_staticTexture?.Dispose();
|
||||||
|
_staticTexture = null;
|
||||||
|
_animation?.Dispose();
|
||||||
|
_animation = animation;
|
||||||
|
Interlocked.Exchange(ref _loadingState, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetStaticTexture(IDalamudTextureWrap texture)
|
||||||
|
{
|
||||||
|
_staticTexture?.Dispose();
|
||||||
|
_staticTexture = texture;
|
||||||
Interlocked.Exchange(ref _loadingState, 0);
|
Interlocked.Exchange(ref _loadingState, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,5 +760,11 @@ public sealed class ChatEmoteService : IDisposable
|
|||||||
{
|
{
|
||||||
Interlocked.Exchange(ref _loadingState, 0);
|
Interlocked.Exchange(ref _loadingState, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_animation?.Dispose();
|
||||||
|
_staticTexture?.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
{
|
{
|
||||||
return gameData.GetExcelSheet<Lumina.Excel.Sheets.World>(clientLanguage)!
|
return gameData.GetExcelSheet<Lumina.Excel.Sheets.World>(clientLanguage)!
|
||||||
.Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0])
|
.Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0])
|
||||||
|| w is { RowId: > 1000, Region: 101 or 201 }))
|
|| w is { RowId: > 1000, UserType: 101 or 201 }))
|
||||||
.ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString());
|
.ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString());
|
||||||
});
|
});
|
||||||
JobData = new(() =>
|
JobData = new(() =>
|
||||||
@@ -666,7 +666,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
|
|
||||||
var location = new LocationInfo();
|
var location = new LocationInfo();
|
||||||
location.ServerId = _playerState.CurrentWorld.RowId;
|
location.ServerId = _playerState.CurrentWorld.RowId;
|
||||||
location.InstanceId = UIState.Instance()->PublicInstance.InstanceId;
|
location.InstanceId = UIState.Instance()->PublicInstance.InstanceId;
|
||||||
location.TerritoryId = _clientState.TerritoryType;
|
location.TerritoryId = _clientState.TerritoryType;
|
||||||
location.MapId = _clientState.MapId;
|
location.MapId = _clientState.MapId;
|
||||||
if (houseMan != null)
|
if (houseMan != null)
|
||||||
@@ -699,13 +699,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
}
|
}
|
||||||
return location;
|
return location;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string LocationToString(LocationInfo location)
|
public string LocationToString(LocationInfo location)
|
||||||
{
|
{
|
||||||
if (location.ServerId is 0 || location.TerritoryId is 0) return String.Empty;
|
if (location.ServerId is 0 || location.TerritoryId is 0) return String.Empty;
|
||||||
var str = WorldData.Value[(ushort)location.ServerId];
|
var str = WorldData.Value[(ushort)location.ServerId];
|
||||||
|
|
||||||
if (ContentFinderData.Value.TryGetValue(location.TerritoryId , out var dutyName))
|
if (ContentFinderData.Value.TryGetValue(location.TerritoryId, out var dutyName))
|
||||||
{
|
{
|
||||||
str += $" - [In Duty]{dutyName}";
|
str += $" - [In Duty]{dutyName}";
|
||||||
}
|
}
|
||||||
@@ -816,9 +816,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("Starting DalamudUtilService");
|
_logger.LogInformation("Starting DalamudUtilService");
|
||||||
_framework.Update += FrameworkOnUpdate;
|
_framework.Update += FrameworkOnUpdate;
|
||||||
if (IsLoggedIn)
|
_clientState.Login += OnClientLogin;
|
||||||
|
_clientState.Logout += OnClientLogout;
|
||||||
|
|
||||||
|
if (_clientState.IsLoggedIn)
|
||||||
{
|
{
|
||||||
_classJobId = _objectTable.LocalPlayer!.ClassJob.RowId;
|
OnClientLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Started DalamudUtilService");
|
_logger.LogInformation("Started DalamudUtilService");
|
||||||
@@ -831,6 +834,9 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
|
|
||||||
Mediator.UnsubscribeAll(this);
|
Mediator.UnsubscribeAll(this);
|
||||||
_framework.Update -= FrameworkOnUpdate;
|
_framework.Update -= FrameworkOnUpdate;
|
||||||
|
_clientState.Login -= OnClientLogin;
|
||||||
|
_clientState.Logout -= OnClientLogout;
|
||||||
|
|
||||||
if (_FocusPairIdent.HasValue)
|
if (_FocusPairIdent.HasValue)
|
||||||
{
|
{
|
||||||
if (_framework.IsInFrameworkUpdateThread)
|
if (_framework.IsInFrameworkUpdateThread)
|
||||||
@@ -845,6 +851,41 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnClientLogin()
|
||||||
|
{
|
||||||
|
if (IsLoggedIn)
|
||||||
|
return;
|
||||||
|
_ = RunOnFrameworkThread(() =>
|
||||||
|
{
|
||||||
|
if (IsLoggedIn)
|
||||||
|
return;
|
||||||
|
var localPlayer = _objectTable.LocalPlayer;
|
||||||
|
IsLoggedIn = true;
|
||||||
|
_lastZone = _clientState.TerritoryType;
|
||||||
|
if (localPlayer != null)
|
||||||
|
{
|
||||||
|
_lastWorldId = (ushort)localPlayer.CurrentWorld.RowId;
|
||||||
|
_classJobId = localPlayer.ClassJob.RowId;
|
||||||
|
}
|
||||||
|
_cid = RebuildCID();
|
||||||
|
Mediator.Publish(new DalamudLoginMessage());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClientLogout(int type, int code)
|
||||||
|
{
|
||||||
|
if (!IsLoggedIn)
|
||||||
|
return;
|
||||||
|
_ = RunOnFrameworkThread(() =>
|
||||||
|
{
|
||||||
|
if (!IsLoggedIn)
|
||||||
|
return;
|
||||||
|
IsLoggedIn = false;
|
||||||
|
_lastWorldId = 0;
|
||||||
|
Mediator.Publish(new DalamudLogoutMessage());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async Task WaitWhileCharacterIsDrawing(
|
public async Task WaitWhileCharacterIsDrawing(
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
GameObjectHandler handler,
|
GameObjectHandler handler,
|
||||||
@@ -856,7 +897,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
|
|
||||||
var token = ct ?? CancellationToken.None;
|
var token = ct ?? CancellationToken.None;
|
||||||
|
|
||||||
const int tick = 250;
|
const int tick = 250;
|
||||||
const int initialSettle = 50;
|
const int initialSettle = 50;
|
||||||
|
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
@@ -881,7 +922,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
{
|
{
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
catch (AccessViolationException ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler);
|
logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler);
|
||||||
}
|
}
|
||||||
@@ -922,11 +963,11 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
public string? GetWorldNameFromPlayerAddress(nint address)
|
public string? GetWorldNameFromPlayerAddress(nint address)
|
||||||
{
|
{
|
||||||
if (address == nint.Zero) return null;
|
if (address == nint.Zero) return null;
|
||||||
|
|
||||||
EnsureIsOnFramework();
|
EnsureIsOnFramework();
|
||||||
var playerCharacter = _objectTable.OfType<IPlayerCharacter>().FirstOrDefault(p => p.Address == address);
|
var playerCharacter = _objectTable.OfType<IPlayerCharacter>().FirstOrDefault(p => p.Address == address);
|
||||||
if (playerCharacter == null) return null;
|
if (playerCharacter == null) return null;
|
||||||
|
|
||||||
var worldId = (ushort)playerCharacter.HomeWorld.RowId;
|
var worldId = (ushort)playerCharacter.HomeWorld.RowId;
|
||||||
return WorldData.Value.TryGetValue(worldId, out var worldName) ? worldName : null;
|
return WorldData.Value.TryGetValue(worldId, out var worldName) ? worldName : null;
|
||||||
}
|
}
|
||||||
@@ -953,37 +994,87 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern bool IsBadReadPtr(IntPtr ptr, UIntPtr size);
|
||||||
|
|
||||||
|
private static bool IsValidPointer(nint ptr, int size = 8)
|
||||||
|
{
|
||||||
|
if (ptr == nint.Zero)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Util.IsWine())
|
||||||
|
{
|
||||||
|
return !IsBadReadPtr(ptr, (UIntPtr)size);
|
||||||
|
}
|
||||||
|
return ptr != nint.Zero && (ptr % IntPtr.Size) == 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private unsafe void CheckCharacterForDrawing(nint address, string characterName)
|
private unsafe void CheckCharacterForDrawing(nint address, string characterName)
|
||||||
{
|
{
|
||||||
|
if (address == nint.Zero)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!IsValidPointer(address))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Invalid pointer for character {name} at {addr}", characterName, address.ToString("X"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var gameObj = (GameObject*)address;
|
var gameObj = (GameObject*)address;
|
||||||
|
|
||||||
|
if (gameObj == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!_objectTable.Any(o => o?.Address == address))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Character {name} at {addr} no longer in object table", characterName, address.ToString("X"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameObj->ObjectKind == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
var drawObj = gameObj->DrawObject;
|
var drawObj = gameObj->DrawObject;
|
||||||
bool isDrawing = false;
|
bool isDrawing = false;
|
||||||
bool isDrawingChanged = false;
|
bool isDrawingChanged = false;
|
||||||
if ((nint)drawObj != IntPtr.Zero)
|
|
||||||
|
if ((nint)drawObj != IntPtr.Zero && IsValidPointer((nint)drawObj))
|
||||||
{
|
{
|
||||||
isDrawing = gameObj->RenderFlags == (VisibilityFlags)0b100000000000;
|
isDrawing = gameObj->RenderFlags == (VisibilityFlags)0b100000000000;
|
||||||
|
|
||||||
if (!isDrawing)
|
if (!isDrawing)
|
||||||
{
|
{
|
||||||
isDrawing = ((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0;
|
var charBase = (CharacterBase*)drawObj;
|
||||||
if (!isDrawing)
|
if (charBase != null && IsValidPointer((nint)charBase))
|
||||||
{
|
{
|
||||||
isDrawing = ((CharacterBase*)drawObj)->HasModelFilesInSlotLoaded != 0;
|
isDrawing = charBase->HasModelInSlotLoaded != 0;
|
||||||
if (isDrawing && !string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal)
|
if (!isDrawing)
|
||||||
&& !string.Equals(_lastGlobalBlockReason, "HasModelFilesInSlotLoaded", StringComparison.Ordinal))
|
|
||||||
{
|
{
|
||||||
_lastGlobalBlockPlayer = characterName;
|
isDrawing = charBase->HasModelFilesInSlotLoaded != 0;
|
||||||
_lastGlobalBlockReason = "HasModelFilesInSlotLoaded";
|
if (isDrawing && !string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal)
|
||||||
isDrawingChanged = true;
|
&& !string.Equals(_lastGlobalBlockReason, "HasModelFilesInSlotLoaded", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_lastGlobalBlockPlayer = characterName;
|
||||||
|
_lastGlobalBlockReason = "HasModelFilesInSlotLoaded";
|
||||||
|
isDrawingChanged = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
else
|
||||||
else
|
|
||||||
{
|
|
||||||
if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal)
|
|
||||||
&& !string.Equals(_lastGlobalBlockReason, "HasModelInSlotLoaded", StringComparison.Ordinal))
|
|
||||||
{
|
{
|
||||||
_lastGlobalBlockPlayer = characterName;
|
if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal)
|
||||||
_lastGlobalBlockReason = "HasModelInSlotLoaded";
|
&& !string.Equals(_lastGlobalBlockReason, "HasModelInSlotLoaded", StringComparison.Ordinal))
|
||||||
isDrawingChanged = true;
|
{
|
||||||
|
_lastGlobalBlockPlayer = characterName;
|
||||||
|
_lastGlobalBlockReason = "HasModelInSlotLoaded";
|
||||||
|
isDrawingChanged = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1014,6 +1105,11 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
|
|
||||||
private unsafe void FrameworkOnUpdateInternal()
|
private unsafe void FrameworkOnUpdateInternal()
|
||||||
{
|
{
|
||||||
|
if (!_clientState.IsLoggedIn || _objectTable.LocalPlayer == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ((_objectTable.LocalPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty])
|
if ((_objectTable.LocalPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty])
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -1033,12 +1129,17 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
}
|
}
|
||||||
|
|
||||||
var playerDescriptors = _actorObjectService.PlayerDescriptors;
|
var playerDescriptors = _actorObjectService.PlayerDescriptors;
|
||||||
for (var i = 0; i < playerDescriptors.Count; i++)
|
var descriptorCount = playerDescriptors.Count;
|
||||||
|
|
||||||
|
for (var i = 0; i < descriptorCount; i++)
|
||||||
{
|
{
|
||||||
|
if (i >= playerDescriptors.Count)
|
||||||
|
break;
|
||||||
|
|
||||||
var actor = playerDescriptors[i];
|
var actor = playerDescriptors[i];
|
||||||
|
|
||||||
var playerAddress = actor.Address;
|
var playerAddress = actor.Address;
|
||||||
if (playerAddress == nint.Zero)
|
if (playerAddress == nint.Zero || !IsValidPointer(playerAddress))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (actor.ObjectIndex >= 200)
|
if (actor.ObjectIndex >= 200)
|
||||||
@@ -1052,17 +1153,16 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
|
|
||||||
if (!IsAnythingDrawing)
|
if (!IsAnythingDrawing)
|
||||||
{
|
{
|
||||||
var gameObj = (GameObject*)playerAddress;
|
if (!_objectTable.Any(o => o?.Address == playerAddress))
|
||||||
var currentName = gameObj != null ? gameObj->NameString ?? string.Empty : string.Empty;
|
{
|
||||||
var charaName = string.IsNullOrEmpty(currentName) ? actor.Name : currentName;
|
continue;
|
||||||
CheckCharacterForDrawing(playerAddress, charaName);
|
}
|
||||||
|
|
||||||
|
CheckCharacterForDrawing(playerAddress, actor.Name);
|
||||||
|
|
||||||
if (IsAnythingDrawing)
|
if (IsAnythingDrawing)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1131,7 +1231,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Cutscene
|
// Cutscene
|
||||||
HandleStateTransition(() => IsInCutscene,v => IsInCutscene = v, shouldBeInCutscene, "Cutscene",
|
HandleStateTransition(() => IsInCutscene, v => IsInCutscene = v, shouldBeInCutscene, "Cutscene",
|
||||||
onEnter: () =>
|
onEnter: () =>
|
||||||
{
|
{
|
||||||
Mediator.Publish(new CutsceneStartMessage());
|
Mediator.Publish(new CutsceneStartMessage());
|
||||||
@@ -1174,7 +1274,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
Mediator.Publish(new ZoneSwitchEndMessage());
|
Mediator.Publish(new ZoneSwitchEndMessage());
|
||||||
Mediator.Publish(new ResumeScanMessage(nameof(ConditionFlag.BetweenAreas)));
|
Mediator.Publish(new ResumeScanMessage(nameof(ConditionFlag.BetweenAreas)));
|
||||||
}
|
}
|
||||||
|
|
||||||
//Map
|
//Map
|
||||||
if (!_sentBetweenAreas)
|
if (!_sentBetweenAreas)
|
||||||
{
|
{
|
||||||
@@ -1185,7 +1285,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
Mediator.Publish(new MapChangedMessage(mapid));
|
Mediator.Publish(new MapChangedMessage(mapid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
var localPlayer = _objectTable.LocalPlayer;
|
var localPlayer = _objectTable.LocalPlayer;
|
||||||
if (localPlayer != null)
|
if (localPlayer != null)
|
||||||
@@ -1213,23 +1313,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
if (isNormalFrameworkUpdate)
|
if (isNormalFrameworkUpdate)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (localPlayer != null && !IsLoggedIn)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Logged in");
|
|
||||||
IsLoggedIn = true;
|
|
||||||
_lastZone = _clientState.TerritoryType;
|
|
||||||
_lastWorldId = (ushort)localPlayer.CurrentWorld.RowId;
|
|
||||||
_cid = RebuildCID();
|
|
||||||
Mediator.Publish(new DalamudLoginMessage());
|
|
||||||
}
|
|
||||||
else if (localPlayer == null && IsLoggedIn)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Logged out");
|
|
||||||
IsLoggedIn = false;
|
|
||||||
_lastWorldId = 0;
|
|
||||||
Mediator.Publish(new DalamudLogoutMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_gameConfig != null
|
if (_gameConfig != null
|
||||||
&& _gameConfig.TryGet(Dalamud.Game.Config.SystemConfigOption.LodType_DX11, out bool lodEnabled))
|
&& _gameConfig.TryGet(Dalamud.Game.Config.SystemConfigOption.LodType_DX11, out bool lodEnabled))
|
||||||
{
|
{
|
||||||
@@ -1271,4 +1354,4 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
onExit();
|
onExit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,6 +68,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
ImGuiWindowFlags.NoMove |
|
ImGuiWindowFlags.NoMove |
|
||||||
ImGuiWindowFlags.NoSavedSettings |
|
ImGuiWindowFlags.NoSavedSettings |
|
||||||
ImGuiWindowFlags.NoNav |
|
ImGuiWindowFlags.NoNav |
|
||||||
|
ImGuiWindowFlags.NoFocusOnAppearing |
|
||||||
ImGuiWindowFlags.NoInputs;
|
ImGuiWindowFlags.NoInputs;
|
||||||
|
|
||||||
private readonly List<RectF> _uiRects = new(128);
|
private readonly List<RectF> _uiRects = new(128);
|
||||||
|
|||||||
@@ -83,12 +83,12 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
foreach (var address in _actorTracker.PlayerAddresses)
|
foreach (var descriptor in _actorTracker.PlayerDescriptors)
|
||||||
{
|
{
|
||||||
if (address == nint.Zero)
|
if (string.IsNullOrEmpty(descriptor.HashedContentId))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address);
|
var cid = descriptor.HashedContentId;
|
||||||
var isStale = !_broadcastCache.TryGetValue(cid, out var entry) || entry.ExpiryTime <= now;
|
var isStale = !_broadcastCache.TryGetValue(cid, out var entry) || entry.ExpiryTime <= now;
|
||||||
|
|
||||||
if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < _maxQueueSize)
|
if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < _maxQueueSize)
|
||||||
|
|||||||
@@ -138,5 +138,6 @@ public record GroupCollectionChangedMessage : MessageBase;
|
|||||||
public record OpenUserProfileMessage(UserData User) : MessageBase;
|
public record OpenUserProfileMessage(UserData User) : MessageBase;
|
||||||
public record LocationSharingMessage(UserData User, LocationInfo LocationInfo, DateTimeOffset ExpireAt) : MessageBase;
|
public record LocationSharingMessage(UserData User, LocationInfo LocationInfo, DateTimeOffset ExpireAt) : MessageBase;
|
||||||
public record MapChangedMessage(uint MapId) : MessageBase;
|
public record MapChangedMessage(uint MapId) : MessageBase;
|
||||||
|
public record PenumbraTempCollectionsCleanedMessage : MessageBase;
|
||||||
#pragma warning restore S2094
|
#pragma warning restore S2094
|
||||||
#pragma warning restore MA0048 // File name must match type name
|
#pragma warning restore MA0048 // File name must match type name
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ namespace LightlessSync.Services;
|
|||||||
public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriberBase
|
public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriberBase
|
||||||
{
|
{
|
||||||
private readonly IpcManager _ipc;
|
private readonly IpcManager _ipc;
|
||||||
private readonly LightlessConfigService _config;
|
private readonly PenumbraJanitorConfigService _config;
|
||||||
private int _ran;
|
private int _ran;
|
||||||
|
|
||||||
public PenumbraTempCollectionJanitor(
|
public PenumbraTempCollectionJanitor(
|
||||||
ILogger<PenumbraTempCollectionJanitor> logger,
|
ILogger<PenumbraTempCollectionJanitor> logger,
|
||||||
LightlessMediator mediator,
|
LightlessMediator mediator,
|
||||||
IpcManager ipc,
|
IpcManager ipc,
|
||||||
LightlessConfigService config) : base(logger, mediator)
|
PenumbraJanitorConfigService config) : base(logger, mediator)
|
||||||
{
|
{
|
||||||
_ipc = ipc;
|
_ipc = ipc;
|
||||||
_config = config;
|
_config = config;
|
||||||
@@ -67,5 +67,8 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber
|
|||||||
|
|
||||||
_config.Current.OrphanableTempCollections.Clear();
|
_config.Current.OrphanableTempCollections.Clear();
|
||||||
_config.Save();
|
_config.Save();
|
||||||
|
|
||||||
|
// Notify cleanup complete
|
||||||
|
Mediator.Publish(new PenumbraTempCollectionsCleanedMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||||
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
||||||
|
using FFXIVClientStructs.Havok.Common.Serialize.Resource;
|
||||||
using FFXIVClientStructs.Havok.Animation;
|
using FFXIVClientStructs.Havok.Animation;
|
||||||
using FFXIVClientStructs.Havok.Common.Base.Types;
|
using FFXIVClientStructs.Havok.Common.Base.Types;
|
||||||
|
using FFXIVClientStructs.Havok.Common.Serialize.Resource;
|
||||||
using FFXIVClientStructs.Havok.Common.Serialize.Util;
|
using FFXIVClientStructs.Havok.Common.Serialize.Util;
|
||||||
using LightlessSync.FileCache;
|
using LightlessSync.FileCache;
|
||||||
using LightlessSync.Interop.GameModel;
|
using LightlessSync.Interop.GameModel;
|
||||||
@@ -9,6 +11,7 @@ using LightlessSync.LightlessConfiguration;
|
|||||||
using LightlessSync.PlayerData.Factories;
|
using LightlessSync.PlayerData.Factories;
|
||||||
using LightlessSync.PlayerData.Handlers;
|
using LightlessSync.PlayerData.Handlers;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using OtterGui.Text.EndObjects;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
@@ -129,118 +132,97 @@ public sealed partial class XivDataAnalyzer
|
|||||||
return (output.Count != 0 && output.Values.All(v => v.Count > 0)) ? output : null;
|
return (output.Count != 0 && output.Values.All(v => v.Count > 0)) ? output : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public unsafe Dictionary<string, List<ushort>>? GetBoneIndicesFromPap(string hash, bool persistToConfig = true)
|
public static byte[]? ReadHavokBytesFromPap(string papPath)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(hash))
|
using var fs = File.Open(papPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
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);
|
using var reader = new BinaryReader(fs);
|
||||||
|
|
||||||
// PAP header (mostly from vfxeditor)
|
_ = reader.ReadInt32();
|
||||||
_ = reader.ReadInt32(); // ignore
|
_ = reader.ReadInt32();
|
||||||
_ = reader.ReadInt32(); // ignore
|
_ = reader.ReadInt16();
|
||||||
_ = reader.ReadInt16(); // num animations
|
_ = reader.ReadInt16();
|
||||||
_ = reader.ReadInt16(); // modelid
|
|
||||||
|
|
||||||
var type = reader.ReadByte(); // type
|
var type = reader.ReadByte();
|
||||||
if (type != 0)
|
if (type != 0) return null;
|
||||||
return null; // not human
|
|
||||||
|
|
||||||
_ = reader.ReadByte(); // variant
|
_ = reader.ReadByte();
|
||||||
_ = reader.ReadInt32(); // ignore
|
_ = reader.ReadInt32();
|
||||||
|
|
||||||
var havokPosition = reader.ReadInt32();
|
var havokPosition = reader.ReadInt32();
|
||||||
var footerPosition = reader.ReadInt32();
|
var footerPosition = reader.ReadInt32();
|
||||||
|
|
||||||
// sanity checks
|
|
||||||
if (havokPosition <= 0 || footerPosition <= havokPosition || footerPosition > fs.Length)
|
if (havokPosition <= 0 || footerPosition <= havokPosition || footerPosition > fs.Length)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var havokDataSizeLong = (long)footerPosition - havokPosition;
|
var sizeLong = (long)footerPosition - havokPosition;
|
||||||
if (havokDataSizeLong <= 8 || havokDataSizeLong > int.MaxValue)
|
if (sizeLong <= 8 || sizeLong > int.MaxValue)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var havokDataSize = (int)havokDataSizeLong;
|
var size = (int)sizeLong;
|
||||||
|
|
||||||
reader.BaseStream.Position = havokPosition;
|
fs.Position = havokPosition;
|
||||||
var havokData = reader.ReadBytes(havokDataSize);
|
var bytes = reader.ReadBytes(size);
|
||||||
if (havokData.Length <= 8)
|
return bytes.Length > 8 ? bytes : null;
|
||||||
return null;
|
}
|
||||||
|
|
||||||
|
public unsafe Dictionary<string, List<ushort>>? ParseHavokBytesOnFrameworkThread(
|
||||||
|
byte[] havokData,
|
||||||
|
string hash,
|
||||||
|
bool persistToConfig)
|
||||||
|
{
|
||||||
var tempSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
|
var tempSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx");
|
var tempHkxPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx");
|
||||||
IntPtr tempHavokDataPathAnsi = IntPtr.Zero;
|
IntPtr pathAnsi = IntPtr.Zero;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
File.WriteAllBytes(tempHavokDataPath, havokData);
|
File.WriteAllBytes(tempHkxPath, havokData);
|
||||||
|
|
||||||
if (!File.Exists(tempHavokDataPath))
|
pathAnsi = Marshal.StringToHGlobalAnsi(tempHkxPath);
|
||||||
{
|
|
||||||
_logger.LogTrace("Temporary havok file did not exist when attempting to load: {path}", tempHavokDataPath);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath);
|
hkSerializeUtil.LoadOptions loadOptions = default;
|
||||||
|
loadOptions.TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry();
|
||||||
var loadoptions = stackalloc hkSerializeUtil.LoadOptions[1];
|
loadOptions.ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry();
|
||||||
loadoptions->TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry();
|
loadOptions.Flags = new hkFlags<hkSerializeUtil.LoadOptionBits, int>
|
||||||
loadoptions->ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry();
|
|
||||||
loadoptions->Flags = new hkFlags<hkSerializeUtil.LoadOptionBits, int>
|
|
||||||
{
|
{
|
||||||
Storage = (int)hkSerializeUtil.LoadOptionBits.Default
|
Storage = (int)hkSerializeUtil.LoadOptionBits.Default
|
||||||
};
|
};
|
||||||
|
|
||||||
var resource = hkSerializeUtil.LoadFromFile((byte*)tempHavokDataPathAnsi, null, loadoptions);
|
hkSerializeUtil.LoadOptions* pOpts = &loadOptions;
|
||||||
|
|
||||||
|
var resource = hkSerializeUtil.LoadFromFile((byte*)pathAnsi, errorResult: null, pOpts);
|
||||||
if (resource == null)
|
if (resource == null)
|
||||||
{
|
|
||||||
_logger.LogWarning("Havok resource was null after loading from {path}", tempHavokDataPath);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
var rootLevelName = @"hkRootLevelContainer"u8;
|
var rootLevelName = @"hkRootLevelContainer"u8;
|
||||||
fixed (byte* n1 = rootLevelName)
|
fixed (byte* n1 = rootLevelName)
|
||||||
{
|
{
|
||||||
var container = (hkRootLevelContainer*)resource->GetContentsPointer(n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry());
|
var container = (hkRootLevelContainer*)resource->GetContentsPointer(
|
||||||
if (container == null)
|
n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry());
|
||||||
return null;
|
|
||||||
|
if (container == null) return null;
|
||||||
|
|
||||||
var animationName = @"hkaAnimationContainer"u8;
|
var animationName = @"hkaAnimationContainer"u8;
|
||||||
fixed (byte* n2 = animationName)
|
fixed (byte* n2 = animationName)
|
||||||
{
|
{
|
||||||
var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null);
|
var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null);
|
||||||
if (animContainer == null)
|
if (animContainer == null) return null;
|
||||||
return null;
|
|
||||||
|
|
||||||
for (int i = 0; i < animContainer->Bindings.Length; i++)
|
for (int i = 0; i < animContainer->Bindings.Length; i++)
|
||||||
{
|
{
|
||||||
var binding = animContainer->Bindings[i].ptr;
|
var binding = animContainer->Bindings[i].ptr;
|
||||||
if (binding == null)
|
if (binding == null) continue;
|
||||||
continue;
|
|
||||||
|
|
||||||
var rawSkel = binding->OriginalSkeletonName.String;
|
var rawSkel = binding->OriginalSkeletonName.String;
|
||||||
var skeletonKey = CanonicalizeSkeletonKey(rawSkel);
|
var skeletonKey = CanonicalizeSkeletonKey(rawSkel);
|
||||||
if (string.IsNullOrEmpty(skeletonKey))
|
if (string.IsNullOrEmpty(skeletonKey)) continue;
|
||||||
continue;
|
|
||||||
|
|
||||||
var boneTransform = binding->TransformTrackToBoneIndices;
|
var boneTransform = binding->TransformTrackToBoneIndices;
|
||||||
if (boneTransform.Length <= 0)
|
if (boneTransform.Length <= 0) continue;
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!tempSets.TryGetValue(skeletonKey, out var set))
|
if (!tempSets.TryGetValue(skeletonKey, out var set))
|
||||||
{
|
tempSets[skeletonKey] = set = [];
|
||||||
set = [];
|
|
||||||
tempSets[skeletonKey] = set;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++)
|
for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++)
|
||||||
{
|
{
|
||||||
@@ -252,52 +234,34 @@ public sealed partial class XivDataAnalyzer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Could not load havok file in {path}", tempHavokDataPath);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (tempHavokDataPathAnsi != IntPtr.Zero)
|
if (pathAnsi != IntPtr.Zero)
|
||||||
Marshal.FreeHGlobal(tempHavokDataPathAnsi);
|
Marshal.FreeHGlobal(pathAnsi);
|
||||||
|
|
||||||
try
|
try { if (File.Exists(tempHkxPath)) File.Delete(tempHkxPath); }
|
||||||
{
|
catch { /* ignore */ }
|
||||||
if (File.Exists(tempHavokDataPath))
|
|
||||||
File.Delete(tempHavokDataPath);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogTrace(ex, "Could not delete temporary havok file: {path}", tempHavokDataPath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tempSets.Count == 0)
|
if (tempSets.Count == 0) return null;
|
||||||
return null;
|
|
||||||
|
|
||||||
var output = new Dictionary<string, List<ushort>>(tempSets.Count, StringComparer.OrdinalIgnoreCase);
|
var output = new Dictionary<string, List<ushort>>(tempSets.Count, StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var (key, set) in tempSets)
|
foreach (var (key, set) in tempSets)
|
||||||
{
|
{
|
||||||
if (set.Count == 0) continue;
|
if (set.Count == 0) continue;
|
||||||
|
|
||||||
var list = set.ToList();
|
var list = set.ToList();
|
||||||
list.Sort();
|
list.Sort();
|
||||||
output[key] = list;
|
output[key] = list;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (output.Count == 0)
|
if (output.Count == 0) return null;
|
||||||
return null;
|
|
||||||
|
|
||||||
_configService.Current.BonesDictionary[hash] = output;
|
_configService.Current.BonesDictionary[hash] = output;
|
||||||
|
if (persistToConfig) _configService.Save();
|
||||||
if (persistToConfig)
|
|
||||||
_configService.Save();
|
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public static string CanonicalizeSkeletonKey(string? raw)
|
public static string CanonicalizeSkeletonKey(string? raw)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(raw))
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
@@ -375,41 +339,56 @@ public sealed partial class XivDataAnalyzer
|
|||||||
if (mode == AnimationValidationMode.Unsafe)
|
if (mode == AnimationValidationMode.Unsafe)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
var papBuckets = papBoneIndices.Keys
|
var papByBucket = new Dictionary<string, List<ushort>>(StringComparer.OrdinalIgnoreCase);
|
||||||
.Select(CanonicalizeSkeletonKey)
|
|
||||||
.Where(k => !string.IsNullOrEmpty(k))
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (papBuckets.Count == 0)
|
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)
|
||||||
{
|
{
|
||||||
reason = "No skeleton bucket bindings found in the PAP";
|
reason = "No skeleton bucket bindings found in the PAP";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode == AnimationValidationMode.Safe)
|
static bool AllIndicesOk(
|
||||||
|
HashSet<ushort> available,
|
||||||
|
List<ushort> indices,
|
||||||
|
bool papLikelyOneBased,
|
||||||
|
bool allowOneBasedShift,
|
||||||
|
bool allowNeighborTolerance,
|
||||||
|
out ushort missing)
|
||||||
{
|
{
|
||||||
if (papBuckets.Any(b => localBoneSets.ContainsKey(b)))
|
foreach (var idx in indices)
|
||||||
return true;
|
|
||||||
|
|
||||||
reason = $"No matching skeleton bucket between PAP [{string.Join(", ", papBuckets)}] and local [{string.Join(", ", localBoneSets.Keys.Order())}].";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var bucket in papBuckets)
|
|
||||||
{
|
|
||||||
if (!localBoneSets.TryGetValue(bucket, out var available))
|
|
||||||
{
|
{
|
||||||
reason = $"Missing skeleton bucket '{bucket}' on local actor.";
|
if (!ContainsIndexCompat(available, idx, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance))
|
||||||
return false;
|
{
|
||||||
|
missing = idx;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var indices = papBoneIndices
|
missing = 0;
|
||||||
.Where(kvp => string.Equals(CanonicalizeSkeletonKey(kvp.Key), bucket, StringComparison.OrdinalIgnoreCase))
|
return true;
|
||||||
.SelectMany(kvp => kvp.Value ?? Enumerable.Empty<ushort>())
|
}
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
|
foreach (var (bucket, indices) in papByBucket)
|
||||||
|
{
|
||||||
if (indices.Count == 0)
|
if (indices.Count == 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -423,14 +402,32 @@ public sealed partial class XivDataAnalyzer
|
|||||||
}
|
}
|
||||||
bool papLikelyOneBased = allowOneBasedShift && (min == 1) && has1 && !has0;
|
bool papLikelyOneBased = allowOneBasedShift && (min == 1) && has1 && !has0;
|
||||||
|
|
||||||
foreach (var idx in indices)
|
if (string.Equals(bucket, "__any__", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
if (!ContainsIndexCompat(available, idx, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance))
|
foreach (var (lk, ls) in localBoneSets)
|
||||||
{
|
{
|
||||||
reason = $"No compatible local skeleton for PAP '{bucket}': missing bone index {idx}.";
|
if (AllIndicesOk(ls, indices, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance, out _))
|
||||||
return false;
|
goto nextBucket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
return true;
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ public sealed class DtrEntry : IDisposable, IHostedService
|
|||||||
|
|
||||||
public async Task StopAsync(CancellationToken cancellationToken)
|
public async Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_cancellationTokenSource.Cancel();
|
await _cancellationTokenSource.CancelAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
if (_dalamudUtilService.IsOnFrameworkThread)
|
if (_dalamudUtilService.IsOnFrameworkThread)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ using Dalamud.Utility;
|
|||||||
using LightlessSync.FileCache;
|
using LightlessSync.FileCache;
|
||||||
using LightlessSync.LightlessConfiguration;
|
using LightlessSync.LightlessConfiguration;
|
||||||
using LightlessSync.LightlessConfiguration.Models;
|
using LightlessSync.LightlessConfiguration.Models;
|
||||||
using LightlessSync.Localization;
|
|
||||||
using LightlessSync.Services;
|
using LightlessSync.Services;
|
||||||
using LightlessSync.Services.Mediator;
|
using LightlessSync.Services.Mediator;
|
||||||
using LightlessSync.Services.ServerConfiguration;
|
using LightlessSync.Services.ServerConfiguration;
|
||||||
using LightlessSync.Utils;
|
using LightlessSync.Utils;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Globalization;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ public partial class IntroUi : WindowMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
private readonly LightlessConfigService _configService;
|
private readonly LightlessConfigService _configService;
|
||||||
private readonly CacheMonitor _cacheMonitor;
|
private readonly CacheMonitor _cacheMonitor;
|
||||||
private readonly Dictionary<string, string> _languages = new(StringComparer.Ordinal) { { "English", "en" }, { "Deutsch", "de" }, { "Français", "fr" } };
|
private readonly Dictionary<string, string> _languages = new(StringComparer.Ordinal) { { "English", "en" }, { "Deutsch", "de" }, { "Français", "fr" }, { "中文", "zh"} };
|
||||||
private readonly ServerConfigurationManager _serverConfigurationManager;
|
private readonly ServerConfigurationManager _serverConfigurationManager;
|
||||||
private readonly DalamudUtilService _dalamudUtilService;
|
private readonly DalamudUtilService _dalamudUtilService;
|
||||||
private readonly UiSharedService _uiShared;
|
private readonly UiSharedService _uiShared;
|
||||||
@@ -31,7 +31,6 @@ public partial class IntroUi : WindowMediatorSubscriberBase
|
|||||||
private string _secretKey = string.Empty;
|
private string _secretKey = string.Empty;
|
||||||
private string _timeoutLabel = string.Empty;
|
private string _timeoutLabel = string.Empty;
|
||||||
private Task? _timeoutTask;
|
private Task? _timeoutTask;
|
||||||
private string[]? _tosParagraphs;
|
|
||||||
private bool _useLegacyLogin = false;
|
private bool _useLegacyLogin = false;
|
||||||
|
|
||||||
public IntroUi(ILogger<IntroUi> logger, UiSharedService uiShared, LightlessConfigService configService,
|
public IntroUi(ILogger<IntroUi> logger, UiSharedService uiShared, LightlessConfigService configService,
|
||||||
@@ -50,8 +49,7 @@ public partial class IntroUi : WindowMediatorSubscriberBase
|
|||||||
WindowBuilder.For(this)
|
WindowBuilder.For(this)
|
||||||
.SetSizeConstraints(new Vector2(600, 400), new Vector2(600, 2000))
|
.SetSizeConstraints(new Vector2(600, 400), new Vector2(600, 2000))
|
||||||
.Apply();
|
.Apply();
|
||||||
|
|
||||||
GetToSLocalization();
|
|
||||||
|
|
||||||
Mediator.Subscribe<SwitchToMainUiMessage>(this, (_) => IsOpen = false);
|
Mediator.Subscribe<SwitchToMainUiMessage>(this, (_) => IsOpen = false);
|
||||||
Mediator.Subscribe<SwitchToIntroUiMessage>(this, (_) =>
|
Mediator.Subscribe<SwitchToIntroUiMessage>(this, (_) =>
|
||||||
@@ -88,7 +86,7 @@ public partial class IntroUi : WindowMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
for (int i = 60; i > 0; i--)
|
for (int i = 60; i > 0; i--)
|
||||||
{
|
{
|
||||||
_timeoutLabel = $"{Strings.ToS.ButtonWillBeAvailableIn} {i}s";
|
_timeoutLabel = $"{Resources.Resources.ToSStrings_ButtonWillBeAvailableIn} {i}s";
|
||||||
await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
|
await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -102,44 +100,46 @@ public partial class IntroUi : WindowMediatorSubscriberBase
|
|||||||
Vector2 textSize;
|
Vector2 textSize;
|
||||||
using (_uiShared.UidFont.Push())
|
using (_uiShared.UidFont.Push())
|
||||||
{
|
{
|
||||||
textSize = ImGui.CalcTextSize(Strings.ToS.LanguageLabel);
|
textSize = ImGui.CalcTextSize(Resources.Resources.ToSStrings_LanguageLabel);
|
||||||
ImGui.TextUnformatted(Strings.ToS.AgreementLabel);
|
ImGui.TextUnformatted(Resources.Resources.ToSStrings_AgreementLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui.SameLine();
|
ImGui.SameLine();
|
||||||
var languageSize = ImGui.CalcTextSize(Strings.ToS.LanguageLabel);
|
var languageSize = ImGui.CalcTextSize(Resources.Resources.ToSStrings_LanguageLabel);
|
||||||
ImGui.SetCursorPosX(ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X - languageSize.X - 80);
|
ImGui.SetCursorPosX(ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X - languageSize.X - 80);
|
||||||
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + textSize.Y / 2 - languageSize.Y / 2);
|
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + textSize.Y / 2 - languageSize.Y / 2);
|
||||||
|
|
||||||
ImGui.TextUnformatted(Strings.ToS.LanguageLabel);
|
ImGui.TextUnformatted(Resources.Resources.ToSStrings_LanguageLabel);
|
||||||
ImGui.SameLine();
|
ImGui.SameLine();
|
||||||
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + textSize.Y / 2 - (languageSize.Y + ImGui.GetStyle().FramePadding.Y) / 2);
|
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + textSize.Y / 2 - (languageSize.Y + ImGui.GetStyle().FramePadding.Y) / 2);
|
||||||
ImGui.SetNextItemWidth(80);
|
ImGui.SetNextItemWidth(80);
|
||||||
if (ImGui.Combo("", ref _currentLanguage, _languages.Keys.ToArray(), _languages.Count))
|
if (ImGui.Combo("", ref _currentLanguage, _languages.Keys.ToArray(), _languages.Count))
|
||||||
{
|
{
|
||||||
GetToSLocalization(_currentLanguage);
|
var culture = new CultureInfo(_languages.Values.ToArray()[_currentLanguage]);
|
||||||
|
CultureInfo.DefaultThreadCurrentCulture = culture;
|
||||||
|
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui.Separator();
|
ImGui.Separator();
|
||||||
ImGui.SetWindowFontScale(1.5f);
|
ImGui.SetWindowFontScale(1.5f);
|
||||||
string readThis = Strings.ToS.ReadLabel;
|
string readThis = Resources.Resources.ToSStrings_ReadLabel;
|
||||||
textSize = ImGui.CalcTextSize(readThis);
|
textSize = ImGui.CalcTextSize(readThis);
|
||||||
ImGui.SetCursorPosX(ImGui.GetWindowSize().X / 2 - textSize.X / 2);
|
ImGui.SetCursorPosX(ImGui.GetWindowSize().X / 2 - textSize.X / 2);
|
||||||
UiSharedService.ColorText(readThis, ImGuiColors.DalamudRed);
|
UiSharedService.ColorText(readThis, ImGuiColors.DalamudRed);
|
||||||
ImGui.SetWindowFontScale(1.0f);
|
ImGui.SetWindowFontScale(1.0f);
|
||||||
ImGui.Separator();
|
ImGui.Separator();
|
||||||
|
|
||||||
UiSharedService.TextWrapped(_tosParagraphs![0]);
|
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph1);
|
||||||
UiSharedService.TextWrapped(_tosParagraphs![1]);
|
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph2);
|
||||||
UiSharedService.TextWrapped(_tosParagraphs![2]);
|
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph3);
|
||||||
UiSharedService.TextWrapped(_tosParagraphs![3]);
|
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph4);
|
||||||
UiSharedService.TextWrapped(_tosParagraphs![4]);
|
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph5);
|
||||||
UiSharedService.TextWrapped(_tosParagraphs![5]);
|
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph6);
|
||||||
|
|
||||||
ImGui.Separator();
|
ImGui.Separator();
|
||||||
if (_timeoutTask?.IsCompleted ?? true)
|
if (_timeoutTask?.IsCompleted ?? true)
|
||||||
{
|
{
|
||||||
if (ImGui.Button(Strings.ToS.AgreeLabel + "##toSetup"))
|
if (ImGui.Button(Resources.Resources.ToSStrings_AgreeLabel + "##toSetup"))
|
||||||
{
|
{
|
||||||
_configService.Current.AcceptedAgreement = true;
|
_configService.Current.AcceptedAgreement = true;
|
||||||
_configService.Save();
|
_configService.Save();
|
||||||
@@ -349,16 +349,6 @@ public partial class IntroUi : WindowMediatorSubscriberBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void GetToSLocalization(int changeLanguageTo = -1)
|
|
||||||
{
|
|
||||||
if (changeLanguageTo != -1)
|
|
||||||
{
|
|
||||||
_uiShared.LoadLocalization(_languages.ElementAt(changeLanguageTo).Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
_tosParagraphs = [Strings.ToS.Paragraph1, Strings.ToS.Paragraph2, Strings.ToS.Paragraph3, Strings.ToS.Paragraph4, Strings.ToS.Paragraph5, Strings.ToS.Paragraph6];
|
|
||||||
}
|
|
||||||
|
|
||||||
[GeneratedRegex("^[A-F0-9]{64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant)]
|
[GeneratedRegex("^[A-F0-9]{64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant)]
|
||||||
private static partial Regex SecretRegex();
|
private static partial Regex SecretRegex();
|
||||||
}
|
}
|
||||||
@@ -497,7 +497,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
var cardData = new List<(GroupJoinDto Shell, string BroadcasterName, bool IsOwnBroadcast)>();
|
var cardData = new List<(GroupJoinDto Shell, string BroadcasterName, bool IsOwnBroadcast)>();
|
||||||
|
|
||||||
foreach (var shell in _nearbySyncshells)
|
foreach (var shell in _nearbySyncshells.ToArray())
|
||||||
{
|
{
|
||||||
if (shell?.Group == null || string.IsNullOrEmpty(shell.Group.GID))
|
if (shell?.Group == null || string.IsNullOrEmpty(shell.Group.GID))
|
||||||
continue;
|
continue;
|
||||||
@@ -759,29 +759,22 @@ public class LightFinderUI : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
var scale = ImGuiHelpers.GlobalScale;
|
var scale = ImGuiHelpers.GlobalScale;
|
||||||
|
|
||||||
// if not already open
|
|
||||||
if (!ImGui.IsPopupOpen("JoinSyncshellModal"))
|
|
||||||
ImGui.OpenPopup("JoinSyncshellModal");
|
|
||||||
|
|
||||||
Vector2 windowPos = ImGui.GetWindowPos();
|
|
||||||
Vector2 windowSize = ImGui.GetWindowSize();
|
Vector2 windowSize = ImGui.GetWindowSize();
|
||||||
float modalWidth = Math.Min(420f * scale, windowSize.X - 40f * scale);
|
float modalWidth = Math.Min(420f * scale, windowSize.X - 40f * scale);
|
||||||
float modalHeight = 295f * scale;
|
float modalHeight = 295f * scale;
|
||||||
ImGui.SetNextWindowPos(new Vector2(
|
Vector2 childPos = new Vector2(
|
||||||
windowPos.X + (windowSize.X - modalWidth) * 0.5f,
|
(windowSize.X - modalWidth) * 0.5f,
|
||||||
windowPos.Y + (windowSize.Y - modalHeight) * 0.5f
|
(windowSize.Y - modalHeight) * 0.5f
|
||||||
), ImGuiCond.Always);
|
);
|
||||||
ImGui.SetNextWindowSize(new Vector2(modalWidth, modalHeight));
|
ImGui.SetCursorPos(childPos);
|
||||||
ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * ImGuiHelpers.GlobalScale);
|
|
||||||
ImGui.PushStyleColor(ImGuiCol.Border, Vector4.Zero);
|
|
||||||
|
|
||||||
using ImRaii.Color modalBorder = ImRaii.PushColor(ImGuiCol.Border, UIColors.Get("LightlessPurple").WithAlpha(0.5f));
|
using var modalBorder = ImRaii.PushColor(ImGuiCol.Border, UIColors.Get("LightlessPurple").WithAlpha(0.5f));
|
||||||
using ImRaii.Style rounding = ImRaii.PushStyle(ImGuiStyleVar.WindowRounding, 8f * scale);
|
using var childBg = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetStyle().Colors[(int)ImGuiCol.WindowBg]);
|
||||||
using ImRaii.Style borderSize = ImRaii.PushStyle(ImGuiStyleVar.WindowBorderSize, 2f * scale);
|
using var rounding = ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 8f * scale);
|
||||||
using ImRaii.Style padding = ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(16f * scale, 16f * scale));
|
using var borderSize = ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, 2f * scale);
|
||||||
|
using var padding = ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(16f * scale, 16f * scale));
|
||||||
|
|
||||||
ImGuiWindowFlags flags = ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoScrollbar;
|
if (ImGui.BeginChild("JoinSyncshellOverlay", new Vector2(modalWidth, modalHeight), true, ImGuiWindowFlags.NoScrollbar))
|
||||||
if (ImGui.BeginPopupModal("JoinSyncshellModal", ref _joinModalOpen, flags))
|
|
||||||
{
|
{
|
||||||
float contentWidth = ImGui.GetContentRegionAvail().X;
|
float contentWidth = ImGui.GetContentRegionAvail().X;
|
||||||
|
|
||||||
@@ -843,7 +836,7 @@ public class LightFinderUI : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
_joinDto = null;
|
_joinDto = null;
|
||||||
_joinInfo = null;
|
_joinInfo = null;
|
||||||
ImGui.CloseCurrentPopup();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -858,20 +851,13 @@ public class LightFinderUI : WindowMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
_joinDto = null;
|
_joinDto = null;
|
||||||
_joinInfo = null;
|
_joinInfo = null;
|
||||||
ImGui.CloseCurrentPopup();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle modal close via the bool ref
|
|
||||||
if (!_joinModalOpen)
|
|
||||||
{
|
|
||||||
_joinDto = null;
|
|
||||||
_joinInfo = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.EndPopup();
|
|
||||||
}
|
}
|
||||||
|
ImGui.EndChild();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawPermissionToggleRow(string label, FontAwesomeIcon icon, bool suggested, bool current, Action<bool> apply, float contentWidth)
|
private void DrawPermissionToggleRow(string label, FontAwesomeIcon icon, bool suggested, bool current, Action<bool> apply, float contentWidth)
|
||||||
@@ -1580,11 +1566,20 @@ public class LightFinderUI : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
if (previousGid != null)
|
if (previousGid != null)
|
||||||
{
|
{
|
||||||
var newIndex = _nearbySyncshells.FindIndex(s => string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal));
|
try
|
||||||
if (newIndex >= 0)
|
|
||||||
{
|
{
|
||||||
_selectedNearbyIndex = newIndex;
|
var nearbySyncshellsSnapshot = _nearbySyncshells.ToArray();
|
||||||
return;
|
var newIndex = Array.FindIndex(nearbySyncshellsSnapshot,
|
||||||
|
s => string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal));
|
||||||
|
if (newIndex >= 0)
|
||||||
|
{
|
||||||
|
_selectedNearbyIndex = newIndex;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
ClearSelection();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1626,9 +1621,18 @@ public class LightFinderUI : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
private string? GetSelectedGid()
|
private string? GetSelectedGid()
|
||||||
{
|
{
|
||||||
if (_selectedNearbyIndex < 0 || _selectedNearbyIndex >= _nearbySyncshells.Count)
|
try
|
||||||
|
{
|
||||||
|
var index = _selectedNearbyIndex;
|
||||||
|
var list = _nearbySyncshells.ToArray();
|
||||||
|
if (index < 0 || index >= list.Length)
|
||||||
|
return null;
|
||||||
|
return list[index].Group.GID;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
return null;
|
return null;
|
||||||
return _nearbySyncshells[_selectedNearbyIndex].Group.GID;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase
|
|||||||
private readonly Dictionary<string, float> _notificationTargetYOffsets = [];
|
private readonly Dictionary<string, float> _notificationTargetYOffsets = [];
|
||||||
private readonly Dictionary<string, Vector4> _notificationBackgrounds = [];
|
private readonly Dictionary<string, Vector4> _notificationBackgrounds = [];
|
||||||
|
|
||||||
public LightlessNotificationUi(ILogger<LightlessNotificationUi> logger, LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessConfigService configService)
|
public LightlessNotificationUi(ILogger<LightlessNotificationUi> logger, LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessConfigService configService)
|
||||||
: base(logger, mediator, "Lightless Notifications##LightlessNotifications", performanceCollector)
|
: base(logger, mediator, "Lightless Notifications##LightlessNotifications", performanceCollector)
|
||||||
{
|
{
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
Flags = ImGuiWindowFlags.NoDecoration |
|
Flags = ImGuiWindowFlags.NoDecoration |
|
||||||
ImGuiWindowFlags.NoMove |
|
ImGuiWindowFlags.NoMove |
|
||||||
ImGuiWindowFlags.NoResize |
|
ImGuiWindowFlags.NoResize |
|
||||||
ImGuiWindowFlags.NoSavedSettings |
|
ImGuiWindowFlags.NoSavedSettings |
|
||||||
ImGuiWindowFlags.NoFocusOnAppearing |
|
ImGuiWindowFlags.NoFocusOnAppearing |
|
||||||
ImGuiWindowFlags.NoNav |
|
ImGuiWindowFlags.NoNav |
|
||||||
@@ -47,6 +47,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase
|
|||||||
ImGuiWindowFlags.NoCollapse |
|
ImGuiWindowFlags.NoCollapse |
|
||||||
ImGuiWindowFlags.NoTitleBar |
|
ImGuiWindowFlags.NoTitleBar |
|
||||||
ImGuiWindowFlags.NoScrollbar |
|
ImGuiWindowFlags.NoScrollbar |
|
||||||
|
ImGuiWindowFlags.NoFocusOnAppearing |
|
||||||
ImGuiWindowFlags.AlwaysAutoResize;
|
ImGuiWindowFlags.AlwaysAutoResize;
|
||||||
|
|
||||||
PositionCondition = ImGuiCond.Always;
|
PositionCondition = ImGuiCond.Always;
|
||||||
|
|||||||
@@ -1688,6 +1688,46 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
ImGui.EndTable();
|
ImGui.EndTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ImGui.Separator();
|
||||||
|
ImGui.TextUnformatted("Owned / Minion-Mount");
|
||||||
|
|
||||||
|
if (ImGui.BeginTable("##pairDebugOwnedMinion", 2, ImGuiTableFlags.SizingStretchProp))
|
||||||
|
{
|
||||||
|
DrawPairPropertyRow("Owned Temp Collection", debugInfo.OwnedPenumbraCollectionId == Guid.Empty
|
||||||
|
? "n/a"
|
||||||
|
: debugInfo.OwnedPenumbraCollectionId.ToString());
|
||||||
|
|
||||||
|
DrawPairPropertyRow("Needs Collection Rebuild", FormatBool(debugInfo.NeedsCollectionRebuild));
|
||||||
|
|
||||||
|
DrawPairPropertyRow("Minion Ptr", string.IsNullOrEmpty(debugInfo.MinionAddressHex)
|
||||||
|
? "n/a"
|
||||||
|
: debugInfo.MinionAddressHex);
|
||||||
|
|
||||||
|
DrawPairPropertyRow("Minion ObjectIndex", debugInfo.MinionObjectIndex.HasValue
|
||||||
|
? debugInfo.MinionObjectIndex.Value.ToString(CultureInfo.InvariantCulture)
|
||||||
|
: "n/a");
|
||||||
|
|
||||||
|
DrawPairPropertyRow("Minion Resolved At", FormatTimestamp(debugInfo.MinionResolvedAtUtc));
|
||||||
|
|
||||||
|
DrawPairPropertyRow("Minion Resolve Stage", string.IsNullOrEmpty(debugInfo.MinionResolveStage)
|
||||||
|
? "n/a"
|
||||||
|
: debugInfo.MinionResolveStage);
|
||||||
|
|
||||||
|
DrawPairPropertyRow("Minion Resolve Failure", string.IsNullOrEmpty(debugInfo.MinionResolveFailureReason)
|
||||||
|
? "n/a"
|
||||||
|
: debugInfo.MinionResolveFailureReason);
|
||||||
|
|
||||||
|
DrawPairPropertyRow("Minion Pending Retry", FormatBool(debugInfo.MinionPendingRetry));
|
||||||
|
|
||||||
|
var retryChanges = debugInfo.MinionPendingRetryChanges is { Count: > 0 }
|
||||||
|
? string.Join(", ", debugInfo.MinionPendingRetryChanges)
|
||||||
|
: "n/a";
|
||||||
|
DrawPairPropertyRow("Minion Pending Changes", retryChanges);
|
||||||
|
|
||||||
|
DrawPairPropertyRow("Minion Has Appearance Data", FormatBool(debugInfo.MinionHasAppearanceData));
|
||||||
|
|
||||||
|
ImGui.EndTable();
|
||||||
|
}
|
||||||
ImGui.Separator();
|
ImGui.Separator();
|
||||||
ImGui.TextUnformatted("Syncshell Memberships");
|
ImGui.TextUnformatted("Syncshell Memberships");
|
||||||
if (snapshot.PairsWithGroups.TryGetValue(pair, out var groups) && groups.Count > 0)
|
if (snapshot.PairsWithGroups.TryGetValue(pair, out var groups) && groups.Count > 0)
|
||||||
@@ -3249,16 +3289,16 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
var labels = new[]
|
var labels = new[]
|
||||||
{
|
{
|
||||||
"Unsafe",
|
"Unsafe (Off)",
|
||||||
"Safe (Race)",
|
"Safe (Race Check)",
|
||||||
"Safest (Race + Bones)",
|
"Safest (Race + Bones Check)",
|
||||||
};
|
};
|
||||||
|
|
||||||
var tooltips = new[]
|
var tooltips = new[]
|
||||||
{
|
{
|
||||||
"No validation. Fastest, but may allow incompatible animations (riskier).",
|
"No validation. Fastest, but may allow incompatible animations.",
|
||||||
"Validates skeleton race + modded skeleton check (recommended).",
|
"Validates skeleton race + modded skeleton check. Will be safer to use but will block some animations",
|
||||||
"Requires matching skeleton race + bone compatibility (strictest).",
|
"Requires matching skeleton race + bone compatibility. Will block alot, not recommended.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -4812,7 +4852,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
ImGui.TextColored(UIColors.Get("LightlessBlue"),
|
ImGui.TextColored(UIColors.Get("LightlessBlue"),
|
||||||
_apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture));
|
_apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture));
|
||||||
ImGui.SameLine();
|
ImGui.SameLine();
|
||||||
ImGui.TextUnformatted("Users Online");
|
ImGui.TextUnformatted(Resources.Resources.Users_Online);
|
||||||
ImGui.SameLine();
|
ImGui.SameLine();
|
||||||
ImGui.TextUnformatted(")");
|
ImGui.TextUnformatted(")");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ using LightlessSync.Interop.Ipc;
|
|||||||
using LightlessSync.Interop.Ipc.Framework;
|
using LightlessSync.Interop.Ipc.Framework;
|
||||||
using LightlessSync.LightlessConfiguration;
|
using LightlessSync.LightlessConfiguration;
|
||||||
using LightlessSync.LightlessConfiguration.Models;
|
using LightlessSync.LightlessConfiguration.Models;
|
||||||
using LightlessSync.Localization;
|
|
||||||
using LightlessSync.PlayerData.Pairs;
|
using LightlessSync.PlayerData.Pairs;
|
||||||
using LightlessSync.Services;
|
using LightlessSync.Services;
|
||||||
using LightlessSync.Services.Mediator;
|
using LightlessSync.Services.Mediator;
|
||||||
@@ -1468,12 +1467,6 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void LoadLocalization(string languageCode)
|
|
||||||
{
|
|
||||||
_localization.SetupWithLangCode(languageCode);
|
|
||||||
Strings.ToS = new Strings.ToSStrings();
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static void DistanceSeparator()
|
internal static void DistanceSeparator()
|
||||||
{
|
{
|
||||||
ImGuiHelpers.ScaledDummy(5);
|
ImGuiHelpers.ScaledDummy(5);
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ using LightlessSync.UI.Services;
|
|||||||
using LightlessSync.UI.Style;
|
using LightlessSync.UI.Style;
|
||||||
using LightlessSync.Utils;
|
using LightlessSync.Utils;
|
||||||
using Dalamud.Interface.Textures.TextureWraps;
|
using Dalamud.Interface.Textures.TextureWraps;
|
||||||
using OtterGui.Text;
|
|
||||||
using LightlessSync.WebAPI;
|
using LightlessSync.WebAPI;
|
||||||
using LightlessSync.WebAPI.SignalR.Utils;
|
using LightlessSync.WebAPI.SignalR.Utils;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -429,150 +428,182 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var itemHeight = ImGui.GetTextLineHeightWithSpacing();
|
var messageCount = channel.Messages.Count;
|
||||||
using var clipper = ImUtf8.ListClipper(channel.Messages.Count, itemHeight);
|
var contentMaxX = ImGui.GetWindowContentRegionMax().X;
|
||||||
while (clipper.Step())
|
var cursorStartX = ImGui.GetCursorPosX();
|
||||||
|
var lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing();
|
||||||
|
var prefix = new float[messageCount + 1];
|
||||||
|
var totalHeight = 0f;
|
||||||
|
|
||||||
|
for (var i = 0; i < messageCount; i++)
|
||||||
{
|
{
|
||||||
for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++)
|
var messageHeight = MeasureMessageHeight(channel, channel.Messages[i], showTimestamps, cursorStartX, contentMaxX, itemSpacing, ref pairSnapshot);
|
||||||
|
if (messageHeight <= 0f)
|
||||||
{
|
{
|
||||||
var message = channel.Messages[i];
|
messageHeight = lineHeightWithSpacing;
|
||||||
ImGui.PushID(i);
|
}
|
||||||
|
|
||||||
if (message.IsSystem)
|
totalHeight += messageHeight;
|
||||||
|
prefix[i + 1] = totalHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scrollY = ImGui.GetScrollY();
|
||||||
|
var windowHeight = ImGui.GetWindowHeight();
|
||||||
|
var startIndex = Math.Max(0, UpperBound(prefix, scrollY) - 1);
|
||||||
|
var endIndex = Math.Min(messageCount, LowerBound(prefix, scrollY + windowHeight));
|
||||||
|
startIndex = Math.Max(0, startIndex - 2);
|
||||||
|
endIndex = Math.Min(messageCount, endIndex + 2);
|
||||||
|
|
||||||
|
if (startIndex > 0)
|
||||||
|
{
|
||||||
|
ImGui.Dummy(new Vector2(1f, prefix[startIndex]));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = startIndex; i < endIndex; i++)
|
||||||
|
{
|
||||||
|
var message = channel.Messages[i];
|
||||||
|
ImGui.PushID(i);
|
||||||
|
|
||||||
|
if (message.IsSystem)
|
||||||
|
{
|
||||||
|
DrawSystemEntry(message);
|
||||||
|
ImGui.PopID();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.Payload is not { } payload)
|
||||||
|
{
|
||||||
|
ImGui.PopID();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var timestampText = string.Empty;
|
||||||
|
if (showTimestamps)
|
||||||
|
{
|
||||||
|
timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] ";
|
||||||
|
}
|
||||||
|
var color = message.FromSelf ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudWhite;
|
||||||
|
var showRoleIcons = false;
|
||||||
|
var isOwner = false;
|
||||||
|
var isModerator = false;
|
||||||
|
var isPinned = false;
|
||||||
|
|
||||||
|
if (channel.Type == ChatChannelType.Group
|
||||||
|
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
|
||||||
|
&& payload.Sender.User is not null)
|
||||||
|
{
|
||||||
|
pairSnapshot ??= _pairUiService.GetSnapshot();
|
||||||
|
var groupId = channel.Descriptor.CustomKey;
|
||||||
|
if (!string.IsNullOrWhiteSpace(groupId)
|
||||||
|
&& pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo))
|
||||||
{
|
{
|
||||||
DrawSystemEntry(message);
|
var senderUid = payload.Sender.User.UID;
|
||||||
ImGui.PopID();
|
isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal);
|
||||||
continue;
|
if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info))
|
||||||
|
{
|
||||||
|
isModerator = info.IsModerator();
|
||||||
|
isPinned = info.IsPinned();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.Payload is not { } payload)
|
showRoleIcons = isOwner || isModerator || isPinned;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.BeginGroup();
|
||||||
|
ImGui.PushStyleColor(ImGuiCol.Text, color);
|
||||||
|
if (showRoleIcons)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(timestampText))
|
||||||
{
|
{
|
||||||
ImGui.PopID();
|
ImGui.TextUnformatted(timestampText);
|
||||||
continue;
|
ImGui.SameLine(0f, 0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
var timestampText = string.Empty;
|
var hasIcon = false;
|
||||||
if (showTimestamps)
|
if (isModerator)
|
||||||
{
|
{
|
||||||
timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] ";
|
_uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple"));
|
||||||
}
|
UiSharedService.AttachToolTip("Moderator");
|
||||||
var color = message.FromSelf ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudWhite;
|
hasIcon = true;
|
||||||
var showRoleIcons = false;
|
|
||||||
var isOwner = false;
|
|
||||||
var isModerator = false;
|
|
||||||
var isPinned = false;
|
|
||||||
|
|
||||||
if (channel.Type == ChatChannelType.Group
|
|
||||||
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
|
|
||||||
&& payload.Sender.User is not null)
|
|
||||||
{
|
|
||||||
pairSnapshot ??= _pairUiService.GetSnapshot();
|
|
||||||
var groupId = channel.Descriptor.CustomKey;
|
|
||||||
if (!string.IsNullOrWhiteSpace(groupId)
|
|
||||||
&& pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo))
|
|
||||||
{
|
|
||||||
var senderUid = payload.Sender.User.UID;
|
|
||||||
isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal);
|
|
||||||
if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info))
|
|
||||||
{
|
|
||||||
isModerator = info.IsModerator();
|
|
||||||
isPinned = info.IsPinned();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showRoleIcons = isOwner || isModerator || isPinned;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui.BeginGroup();
|
if (isOwner)
|
||||||
ImGui.PushStyleColor(ImGuiCol.Text, color);
|
|
||||||
if (showRoleIcons)
|
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(timestampText))
|
|
||||||
{
|
|
||||||
ImGui.TextUnformatted(timestampText);
|
|
||||||
ImGui.SameLine(0f, 0f);
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasIcon = false;
|
|
||||||
if (isModerator)
|
|
||||||
{
|
|
||||||
_uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple"));
|
|
||||||
UiSharedService.AttachToolTip("Moderator");
|
|
||||||
hasIcon = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOwner)
|
|
||||||
{
|
|
||||||
if (hasIcon)
|
|
||||||
{
|
|
||||||
ImGui.SameLine(0f, itemSpacing);
|
|
||||||
}
|
|
||||||
|
|
||||||
_uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow"));
|
|
||||||
UiSharedService.AttachToolTip("Owner");
|
|
||||||
hasIcon = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPinned)
|
|
||||||
{
|
|
||||||
if (hasIcon)
|
|
||||||
{
|
|
||||||
ImGui.SameLine(0f, itemSpacing);
|
|
||||||
}
|
|
||||||
|
|
||||||
_uiSharedService.IconText(FontAwesomeIcon.Thumbtack, UIColors.Get("LightlessBlue"));
|
|
||||||
UiSharedService.AttachToolTip("Pinned");
|
|
||||||
hasIcon = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasIcon)
|
if (hasIcon)
|
||||||
{
|
{
|
||||||
ImGui.SameLine(0f, itemSpacing);
|
ImGui.SameLine(0f, itemSpacing);
|
||||||
}
|
}
|
||||||
|
|
||||||
var messageStartX = ImGui.GetCursorPosX();
|
_uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow"));
|
||||||
DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX);
|
UiSharedService.AttachToolTip("Owner");
|
||||||
|
hasIcon = true;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
var messageStartX = ImGui.GetCursorPosX();
|
|
||||||
DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX);
|
|
||||||
}
|
|
||||||
ImGui.PopStyleColor();
|
|
||||||
ImGui.EndGroup();
|
|
||||||
|
|
||||||
ImGui.SetNextWindowSizeConstraints(
|
if (isPinned)
|
||||||
new Vector2(190f * ImGuiHelpers.GlobalScale, 0f),
|
|
||||||
new Vector2(float.MaxValue, float.MaxValue));
|
|
||||||
if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}"))
|
|
||||||
{
|
{
|
||||||
var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime();
|
if (hasIcon)
|
||||||
var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture);
|
|
||||||
ImGui.TextDisabled(contextTimestampText);
|
|
||||||
if (channel.Type == ChatChannelType.Group
|
|
||||||
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
|
|
||||||
&& payload.Sender.User is not null)
|
|
||||||
{
|
{
|
||||||
var aliasOrUid = payload.Sender.User.AliasOrUID;
|
ImGui.SameLine(0f, itemSpacing);
|
||||||
if (!string.IsNullOrWhiteSpace(aliasOrUid)
|
|
||||||
&& !string.Equals(message.DisplayName, aliasOrUid, StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
ImGui.TextDisabled(aliasOrUid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGui.Separator();
|
|
||||||
|
|
||||||
var actionIndex = 0;
|
|
||||||
foreach (var action in GetContextMenuActions(channel, message))
|
|
||||||
{
|
|
||||||
DrawContextMenuAction(action, actionIndex++);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui.EndPopup();
|
_uiSharedService.IconText(FontAwesomeIcon.Thumbtack, UIColors.Get("LightlessBlue"));
|
||||||
|
UiSharedService.AttachToolTip("Pinned");
|
||||||
|
hasIcon = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui.PopID();
|
if (hasIcon)
|
||||||
|
{
|
||||||
|
ImGui.SameLine(0f, itemSpacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
var messageStartX = ImGui.GetCursorPosX();
|
||||||
|
DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var messageStartX = ImGui.GetCursorPosX();
|
||||||
|
DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX);
|
||||||
|
}
|
||||||
|
ImGui.PopStyleColor();
|
||||||
|
ImGui.EndGroup();
|
||||||
|
|
||||||
|
ImGui.SetNextWindowSizeConstraints(
|
||||||
|
new Vector2(190f * ImGuiHelpers.GlobalScale, 0f),
|
||||||
|
new Vector2(float.MaxValue, float.MaxValue));
|
||||||
|
if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}"))
|
||||||
|
{
|
||||||
|
var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime();
|
||||||
|
var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture);
|
||||||
|
ImGui.TextDisabled(contextTimestampText);
|
||||||
|
if (channel.Type == ChatChannelType.Group
|
||||||
|
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
|
||||||
|
&& payload.Sender.User is not null)
|
||||||
|
{
|
||||||
|
var aliasOrUid = payload.Sender.User.AliasOrUID;
|
||||||
|
if (!string.IsNullOrWhiteSpace(aliasOrUid)
|
||||||
|
&& !string.Equals(message.DisplayName, aliasOrUid, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
ImGui.TextDisabled(aliasOrUid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui.Separator();
|
||||||
|
|
||||||
|
var actionIndex = 0;
|
||||||
|
foreach (var action in GetContextMenuActions(channel, message))
|
||||||
|
{
|
||||||
|
DrawContextMenuAction(action, actionIndex++);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.EndPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.PopID();
|
||||||
|
}
|
||||||
|
|
||||||
|
var remainingHeight = totalHeight - prefix[endIndex];
|
||||||
|
if (remainingHeight > 0f)
|
||||||
|
{
|
||||||
|
ImGui.Dummy(new Vector2(1f, remainingHeight));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,7 +739,20 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
var clicked = false;
|
var clicked = false;
|
||||||
if (texture is not null)
|
if (texture is not null)
|
||||||
{
|
{
|
||||||
clicked = ImGui.ImageButton(texture.Handle, new Vector2(emoteSize));
|
var buttonSize = new Vector2(itemWidth, itemHeight);
|
||||||
|
clicked = ImGui.InvisibleButton("##emote_button", buttonSize);
|
||||||
|
var drawList = ImGui.GetWindowDrawList();
|
||||||
|
var itemMin = ImGui.GetItemRectMin();
|
||||||
|
var itemMax = ImGui.GetItemRectMax();
|
||||||
|
var bgColor = ImGui.IsItemActive()
|
||||||
|
? ImGui.GetColorU32(ImGuiCol.ButtonActive)
|
||||||
|
: ImGui.IsItemHovered()
|
||||||
|
? ImGui.GetColorU32(ImGuiCol.ButtonHovered)
|
||||||
|
: ImGui.GetColorU32(ImGuiCol.Button);
|
||||||
|
drawList.AddRectFilled(itemMin, itemMax, bgColor, style.FrameRounding);
|
||||||
|
var imageMin = itemMin + style.FramePadding;
|
||||||
|
var imageMax = imageMin + new Vector2(emoteSize);
|
||||||
|
drawList.AddImage(texture.Handle, imageMin, imageMax);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -878,7 +922,232 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
private static bool IsEmoteChar(char value)
|
private static bool IsEmoteChar(char value)
|
||||||
{
|
{
|
||||||
return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '!';
|
return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '!' || value == '(' || value == ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
private float MeasureMessageHeight(
|
||||||
|
ChatChannelSnapshot channel,
|
||||||
|
ChatMessageEntry message,
|
||||||
|
bool showTimestamps,
|
||||||
|
float cursorStartX,
|
||||||
|
float contentMaxX,
|
||||||
|
float itemSpacing,
|
||||||
|
ref PairUiSnapshot? pairSnapshot)
|
||||||
|
{
|
||||||
|
if (message.IsSystem)
|
||||||
|
{
|
||||||
|
return MeasureSystemEntryHeight(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.Payload is not { } payload)
|
||||||
|
{
|
||||||
|
return 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
var timestampText = string.Empty;
|
||||||
|
if (showTimestamps)
|
||||||
|
{
|
||||||
|
timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] ";
|
||||||
|
}
|
||||||
|
|
||||||
|
var showRoleIcons = false;
|
||||||
|
var isOwner = false;
|
||||||
|
var isModerator = false;
|
||||||
|
var isPinned = false;
|
||||||
|
|
||||||
|
if (channel.Type == ChatChannelType.Group
|
||||||
|
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
|
||||||
|
&& payload.Sender.User is not null)
|
||||||
|
{
|
||||||
|
pairSnapshot ??= _pairUiService.GetSnapshot();
|
||||||
|
var groupId = channel.Descriptor.CustomKey;
|
||||||
|
if (!string.IsNullOrWhiteSpace(groupId)
|
||||||
|
&& pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo))
|
||||||
|
{
|
||||||
|
var senderUid = payload.Sender.User.UID;
|
||||||
|
isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal);
|
||||||
|
if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info))
|
||||||
|
{
|
||||||
|
isModerator = info.IsModerator();
|
||||||
|
isPinned = info.IsPinned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showRoleIcons = isOwner || isModerator || isPinned;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lineStartX = cursorStartX;
|
||||||
|
string prefix;
|
||||||
|
if (showRoleIcons)
|
||||||
|
{
|
||||||
|
lineStartX += MeasureRolePrefixWidth(timestampText, isOwner, isModerator, isPinned, itemSpacing);
|
||||||
|
prefix = $"{message.DisplayName}: ";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
prefix = $"{timestampText}{message.DisplayName}: ";
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = MeasureChatMessageLines(prefix, payload.Message, lineStartX, contentMaxX);
|
||||||
|
return Math.Max(1, lines) * ImGui.GetTextLineHeightWithSpacing();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int MeasureChatMessageLines(string prefix, string message, float lineStartX, float contentMaxX)
|
||||||
|
{
|
||||||
|
var segments = BuildChatSegments(prefix, message);
|
||||||
|
if (segments.Count == 0)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var emoteWidth = ImGui.GetTextLineHeight();
|
||||||
|
var availableWidth = Math.Max(1f, contentMaxX - lineStartX);
|
||||||
|
var remainingWidth = availableWidth;
|
||||||
|
var firstOnLine = true;
|
||||||
|
var lines = 1;
|
||||||
|
|
||||||
|
foreach (var segment in segments)
|
||||||
|
{
|
||||||
|
if (segment.IsLineBreak)
|
||||||
|
{
|
||||||
|
lines++;
|
||||||
|
firstOnLine = true;
|
||||||
|
remainingWidth = availableWidth;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segment.IsWhitespace && firstOnLine)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var segmentWidth = segment.IsEmote ? emoteWidth : ImGui.CalcTextSize(segment.Text).X;
|
||||||
|
if (!firstOnLine)
|
||||||
|
{
|
||||||
|
if (segmentWidth > remainingWidth)
|
||||||
|
{
|
||||||
|
lines++;
|
||||||
|
firstOnLine = true;
|
||||||
|
remainingWidth = availableWidth;
|
||||||
|
if (segment.IsWhitespace)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingWidth -= segmentWidth;
|
||||||
|
firstOnLine = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private float MeasureRolePrefixWidth(string timestampText, bool isOwner, bool isModerator, bool isPinned, float itemSpacing)
|
||||||
|
{
|
||||||
|
var width = 0f;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(timestampText))
|
||||||
|
{
|
||||||
|
width += ImGui.CalcTextSize(timestampText).X;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasIcon = false;
|
||||||
|
if (isModerator)
|
||||||
|
{
|
||||||
|
width += MeasureIconWidth(FontAwesomeIcon.UserShield);
|
||||||
|
hasIcon = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOwner)
|
||||||
|
{
|
||||||
|
if (hasIcon)
|
||||||
|
{
|
||||||
|
width += itemSpacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
width += MeasureIconWidth(FontAwesomeIcon.Crown);
|
||||||
|
hasIcon = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPinned)
|
||||||
|
{
|
||||||
|
if (hasIcon)
|
||||||
|
{
|
||||||
|
width += itemSpacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
width += MeasureIconWidth(FontAwesomeIcon.Thumbtack);
|
||||||
|
hasIcon = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasIcon)
|
||||||
|
{
|
||||||
|
width += itemSpacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
private float MeasureIconWidth(FontAwesomeIcon icon)
|
||||||
|
{
|
||||||
|
using var font = _uiSharedService.IconFont.Push();
|
||||||
|
return ImGui.CalcTextSize(icon.ToIconString()).X;
|
||||||
|
}
|
||||||
|
|
||||||
|
private float MeasureSystemEntryHeight(ChatMessageEntry entry)
|
||||||
|
{
|
||||||
|
_ = entry;
|
||||||
|
var spacing = ImGui.GetStyle().ItemSpacing.Y;
|
||||||
|
var lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing();
|
||||||
|
var separatorHeight = Math.Max(1f, ImGuiHelpers.GlobalScale);
|
||||||
|
|
||||||
|
var height = spacing;
|
||||||
|
height += lineHeightWithSpacing;
|
||||||
|
height += spacing * 0.35f;
|
||||||
|
height += separatorHeight;
|
||||||
|
height += spacing;
|
||||||
|
return height;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int LowerBound(float[] values, float target)
|
||||||
|
{
|
||||||
|
var low = 0;
|
||||||
|
var high = values.Length;
|
||||||
|
while (low < high)
|
||||||
|
{
|
||||||
|
var mid = (low + high) / 2;
|
||||||
|
if (values[mid] < target)
|
||||||
|
{
|
||||||
|
low = mid + 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
high = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return low;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int UpperBound(float[] values, float target)
|
||||||
|
{
|
||||||
|
var low = 0;
|
||||||
|
var high = values.Length;
|
||||||
|
while (low < high)
|
||||||
|
{
|
||||||
|
var mid = (low + high) / 2;
|
||||||
|
if (values[mid] <= target)
|
||||||
|
{
|
||||||
|
low = mid + 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
high = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return low;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawEmoteTooltip(string name, IDalamudTextureWrap? texture)
|
private void DrawEmoteTooltip(string name, IDalamudTextureWrap? texture)
|
||||||
@@ -2092,6 +2361,17 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
ImGui.SetTooltip("When enabled, your notes replace user names in syncshell chat.");
|
ImGui.SetTooltip("When enabled, your notes replace user names in syncshell chat.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var enableAnimatedEmotes = chatConfig.EnableAnimatedEmotes;
|
||||||
|
if (ImGui.Checkbox("Enable animated emotes", ref enableAnimatedEmotes))
|
||||||
|
{
|
||||||
|
chatConfig.EnableAnimatedEmotes = enableAnimatedEmotes;
|
||||||
|
_chatConfigService.Save();
|
||||||
|
}
|
||||||
|
if (ImGui.IsItemHovered())
|
||||||
|
{
|
||||||
|
ImGui.SetTooltip("When disabled, emotes render as static images.");
|
||||||
|
}
|
||||||
|
|
||||||
ImGui.Separator();
|
ImGui.Separator();
|
||||||
ImGui.TextUnformatted("Chat Visibility");
|
ImGui.TextUnformatted("Chat Visibility");
|
||||||
|
|
||||||
|
|||||||
41
LightlessSync/Utils/MemoryProcessProbe.cs
Normal file
41
LightlessSync/Utils/MemoryProcessProbe.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace LightlessSync.Utils;
|
||||||
|
|
||||||
|
internal static class MemoryProcessProbe
|
||||||
|
{
|
||||||
|
[DllImport("kernel32.dll")]
|
||||||
|
private static extern nint GetCurrentProcess();
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern bool ReadProcessMemory(
|
||||||
|
nint hProcess,
|
||||||
|
nint lpBaseAddress,
|
||||||
|
byte[] lpBuffer,
|
||||||
|
int dwSize,
|
||||||
|
out nint lpNumberOfBytesRead);
|
||||||
|
|
||||||
|
private static readonly nint _proc = GetCurrentProcess();
|
||||||
|
|
||||||
|
public static bool TryReadIntPtr(nint address, out nint value)
|
||||||
|
{
|
||||||
|
value = nint.Zero;
|
||||||
|
|
||||||
|
if (address == nint.Zero)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if ((ulong)address < 0x10000UL)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var buf = new byte[IntPtr.Size];
|
||||||
|
if (!ReadProcessMemory(_proc, address, buf, buf.Length, out var read) || read != (nint)buf.Length)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
value = IntPtr.Size == 8
|
||||||
|
? (nint)BitConverter.ToInt64(buf, 0)
|
||||||
|
: (nint)BitConverter.ToInt32(buf, 0);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,6 @@ using LightlessSync.API.Data;
|
|||||||
using LightlessSync.API.Data.Enum;
|
using LightlessSync.API.Data.Enum;
|
||||||
using LightlessSync.PlayerData.Pairs;
|
using LightlessSync.PlayerData.Pairs;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace LightlessSync.Utils;
|
namespace LightlessSync.Utils;
|
||||||
@@ -56,164 +54,168 @@ public static class VariousExtensions
|
|||||||
return new CancellationTokenSource();
|
return new CancellationTokenSource();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Dictionary<ObjectKind, HashSet<PlayerChanges>> CheckUpdatedData(this CharacterData newData, Guid applicationBase,
|
public static Dictionary<ObjectKind, HashSet<PlayerChanges>> CheckUpdatedData(
|
||||||
CharacterData? oldData, ILogger logger, IPairPerformanceSubject cachedPlayer, bool forceApplyCustomization, bool forceApplyMods,
|
this CharacterData newData,
|
||||||
|
Guid applicationBase,
|
||||||
|
CharacterData? oldData,
|
||||||
|
ILogger logger,
|
||||||
|
IPairPerformanceSubject cachedPlayer,
|
||||||
|
bool forceApplyCustomization,
|
||||||
|
bool forceApplyMods,
|
||||||
bool suppressForcedRedrawOnForcedModApply = false)
|
bool suppressForcedRedrawOnForcedModApply = false)
|
||||||
{
|
{
|
||||||
oldData ??= new();
|
oldData ??= new();
|
||||||
|
|
||||||
|
static bool HasFiles(List<FileReplacementData>? list) => list is { Count: > 0 };
|
||||||
|
static bool HasText(string? s) => !string.IsNullOrEmpty(s);
|
||||||
|
static string Norm(string? s) => s ?? string.Empty;
|
||||||
|
|
||||||
|
var forceRedrawOnForcedApply = forceApplyMods && !suppressForcedRedrawOnForcedModApply;
|
||||||
|
|
||||||
var charaDataToUpdate = new Dictionary<ObjectKind, HashSet<PlayerChanges>>();
|
var charaDataToUpdate = new Dictionary<ObjectKind, HashSet<PlayerChanges>>();
|
||||||
|
|
||||||
foreach (ObjectKind objectKind in Enum.GetValues<ObjectKind>())
|
foreach (ObjectKind objectKind in Enum.GetValues<ObjectKind>())
|
||||||
{
|
{
|
||||||
charaDataToUpdate[objectKind] = [];
|
var set = new HashSet<PlayerChanges>();
|
||||||
oldData.FileReplacements.TryGetValue(objectKind, out var existingFileReplacements);
|
|
||||||
newData.FileReplacements.TryGetValue(objectKind, out var newFileReplacements);
|
|
||||||
oldData.GlamourerData.TryGetValue(objectKind, out var existingGlamourerData);
|
|
||||||
newData.GlamourerData.TryGetValue(objectKind, out var newGlamourerData);
|
|
||||||
|
|
||||||
bool hasNewButNotOldFileReplacements = newFileReplacements != null && existingFileReplacements == null;
|
oldData.FileReplacements.TryGetValue(objectKind, out var oldFileRepls);
|
||||||
bool hasOldButNotNewFileReplacements = existingFileReplacements != null && newFileReplacements == null;
|
newData.FileReplacements.TryGetValue(objectKind, out var newFileRepls);
|
||||||
|
|
||||||
bool hasNewButNotOldGlamourerData = newGlamourerData != null && existingGlamourerData == null;
|
oldData.GlamourerData.TryGetValue(objectKind, out var oldGlam);
|
||||||
bool hasOldButNotNewGlamourerData = existingGlamourerData != null && newGlamourerData == null;
|
newData.GlamourerData.TryGetValue(objectKind, out var newGlam);
|
||||||
|
|
||||||
bool hasNewAndOldFileReplacements = newFileReplacements != null && existingFileReplacements != null;
|
var oldHasFiles = HasFiles(oldFileRepls);
|
||||||
bool hasNewAndOldGlamourerData = newGlamourerData != null && existingGlamourerData != null;
|
var newHasFiles = HasFiles(newFileRepls);
|
||||||
var forceRedrawOnForcedApply = forceApplyMods && !suppressForcedRedrawOnForcedModApply;
|
|
||||||
|
|
||||||
if (hasNewButNotOldFileReplacements || hasOldButNotNewFileReplacements || hasNewButNotOldGlamourerData || hasOldButNotNewGlamourerData)
|
if (oldHasFiles != newHasFiles)
|
||||||
{
|
{
|
||||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Some new data arrived: NewButNotOldFiles:{hasNewButNotOldFileReplacements}," +
|
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (File presence changed old={old} new={new}) => {change}",
|
||||||
" OldButNotNewFiles:{hasOldButNotNewFileReplacements}, NewButNotOldGlam:{hasNewButNotOldGlamourerData}, OldButNotNewGlam:{hasOldButNotNewGlamourerData}) => {change}, {change2}",
|
applicationBase, cachedPlayer, objectKind, oldHasFiles, newHasFiles, PlayerChanges.ModFiles);
|
||||||
applicationBase,
|
|
||||||
cachedPlayer, objectKind, hasNewButNotOldFileReplacements, hasOldButNotNewFileReplacements, hasNewButNotOldGlamourerData, hasOldButNotNewGlamourerData, PlayerChanges.ModFiles, PlayerChanges.Glamourer);
|
set.Add(PlayerChanges.ModFiles);
|
||||||
charaDataToUpdate[objectKind].Add(PlayerChanges.ModFiles);
|
if (objectKind != ObjectKind.Player || forceRedrawOnForcedApply)
|
||||||
charaDataToUpdate[objectKind].Add(PlayerChanges.Glamourer);
|
|
||||||
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (hasNewAndOldFileReplacements)
|
|
||||||
{
|
{
|
||||||
var oldList = oldData.FileReplacements[objectKind];
|
set.Add(PlayerChanges.ForcedRedraw);
|
||||||
var newList = newData.FileReplacements[objectKind];
|
|
||||||
var listsAreEqual = oldList.SequenceEqual(newList, PlayerData.Data.FileReplacementDataComparer.Instance);
|
|
||||||
if (!listsAreEqual || forceApplyMods)
|
|
||||||
{
|
|
||||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (FileReplacements not equal) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModFiles);
|
|
||||||
charaDataToUpdate[objectKind].Add(PlayerChanges.ModFiles);
|
|
||||||
if (objectKind != ObjectKind.Player || forceRedrawOnForcedApply)
|
|
||||||
{
|
|
||||||
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var existingFace = existingFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/face/", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
|
||||||
var existingHair = existingFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/hair/", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
|
||||||
var existingTail = existingFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/tail/", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
|
||||||
var newFace = newFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/face/", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
|
||||||
var newHair = newFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/hair/", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
|
||||||
var newTail = newFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/tail/", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
|
||||||
var existingTransients = existingFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl") && !g.EndsWith("tex") && !g.EndsWith("mtrl")))
|
|
||||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
|
||||||
var newTransients = newFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl") && !g.EndsWith("tex") && !g.EndsWith("mtrl")))
|
|
||||||
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
|
||||||
|
|
||||||
logger.LogTrace("[BASE-{appbase}] ExistingFace: {of}, NewFace: {fc}; ExistingHair: {eh}, NewHair: {nh}; ExistingTail: {et}, NewTail: {nt}; ExistingTransient: {etr}, NewTransient: {ntr}", applicationBase,
|
|
||||||
existingFace.Count, newFace.Count, existingHair.Count, newHair.Count, existingTail.Count, newTail.Count, existingTransients.Count, newTransients.Count);
|
|
||||||
|
|
||||||
var differentFace = !existingFace.SequenceEqual(newFace, PlayerData.Data.FileReplacementDataComparer.Instance);
|
|
||||||
var differentHair = !existingHair.SequenceEqual(newHair, PlayerData.Data.FileReplacementDataComparer.Instance);
|
|
||||||
var differentTail = !existingTail.SequenceEqual(newTail, PlayerData.Data.FileReplacementDataComparer.Instance);
|
|
||||||
var differenTransients = !existingTransients.SequenceEqual(newTransients, PlayerData.Data.FileReplacementDataComparer.Instance);
|
|
||||||
if (differentFace || differentHair || differentTail || differenTransients)
|
|
||||||
{
|
|
||||||
logger.LogDebug("[BASE-{appbase}] Different Subparts: Face: {face}, Hair: {hair}, Tail: {tail}, Transients: {transients} => {change}", applicationBase,
|
|
||||||
differentFace, differentHair, differentTail, differenTransients, PlayerChanges.ForcedRedraw);
|
|
||||||
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
else if (newHasFiles)
|
||||||
|
{
|
||||||
|
var listsAreEqual = oldFileRepls!.SequenceEqual(newFileRepls!, PlayerData.Data.FileReplacementDataComparer.Instance);
|
||||||
|
|
||||||
if (hasNewAndOldGlamourerData)
|
if (!listsAreEqual || forceApplyMods)
|
||||||
{
|
{
|
||||||
bool glamourerDataDifferent = !string.Equals(oldData.GlamourerData[objectKind], newData.GlamourerData[objectKind], StringComparison.Ordinal);
|
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (FileReplacements changed or forceApplyMods) => {change}",
|
||||||
if (glamourerDataDifferent || forceApplyCustomization)
|
applicationBase, cachedPlayer, objectKind, PlayerChanges.ModFiles);
|
||||||
|
|
||||||
|
set.Add(PlayerChanges.ModFiles);
|
||||||
|
|
||||||
|
if (objectKind != ObjectKind.Player || forceRedrawOnForcedApply)
|
||||||
{
|
{
|
||||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Glamourer different) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Glamourer);
|
set.Add(PlayerChanges.ForcedRedraw);
|
||||||
charaDataToUpdate[objectKind].Add(PlayerChanges.Glamourer);
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var existingFace = oldFileRepls.Where(g => g.GamePaths.Any(p => p.Contains("/face/", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
var existingHair = oldFileRepls.Where(g => g.GamePaths.Any(p => p.Contains("/hair/", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
var existingTail = oldFileRepls.Where(g => g.GamePaths.Any(p => p.Contains("/tail/", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
|
||||||
|
var newFace = newFileRepls!.Where(g => g.GamePaths.Any(p => p.Contains("/face/", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
var newHair = newFileRepls.Where(g => g.GamePaths.Any(p => p.Contains("/hair/", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
var newTail = newFileRepls.Where(g => g.GamePaths.Any(p => p.Contains("/tail/", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
|
||||||
|
var existingTransients = oldFileRepls.Where(g => g.GamePaths.Any(p => !p.EndsWith("mdl", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !p.EndsWith("tex", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !p.EndsWith("mtrl", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
|
||||||
|
var newTransients = newFileRepls.Where(g => g.GamePaths.Any(p => !p.EndsWith("mdl", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !p.EndsWith("tex", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !p.EndsWith("mtrl", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
|
||||||
|
var differentFace = !existingFace.SequenceEqual(newFace, PlayerData.Data.FileReplacementDataComparer.Instance);
|
||||||
|
var differentHair = !existingHair.SequenceEqual(newHair, PlayerData.Data.FileReplacementDataComparer.Instance);
|
||||||
|
var differentTail = !existingTail.SequenceEqual(newTail, PlayerData.Data.FileReplacementDataComparer.Instance);
|
||||||
|
var differentTransients = !existingTransients.SequenceEqual(newTransients, PlayerData.Data.FileReplacementDataComparer.Instance);
|
||||||
|
|
||||||
|
if (differentFace || differentHair || differentTail || differentTransients)
|
||||||
|
set.Add(PlayerChanges.ForcedRedraw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
oldData.CustomizePlusData.TryGetValue(objectKind, out var oldCustomizePlusData);
|
var oldGlamNorm = Norm(oldGlam);
|
||||||
newData.CustomizePlusData.TryGetValue(objectKind, out var newCustomizePlusData);
|
var newGlamNorm = Norm(newGlam);
|
||||||
|
|
||||||
oldCustomizePlusData ??= string.Empty;
|
if (!string.Equals(oldGlamNorm, newGlamNorm, StringComparison.Ordinal)
|
||||||
newCustomizePlusData ??= string.Empty;
|
|| (forceApplyCustomization && HasText(newGlamNorm)))
|
||||||
|
|
||||||
bool customizeDataDifferent = !string.Equals(oldCustomizePlusData, newCustomizePlusData, StringComparison.Ordinal);
|
|
||||||
if (customizeDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newCustomizePlusData)))
|
|
||||||
{
|
{
|
||||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff customize data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Customize);
|
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Glamourer different) => {change}",
|
||||||
charaDataToUpdate[objectKind].Add(PlayerChanges.Customize);
|
applicationBase, cachedPlayer, objectKind, PlayerChanges.Glamourer);
|
||||||
|
set.Add(PlayerChanges.Glamourer);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (objectKind != ObjectKind.Player) continue;
|
oldData.CustomizePlusData.TryGetValue(objectKind, out var oldC);
|
||||||
|
newData.CustomizePlusData.TryGetValue(objectKind, out var newC);
|
||||||
|
|
||||||
bool manipDataDifferent = !string.Equals(oldData.ManipulationData, newData.ManipulationData, StringComparison.Ordinal);
|
var oldCNorm = Norm(oldC);
|
||||||
if (manipDataDifferent || forceRedrawOnForcedApply)
|
var newCNorm = Norm(newC);
|
||||||
|
|
||||||
|
if (!string.Equals(oldCNorm, newCNorm, StringComparison.Ordinal)
|
||||||
|
|| (forceApplyCustomization && HasText(newCNorm)))
|
||||||
{
|
{
|
||||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff manip data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModManip);
|
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Customize+ different) => {change}",
|
||||||
charaDataToUpdate[objectKind].Add(PlayerChanges.ModManip);
|
applicationBase, cachedPlayer, objectKind, PlayerChanges.Customize);
|
||||||
charaDataToUpdate[objectKind].Add(PlayerChanges.ForcedRedraw);
|
set.Add(PlayerChanges.Customize);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool heelsOffsetDifferent = !string.Equals(oldData.HeelsData, newData.HeelsData, StringComparison.Ordinal);
|
if (objectKind == ObjectKind.Player)
|
||||||
if (heelsOffsetDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.HeelsData)))
|
|
||||||
{
|
{
|
||||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff heels data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Heels);
|
var oldManip = Norm(oldData.ManipulationData);
|
||||||
charaDataToUpdate[objectKind].Add(PlayerChanges.Heels);
|
var newManip = Norm(newData.ManipulationData);
|
||||||
|
|
||||||
|
if (!string.Equals(oldManip, newManip, StringComparison.Ordinal) || forceRedrawOnForcedApply)
|
||||||
|
{
|
||||||
|
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Manip different) => {change}",
|
||||||
|
applicationBase, cachedPlayer, objectKind, PlayerChanges.ModManip);
|
||||||
|
set.Add(PlayerChanges.ModManip);
|
||||||
|
set.Add(PlayerChanges.ForcedRedraw);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(Norm(oldData.HeelsData), Norm(newData.HeelsData), StringComparison.Ordinal)
|
||||||
|
|| (forceApplyCustomization && HasText(newData.HeelsData)))
|
||||||
|
set.Add(PlayerChanges.Heels);
|
||||||
|
|
||||||
|
if (!string.Equals(Norm(oldData.HonorificData), Norm(newData.HonorificData), StringComparison.Ordinal)
|
||||||
|
|| (forceApplyCustomization && HasText(newData.HonorificData)))
|
||||||
|
set.Add(PlayerChanges.Honorific);
|
||||||
|
|
||||||
|
if (!string.Equals(Norm(oldData.MoodlesData), Norm(newData.MoodlesData), StringComparison.Ordinal)
|
||||||
|
|| (forceApplyCustomization && HasText(newData.MoodlesData)))
|
||||||
|
set.Add(PlayerChanges.Moodles);
|
||||||
|
|
||||||
|
if (!string.Equals(Norm(oldData.PetNamesData), Norm(newData.PetNamesData), StringComparison.Ordinal)
|
||||||
|
|| (forceApplyCustomization && HasText(newData.PetNamesData)))
|
||||||
|
set.Add(PlayerChanges.PetNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool honorificDataDifferent = !string.Equals(oldData.HonorificData, newData.HonorificData, StringComparison.Ordinal);
|
if (set.Count > 0)
|
||||||
if (honorificDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.HonorificData)))
|
charaDataToUpdate[objectKind] = set;
|
||||||
{
|
|
||||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff honorific data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Honorific);
|
|
||||||
charaDataToUpdate[objectKind].Add(PlayerChanges.Honorific);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool moodlesDataDifferent = !string.Equals(oldData.MoodlesData, newData.MoodlesData, StringComparison.Ordinal);
|
|
||||||
if (moodlesDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.MoodlesData)))
|
|
||||||
{
|
|
||||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff moodles data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.Moodles);
|
|
||||||
charaDataToUpdate[objectKind].Add(PlayerChanges.Moodles);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool petNamesDataDifferent = !string.Equals(oldData.PetNamesData, newData.PetNamesData, StringComparison.Ordinal);
|
|
||||||
if (petNamesDataDifferent || (forceApplyCustomization && !string.IsNullOrEmpty(newData.PetNamesData)))
|
|
||||||
{
|
|
||||||
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff petnames data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.PetNames);
|
|
||||||
charaDataToUpdate[objectKind].Add(PlayerChanges.PetNames);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (KeyValuePair<ObjectKind, HashSet<PlayerChanges>> data in charaDataToUpdate.ToList())
|
foreach (var k in charaDataToUpdate.Keys.ToList())
|
||||||
{
|
charaDataToUpdate[k] = [.. charaDataToUpdate[k].OrderBy(p => (int)p)];
|
||||||
if (!data.Value.Any()) charaDataToUpdate.Remove(data.Key);
|
|
||||||
else charaDataToUpdate[data.Key] = [.. data.Value.OrderByDescending(p => (int)p)];
|
|
||||||
}
|
|
||||||
|
|
||||||
return charaDataToUpdate;
|
return charaDataToUpdate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public static T DeepClone<T>(this T obj)
|
public static T DeepClone<T>(this T obj)
|
||||||
{
|
{
|
||||||
return JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(obj))!;
|
return JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(obj))!;
|
||||||
|
|||||||
@@ -436,11 +436,9 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
Logger.LogDebug("GUID {requestId} on server {uri} for files {files}",
|
Logger.LogDebug("GUID {requestId} on server {uri} for files {files}",
|
||||||
requestId, transfers[0].DownloadUri, string.Join(", ", transfers.Select(c => c.Hash)));
|
requestId, transfers[0].DownloadUri, string.Join(", ", transfers.Select(c => c.Hash)));
|
||||||
|
|
||||||
// Wait for ready WITHOUT holding a slot
|
|
||||||
SetStatus(statusKey, DownloadStatus.WaitingForQueue);
|
SetStatus(statusKey, DownloadStatus.WaitingForQueue);
|
||||||
await WaitForDownloadReady(transfers, requestId, ct).ConfigureAwait(false);
|
await WaitForDownloadReady(transfers, requestId, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
// Hold slot ONLY for the GET
|
|
||||||
SetStatus(statusKey, DownloadStatus.WaitingForSlot);
|
SetStatus(statusKey, DownloadStatus.WaitingForSlot);
|
||||||
await using ((await AcquireSlotAsync(ct).ConfigureAwait(false)).ConfigureAwait(false))
|
await using ((await AcquireSlotAsync(ct).ConfigureAwait(false)).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
@@ -462,7 +460,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
bool skipDecimation)
|
bool skipDecimation)
|
||||||
{
|
{
|
||||||
SetStatus(downloadStatusKey, DownloadStatus.Decompressing);
|
SetStatus(downloadStatusKey, DownloadStatus.Decompressing);
|
||||||
MarkTransferredFiles(downloadStatusKey, 1);
|
|
||||||
|
var extracted = 0;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -471,6 +470,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
while (fileBlockStream.Position < fileBlockStream.Length)
|
while (fileBlockStream.Position < fileBlockStream.Length)
|
||||||
{
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
(string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream);
|
(string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream);
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -480,72 +481,69 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
var len = checked((int)fileLengthBytes);
|
var len = checked((int)fileLengthBytes);
|
||||||
|
|
||||||
|
if (fileBlockStream.Position + len > fileBlockStream.Length)
|
||||||
|
throw new EndOfStreamException();
|
||||||
|
|
||||||
if (!replacementLookup.TryGetValue(fileHash, out var repl))
|
if (!replacementLookup.TryGetValue(fileHash, out var repl))
|
||||||
{
|
{
|
||||||
Logger.LogWarning("{dlName}: No replacement mapping for {fileHash}", downloadLabel, fileHash);
|
Logger.LogWarning("{dlName}: No replacement mapping for {fileHash}, skipping {len} bytes",
|
||||||
// still need to skip bytes:
|
downloadLabel, fileHash, len);
|
||||||
var skip = checked((int)fileLengthBytes);
|
|
||||||
fileBlockStream.Position += skip;
|
fileBlockStream.Seek(len, SeekOrigin.Current);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var filePath = _fileDbManager.GetCacheFilePath(fileHash, repl.Extension);
|
var filePath = _fileDbManager.GetCacheFilePath(fileHash, repl.Extension);
|
||||||
Logger.LogTrace("{dlName}: Decompressing {file}:{len} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath);
|
Logger.LogTrace("{dlName}: Extracting {fileHash}:{len} => {dest}",
|
||||||
|
downloadLabel, fileHash, len, filePath);
|
||||||
|
|
||||||
var compressed = new byte[len];
|
var compressed = new byte[len];
|
||||||
|
|
||||||
await ReadExactlyAsync(fileBlockStream, compressed.AsMemory(0, len), ct).ConfigureAwait(false);
|
await ReadExactlyAsync(fileBlockStream, compressed.AsMemory(0, len), ct).ConfigureAwait(false);
|
||||||
|
|
||||||
MungeBuffer(compressed);
|
|
||||||
var decompressed = LZ4Wrapper.Unwrap(compressed);
|
|
||||||
|
|
||||||
if (rawSizeLookup.TryGetValue(fileHash, out var expectedRawSize)
|
|
||||||
&& expectedRawSize > 0
|
|
||||||
&& decompressed.LongLength != expectedRawSize)
|
|
||||||
{
|
|
||||||
await _fileCompactor.WriteAllBytesAsync(filePath, Array.Empty<byte>(), ct).ConfigureAwait(false);
|
|
||||||
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
MungeBuffer(compressed);
|
MungeBuffer(compressed);
|
||||||
|
|
||||||
await _decompressGate.WaitAsync(ct).ConfigureAwait(false);
|
await _decompressGate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
byte[] decompressed;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// offload CPU-intensive decompression to threadpool to free up worker
|
decompressed = await Task.Run(() => LZ4Wrapper.Unwrap(compressed), ct).ConfigureAwait(false);
|
||||||
await Task.Run(async () =>
|
|
||||||
{
|
|
||||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
||||||
|
|
||||||
// decompress
|
|
||||||
var decompressed = LZ4Wrapper.Unwrap(compressed);
|
|
||||||
|
|
||||||
Logger.LogTrace("{dlName}: Unwrap {fileHash} took {ms}ms (compressed {c} bytes, decompressed {d} bytes)",
|
|
||||||
downloadLabel, fileHash, sw.ElapsedMilliseconds, compressed.Length, decompressed?.Length ?? -1);
|
|
||||||
|
|
||||||
// write to file without compacting during download
|
|
||||||
await File.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false);
|
|
||||||
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation);
|
|
||||||
}, ct).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_decompressGate.Release();
|
_decompressGate.Release();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rawSizeLookup.TryGetValue(fileHash, out var expectedRawSize)
|
||||||
|
&& expectedRawSize > 0
|
||||||
|
&& decompressed.LongLength != expectedRawSize)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(
|
||||||
|
"{dlName}: Size mismatch for {fileHash} (expected {expected}, got {actual}). Treating as corrupt.",
|
||||||
|
downloadLabel, fileHash, expectedRawSize, decompressed.LongLength);
|
||||||
|
|
||||||
|
try { if (File.Exists(filePath)) File.Delete(filePath); } catch { /* ignore */ }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _fileCompactor.WriteAllBytesAsync(filePath, decompressed, ct, enqueueCompaction: false).ConfigureAwait(false);
|
||||||
|
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation);
|
||||||
|
|
||||||
|
extracted++;
|
||||||
|
MarkTransferredFiles(downloadStatusKey, extracted);
|
||||||
}
|
}
|
||||||
catch (EndOfStreamException)
|
catch (EndOfStreamException)
|
||||||
{
|
{
|
||||||
Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", downloadLabel, fileHash);
|
Logger.LogWarning("{dlName}: Block ended mid-entry while extracting {fileHash}", downloadLabel, fileHash);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogWarning(e, "{dlName}: Error during decompression", downloadLabel);
|
Logger.LogWarning(ex, "{dlName}: Error extracting {fileHash} from block", downloadLabel, fileHash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
SetStatus(downloadStatusKey, DownloadStatus.Completed);
|
SetStatus(downloadStatusKey, DownloadStatus.Completed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (EndOfStreamException)
|
catch (EndOfStreamException)
|
||||||
{
|
{
|
||||||
@@ -601,11 +599,10 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
_orchestrator.ForbiddenTransfers.Add(new DownloadFileTransfer(dto));
|
_orchestrator.ForbiddenTransfers.Add(new DownloadFileTransfer(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
CurrentDownloads = downloadFileInfoFromService
|
CurrentDownloads = [.. downloadFileInfoFromService
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.Select(d => new DownloadFileTransfer(d))
|
.Select(d => new DownloadFileTransfer(d))
|
||||||
.Where(d => d.CanBeTransferred)
|
.Where(d => d.CanBeTransferred)];
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return CurrentDownloads;
|
return CurrentDownloads;
|
||||||
}
|
}
|
||||||
@@ -1033,48 +1030,58 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
private async Task ProcessDeferredCompressionsAsync(CancellationToken ct)
|
private async Task ProcessDeferredCompressionsAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (_deferredCompressionQueue.IsEmpty)
|
if (_deferredCompressionQueue.IsEmpty || !_configService.Current.UseCompactor)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var filesToCompress = new List<string>();
|
// Drain queue into a unique set (same file can be enqueued multiple times)
|
||||||
|
var filesToCompact = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
while (_deferredCompressionQueue.TryDequeue(out var filePath))
|
while (_deferredCompressionQueue.TryDequeue(out var filePath))
|
||||||
{
|
{
|
||||||
if (File.Exists(filePath))
|
if (!string.IsNullOrWhiteSpace(filePath))
|
||||||
filesToCompress.Add(filePath);
|
filesToCompact.Add(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filesToCompress.Count == 0)
|
if (filesToCompact.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
Logger.LogDebug("Starting deferred compression of {count} files", filesToCompress.Count);
|
Logger.LogDebug("Starting deferred compaction of {count} files", filesToCompact.Count);
|
||||||
|
|
||||||
var compressionWorkers = Math.Clamp(Environment.ProcessorCount / 4, 2, 4);
|
var enqueueWorkers = Math.Clamp(Environment.ProcessorCount / 4, 1, 2);
|
||||||
|
|
||||||
await Parallel.ForEachAsync(filesToCompress,
|
await Parallel.ForEachAsync(
|
||||||
new ParallelOptions
|
filesToCompact,
|
||||||
{
|
new ParallelOptions
|
||||||
MaxDegreeOfParallelism = compressionWorkers,
|
{
|
||||||
CancellationToken = ct
|
MaxDegreeOfParallelism = enqueueWorkers,
|
||||||
|
CancellationToken = ct
|
||||||
},
|
},
|
||||||
async (filePath, token) =>
|
async (filePath, token) =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
return;
|
||||||
|
|
||||||
await Task.Yield();
|
await Task.Yield();
|
||||||
if (_configService.Current.UseCompactor && File.Exists(filePath))
|
|
||||||
{
|
_fileCompactor.RequestCompaction(filePath);
|
||||||
var bytes = await File.ReadAllBytesAsync(filePath, token).ConfigureAwait(false);
|
|
||||||
await _fileCompactor.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false);
|
Logger.LogTrace("Deferred compaction queued: {filePath}", filePath);
|
||||||
Logger.LogTrace("Compressed file: {filePath}", filePath);
|
}
|
||||||
}
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Logger.LogTrace("Deferred compaction cancelled for file: {filePath}", filePath);
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogWarning(ex, "Failed to compress file: {filePath}", filePath);
|
Logger.LogWarning(ex, "Failed to queue deferred compaction for file: {filePath}", filePath);
|
||||||
}
|
}
|
||||||
}).ConfigureAwait(false);
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
Logger.LogDebug("Completed deferred compression of {count} files", filesToCompress.Count);
|
Logger.LogDebug("Completed queuing deferred compaction of {count} files", filesToCompact.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class InlineProgress : IProgress<long>
|
private sealed class InlineProgress : IProgress<long>
|
||||||
|
|||||||
Reference in New Issue
Block a user