Compare commits
117 Commits
master
...
2.0.2.78-D
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac711d9a43 | ||
|
|
b875e0c3a1 | ||
|
|
d6437998ac | ||
|
|
4fa9876c1c | ||
|
|
46e76bbfe6 | ||
| 9dd8e19fb7 | |||
| 5167465d28 | |||
| e8c7539770 | |||
| 54d6a0a1a4 | |||
| b57d54d69c | |||
| 8be0811b4a | |||
| 7c281926a5 | |||
| 6c7e4e6303 | |||
| e2d663cae9 | |||
| 96123d00a2 | |||
|
|
4502cadaeb | ||
| 7f33b6a4ce | |||
| 61f584f059 | |||
| 95d286f990 | |||
|
|
42d6a19db1 | ||
|
|
05f7d256d7 | ||
|
|
058ba504cb | ||
|
|
19966f3828 | ||
|
|
3654365f2a | ||
|
|
9b256dd185 | ||
|
|
d8b9e9cf19 | ||
|
|
ad34d88336 | ||
| 9167bb1afd | |||
|
|
5161c6bad3 | ||
| 59ed03a825 | |||
| ae76efedf8 | |||
| 0e24da75d5 | |||
|
|
223ade39cb | ||
|
|
5aca9e70b2 | ||
|
|
ce28799db3 | ||
|
|
92772cf334 | ||
|
|
0395e81a9f | ||
|
|
9b9010ab8e | ||
|
|
7734a7bf7e | ||
|
|
db2d19bb1e | ||
|
|
032201ed9e | ||
|
|
775b128cf3 | ||
|
|
4bb8db8c03 | ||
|
|
f307c65c66 | ||
|
|
ab305a249c | ||
|
|
9d104a9dd8 | ||
|
|
4eec363cd2 | ||
|
|
d00df84ed6 | ||
|
|
bcd3bd5ca2 | ||
|
|
9048b3bd87 | ||
|
|
c1829a9837 | ||
|
|
a2ed9f8d2b | ||
| 8e08da7471 | |||
|
|
cca23f6e05 | ||
|
|
3205e6e0c3 | ||
|
|
d16e46200d | ||
|
|
5fc13647ae | ||
|
|
39d5d9d7c1 | ||
|
|
c19db58ead | ||
| 30717ba200 | |||
| e0b8070aa8 | |||
| 3241b9222b | |||
| 80b082240f | |||
| b8c8f3dffd | |||
| 543ea6c865 | |||
|
|
de9c9955ef | ||
|
|
2eb0c463e3 | ||
|
|
cd510f93af | ||
|
|
3bbda69699 | ||
|
|
deb7f67e59 | ||
|
|
9ba45670c5 | ||
|
|
f7bb73bcd1 | ||
|
|
4c07162ee3 | ||
|
|
a4d62af73d | ||
|
|
5fba3c01e7 | ||
|
|
df33a0f0a2 | ||
| c439d1c822 | |||
|
|
906dda3885 | ||
|
|
f812b6d09e | ||
| 7e61954541 | |||
|
|
89f59a98f5 | ||
|
|
fb58d8657d | ||
| bbb3375661 | |||
|
|
e95a2c3352 | ||
|
|
a8340c3279 | ||
|
|
e25979e089 | ||
|
|
ca7375b9c3 | ||
|
|
f8752fcb4d | ||
|
|
d1c955c74f | ||
|
|
91e60694ad | ||
|
|
f37fdefddd | ||
|
|
18fa0a47b1 | ||
|
|
9f5cc9e0d1 | ||
|
|
b02db4c1e1 | ||
|
|
d6b31ed5b9 | ||
|
|
9e600bfae0 | ||
|
|
1a73d5a4d9 | ||
|
|
a933330418 | ||
|
|
ea34b18f40 | ||
|
|
67dc215e83 | ||
|
|
baf3869cec | ||
|
|
eeda5aeb66 | ||
|
|
754df95071 | ||
|
|
24fca31606 | ||
|
|
a99c1c01b0 | ||
|
|
85999fab8f | ||
|
|
70745613e1 | ||
|
|
5c8e239a7b | ||
|
|
5eed65149a | ||
|
|
1ab4e2f94b | ||
|
|
f792bc1954 | ||
|
|
ced72ab9eb | ||
|
|
6c1cc77aaa | ||
|
|
5b81caf5a8 | ||
|
|
4e03b381dc | ||
|
|
3222133aa0 | ||
|
|
0ec423e65c |
@@ -9,7 +9,8 @@ env:
|
||||
DOTNET_VERSION: |
|
||||
10.x.x
|
||||
9.x.x
|
||||
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: true
|
||||
|
||||
jobs:
|
||||
tag-and-release:
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -32,16 +33,14 @@ jobs:
|
||||
|
||||
- name: Download Dalamud
|
||||
run: |
|
||||
cd /
|
||||
mkdir -p root/.xlcore/dalamud/Hooks/dev
|
||||
mkdir -p ~/.xlcore/dalamud/Hooks/dev
|
||||
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!
|
||||
run: |
|
||||
dotnet restore
|
||||
dotnet build --configuration Release --no-restore
|
||||
dotnet publish --configuration Release --no-build
|
||||
dotnet publish --configuration Release
|
||||
mv LightlessSync/bin/x64/Release/LightlessSync/latest.zip LightlessClient.zip
|
||||
|
||||
- name: Get version
|
||||
id: package_version
|
||||
@@ -53,19 +52,6 @@ jobs:
|
||||
run: |
|
||||
echo "Version: ${{ steps.package_version.outputs.version }}"
|
||||
|
||||
- name: Prepare Lightless Client
|
||||
run: |
|
||||
PUBLISH_PATH="/workspace/Lightless-Sync/LightlessClient/LightlessSync/bin/x64/Release/publish/"
|
||||
if [ -d "$PUBLISH_PATH" ]; then
|
||||
rm -rf "$PUBLISH_PATH"
|
||||
echo "Removed $PUBLISH_PATH"
|
||||
else
|
||||
echo "$PUBLISH_PATH does not exist, nothing to remove."
|
||||
fi
|
||||
|
||||
mkdir -p output
|
||||
(cd /workspace/Lightless-Sync/LightlessClient/LightlessSync/bin/x64/Release/ && zip -r $OLDPWD/output/LightlessClient.zip *)
|
||||
|
||||
- name: Create Git tag if not exists (master)
|
||||
if: github.ref == 'refs/heads/master'
|
||||
run: |
|
||||
@@ -162,14 +148,7 @@ jobs:
|
||||
echo "release_id=$release_id"
|
||||
echo "release_id=$release_id" >> $GITHUB_OUTPUT || echo "::set-output name=release_id::$release_id"
|
||||
echo "RELEASE_ID=$release_id" >> $GITHUB_ENV
|
||||
|
||||
- name: Check asset exists
|
||||
run: |
|
||||
if [ ! -f output/LightlessClient.zip ]; then
|
||||
echo "output/LightlessClient.zip does not exist!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
- name: Upload Assets to release
|
||||
env:
|
||||
RELEASE_ID: ${{ env.RELEASE_ID }}
|
||||
@@ -177,7 +156,7 @@ jobs:
|
||||
echo "Uploading to release ID: $RELEASE_ID"
|
||||
curl --fail-with-body -s -X POST \
|
||||
-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"
|
||||
|
||||
- name: Clone plugin hosting repo
|
||||
@@ -186,7 +165,7 @@ jobs:
|
||||
cd LightlessSyncRepo
|
||||
git clone https://git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessSync.git
|
||||
env:
|
||||
GIT_TERMINAL_PROMPT: 0
|
||||
GIT_TERMINAL_PROMPT: 0
|
||||
|
||||
- name: Update plogonmaster.json with version (master)
|
||||
if: github.ref == 'refs/heads/master'
|
||||
@@ -282,8 +261,8 @@ jobs:
|
||||
- name: Commit and push to LightlessSync
|
||||
run: |
|
||||
cd LightlessSyncRepo/LightlessSync
|
||||
git config user.name "github-actions"
|
||||
git config user.email "github-actions@github.com"
|
||||
git config user.name "Gitea-Automation"
|
||||
git config user.email "aaa@aaaaaaa.aaa"
|
||||
git add .
|
||||
git diff-index --quiet HEAD || git commit -m "Update ${{ env.PLUGIN_NAME }} to ${{ steps.package_version.outputs.version }}"
|
||||
git push https://x-access-token:${{ secrets.AUTOMATION_TOKEN }}@git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessSync.git HEAD:main
|
||||
|
||||
Submodule LightlessAPI updated: 56566003e0...4ecd5375e6
18
LightlessCompactor/FileCache/CompactorInterfaces.cs
Normal file
18
LightlessCompactor/FileCache/CompactorInterfaces.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace LightlessSync.FileCache;
|
||||
|
||||
public interface ICompactorContext
|
||||
{
|
||||
bool UseCompactor { get; }
|
||||
string CacheFolder { get; }
|
||||
bool IsWine { get; }
|
||||
}
|
||||
|
||||
public interface ICompactionExecutor
|
||||
{
|
||||
bool TryCompact(string filePath);
|
||||
}
|
||||
|
||||
public sealed class NoopCompactionExecutor : ICompactionExecutor
|
||||
{
|
||||
public bool TryCompact(string filePath) => false;
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Compactor;
|
||||
using LightlessSync.Services.Compactor;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
using System.Collections.Concurrent;
|
||||
@@ -20,8 +18,8 @@ public sealed partial class FileCompactor : IDisposable
|
||||
|
||||
private readonly ConcurrentDictionary<string, byte> _pendingCompactions;
|
||||
private readonly ILogger<FileCompactor> _logger;
|
||||
private readonly LightlessConfigService _lightlessConfigService;
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private readonly ICompactorContext _context;
|
||||
private readonly ICompactionExecutor _compactionExecutor;
|
||||
|
||||
private readonly Channel<string> _compactionQueue;
|
||||
private readonly CancellationTokenSource _compactionCts = new();
|
||||
@@ -59,12 +57,12 @@ public sealed partial class FileCompactor : IDisposable
|
||||
XPRESS16K = 3
|
||||
}
|
||||
|
||||
public FileCompactor(ILogger<FileCompactor> logger, LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService)
|
||||
public FileCompactor(ILogger<FileCompactor> logger, ICompactorContext context, ICompactionExecutor compactionExecutor)
|
||||
{
|
||||
_pendingCompactions = new(StringComparer.OrdinalIgnoreCase);
|
||||
_logger = logger;
|
||||
_lightlessConfigService = lightlessConfigService;
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_compactionExecutor = compactionExecutor ?? throw new ArgumentNullException(nameof(compactionExecutor));
|
||||
_isWindows = OperatingSystem.IsWindows();
|
||||
|
||||
_compactionQueue = Channel.CreateUnbounded<string>(new UnboundedChannelOptions
|
||||
@@ -94,7 +92,7 @@ public sealed partial class FileCompactor : IDisposable
|
||||
|
||||
//Uses an batching service for the filefrag command on Linux
|
||||
_fragBatch = new BatchFilefragService(
|
||||
useShell: _dalamudUtilService.IsWine,
|
||||
useShell: _context.IsWine,
|
||||
log: _logger,
|
||||
batchSize: 64,
|
||||
flushMs: 25,
|
||||
@@ -118,7 +116,7 @@ public sealed partial class FileCompactor : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
var folder = _lightlessConfigService.Current.CacheFolder;
|
||||
var folder = _context.CacheFolder;
|
||||
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Warning))
|
||||
@@ -127,7 +125,7 @@ public sealed partial class FileCompactor : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
var files = Directory.EnumerateFiles(folder).ToArray();
|
||||
var files = Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).ToArray();
|
||||
var total = files.Length;
|
||||
Progress = $"0/{total}";
|
||||
if (total == 0) return;
|
||||
@@ -155,7 +153,7 @@ public sealed partial class FileCompactor : IDisposable
|
||||
{
|
||||
if (compress)
|
||||
{
|
||||
if (_lightlessConfigService.Current.UseCompactor)
|
||||
if (_context.UseCompactor)
|
||||
CompactFile(file, workerId);
|
||||
}
|
||||
else
|
||||
@@ -221,19 +219,52 @@ public sealed partial class FileCompactor : IDisposable
|
||||
|
||||
await File.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false);
|
||||
|
||||
if (_lightlessConfigService.Current.UseCompactor)
|
||||
if (_context.UseCompactor)
|
||||
EnqueueCompaction(filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify the compactor that a file was written directly (streamed) so it can enqueue compaction.
|
||||
/// </summary>
|
||||
public void NotifyFileWritten(string filePath)
|
||||
{
|
||||
EnqueueCompaction(filePath);
|
||||
}
|
||||
|
||||
public bool TryCompactFile(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
return false;
|
||||
|
||||
if (!_context.UseCompactor || !File.Exists(filePath))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
CompactFile(filePath, workerId: -1);
|
||||
return true;
|
||||
}
|
||||
catch (IOException ioEx)
|
||||
{
|
||||
_logger.LogDebug(ioEx, "File being read/written, skipping file: {file}", filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error compacting file: {file}", filePath);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the File size for an BTRFS or NTFS file system for the given FileInfo
|
||||
/// </summary>
|
||||
/// <param name="path">Amount of blocks used in the disk</param>
|
||||
public long GetFileSizeOnDisk(FileInfo fileInfo)
|
||||
{
|
||||
var fsType = GetFilesystemType(fileInfo.FullName, _dalamudUtilService.IsWine);
|
||||
var fsType = GetFilesystemType(fileInfo.FullName, _context.IsWine);
|
||||
|
||||
if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine)
|
||||
if (fsType == FilesystemType.NTFS && !_context.IsWine)
|
||||
{
|
||||
(bool flowControl, long value) = GetFileSizeNTFS(fileInfo);
|
||||
if (!flowControl)
|
||||
@@ -290,7 +321,7 @@ public sealed partial class FileCompactor : IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
var blockSize = GetBlockSizeForPath(fileInfo.FullName, _logger, _dalamudUtilService.IsWine);
|
||||
var blockSize = GetBlockSizeForPath(fileInfo.FullName, _logger, _context.IsWine);
|
||||
if (blockSize <= 0)
|
||||
throw new InvalidOperationException($"Invalid block size {blockSize} for {fileInfo.FullName}");
|
||||
|
||||
@@ -330,7 +361,7 @@ public sealed partial class FileCompactor : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine);
|
||||
var fsType = GetFilesystemType(filePath, _context.IsWine);
|
||||
var oldSize = fi.Length;
|
||||
int blockSize = (int)(GetFileSizeOnDisk(fi) / 512);
|
||||
|
||||
@@ -346,7 +377,7 @@ public sealed partial class FileCompactor : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine)
|
||||
if (fsType == FilesystemType.NTFS && !_context.IsWine)
|
||||
{
|
||||
if (!IsWOFCompactedFile(filePath))
|
||||
{
|
||||
@@ -402,9 +433,9 @@ public sealed partial class FileCompactor : IDisposable
|
||||
private void DecompressFile(string filePath, int workerId)
|
||||
{
|
||||
_logger.LogDebug("[W{worker}] Decompress request: {file}", workerId, filePath);
|
||||
var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine);
|
||||
var fsType = GetFilesystemType(filePath, _context.IsWine);
|
||||
|
||||
if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine)
|
||||
if (fsType == FilesystemType.NTFS && !_context.IsWine)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -448,7 +479,7 @@ public sealed partial class FileCompactor : IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
bool isWine = _dalamudUtilService?.IsWine ?? false;
|
||||
bool isWine = _context.IsWine;
|
||||
string linuxPath = isWine ? ToLinuxPathIfWine(path, isWine) : path;
|
||||
|
||||
var opts = GetMountOptionsForPath(linuxPath);
|
||||
@@ -961,7 +992,7 @@ public sealed partial class FileCompactor : IDisposable
|
||||
if (finished != bothTasks)
|
||||
return KillProcess(proc, outTask, errTask, token);
|
||||
|
||||
bool isWine = _dalamudUtilService?.IsWine ?? false;
|
||||
bool isWine = _context.IsWine;
|
||||
if (!isWine)
|
||||
{
|
||||
try { proc.WaitForExit(); } catch { /* ignore quirks */ }
|
||||
@@ -1005,7 +1036,7 @@ public sealed partial class FileCompactor : IDisposable
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
return;
|
||||
|
||||
if (!_lightlessConfigService.Current.UseCompactor)
|
||||
if (!_context.UseCompactor)
|
||||
return;
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
@@ -1017,7 +1048,7 @@ public sealed partial class FileCompactor : IDisposable
|
||||
bool enqueued = false;
|
||||
try
|
||||
{
|
||||
bool isWine = _dalamudUtilService?.IsWine ?? false;
|
||||
bool isWine = _context.IsWine;
|
||||
var fsType = GetFilesystemType(filePath, isWine);
|
||||
|
||||
// If under Wine, we should skip NTFS because its not Windows but might return NTFS.
|
||||
@@ -1070,8 +1101,11 @@ public sealed partial class FileCompactor : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
if (_lightlessConfigService.Current.UseCompactor && File.Exists(filePath))
|
||||
CompactFile(filePath, workerId);
|
||||
if (_context.UseCompactor && File.Exists(filePath))
|
||||
{
|
||||
if (!_compactionExecutor.TryCompact(filePath))
|
||||
CompactFile(filePath, workerId);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
15
LightlessCompactor/LightlessCompactor.csproj
Normal file
15
LightlessCompactor/LightlessCompactor.csproj
Normal file
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
19
LightlessCompactorWorker/LightlessCompactorWorker.csproj
Normal file
19
LightlessCompactorWorker/LightlessCompactorWorker.csproj
Normal file
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LightlessCompactor\LightlessCompactor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
270
LightlessCompactorWorker/Program.cs
Normal file
270
LightlessCompactorWorker/Program.cs
Normal file
@@ -0,0 +1,270 @@
|
||||
using LightlessSync.FileCache;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipes;
|
||||
using System.Text.Json;
|
||||
|
||||
internal sealed class WorkerCompactorContext : ICompactorContext
|
||||
{
|
||||
public WorkerCompactorContext(string cacheFolder, bool isWine)
|
||||
{
|
||||
CacheFolder = cacheFolder;
|
||||
IsWine = isWine;
|
||||
}
|
||||
|
||||
public bool UseCompactor => true;
|
||||
public string CacheFolder { get; }
|
||||
public bool IsWine { get; }
|
||||
}
|
||||
|
||||
internal sealed class WorkerOptions
|
||||
{
|
||||
public string? FilePath { get; init; }
|
||||
public bool IsWine { get; init; }
|
||||
public string CacheFolder { get; init; } = string.Empty;
|
||||
public LogLevel LogLevel { get; init; } = LogLevel.Information;
|
||||
public string PipeName { get; init; } = "LightlessCompactor";
|
||||
public int? ParentProcessId { get; init; }
|
||||
}
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
var options = ParseOptions(args, out var error);
|
||||
if (options is null)
|
||||
{
|
||||
Console.Error.WriteLine(error ?? "Invalid arguments.");
|
||||
Console.Error.WriteLine("Usage: LightlessCompactorWorker --file <path> [--wine] [--cache-folder <path>] [--verbose]");
|
||||
Console.Error.WriteLine(" or: LightlessCompactorWorker --pipe <name> [--wine] [--parent <pid>] [--verbose]");
|
||||
return 2;
|
||||
}
|
||||
|
||||
TrySetLowPriority();
|
||||
|
||||
using var loggerFactory = LoggerFactory.Create(builder =>
|
||||
{
|
||||
builder.SetMinimumLevel(options.LogLevel);
|
||||
builder.AddSimpleConsole(o =>
|
||||
{
|
||||
o.SingleLine = true;
|
||||
o.TimestampFormat = "HH:mm:ss.fff ";
|
||||
});
|
||||
});
|
||||
|
||||
var logger = loggerFactory.CreateLogger<FileCompactor>();
|
||||
var context = new WorkerCompactorContext(options.CacheFolder, options.IsWine);
|
||||
|
||||
using var compactor = new FileCompactor(logger, context, new NoopCompactionExecutor());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.FilePath))
|
||||
{
|
||||
var success = compactor.TryCompactFile(options.FilePath!);
|
||||
return success ? 0 : 1;
|
||||
}
|
||||
|
||||
var serverLogger = loggerFactory.CreateLogger("CompactorWorker");
|
||||
return await RunServerAsync(compactor, options, serverLogger).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<int> RunServerAsync(FileCompactor compactor, WorkerOptions options, ILogger serverLogger)
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var token = cts.Token;
|
||||
|
||||
if (options.ParentProcessId.HasValue)
|
||||
{
|
||||
_ = Task.Run(() => MonitorParent(options.ParentProcessId.Value, cts));
|
||||
}
|
||||
|
||||
serverLogger.LogInformation("Compactor worker listening on pipe {pipe}", options.PipeName);
|
||||
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var server = new NamedPipeServerStream(
|
||||
options.PipeName,
|
||||
PipeDirection.InOut,
|
||||
NamedPipeServerStream.MaxAllowedServerInstances,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
try
|
||||
{
|
||||
await server.WaitForConnectionAsync(token).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
server.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
_ = Task.Run(() => HandleClientAsync(server, compactor, cts));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// shutdown requested
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
serverLogger.LogWarning(ex, "Compactor worker terminated unexpectedly.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task HandleClientAsync(NamedPipeServerStream pipe, FileCompactor compactor, CancellationTokenSource shutdownCts)
|
||||
{
|
||||
await using var _ = pipe;
|
||||
using var reader = new StreamReader(pipe);
|
||||
using var writer = new StreamWriter(pipe) { AutoFlush = true };
|
||||
|
||||
var line = await reader.ReadLineAsync().ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
return;
|
||||
|
||||
CompactorRequest? request = null;
|
||||
try
|
||||
{
|
||||
request = JsonSerializer.Deserialize<CompactorRequest>(line);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
CompactorResponse response;
|
||||
if (request is null)
|
||||
{
|
||||
response = new CompactorResponse { Success = false, Error = "Invalid request." };
|
||||
}
|
||||
else if (string.Equals(request.Type, "shutdown", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
shutdownCts.Cancel();
|
||||
response = new CompactorResponse { Success = true };
|
||||
}
|
||||
else if (string.Equals(request.Type, "compact", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var success = compactor.TryCompactFile(request.Path ?? string.Empty);
|
||||
response = new CompactorResponse { Success = success };
|
||||
}
|
||||
else
|
||||
{
|
||||
response = new CompactorResponse { Success = false, Error = "Unknown request type." };
|
||||
}
|
||||
|
||||
await writer.WriteLineAsync(JsonSerializer.Serialize(response)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void MonitorParent(int parentPid, CancellationTokenSource shutdownCts)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parent = Process.GetProcessById(parentPid);
|
||||
parent.WaitForExit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// parent missing
|
||||
}
|
||||
finally
|
||||
{
|
||||
shutdownCts.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private static WorkerOptions? ParseOptions(string[] args, out string? error)
|
||||
{
|
||||
string? filePath = null;
|
||||
bool isWine = false;
|
||||
string cacheFolder = string.Empty;
|
||||
var logLevel = LogLevel.Information;
|
||||
string pipeName = "LightlessCompactor";
|
||||
int? parentPid = null;
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
var arg = args[i];
|
||||
switch (arg)
|
||||
{
|
||||
case "--file":
|
||||
if (i + 1 >= args.Length)
|
||||
{
|
||||
error = "Missing value for --file.";
|
||||
return null;
|
||||
}
|
||||
filePath = args[++i];
|
||||
break;
|
||||
case "--cache-folder":
|
||||
if (i + 1 >= args.Length)
|
||||
{
|
||||
error = "Missing value for --cache-folder.";
|
||||
return null;
|
||||
}
|
||||
cacheFolder = args[++i];
|
||||
break;
|
||||
case "--pipe":
|
||||
if (i + 1 >= args.Length)
|
||||
{
|
||||
error = "Missing value for --pipe.";
|
||||
return null;
|
||||
}
|
||||
pipeName = args[++i];
|
||||
break;
|
||||
case "--parent":
|
||||
if (i + 1 >= args.Length || !int.TryParse(args[++i], out var pid))
|
||||
{
|
||||
error = "Invalid value for --parent.";
|
||||
return null;
|
||||
}
|
||||
parentPid = pid;
|
||||
break;
|
||||
case "--wine":
|
||||
isWine = true;
|
||||
break;
|
||||
case "--verbose":
|
||||
logLevel = LogLevel.Trace;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
error = null;
|
||||
return new WorkerOptions
|
||||
{
|
||||
FilePath = filePath,
|
||||
IsWine = isWine,
|
||||
CacheFolder = cacheFolder,
|
||||
LogLevel = logLevel,
|
||||
PipeName = pipeName,
|
||||
ParentProcessId = parentPid
|
||||
};
|
||||
}
|
||||
|
||||
private static void TrySetLowPriority()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.BelowNormal;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CompactorRequest
|
||||
{
|
||||
public string Type { get; init; } = "compact";
|
||||
public string? Path { get; init; }
|
||||
}
|
||||
|
||||
private sealed class CompactorResponse
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OtterGui", "OtterGui\OtterG
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pictomancy", "ffxiv_pictomancy\Pictomancy\Pictomancy.csproj", "{825F17D8-2704-24F6-DF8B-2542AC92C765}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessCompactor", "LightlessCompactor\LightlessCompactor.csproj", "{01F31917-9F1E-426D-BDAE-17268CBF9523}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessCompactorWorker", "LightlessCompactorWorker\LightlessCompactorWorker.csproj", "{72BE3664-CD0E-4DA4-B040-91338A2798E0}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -116,6 +120,30 @@ Global
|
||||
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x64.Build.0 = Release|x64
|
||||
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x86.ActiveCfg = Release|x64
|
||||
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x86.Build.0 = Release|x64
|
||||
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|x64.Build.0 = Release|Any CPU
|
||||
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|x86.Build.0 = Release|Any CPU
|
||||
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -103,6 +103,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
}
|
||||
|
||||
record WatcherChange(WatcherChangeTypes ChangeType, string? OldPath = null);
|
||||
private readonly record struct CacheEvictionCandidate(string FullPath, long Size, DateTime LastAccessTime);
|
||||
private readonly Dictionary<string, WatcherChange> _watcherChanges = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, WatcherChange> _lightlessChanges = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -441,116 +442,40 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
Logger.LogWarning(ex, "Could not determine drive size for storage folder {folder}", _configService.Current.CacheFolder);
|
||||
}
|
||||
|
||||
var files = Directory.EnumerateFiles(_configService.Current.CacheFolder)
|
||||
.Select(f => new FileInfo(f))
|
||||
.OrderBy(f => f.LastAccessTime)
|
||||
.ToList();
|
||||
|
||||
var cacheFolder = _configService.Current.CacheFolder;
|
||||
var candidates = new List<CacheEvictionCandidate>();
|
||||
long totalSize = 0;
|
||||
|
||||
foreach (var f in files)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
long size = 0;
|
||||
|
||||
if (!isWine)
|
||||
{
|
||||
try
|
||||
{
|
||||
size = _fileCompactor.GetFileSizeOnDisk(f);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogTrace(ex, "GetFileSizeOnDisk failed for {file}, using fallback length", f.FullName);
|
||||
size = f.Length;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
size = f.Length;
|
||||
}
|
||||
|
||||
totalSize += size;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogTrace(ex, "Error getting size for {file}", f.FullName);
|
||||
}
|
||||
}
|
||||
totalSize += AddFolderCandidates(cacheFolder, candidates, token, isWine);
|
||||
totalSize += AddFolderCandidates(Path.Combine(cacheFolder, "downscaled"), candidates, token, isWine);
|
||||
totalSize += AddFolderCandidates(Path.Combine(cacheFolder, "decimated"), candidates, token, isWine);
|
||||
|
||||
FileCacheSize = totalSize;
|
||||
|
||||
if (Directory.Exists(_configService.Current.CacheFolder + "/downscaled"))
|
||||
{
|
||||
var filesDownscaled = Directory.EnumerateFiles(_configService.Current.CacheFolder + "/downscaled").Select(f => new FileInfo(f)).OrderBy(f => f.LastAccessTime).ToList();
|
||||
|
||||
long totalSizeDownscaled = 0;
|
||||
|
||||
foreach (var f in filesDownscaled)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
long size = 0;
|
||||
|
||||
if (!isWine)
|
||||
{
|
||||
try
|
||||
{
|
||||
size = _fileCompactor.GetFileSizeOnDisk(f);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogTrace(ex, "GetFileSizeOnDisk failed for {file}, using fallback length", f.FullName);
|
||||
size = f.Length;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
size = f.Length;
|
||||
}
|
||||
|
||||
totalSizeDownscaled += size;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogTrace(ex, "Error getting size for {file}", f.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
FileCacheSize = (totalSize + totalSizeDownscaled);
|
||||
}
|
||||
else
|
||||
{
|
||||
FileCacheSize = totalSize;
|
||||
}
|
||||
|
||||
var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d);
|
||||
if (FileCacheSize < maxCacheInBytes)
|
||||
return;
|
||||
|
||||
var maxCacheBuffer = maxCacheInBytes * 0.05d;
|
||||
|
||||
while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer && files.Count > 0)
|
||||
candidates.Sort(static (a, b) => a.LastAccessTime.CompareTo(b.LastAccessTime));
|
||||
|
||||
var evictionTarget = maxCacheInBytes - (long)maxCacheBuffer;
|
||||
var index = 0;
|
||||
while (FileCacheSize > evictionTarget && index < candidates.Count)
|
||||
{
|
||||
var oldestFile = files[0];
|
||||
var oldestFile = candidates[index];
|
||||
|
||||
try
|
||||
{
|
||||
long fileSize = oldestFile.Length;
|
||||
File.Delete(oldestFile.FullName);
|
||||
FileCacheSize -= fileSize;
|
||||
EvictCacheCandidate(oldestFile, cacheFolder);
|
||||
FileCacheSize -= oldestFile.Size;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogTrace(ex, "Failed to delete old file {file}", oldestFile.FullName);
|
||||
Logger.LogTrace(ex, "Failed to delete old file {file}", oldestFile.FullPath);
|
||||
}
|
||||
|
||||
files.RemoveAt(0);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,6 +484,114 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
HaltScanLocks.Clear();
|
||||
}
|
||||
|
||||
private long AddFolderCandidates(string directory, List<CacheEvictionCandidate> candidates, CancellationToken token, bool isWine)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
long totalSize = 0;
|
||||
foreach (var path in Directory.EnumerateFiles(directory))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var file = new FileInfo(path);
|
||||
var size = GetFileSizeOnDisk(file, isWine);
|
||||
totalSize += size;
|
||||
candidates.Add(new CacheEvictionCandidate(file.FullName, size, file.LastAccessTime));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogTrace(ex, "Error getting size for {file}", path);
|
||||
}
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
private long GetFileSizeOnDisk(FileInfo file, bool isWine)
|
||||
{
|
||||
if (isWine)
|
||||
{
|
||||
return file.Length;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return _fileCompactor.GetFileSizeOnDisk(file);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogTrace(ex, "GetFileSizeOnDisk failed for {file}, using fallback length", file.FullName);
|
||||
return file.Length;
|
||||
}
|
||||
}
|
||||
|
||||
private void EvictCacheCandidate(CacheEvictionCandidate candidate, string cacheFolder)
|
||||
{
|
||||
if (TryGetCacheHashAndPrefixedPath(candidate.FullPath, cacheFolder, out var hash, out var prefixedPath))
|
||||
{
|
||||
_fileDbManager.RemoveHashedFile(hash, prefixedPath);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(candidate.FullPath))
|
||||
{
|
||||
File.Delete(candidate.FullPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogTrace(ex, "Failed to delete old file {file}", candidate.FullPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetCacheHashAndPrefixedPath(string filePath, string cacheFolder, out string hash, out string prefixedPath)
|
||||
{
|
||||
hash = string.Empty;
|
||||
prefixedPath = string.Empty;
|
||||
|
||||
if (string.IsNullOrEmpty(cacheFolder))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileNameWithoutExtension(filePath);
|
||||
if (string.IsNullOrEmpty(fileName) || !IsSha1Hash(fileName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var relative = Path.GetRelativePath(cacheFolder, filePath)
|
||||
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
var sanitizedRelative = relative.TrimStart(Path.DirectorySeparatorChar);
|
||||
prefixedPath = Path.Combine(FileCacheManager.CachePrefix, sanitizedRelative);
|
||||
hash = fileName;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsSha1Hash(string value)
|
||||
{
|
||||
if (value.Length != 40)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (!Uri.IsHexDigit(ch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ResumeScan(string source)
|
||||
{
|
||||
if (!HaltScanLocks.ContainsKey(source)) HaltScanLocks[source] = 0;
|
||||
|
||||
241
LightlessSync/FileCache/ExternalCompactionExecutor.cs
Normal file
241
LightlessSync/FileCache/ExternalCompactionExecutor.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipes;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LightlessSync.FileCache;
|
||||
|
||||
internal sealed class ExternalCompactionExecutor : ICompactionExecutor, IDisposable
|
||||
{
|
||||
private readonly ILogger<ExternalCompactionExecutor> _logger;
|
||||
private readonly ICompactorContext _context;
|
||||
private readonly TimeSpan _timeout = TimeSpan.FromMinutes(5);
|
||||
private readonly string _pipeName;
|
||||
private Process? _workerProcess;
|
||||
private bool _disposed;
|
||||
private readonly object _sync = new();
|
||||
|
||||
public ExternalCompactionExecutor(ILogger<ExternalCompactionExecutor> logger, ICompactorContext context)
|
||||
{
|
||||
_logger = logger;
|
||||
_context = context;
|
||||
_pipeName = $"LightlessCompactor-{Environment.ProcessId}";
|
||||
}
|
||||
|
||||
public bool TryCompact(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
return false;
|
||||
|
||||
if (!EnsureWorkerRunning())
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var request = new CompactorRequest
|
||||
{
|
||||
Type = "compact",
|
||||
Path = filePath
|
||||
};
|
||||
|
||||
return SendRequest(request, out var response) && response?.Success == true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "External compactor failed for {file}", filePath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
|
||||
try
|
||||
{
|
||||
SendRequest(new CompactorRequest { Type = "shutdown" }, out _);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (_workerProcess is null)
|
||||
return;
|
||||
|
||||
TryKill(_workerProcess);
|
||||
_workerProcess.Dispose();
|
||||
_workerProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool EnsureWorkerRunning()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_workerProcess is { HasExited: false })
|
||||
return true;
|
||||
|
||||
_workerProcess?.Dispose();
|
||||
_workerProcess = null;
|
||||
|
||||
var workerPath = ResolveWorkerPath();
|
||||
if (string.IsNullOrEmpty(workerPath))
|
||||
return false;
|
||||
|
||||
var args = BuildArguments();
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = workerPath,
|
||||
Arguments = args,
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true
|
||||
};
|
||||
|
||||
var process = new Process { StartInfo = startInfo };
|
||||
if (!process.Start())
|
||||
return false;
|
||||
|
||||
TrySetLowPriority(process);
|
||||
_ = DrainAsync(process.StandardOutput, "stdout");
|
||||
_ = DrainAsync(process.StandardError, "stderr");
|
||||
|
||||
_workerProcess = process;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private bool SendRequest(CompactorRequest request, out CompactorResponse? response)
|
||||
{
|
||||
response = null;
|
||||
using var pipe = new NamedPipeClientStream(".", _pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
|
||||
try
|
||||
{
|
||||
pipe.Connect((int)_timeout.TotalMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Compactor pipe connection failed.");
|
||||
return false;
|
||||
}
|
||||
|
||||
using var writer = new StreamWriter(pipe) { AutoFlush = true };
|
||||
using var reader = new StreamReader(pipe);
|
||||
|
||||
var payload = JsonSerializer.Serialize(request);
|
||||
writer.WriteLine(payload);
|
||||
|
||||
var readTask = reader.ReadLineAsync();
|
||||
if (!readTask.Wait(_timeout))
|
||||
{
|
||||
_logger.LogWarning("Compactor pipe timed out waiting for response.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var line = readTask.Result;
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
response = JsonSerializer.Deserialize<CompactorResponse>(line);
|
||||
return response is not null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse compactor response.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private string? ResolveWorkerPath()
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var exeName = OperatingSystem.IsWindows() || _context.IsWine
|
||||
? "LightlessCompactorWorker.exe"
|
||||
: "LightlessCompactorWorker";
|
||||
var path = Path.Combine(baseDir, exeName);
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
private string BuildArguments()
|
||||
{
|
||||
var args = new List<string> { "--pipe", Quote(_pipeName), "--parent", Environment.ProcessId.ToString() };
|
||||
if (_context.IsWine)
|
||||
args.Add("--wine");
|
||||
return string.Join(' ', args);
|
||||
}
|
||||
|
||||
private static string Quote(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return "\"\"";
|
||||
|
||||
if (!value.Contains('"', StringComparison.Ordinal))
|
||||
return "\"" + value + "\"";
|
||||
|
||||
return "\"" + value.Replace("\"", "\\\"", StringComparison.Ordinal) + "\"";
|
||||
}
|
||||
|
||||
private static void TrySetLowPriority(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
process.PriorityClass = ProcessPriorityClass.BelowNormal;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DrainAsync(StreamReader reader, string label)
|
||||
{
|
||||
try
|
||||
{
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Trace))
|
||||
_logger.LogTrace("Compactor {label}: {line}", label, line);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryKill(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CompactorRequest
|
||||
{
|
||||
public string Type { get; init; } = "compact";
|
||||
public string? Path { get; init; }
|
||||
}
|
||||
|
||||
private sealed class CompactorResponse
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ public sealed class FileCacheManager : IHostedService
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, FileCacheEntity>> _fileCaches = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, FileCacheEntity> _fileCachesByPrefixedPath = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly SemaphoreSlim _getCachesByPathsSemaphore = new(1, 1);
|
||||
private readonly SemaphoreSlim _evictSemaphore = new(1, 1);
|
||||
private readonly Lock _fileWriteLock = new();
|
||||
private readonly IpcManager _ipcManager;
|
||||
private readonly ILogger<FileCacheManager> _logger;
|
||||
@@ -114,6 +115,35 @@ public sealed class FileCacheManager : IHostedService
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryGetHashFromFileName(FileInfo fileInfo, out string hash)
|
||||
{
|
||||
hash = Path.GetFileNameWithoutExtension(fileInfo.Name);
|
||||
if (string.IsNullOrWhiteSpace(hash))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hash.Length is not (40 or 64))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < hash.Length; i++)
|
||||
{
|
||||
var c = hash[i];
|
||||
var isHex = (c >= '0' && c <= '9')
|
||||
|| (c >= 'a' && c <= 'f')
|
||||
|| (c >= 'A' && c <= 'F');
|
||||
if (!isHex)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
hash = hash.ToUpperInvariant();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string BuildVersionHeader() => $"{FileCacheVersionHeaderPrefix}{FileCacheVersion}";
|
||||
|
||||
private static bool TryParseVersionHeader(string? line, out int version)
|
||||
@@ -226,13 +256,23 @@ public sealed class FileCacheManager : IHostedService
|
||||
var compressed = LZ4Wrapper.WrapHC(raw, 0, raw.Length);
|
||||
|
||||
var tmpPath = compressedPath + ".tmp";
|
||||
await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false);
|
||||
File.Move(tmpPath, compressedPath, overwrite: true);
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false);
|
||||
File.Move(tmpPath, compressedPath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
var compressedSize = compressed.LongLength;
|
||||
var compressedSize = new FileInfo(compressedPath).Length;
|
||||
SetSizeInfo(hash, originalSize, compressedSize);
|
||||
UpdateEntitiesSizes(hash, originalSize, compressedSize);
|
||||
|
||||
var maxBytes = GiBToBytes(_configService.Current.MaxLocalCacheInGiB);
|
||||
await EnforceCacheLimitAsync(maxBytes, token).ConfigureAwait(false);
|
||||
|
||||
return compressed;
|
||||
}
|
||||
finally
|
||||
@@ -277,9 +317,34 @@ public sealed class FileCacheManager : IHostedService
|
||||
_logger.LogTrace("Creating cache entry for {path}", path);
|
||||
var cacheFolder = _configService.Current.CacheFolder;
|
||||
if (string.IsNullOrEmpty(cacheFolder)) return null;
|
||||
if (TryGetHashFromFileName(fi, out var hash))
|
||||
{
|
||||
return CreateCacheEntryWithKnownHash(fi.FullName, hash);
|
||||
}
|
||||
|
||||
return CreateFileEntity(cacheFolder, CachePrefix, fi);
|
||||
}
|
||||
|
||||
public FileCacheEntity? CreateCacheEntryWithKnownHash(string path, string hash)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hash))
|
||||
{
|
||||
return CreateCacheEntry(path);
|
||||
}
|
||||
|
||||
FileInfo fi = new(path);
|
||||
if (!fi.Exists) return null;
|
||||
_logger.LogTrace("Creating cache entry for {path} using provided hash", path);
|
||||
var cacheFolder = _configService.Current.CacheFolder;
|
||||
if (string.IsNullOrEmpty(cacheFolder)) return null;
|
||||
if (!TryBuildPrefixedPath(fi.FullName, cacheFolder, CachePrefix, out var prefixedPath, out _))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return CreateFileCacheEntity(fi, prefixedPath, hash);
|
||||
}
|
||||
|
||||
public FileCacheEntity? CreateFileEntry(string path)
|
||||
{
|
||||
FileInfo fi = new(path);
|
||||
@@ -562,9 +627,10 @@ public sealed class FileCacheManager : IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveHashedFile(string hash, string prefixedFilePath)
|
||||
public void RemoveHashedFile(string hash, string prefixedFilePath, bool removeDerivedFiles = true)
|
||||
{
|
||||
var normalizedPath = NormalizePrefixedPathKey(prefixedFilePath);
|
||||
var removedHash = false;
|
||||
|
||||
if (_fileCaches.TryGetValue(hash, out var caches))
|
||||
{
|
||||
@@ -577,11 +643,16 @@ public sealed class FileCacheManager : IHostedService
|
||||
|
||||
if (caches.IsEmpty)
|
||||
{
|
||||
_fileCaches.TryRemove(hash, out _);
|
||||
removedHash = _fileCaches.TryRemove(hash, out _);
|
||||
}
|
||||
}
|
||||
|
||||
_fileCachesByPrefixedPath.TryRemove(normalizedPath, out _);
|
||||
|
||||
if (removeDerivedFiles && removedHash)
|
||||
{
|
||||
RemoveDerivedCacheFiles(hash);
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateHashedFile(FileCacheEntity fileCache, bool computeProperties = true)
|
||||
@@ -597,7 +668,8 @@ public sealed class FileCacheManager : IHostedService
|
||||
fileCache.Hash = Crypto.ComputeFileHash(fileCache.ResolvedFilepath, Crypto.HashAlgo.Sha1);
|
||||
fileCache.LastModifiedDateTicks = fi.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
RemoveHashedFile(oldHash, prefixedPath);
|
||||
var removeDerivedFiles = !string.Equals(oldHash, fileCache.Hash, StringComparison.OrdinalIgnoreCase);
|
||||
RemoveHashedFile(oldHash, prefixedPath, removeDerivedFiles);
|
||||
AddHashedFile(fileCache);
|
||||
}
|
||||
|
||||
@@ -747,7 +819,7 @@ public sealed class FileCacheManager : IHostedService
|
||||
{
|
||||
try
|
||||
{
|
||||
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
|
||||
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath, removeDerivedFiles: false);
|
||||
var extensionPath = fileCache.ResolvedFilepath.ToUpper(CultureInfo.InvariantCulture) + "." + ext;
|
||||
File.Move(fileCache.ResolvedFilepath, extensionPath, overwrite: true);
|
||||
var newHashedEntity = new FileCacheEntity(fileCache.Hash, fileCache.PrefixedFilePath + "." + ext, DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture));
|
||||
@@ -764,6 +836,33 @@ public sealed class FileCacheManager : IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveDerivedCacheFiles(string hash)
|
||||
{
|
||||
var cacheFolder = _configService.Current.CacheFolder;
|
||||
if (string.IsNullOrWhiteSpace(cacheFolder))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryDeleteDerivedCacheFile(Path.Combine(cacheFolder, "downscaled", $"{hash}.tex"));
|
||||
TryDeleteDerivedCacheFile(Path.Combine(cacheFolder, "decimated", $"{hash}.mdl"));
|
||||
}
|
||||
|
||||
private void TryDeleteDerivedCacheFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogTrace(ex, "Failed to delete derived cache file {path}", path);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddHashedFile(FileCacheEntity fileCache)
|
||||
{
|
||||
var normalizedPath = NormalizePrefixedPathKey(fileCache.PrefixedFilePath);
|
||||
@@ -877,6 +976,83 @@ public sealed class FileCacheManager : IHostedService
|
||||
}, token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task EnforceCacheLimitAsync(long maxBytes, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(CacheFolder) || maxBytes <= 0) return;
|
||||
|
||||
await _evictSemaphore.WaitAsync(token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(CacheFolder);
|
||||
|
||||
foreach (var tmp in Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension + ".tmp"))
|
||||
{
|
||||
try { File.Delete(tmp); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
var files = Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension, SearchOption.TopDirectoryOnly)
|
||||
.Select(p => new FileInfo(p))
|
||||
.Where(fi => fi.Exists)
|
||||
.OrderBy(fi => fi.LastWriteTimeUtc)
|
||||
.ToList();
|
||||
|
||||
long total = files.Sum(f => f.Length);
|
||||
if (total <= maxBytes) return;
|
||||
|
||||
foreach (var fi in files)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (total <= maxBytes) break;
|
||||
|
||||
var hash = Path.GetFileNameWithoutExtension(fi.Name);
|
||||
|
||||
try
|
||||
{
|
||||
var len = fi.Length;
|
||||
fi.Delete();
|
||||
total -= len;
|
||||
_sizeCache.TryRemove(hash, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to evict cache file {file}", fi.FullName);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_evictSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static long GiBToBytes(double gib)
|
||||
{
|
||||
if (double.IsNaN(gib) || double.IsInfinity(gib) || gib <= 0)
|
||||
return 0;
|
||||
|
||||
var bytes = gib * 1024d * 1024d * 1024d;
|
||||
|
||||
if (bytes >= long.MaxValue) return long.MaxValue;
|
||||
|
||||
return (long)Math.Round(bytes, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private void CleanupOrphanCompressedCache()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(CacheFolder) || !Directory.Exists(CacheFolder))
|
||||
return;
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension))
|
||||
{
|
||||
var hash = Path.GetFileNameWithoutExtension(path);
|
||||
if (!_fileCaches.ContainsKey(hash))
|
||||
{
|
||||
try { File.Delete(path); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Failed deleting orphan {file}", path); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting FileCacheManager");
|
||||
@@ -1060,6 +1236,8 @@ public sealed class FileCacheManager : IHostedService
|
||||
{
|
||||
await WriteOutFullCsvAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
CleanupOrphanCompressedCache();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Started FileCacheManager");
|
||||
|
||||
20
LightlessSync/FileCache/PluginCompactorContext.cs
Normal file
20
LightlessSync/FileCache/PluginCompactorContext.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services;
|
||||
|
||||
namespace LightlessSync.FileCache;
|
||||
|
||||
internal sealed class PluginCompactorContext : ICompactorContext
|
||||
{
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
|
||||
public PluginCompactorContext(LightlessConfigService configService, DalamudUtilService dalamudUtilService)
|
||||
{
|
||||
_configService = configService;
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
}
|
||||
|
||||
public bool UseCompactor => _configService.Current.UseCompactor;
|
||||
public string CacheFolder => _configService.Current.CacheFolder;
|
||||
public bool IsWine => _dalamudUtilService.IsWine;
|
||||
}
|
||||
@@ -25,7 +25,6 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
private readonly object _ownedHandlerLock = new();
|
||||
private readonly string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk", "kdb"];
|
||||
private readonly string[] _handledRecordingFileTypes = ["tex", "mdl", "mtrl"];
|
||||
private readonly string[] _handledFileTypesWithRecording;
|
||||
private readonly HashSet<GameObjectHandler> _playerRelatedPointers = [];
|
||||
private readonly object _playerRelatedLock = new();
|
||||
private readonly ConcurrentDictionary<nint, GameObjectHandler> _playerRelatedByAddress = new();
|
||||
@@ -42,8 +41,6 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
_dalamudUtil = dalamudUtil;
|
||||
_actorObjectService = actorObjectService;
|
||||
_gameObjectHandlerFactory = gameObjectHandlerFactory;
|
||||
_handledFileTypesWithRecording = _handledRecordingFileTypes.Concat(_handledFileTypes).ToArray();
|
||||
|
||||
Mediator.Subscribe<PenumbraResourceLoadMessage>(this, Manager_PenumbraResourceLoadEvent);
|
||||
Mediator.Subscribe<ActorTrackedMessage>(this, msg => HandleActorTracked(msg.Descriptor));
|
||||
Mediator.Subscribe<ActorUntrackedMessage>(this, msg => HandleActorUntracked(msg.Descriptor));
|
||||
@@ -297,7 +294,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
|
||||
private void DalamudUtil_FrameworkUpdate()
|
||||
{
|
||||
RefreshPlayerRelatedAddressMap();
|
||||
_ = Task.Run(() => RefreshPlayerRelatedAddressMap());
|
||||
|
||||
lock (_cacheAdditionLock)
|
||||
{
|
||||
@@ -306,20 +303,64 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
|
||||
if (_lastClassJobId != _dalamudUtil.ClassJobId)
|
||||
{
|
||||
_lastClassJobId = _dalamudUtil.ClassJobId;
|
||||
if (SemiTransientResources.TryGetValue(ObjectKind.Pet, out HashSet<string>? value))
|
||||
{
|
||||
value?.Clear();
|
||||
}
|
||||
|
||||
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
|
||||
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
|
||||
SemiTransientResources[ObjectKind.Pet] = new HashSet<string>(
|
||||
petSpecificData ?? [],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
UpdateClassJobCache();
|
||||
}
|
||||
|
||||
CleanupAbsentObjects();
|
||||
}
|
||||
|
||||
private void RefreshPlayerRelatedAddressMap()
|
||||
{
|
||||
var tempMap = new ConcurrentDictionary<nint, GameObjectHandler>();
|
||||
var updatedFrameAddresses = new ConcurrentDictionary<nint, ObjectKind>();
|
||||
|
||||
lock (_playerRelatedLock)
|
||||
{
|
||||
foreach (var handler in _playerRelatedPointers)
|
||||
{
|
||||
var address = (nint)handler.Address;
|
||||
if (address != nint.Zero)
|
||||
{
|
||||
tempMap[address] = handler;
|
||||
updatedFrameAddresses[address] = handler.ObjectKind;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_playerRelatedByAddress.Clear();
|
||||
foreach (var kvp in tempMap)
|
||||
{
|
||||
_playerRelatedByAddress[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
_cachedFrameAddresses.Clear();
|
||||
foreach (var kvp in updatedFrameAddresses)
|
||||
{
|
||||
_cachedFrameAddresses[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateClassJobCache()
|
||||
{
|
||||
_lastClassJobId = _dalamudUtil.ClassJobId;
|
||||
if (SemiTransientResources.TryGetValue(ObjectKind.Pet, out HashSet<string>? value))
|
||||
{
|
||||
value?.Clear();
|
||||
}
|
||||
|
||||
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
|
||||
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache
|
||||
.Concat(jobSpecificData ?? [])
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
|
||||
SemiTransientResources[ObjectKind.Pet] = new HashSet<string>(
|
||||
petSpecificData ?? [],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void CleanupAbsentObjects()
|
||||
{
|
||||
foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast<ObjectKind>())
|
||||
{
|
||||
if (!_cachedFrameAddresses.Any(k => k.Value == kind) && TransientResources.Remove(kind, out _))
|
||||
@@ -349,26 +390,6 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
_semiTransientResources = null;
|
||||
}
|
||||
|
||||
private void RefreshPlayerRelatedAddressMap()
|
||||
{
|
||||
_playerRelatedByAddress.Clear();
|
||||
var updatedFrameAddresses = new ConcurrentDictionary<nint, ObjectKind>();
|
||||
lock (_playerRelatedLock)
|
||||
{
|
||||
foreach (var handler in _playerRelatedPointers)
|
||||
{
|
||||
var address = (nint)handler.Address;
|
||||
if (address != nint.Zero)
|
||||
{
|
||||
_playerRelatedByAddress[address] = handler;
|
||||
updatedFrameAddresses[address] = handler.ObjectKind;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_cachedFrameAddresses = updatedFrameAddresses;
|
||||
}
|
||||
|
||||
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
|
||||
{
|
||||
if (descriptor.IsInGpose)
|
||||
@@ -499,46 +520,51 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
|
||||
private void Manager_PenumbraResourceLoadEvent(PenumbraResourceLoadMessage msg)
|
||||
{
|
||||
var gamePath = msg.GamePath.ToLowerInvariant();
|
||||
var gameObjectAddress = msg.GameObject;
|
||||
if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind))
|
||||
{
|
||||
if (_actorObjectService.TryGetOwnedKind(gameObjectAddress, out var ownedKind))
|
||||
{
|
||||
objectKind = ownedKind;
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var gamePath = NormalizeGamePath(msg.GamePath);
|
||||
if (string.IsNullOrEmpty(gamePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
var filePath = msg.FilePath;
|
||||
|
||||
// ignore files already processed this frame
|
||||
if (_cachedHandledPaths.Contains(gamePath)) return;
|
||||
|
||||
lock (_cacheAdditionLock)
|
||||
{
|
||||
if (!_cachedHandledPaths.Add(gamePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
_cachedHandledPaths.Add(gamePath);
|
||||
}
|
||||
|
||||
// replace individual mtrl stuff
|
||||
if (filePath.StartsWith("|", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
filePath = filePath.Split("|")[2];
|
||||
}
|
||||
// replace filepath
|
||||
filePath = filePath.ToLowerInvariant().Replace("\\", "/", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// ignore files that are the same
|
||||
var replacedGamePath = gamePath.ToLowerInvariant().Replace("\\", "/", StringComparison.OrdinalIgnoreCase);
|
||||
if (string.Equals(filePath, replacedGamePath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore files to not handle
|
||||
var handledTypes = IsTransientRecording ? _handledFileTypesWithRecording : _handledFileTypes;
|
||||
if (!HasHandledFileType(gamePath, handledTypes))
|
||||
var handledTypes = IsTransientRecording ? _handledRecordingFileTypes.Concat(_handledFileTypes) : _handledFileTypes;
|
||||
if (!handledTypes.Any(type => gamePath.EndsWith(type, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
lock (_cacheAdditionLock)
|
||||
{
|
||||
_cachedHandledPaths.Add(gamePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var filePath = NormalizeFilePath(msg.FilePath);
|
||||
|
||||
// ignore files that are the same
|
||||
if (string.Equals(filePath, gamePath, StringComparison.Ordinal))
|
||||
// ignore files not belonging to anything player related
|
||||
if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind))
|
||||
{
|
||||
lock (_cacheAdditionLock)
|
||||
{
|
||||
_cachedHandledPaths.Add(gamePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -553,12 +579,13 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
_playerRelatedByAddress.TryGetValue(gameObjectAddress, out var owner);
|
||||
bool alreadyTransient = false;
|
||||
|
||||
bool transientContains = transientResources.Contains(gamePath);
|
||||
bool semiTransientContains = SemiTransientResources.Values.Any(value => value.Contains(gamePath));
|
||||
bool transientContains = transientResources.Contains(replacedGamePath);
|
||||
bool semiTransientContains = SemiTransientResources.SelectMany(k => k.Value)
|
||||
.Any(f => string.Equals(f, gamePath, StringComparison.OrdinalIgnoreCase));
|
||||
if (transientContains || semiTransientContains)
|
||||
{
|
||||
if (!IsTransientRecording)
|
||||
Logger.LogTrace("Not adding {replacedPath} => {filePath}, Reason: Transient: {contains}, SemiTransient: {contains2}", gamePath, filePath,
|
||||
Logger.LogTrace("Not adding {replacedPath} => {filePath}, Reason: Transient: {contains}, SemiTransient: {contains2}", replacedGamePath, filePath,
|
||||
transientContains, semiTransientContains);
|
||||
alreadyTransient = true;
|
||||
}
|
||||
@@ -566,10 +593,10 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
if (!IsTransientRecording)
|
||||
{
|
||||
bool isAdded = transientResources.Add(gamePath);
|
||||
bool isAdded = transientResources.Add(replacedGamePath);
|
||||
if (isAdded)
|
||||
{
|
||||
Logger.LogDebug("Adding {replacedGamePath} for {gameObject} ({filePath})", gamePath, owner?.ToString() ?? gameObjectAddress.ToString("X"), filePath);
|
||||
Logger.LogDebug("Adding {replacedGamePath} for {gameObject} ({filePath})", replacedGamePath, owner?.ToString() ?? gameObjectAddress.ToString("X"), filePath);
|
||||
SendTransients(gameObjectAddress, objectKind);
|
||||
}
|
||||
}
|
||||
@@ -577,7 +604,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
|
||||
if (owner != null && IsTransientRecording)
|
||||
{
|
||||
_recordedTransients.Add(new TransientRecord(owner, gamePath, filePath, alreadyTransient) { AddTransient = !alreadyTransient });
|
||||
_recordedTransients.Add(new TransientRecord(owner, replacedGamePath, filePath, alreadyTransient) { AddTransient = !alreadyTransient });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,4 +703,4 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
public bool AddTransient { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Dalamud.Plugin.Services;
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Info;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -11,24 +12,35 @@ public unsafe class BlockedCharacterHandler
|
||||
private readonly Dictionary<CharaData, bool> _blockedCharacterCache = new();
|
||||
|
||||
private readonly ILogger<BlockedCharacterHandler> _logger;
|
||||
private readonly IObjectTable _objectTable;
|
||||
|
||||
public BlockedCharacterHandler(ILogger<BlockedCharacterHandler> logger, IGameInteropProvider gameInteropProvider)
|
||||
public BlockedCharacterHandler(ILogger<BlockedCharacterHandler> logger, IGameInteropProvider gameInteropProvider, IObjectTable objectTable)
|
||||
{
|
||||
gameInteropProvider.InitializeFromAttributes(this);
|
||||
_logger = logger;
|
||||
_objectTable = objectTable;
|
||||
}
|
||||
|
||||
private static CharaData GetIdsFromPlayerPointer(nint ptr)
|
||||
private CharaData? TryGetIdsFromPlayerPointer(nint ptr, ushort objectIndex)
|
||||
{
|
||||
if (ptr == nint.Zero) return new(0, 0);
|
||||
var castChar = ((BattleChara*)ptr);
|
||||
if (ptr == nint.Zero || objectIndex >= 200)
|
||||
return null;
|
||||
|
||||
var obj = _objectTable[objectIndex];
|
||||
if (obj is not IPlayerCharacter player || player.Address != ptr)
|
||||
return null;
|
||||
|
||||
var castChar = (BattleChara*)player.Address;
|
||||
return new(castChar->Character.AccountId, castChar->Character.ContentId);
|
||||
}
|
||||
|
||||
public bool IsCharacterBlocked(nint ptr, out bool firstTime)
|
||||
public bool IsCharacterBlocked(nint ptr, ushort objectIndex, out bool firstTime)
|
||||
{
|
||||
firstTime = false;
|
||||
var combined = GetIdsFromPlayerPointer(ptr);
|
||||
var combined = TryGetIdsFromPlayerPointer(ptr, objectIndex);
|
||||
if (combined == null)
|
||||
return false;
|
||||
|
||||
if (_blockedCharacterCache.TryGetValue(combined, out var isBlocked))
|
||||
return isBlocked;
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Lifestream.Enums;
|
||||
|
||||
public enum ResidentialAetheryteKind
|
||||
{
|
||||
None = -1,
|
||||
Uldah = 9,
|
||||
Gridania = 2,
|
||||
Limsa = 8,
|
||||
Foundation = 70,
|
||||
Kugane = 111,
|
||||
}
|
||||
1
LightlessSync/Interop/InteropModel/GlobalModels.cs
Normal file
1
LightlessSync/Interop/InteropModel/GlobalModels.cs
Normal file
@@ -0,0 +1 @@
|
||||
global using AddressBookEntryTuple = (string Name, int World, int City, int Ward, int PropertyType, int Plot, int Apartment, bool ApartmentSubdivision, bool AliasEnabled, string Alias);
|
||||
129
LightlessSync/Interop/Ipc/IpcCallerLifestream.cs
Normal file
129
LightlessSync/Interop/Ipc/IpcCallerLifestream.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using Dalamud.Plugin;
|
||||
using Dalamud.Plugin.Ipc;
|
||||
using Lifestream.Enums;
|
||||
using LightlessSync.Interop.Ipc.Framework;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
||||
namespace LightlessSync.Interop.Ipc;
|
||||
|
||||
public sealed class IpcCallerLifestream : IpcServiceBase
|
||||
{
|
||||
private static readonly IpcServiceDescriptor LifestreamDescriptor = new("Lifestream", "Lifestream", new Version(0, 0, 0, 0));
|
||||
|
||||
private readonly ICallGateSubscriber<string, object> _executeLifestreamCommand;
|
||||
private readonly ICallGateSubscriber<AddressBookEntryTuple, bool> _isHere;
|
||||
private readonly ICallGateSubscriber<AddressBookEntryTuple, object> _goToHousingAddress;
|
||||
private readonly ICallGateSubscriber<bool> _isBusy;
|
||||
private readonly ICallGateSubscriber<object> _abort;
|
||||
private readonly ICallGateSubscriber<string, bool> _changeWorld;
|
||||
private readonly ICallGateSubscriber<uint, bool> _changeWorldById;
|
||||
private readonly ICallGateSubscriber<string, bool> _aetheryteTeleport;
|
||||
private readonly ICallGateSubscriber<uint, bool> _aetheryteTeleportById;
|
||||
private readonly ICallGateSubscriber<bool> _canChangeInstance;
|
||||
private readonly ICallGateSubscriber<int> _getCurrentInstance;
|
||||
private readonly ICallGateSubscriber<int> _getNumberOfInstances;
|
||||
private readonly ICallGateSubscriber<int, object> _changeInstance;
|
||||
private readonly ICallGateSubscriber<(ResidentialAetheryteKind, int, int)> _getCurrentPlotInfo;
|
||||
|
||||
public IpcCallerLifestream(IDalamudPluginInterface pi, LightlessMediator lightlessMediator, ILogger<IpcCallerLifestream> logger)
|
||||
: base(logger, lightlessMediator, pi, LifestreamDescriptor)
|
||||
{
|
||||
_executeLifestreamCommand = pi.GetIpcSubscriber<string, object>("Lifestream.ExecuteCommand");
|
||||
_isHere = pi.GetIpcSubscriber<AddressBookEntryTuple, bool>("Lifestream.IsHere");
|
||||
_goToHousingAddress = pi.GetIpcSubscriber<AddressBookEntryTuple, object>("Lifestream.GoToHousingAddress");
|
||||
_isBusy = pi.GetIpcSubscriber<bool>("Lifestream.IsBusy");
|
||||
_abort = pi.GetIpcSubscriber<object>("Lifestream.Abort");
|
||||
_changeWorld = pi.GetIpcSubscriber<string, bool>("Lifestream.ChangeWorld");
|
||||
_changeWorldById = pi.GetIpcSubscriber<uint, bool>("Lifestream.ChangeWorldById");
|
||||
_aetheryteTeleport = pi.GetIpcSubscriber<string, bool>("Lifestream.AetheryteTeleport");
|
||||
_aetheryteTeleportById = pi.GetIpcSubscriber<uint, bool>("Lifestream.AetheryteTeleportById");
|
||||
_canChangeInstance = pi.GetIpcSubscriber<bool>("Lifestream.CanChangeInstance");
|
||||
_getCurrentInstance = pi.GetIpcSubscriber<int>("Lifestream.GetCurrentInstance");
|
||||
_getNumberOfInstances = pi.GetIpcSubscriber<int>("Lifestream.GetNumberOfInstances");
|
||||
_changeInstance = pi.GetIpcSubscriber<int, object>("Lifestream.ChangeInstance");
|
||||
_getCurrentPlotInfo = pi.GetIpcSubscriber<(ResidentialAetheryteKind, int, int)>("Lifestream.GetCurrentPlotInfo");
|
||||
CheckAPI();
|
||||
}
|
||||
|
||||
public void ExecuteLifestreamCommand(string command)
|
||||
{
|
||||
if (!APIAvailable) return;
|
||||
_executeLifestreamCommand.InvokeAction(command);
|
||||
}
|
||||
|
||||
public bool IsHere(AddressBookEntryTuple entry)
|
||||
{
|
||||
if (!APIAvailable) return false;
|
||||
return _isHere.InvokeFunc(entry);
|
||||
}
|
||||
|
||||
public void GoToHousingAddress(AddressBookEntryTuple entry)
|
||||
{
|
||||
if (!APIAvailable) return;
|
||||
_goToHousingAddress.InvokeAction(entry);
|
||||
}
|
||||
|
||||
public bool IsBusy()
|
||||
{
|
||||
if (!APIAvailable) return false;
|
||||
return _isBusy.InvokeFunc();
|
||||
}
|
||||
|
||||
public void Abort()
|
||||
{
|
||||
if (!APIAvailable) return;
|
||||
_abort.InvokeAction();
|
||||
}
|
||||
|
||||
public bool ChangeWorld(string worldName)
|
||||
{
|
||||
if (!APIAvailable) return false;
|
||||
return _changeWorld.InvokeFunc(worldName);
|
||||
}
|
||||
|
||||
public bool AetheryteTeleport(string aetheryteName)
|
||||
{
|
||||
if (!APIAvailable) return false;
|
||||
return _aetheryteTeleport.InvokeFunc(aetheryteName);
|
||||
}
|
||||
|
||||
public bool ChangeWorldById(uint worldId)
|
||||
{
|
||||
if (!APIAvailable) return false;
|
||||
return _changeWorldById.InvokeFunc(worldId);
|
||||
}
|
||||
|
||||
public bool AetheryteTeleportById(uint aetheryteId)
|
||||
{
|
||||
if (!APIAvailable) return false;
|
||||
return _aetheryteTeleportById.InvokeFunc(aetheryteId);
|
||||
}
|
||||
|
||||
public bool CanChangeInstance()
|
||||
{
|
||||
if (!APIAvailable) return false;
|
||||
return _canChangeInstance.InvokeFunc();
|
||||
}
|
||||
public int GetCurrentInstance()
|
||||
{
|
||||
if (!APIAvailable) return -1;
|
||||
return _getCurrentInstance.InvokeFunc();
|
||||
}
|
||||
public int GetNumberOfInstances()
|
||||
{
|
||||
if (!APIAvailable) return -1;
|
||||
return _getNumberOfInstances.InvokeFunc();
|
||||
}
|
||||
public void ChangeInstance(int instanceNumber)
|
||||
{
|
||||
if (!APIAvailable) return;
|
||||
_changeInstance.InvokeAction(instanceNumber);
|
||||
}
|
||||
public (ResidentialAetheryteKind, int, int)? GetCurrentPlotInfo()
|
||||
{
|
||||
if (!APIAvailable) return (ResidentialAetheryteKind.None, -1, -1);
|
||||
return _getCurrentPlotInfo.InvokeFunc();
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ using LightlessSync.Interop.Ipc.Penumbra;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.ActorTracking;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Penumbra.Api.Enums;
|
||||
@@ -36,8 +35,7 @@ public sealed class IpcCallerPenumbra : IpcServiceBase
|
||||
IDalamudPluginInterface pluginInterface,
|
||||
DalamudUtilService dalamudUtil,
|
||||
LightlessMediator mediator,
|
||||
RedrawManager redrawManager,
|
||||
ActorObjectService actorObjectService) : base(logger, mediator, pluginInterface, PenumbraDescriptor)
|
||||
RedrawManager redrawManager) : base(logger, mediator, pluginInterface, PenumbraDescriptor)
|
||||
{
|
||||
_penumbraEnabled = new GetEnabledState(pluginInterface);
|
||||
_penumbraGetModDirectory = new GetModDirectory(pluginInterface);
|
||||
@@ -46,7 +44,7 @@ public sealed class IpcCallerPenumbra : IpcServiceBase
|
||||
_penumbraModSettingChanged = ModSettingChanged.Subscriber(pluginInterface, HandlePenumbraModSettingChanged);
|
||||
|
||||
_collections = RegisterInterop(new PenumbraCollections(logger, pluginInterface, dalamudUtil, mediator));
|
||||
_resources = RegisterInterop(new PenumbraResource(logger, pluginInterface, dalamudUtil, mediator, actorObjectService));
|
||||
_resources = RegisterInterop(new PenumbraResource(logger, pluginInterface, dalamudUtil, mediator));
|
||||
_redraw = RegisterInterop(new PenumbraRedraw(logger, pluginInterface, dalamudUtil, mediator, redrawManager));
|
||||
_textures = RegisterInterop(new PenumbraTexture(logger, pluginInterface, dalamudUtil, mediator, _redraw));
|
||||
|
||||
@@ -104,8 +102,11 @@ public sealed class IpcCallerPenumbra : IpcServiceBase
|
||||
public Task RedrawAsync(ILogger logger, GameObjectHandler handler, Guid applicationId, CancellationToken token)
|
||||
=> _redraw.RedrawAsync(logger, handler, applicationId, token);
|
||||
|
||||
public Task ConvertTextureFiles(ILogger logger, IReadOnlyList<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? progress, CancellationToken token)
|
||||
=> _textures.ConvertTextureFilesAsync(logger, jobs, progress, token);
|
||||
public void RequestImmediateRedraw(int objectIndex, RedrawType redrawType)
|
||||
=> _redraw.RequestImmediateRedraw(objectIndex, redrawType);
|
||||
|
||||
public Task ConvertTextureFiles(ILogger logger, IReadOnlyList<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? progress, CancellationToken token, bool requestRedraw = true)
|
||||
=> _textures.ConvertTextureFilesAsync(logger, jobs, progress, token, requestRedraw);
|
||||
|
||||
public Task ConvertTextureFileDirectAsync(TextureConversionJob job, CancellationToken token)
|
||||
=> _textures.ConvertTextureFileDirectAsync(job, token);
|
||||
|
||||
@@ -5,9 +5,12 @@ namespace LightlessSync.Interop.Ipc;
|
||||
|
||||
public sealed partial class IpcManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private bool _wasInitialized;
|
||||
|
||||
public IpcManager(ILogger<IpcManager> logger, LightlessMediator mediator,
|
||||
IpcCallerPenumbra penumbraIpc, IpcCallerGlamourer glamourerIpc, IpcCallerCustomize customizeIpc, IpcCallerHeels heelsIpc,
|
||||
IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio) : base(logger, mediator)
|
||||
IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio,
|
||||
IpcCallerLifestream ipcCallerLifestream) : base(logger, mediator)
|
||||
{
|
||||
CustomizePlus = customizeIpc;
|
||||
Heels = heelsIpc;
|
||||
@@ -17,8 +20,10 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
|
||||
Moodles = moodlesIpc;
|
||||
PetNames = ipcCallerPetNames;
|
||||
Brio = ipcCallerBrio;
|
||||
Lifestream = ipcCallerLifestream;
|
||||
|
||||
if (Initialized)
|
||||
_wasInitialized = Initialized;
|
||||
if (_wasInitialized)
|
||||
{
|
||||
Mediator.Publish(new PenumbraInitializedMessage());
|
||||
}
|
||||
@@ -44,8 +49,8 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
|
||||
public IpcCallerPenumbra Penumbra { get; }
|
||||
public IpcCallerMoodles Moodles { get; }
|
||||
public IpcCallerPetNames PetNames { get; }
|
||||
|
||||
public IpcCallerBrio Brio { get; }
|
||||
public IpcCallerLifestream Lifestream { get; }
|
||||
|
||||
private void PeriodicApiStateCheck()
|
||||
{
|
||||
@@ -58,5 +63,14 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
|
||||
Moodles.CheckAPI();
|
||||
PetNames.CheckAPI();
|
||||
Brio.CheckAPI();
|
||||
|
||||
var initialized = Initialized;
|
||||
if (initialized && !_wasInitialized)
|
||||
{
|
||||
Mediator.Publish(new PenumbraInitializedMessage());
|
||||
}
|
||||
|
||||
_wasInitialized = initialized;
|
||||
Lifestream.CheckAPI();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Dalamud.Plugin;
|
||||
using LightlessSync.Interop.Ipc.Framework;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Penumbra.Api.Enums;
|
||||
using Penumbra.Api.IpcSubscribers;
|
||||
|
||||
namespace LightlessSync.Interop.Ipc.Penumbra;
|
||||
@@ -16,10 +14,6 @@ public sealed class PenumbraCollections : PenumbraBase
|
||||
private readonly DeleteTemporaryCollection _removeTemporaryCollection;
|
||||
private readonly AddTemporaryMod _addTemporaryMod;
|
||||
private readonly RemoveTemporaryMod _removeTemporaryMod;
|
||||
private readonly GetCollections _getCollections;
|
||||
private readonly ConcurrentDictionary<Guid, string> _activeTemporaryCollections = new();
|
||||
|
||||
private int _cleanupScheduled;
|
||||
|
||||
public PenumbraCollections(
|
||||
ILogger logger,
|
||||
@@ -32,7 +26,6 @@ public sealed class PenumbraCollections : PenumbraBase
|
||||
_removeTemporaryCollection = new DeleteTemporaryCollection(pluginInterface);
|
||||
_addTemporaryMod = new AddTemporaryMod(pluginInterface);
|
||||
_removeTemporaryMod = new RemoveTemporaryMod(pluginInterface);
|
||||
_getCollections = new GetCollections(pluginInterface);
|
||||
}
|
||||
|
||||
public override string Name => "Penumbra.Collections";
|
||||
@@ -62,16 +55,11 @@ public sealed class PenumbraCollections : PenumbraBase
|
||||
var (collectionId, collectionName) = await DalamudUtil.RunOnFrameworkThread(() =>
|
||||
{
|
||||
var name = $"Lightless_{uid}";
|
||||
_createNamedTemporaryCollection.Invoke(name, name, out var tempCollectionId);
|
||||
logger.LogTrace("Creating Temp Collection {CollectionName}, GUID: {CollectionId}", name, tempCollectionId);
|
||||
var createResult = _createNamedTemporaryCollection.Invoke(name, name, out var tempCollectionId);
|
||||
logger.LogTrace("Creating Temp Collection {CollectionName}, GUID: {CollectionId}, Result: {Result}", name, tempCollectionId, createResult);
|
||||
return (tempCollectionId, name);
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (collectionId != Guid.Empty)
|
||||
{
|
||||
_activeTemporaryCollections[collectionId] = collectionName;
|
||||
}
|
||||
|
||||
return collectionId;
|
||||
}
|
||||
|
||||
@@ -89,7 +77,6 @@ public sealed class PenumbraCollections : PenumbraBase
|
||||
logger.LogTrace("[{ApplicationId}] RemoveTemporaryCollection: {Result}", applicationId, result);
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
_activeTemporaryCollections.TryRemove(collectionId, out _);
|
||||
}
|
||||
|
||||
public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths)
|
||||
@@ -131,67 +118,5 @@ public sealed class PenumbraCollections : PenumbraBase
|
||||
|
||||
protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current)
|
||||
{
|
||||
if (current == IpcConnectionState.Available)
|
||||
{
|
||||
ScheduleCleanup();
|
||||
}
|
||||
else if (previous == IpcConnectionState.Available && current != IpcConnectionState.Available)
|
||||
{
|
||||
Interlocked.Exchange(ref _cleanupScheduled, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private void ScheduleCleanup()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _cleanupScheduled, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(CleanupTemporaryCollectionsAsync);
|
||||
}
|
||||
|
||||
private async Task CleanupTemporaryCollectionsAsync()
|
||||
{
|
||||
if (!IsAvailable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var collections = await DalamudUtil.RunOnFrameworkThread(() => _getCollections.Invoke()).ConfigureAwait(false);
|
||||
foreach (var (collectionId, name) in collections)
|
||||
{
|
||||
if (!IsLightlessCollectionName(name) || _activeTemporaryCollections.ContainsKey(collectionId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Logger.LogDebug("Cleaning up stale temporary collection {CollectionName} ({CollectionId})", name, collectionId);
|
||||
var deleteResult = await DalamudUtil.RunOnFrameworkThread(() =>
|
||||
{
|
||||
var result = (PenumbraApiEc)_removeTemporaryCollection.Invoke(collectionId);
|
||||
Logger.LogTrace("Cleanup RemoveTemporaryCollection result for {CollectionName} ({CollectionId}): {Result}", name, collectionId, result);
|
||||
return result;
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (deleteResult == PenumbraApiEc.Success)
|
||||
{
|
||||
_activeTemporaryCollections.TryRemove(collectionId, out _);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogDebug("Skipped removing temporary collection {CollectionName} ({CollectionId}). Result: {Result}", name, collectionId, deleteResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to clean up Penumbra temporary collections");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLightlessCollectionName(string? name)
|
||||
=> !string.IsNullOrEmpty(name) && name.StartsWith("Lightless_", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ using Dalamud.Plugin;
|
||||
using LightlessSync.Interop.Ipc.Framework;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.ActorTracking;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Penumbra.Api.Helpers;
|
||||
using Penumbra.Api.IpcSubscribers;
|
||||
|
||||
@@ -12,7 +13,6 @@ namespace LightlessSync.Interop.Ipc.Penumbra;
|
||||
|
||||
public sealed class PenumbraResource : PenumbraBase
|
||||
{
|
||||
private readonly ActorObjectService _actorObjectService;
|
||||
private readonly GetGameObjectResourcePaths _gameObjectResourcePaths;
|
||||
private readonly ResolveGameObjectPath _resolveGameObjectPath;
|
||||
private readonly ReverseResolveGameObjectPath _reverseResolveGameObjectPath;
|
||||
@@ -24,10 +24,8 @@ public sealed class PenumbraResource : PenumbraBase
|
||||
ILogger logger,
|
||||
IDalamudPluginInterface pluginInterface,
|
||||
DalamudUtilService dalamudUtil,
|
||||
LightlessMediator mediator,
|
||||
ActorObjectService actorObjectService) : base(logger, pluginInterface, dalamudUtil, mediator)
|
||||
LightlessMediator mediator) : base(logger, pluginInterface, dalamudUtil, mediator)
|
||||
{
|
||||
_actorObjectService = actorObjectService;
|
||||
_gameObjectResourcePaths = new GetGameObjectResourcePaths(pluginInterface);
|
||||
_resolveGameObjectPath = new ResolveGameObjectPath(pluginInterface);
|
||||
_reverseResolveGameObjectPath = new ReverseResolveGameObjectPath(pluginInterface);
|
||||
@@ -45,17 +43,33 @@ public sealed class PenumbraResource : PenumbraBase
|
||||
return null;
|
||||
}
|
||||
|
||||
return await DalamudUtil.RunOnFrameworkThread(() =>
|
||||
var requestId = Guid.NewGuid();
|
||||
var totalTimer = Stopwatch.StartNew();
|
||||
logger.LogTrace("[{requestId}] Requesting Penumbra.GetGameObjectResourcePaths for {handler}", requestId, handler);
|
||||
|
||||
var result = await DalamudUtil.RunOnFrameworkThread(() =>
|
||||
{
|
||||
logger.LogTrace("Calling On IPC: Penumbra.GetGameObjectResourcePaths");
|
||||
var idx = handler.GetGameObject()?.ObjectIndex;
|
||||
if (idx == null)
|
||||
{
|
||||
logger.LogTrace("[{requestId}] GetGameObjectResourcePaths aborted (missing object index) for {handler}", requestId, handler);
|
||||
return null;
|
||||
}
|
||||
|
||||
return _gameObjectResourcePaths.Invoke(idx.Value)[0];
|
||||
logger.LogTrace("[{requestId}] Invoking Penumbra.GetGameObjectResourcePaths for index {index}", requestId, idx.Value);
|
||||
var invokeTimer = Stopwatch.StartNew();
|
||||
var data = _gameObjectResourcePaths.Invoke(idx.Value)[0];
|
||||
invokeTimer.Stop();
|
||||
logger.LogTrace("[{requestId}] Penumbra.GetGameObjectResourcePaths returned {count} entries in {elapsedMs}ms",
|
||||
requestId, data?.Count ?? 0, invokeTimer.ElapsedMilliseconds);
|
||||
return data;
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
totalTimer.Stop();
|
||||
logger.LogTrace("[{requestId}] Penumbra.GetGameObjectResourcePaths finished in {elapsedMs}ms (null: {isNull})",
|
||||
requestId, totalTimer.ElapsedMilliseconds, result is null);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public string GetMetaManipulations()
|
||||
@@ -79,22 +93,10 @@ public sealed class PenumbraResource : PenumbraBase
|
||||
|
||||
private void HandleResourceLoaded(nint ptr, string gamePath, string resolvedPath)
|
||||
{
|
||||
if (ptr == nint.Zero)
|
||||
if (ptr != nint.Zero && string.Compare(gamePath, resolvedPath, ignoreCase: true, CultureInfo.InvariantCulture) != 0)
|
||||
{
|
||||
return;
|
||||
Mediator.Publish(new PenumbraResourceLoadMessage(ptr, gamePath, resolvedPath));
|
||||
}
|
||||
|
||||
if (!_actorObjectService.TryGetOwnedKind(ptr, out _))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Compare(gamePath, resolvedPath, StringComparison.OrdinalIgnoreCase) == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Mediator.Publish(new PenumbraResourceLoadMessage(ptr, gamePath, resolvedPath));
|
||||
}
|
||||
|
||||
protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current)
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class PenumbraTexture : PenumbraBase
|
||||
|
||||
public override string Name => "Penumbra.Textures";
|
||||
|
||||
public async Task ConvertTextureFilesAsync(ILogger logger, IReadOnlyList<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? progress, CancellationToken token)
|
||||
public async Task ConvertTextureFilesAsync(ILogger logger, IReadOnlyList<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? progress, CancellationToken token, bool requestRedraw)
|
||||
{
|
||||
if (!IsAvailable || jobs.Count == 0)
|
||||
{
|
||||
@@ -57,7 +57,7 @@ public sealed class PenumbraTexture : PenumbraBase
|
||||
Mediator.Publish(new ResumeScanMessage(nameof(ConvertTextureFilesAsync)));
|
||||
}
|
||||
|
||||
if (completedJobs > 0 && !token.IsCancellationRequested)
|
||||
if (requestRedraw && completedJobs > 0 && !token.IsCancellationRequested)
|
||||
{
|
||||
await DalamudUtil.RunOnFrameworkThread(async () =>
|
||||
{
|
||||
|
||||
@@ -11,6 +11,10 @@ public sealed class ChatConfig : ILightlessConfiguration
|
||||
public bool ShowRulesOverlayOnOpen { get; set; } = true;
|
||||
public bool ShowMessageTimestamps { get; set; } = true;
|
||||
public bool ShowNotesInSyncshellChat { get; set; } = true;
|
||||
public bool EnableAnimatedEmotes { get; set; } = true;
|
||||
public float EmoteScale { get; set; } = 1.5f;
|
||||
public bool EnableMentionNotifications { get; set; } = true;
|
||||
public bool AutoOpenChatOnNewMessage { get; set; } = false;
|
||||
public float ChatWindowOpacity { get; set; } = .97f;
|
||||
public bool FadeWhenUnfocused { get; set; } = false;
|
||||
public float UnfocusedWindowOpacity { get; set; } = 0.6f;
|
||||
@@ -22,6 +26,9 @@ public sealed class ChatConfig : ILightlessConfiguration
|
||||
public bool ShowWhenUiHidden { get; set; } = true;
|
||||
public bool ShowInCutscenes { get; set; } = true;
|
||||
public bool ShowInGpose { get; set; } = true;
|
||||
public bool PersistSyncshellHistory { get; set; } = false;
|
||||
public List<string> ChannelOrder { get; set; } = new();
|
||||
public Dictionary<string, bool> HiddenChannels { get; set; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, string> SyncshellChannelHistory { get; set; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, bool> PreferNotesForChannels { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.UI;
|
||||
using LightlessSync.UI.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using LightlessSync.PlayerData.Factories;
|
||||
|
||||
namespace LightlessSync.LightlessConfiguration.Configurations;
|
||||
|
||||
@@ -31,6 +32,8 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
public DtrEntry.Colors DtrColorsLightfinderUnavailable { get; set; } = new(Foreground: 0x000000u, Glow: 0x000000u);
|
||||
public LightfinderDtrDisplayMode LightfinderDtrDisplayMode { get; set; } = LightfinderDtrDisplayMode.PendingPairRequests;
|
||||
public bool UseLightlessRedesign { get; set; } = true;
|
||||
public bool ShowUiWhenUiHidden { get; set; } = true;
|
||||
public bool ShowUiInGpose { get; set; } = true;
|
||||
public bool EnableRightClickMenus { get; set; } = true;
|
||||
public NotificationLocation ErrorNotification { get; set; } = NotificationLocation.Both;
|
||||
public string ExportFolder { get; set; } = string.Empty;
|
||||
@@ -51,6 +54,7 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
public bool PreferNotesOverNamesForVisible { get; set; } = false;
|
||||
public VisiblePairSortMode VisiblePairSortMode { get; set; } = VisiblePairSortMode.Alphabetical;
|
||||
public OnlinePairSortMode OnlinePairSortMode { get; set; } = OnlinePairSortMode.Alphabetical;
|
||||
public TextureFormatSortMode TextureFormatSortMode { get; set; } = TextureFormatSortMode.None;
|
||||
public float ProfileDelay { get; set; } = 1.5f;
|
||||
public bool ProfilePopoutRight { get; set; } = false;
|
||||
public bool ProfilesAllowNsfw { get; set; } = false;
|
||||
@@ -155,5 +159,10 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
public bool SyncshellFinderEnabled { get; set; } = false;
|
||||
public string? SelectedFinderSyncshell { get; set; } = null;
|
||||
public string LastSeenVersion { get; set; } = string.Empty;
|
||||
public bool EnableParticleEffects { get; set; } = true;
|
||||
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
|
||||
public List<OrphanableTempCollectionEntry> OrphanableTempCollectionEntries { get; set; } = [];
|
||||
public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Unsafe;
|
||||
public bool AnimationAllowOneBasedShift { get; set; } = false;
|
||||
public bool AnimationAllowNeighborIndexTolerance { get; set; } = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
namespace LightlessSync.LightlessConfiguration.Configurations;
|
||||
|
||||
public static class ModelDecimationDefaults
|
||||
{
|
||||
public const bool EnableAutoDecimation = false;
|
||||
public const int TriangleThreshold = 15_000;
|
||||
public const double TargetRatio = 0.8;
|
||||
public const bool NormalizeTangents = true;
|
||||
public const bool AvoidBodyIntersection = true;
|
||||
|
||||
/// <summary>Default triangle threshold for batch decimation (0 = no threshold).</summary>
|
||||
public const int BatchTriangleThreshold = 0;
|
||||
|
||||
/// <summary>Default target triangle ratio for batch decimation.</summary>
|
||||
public const double BatchTargetRatio = 0.8;
|
||||
|
||||
/// <summary>Default tangent normalization toggle for batch decimation.</summary>
|
||||
public const bool BatchNormalizeTangents = true;
|
||||
|
||||
/// <summary>Default body collision guard toggle for batch decimation.</summary>
|
||||
public const bool BatchAvoidBodyIntersection = true;
|
||||
|
||||
/// <summary>Default display for the batch decimation warning overlay.</summary>
|
||||
public const bool ShowBatchDecimationWarning = true;
|
||||
|
||||
public const bool KeepOriginalModelFiles = true;
|
||||
public const bool SkipPreferredPairs = true;
|
||||
public const bool AllowBody = false;
|
||||
public const bool AllowFaceHead = false;
|
||||
public const bool AllowTail = false;
|
||||
public const bool AllowClothing = true;
|
||||
public const bool AllowAccessories = true;
|
||||
}
|
||||
|
||||
public sealed class ModelDecimationAdvancedSettings
|
||||
{
|
||||
/// <summary>Minimum triangles per connected component before skipping decimation.</summary>
|
||||
public const int DefaultMinComponentTriangles = 6;
|
||||
|
||||
/// <summary>Average-edge multiplier used to cap collapses.</summary>
|
||||
public const float DefaultMaxCollapseEdgeLengthFactor = 1.25f;
|
||||
|
||||
/// <summary>Maximum normal deviation (degrees) allowed for a collapse.</summary>
|
||||
public const float DefaultNormalSimilarityThresholdDegrees = 60f;
|
||||
|
||||
/// <summary>Minimum bone-weight overlap required to allow a collapse.</summary>
|
||||
public const float DefaultBoneWeightSimilarityThreshold = 0.85f;
|
||||
|
||||
/// <summary>UV similarity threshold to protect seams.</summary>
|
||||
public const float DefaultUvSimilarityThreshold = 0.02f;
|
||||
|
||||
/// <summary>UV seam cosine threshold for blocking seam collapses.</summary>
|
||||
public const float DefaultUvSeamAngleCos = 0.99f;
|
||||
|
||||
/// <summary>Whether to block UV seam vertices from collapsing.</summary>
|
||||
public const bool DefaultBlockUvSeamVertices = true;
|
||||
|
||||
/// <summary>Whether to allow collapses on boundary edges.</summary>
|
||||
public const bool DefaultAllowBoundaryCollapses = false;
|
||||
|
||||
/// <summary>Body collision distance factor for the primary pass.</summary>
|
||||
public const float DefaultBodyCollisionDistanceFactor = 0.75f;
|
||||
|
||||
/// <summary>Body collision distance factor for the relaxed fallback pass.</summary>
|
||||
public const float DefaultBodyCollisionNoOpDistanceFactor = 0.25f;
|
||||
|
||||
/// <summary>Relax multiplier applied when the mesh is close to the body.</summary>
|
||||
public const float DefaultBodyCollisionAdaptiveRelaxFactor = 1.0f;
|
||||
|
||||
/// <summary>Ratio of near-body vertices required to trigger relaxation.</summary>
|
||||
public const float DefaultBodyCollisionAdaptiveNearRatio = 0.4f;
|
||||
|
||||
/// <summary>UV threshold for relaxed body-collision mode.</summary>
|
||||
public const float DefaultBodyCollisionAdaptiveUvThreshold = 0.08f;
|
||||
|
||||
/// <summary>UV seam cosine threshold for relaxed body-collision mode.</summary>
|
||||
public const float DefaultBodyCollisionNoOpUvSeamAngleCos = 0.98f;
|
||||
|
||||
/// <summary>Expansion factor for protected vertices near the body.</summary>
|
||||
public const float DefaultBodyCollisionProtectionFactor = 1.5f;
|
||||
|
||||
/// <summary>Minimum ratio used when decimating the body proxy.</summary>
|
||||
public const float DefaultBodyProxyTargetRatioMin = 0.85f;
|
||||
|
||||
/// <summary>Inflation applied to body collision distances.</summary>
|
||||
public const float DefaultBodyCollisionProxyInflate = 0.0005f;
|
||||
|
||||
/// <summary>Body collision penetration factor used during collapse checks.</summary>
|
||||
public const float DefaultBodyCollisionPenetrationFactor = 0.75f;
|
||||
|
||||
/// <summary>Minimum body collision distance threshold.</summary>
|
||||
public const float DefaultMinBodyCollisionDistance = 0.0001f;
|
||||
|
||||
/// <summary>Minimum cell size for body collision spatial hashing.</summary>
|
||||
public const float DefaultMinBodyCollisionCellSize = 0.0001f;
|
||||
|
||||
/// <summary>Minimum triangles per connected component before skipping decimation.</summary>
|
||||
public int MinComponentTriangles { get; set; } = DefaultMinComponentTriangles;
|
||||
|
||||
/// <summary>Average-edge multiplier used to cap collapses.</summary>
|
||||
public float MaxCollapseEdgeLengthFactor { get; set; } = DefaultMaxCollapseEdgeLengthFactor;
|
||||
|
||||
/// <summary>Maximum normal deviation (degrees) allowed for a collapse.</summary>
|
||||
public float NormalSimilarityThresholdDegrees { get; set; } = DefaultNormalSimilarityThresholdDegrees;
|
||||
|
||||
/// <summary>Minimum bone-weight overlap required to allow a collapse.</summary>
|
||||
public float BoneWeightSimilarityThreshold { get; set; } = DefaultBoneWeightSimilarityThreshold;
|
||||
|
||||
/// <summary>UV similarity threshold to protect seams.</summary>
|
||||
public float UvSimilarityThreshold { get; set; } = DefaultUvSimilarityThreshold;
|
||||
|
||||
/// <summary>UV seam cosine threshold for blocking seam collapses.</summary>
|
||||
public float UvSeamAngleCos { get; set; } = DefaultUvSeamAngleCos;
|
||||
|
||||
/// <summary>Whether to block UV seam vertices from collapsing.</summary>
|
||||
public bool BlockUvSeamVertices { get; set; } = DefaultBlockUvSeamVertices;
|
||||
|
||||
/// <summary>Whether to allow collapses on boundary edges.</summary>
|
||||
public bool AllowBoundaryCollapses { get; set; } = DefaultAllowBoundaryCollapses;
|
||||
|
||||
/// <summary>Body collision distance factor for the primary pass.</summary>
|
||||
public float BodyCollisionDistanceFactor { get; set; } = DefaultBodyCollisionDistanceFactor;
|
||||
|
||||
/// <summary>Body collision distance factor for the relaxed fallback pass.</summary>
|
||||
public float BodyCollisionNoOpDistanceFactor { get; set; } = DefaultBodyCollisionNoOpDistanceFactor;
|
||||
|
||||
/// <summary>Relax multiplier applied when the mesh is close to the body.</summary>
|
||||
public float BodyCollisionAdaptiveRelaxFactor { get; set; } = DefaultBodyCollisionAdaptiveRelaxFactor;
|
||||
|
||||
/// <summary>Ratio of near-body vertices required to trigger relaxation.</summary>
|
||||
public float BodyCollisionAdaptiveNearRatio { get; set; } = DefaultBodyCollisionAdaptiveNearRatio;
|
||||
|
||||
/// <summary>UV threshold for relaxed body-collision mode.</summary>
|
||||
public float BodyCollisionAdaptiveUvThreshold { get; set; } = DefaultBodyCollisionAdaptiveUvThreshold;
|
||||
|
||||
/// <summary>UV seam cosine threshold for relaxed body-collision mode.</summary>
|
||||
public float BodyCollisionNoOpUvSeamAngleCos { get; set; } = DefaultBodyCollisionNoOpUvSeamAngleCos;
|
||||
|
||||
/// <summary>Expansion factor for protected vertices near the body.</summary>
|
||||
public float BodyCollisionProtectionFactor { get; set; } = DefaultBodyCollisionProtectionFactor;
|
||||
|
||||
/// <summary>Minimum ratio used when decimating the body proxy.</summary>
|
||||
public float BodyProxyTargetRatioMin { get; set; } = DefaultBodyProxyTargetRatioMin;
|
||||
|
||||
/// <summary>Inflation applied to body collision distances.</summary>
|
||||
public float BodyCollisionProxyInflate { get; set; } = DefaultBodyCollisionProxyInflate;
|
||||
|
||||
/// <summary>Body collision penetration factor used during collapse checks.</summary>
|
||||
public float BodyCollisionPenetrationFactor { get; set; } = DefaultBodyCollisionPenetrationFactor;
|
||||
|
||||
/// <summary>Minimum body collision distance threshold.</summary>
|
||||
public float MinBodyCollisionDistance { get; set; } = DefaultMinBodyCollisionDistance;
|
||||
|
||||
/// <summary>Minimum cell size for body collision spatial hashing.</summary>
|
||||
public float MinBodyCollisionCellSize { get; set; } = DefaultMinBodyCollisionCellSize;
|
||||
}
|
||||
@@ -21,5 +21,26 @@ public class PlayerPerformanceConfig : ILightlessConfiguration
|
||||
public bool EnableIndexTextureDownscale { get; set; } = false;
|
||||
public int TextureDownscaleMaxDimension { get; set; } = 2048;
|
||||
public bool OnlyDownscaleUncompressedTextures { get; set; } = true;
|
||||
public bool EnableUncompressedTextureCompression { get; set; } = false;
|
||||
public bool SkipUncompressedTextureCompressionMipMaps { get; set; } = false;
|
||||
public bool KeepOriginalTextureFiles { get; set; } = false;
|
||||
public bool SkipTextureDownscaleForPreferredPairs { get; set; } = true;
|
||||
public bool EnableModelDecimation { get; set; } = ModelDecimationDefaults.EnableAutoDecimation;
|
||||
public int ModelDecimationTriangleThreshold { get; set; } = ModelDecimationDefaults.TriangleThreshold;
|
||||
public double ModelDecimationTargetRatio { get; set; } = ModelDecimationDefaults.TargetRatio;
|
||||
public bool ModelDecimationNormalizeTangents { get; set; } = ModelDecimationDefaults.NormalizeTangents;
|
||||
public bool ModelDecimationAvoidBodyIntersection { get; set; } = ModelDecimationDefaults.AvoidBodyIntersection;
|
||||
public ModelDecimationAdvancedSettings ModelDecimationAdvanced { get; set; } = new();
|
||||
public int BatchModelDecimationTriangleThreshold { get; set; } = ModelDecimationDefaults.BatchTriangleThreshold;
|
||||
public double BatchModelDecimationTargetRatio { get; set; } = ModelDecimationDefaults.BatchTargetRatio;
|
||||
public bool BatchModelDecimationNormalizeTangents { get; set; } = ModelDecimationDefaults.BatchNormalizeTangents;
|
||||
public bool BatchModelDecimationAvoidBodyIntersection { get; set; } = ModelDecimationDefaults.BatchAvoidBodyIntersection;
|
||||
public bool ShowBatchModelDecimationWarning { get; set; } = ModelDecimationDefaults.ShowBatchDecimationWarning;
|
||||
public bool KeepOriginalModelFiles { get; set; } = ModelDecimationDefaults.KeepOriginalModelFiles;
|
||||
public bool SkipModelDecimationForPreferredPairs { get; set; } = ModelDecimationDefaults.SkipPreferredPairs;
|
||||
public bool ModelDecimationAllowBody { get; set; } = ModelDecimationDefaults.AllowBody;
|
||||
public bool ModelDecimationAllowFaceHead { get; set; } = ModelDecimationDefaults.AllowFaceHead;
|
||||
public bool ModelDecimationAllowTail { get; set; } = ModelDecimationDefaults.AllowTail;
|
||||
public bool ModelDecimationAllowClothing { get; set; } = ModelDecimationDefaults.AllowClothing;
|
||||
public bool ModelDecimationAllowAccessories { get; set; } = ModelDecimationDefaults.AllowAccessories;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace LightlessSync.LightlessConfiguration.Configurations;
|
||||
public class XivDataStorageConfig : ILightlessConfiguration
|
||||
{
|
||||
public ConcurrentDictionary<string, long> TriangleDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public ConcurrentDictionary<string, long> EffectiveTriangleDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public ConcurrentDictionary<string, Dictionary<string, List<ushort>>> BonesDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public int Version { get; set; } = 0;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LightlessSync.LightlessConfiguration.Models;
|
||||
|
||||
public sealed class OrphanableTempCollectionEntry
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public DateTime RegisteredAtUtc { get; set; } = DateTime.MinValue;
|
||||
}
|
||||
@@ -74,6 +74,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
private readonly LightlessConfigService _lightlessConfigService;
|
||||
private readonly ServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly PairHandlerRegistry _pairHandlerRegistry;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private IServiceScope? _runtimeServiceScope;
|
||||
private Task? _launchTask = null;
|
||||
@@ -81,11 +82,13 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
|
||||
public LightlessPlugin(ILogger<LightlessPlugin> logger, LightlessConfigService lightlessConfigService,
|
||||
ServerConfigurationManager serverConfigurationManager,
|
||||
DalamudUtilService dalamudUtil,
|
||||
PairHandlerRegistry pairHandlerRegistry,
|
||||
IServiceScopeFactory serviceScopeFactory, LightlessMediator mediator) : base(logger, mediator)
|
||||
{
|
||||
_lightlessConfigService = lightlessConfigService;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_dalamudUtil = dalamudUtil;
|
||||
_pairHandlerRegistry = pairHandlerRegistry;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
}
|
||||
|
||||
@@ -108,12 +111,20 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Logger.LogDebug("Halting LightlessPlugin");
|
||||
try
|
||||
{
|
||||
_pairHandlerRegistry.ResetAllHandlers();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to reset pair handlers on shutdown");
|
||||
}
|
||||
|
||||
UnsubscribeAll();
|
||||
|
||||
DalamudUtilOnLogOut();
|
||||
|
||||
Logger.LogDebug("Halting LightlessPlugin");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors></Authors>
|
||||
<Company></Company>
|
||||
<Version>2.0.2</Version>
|
||||
<Version>2.0.2.78</Version>
|
||||
<Description></Description>
|
||||
<Copyright></Copyright>
|
||||
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>
|
||||
@@ -24,6 +24,15 @@
|
||||
<Compile Remove="PlayerData\Export\**" />
|
||||
<EmbeddedResource 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>
|
||||
@@ -37,6 +46,7 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||
<PackageReference Include="Glamourer.Api" Version="2.8.0" />
|
||||
<PackageReference Include="NReco.Logging.File" Version="1.3.1" />
|
||||
@@ -67,8 +77,6 @@
|
||||
</None>
|
||||
<EmbeddedResource Include="Changelog\changelog.yaml" />
|
||||
<EmbeddedResource Include="Changelog\credits.yaml" />
|
||||
<EmbeddedResource Include="Localization\de.json" />
|
||||
<EmbeddedResource Include="Localization\fr.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -77,6 +85,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LightlessAPI\LightlessSyncAPI\LightlessSync.API.csproj" />
|
||||
<ProjectReference Include="..\LightlessCompactor\LightlessCompactor.csproj" />
|
||||
<ProjectReference Include="..\LightlessCompactorWorker\LightlessCompactorWorker.csproj" ReferenceOutputAssembly="false" />
|
||||
<ProjectReference Include="..\Penumbra.Api\Penumbra.Api.csproj" />
|
||||
<ProjectReference Include="..\Penumbra.GameData\Penumbra.GameData.csproj" />
|
||||
<ProjectReference Include="..\Penumbra.String\Penumbra.String.csproj" />
|
||||
@@ -100,5 +110,13 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Update="DalamudPackager" Version="14.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<CompactorWorkerFiles Include="..\LightlessCompactorWorker\bin\$(Configuration)\net10.0\*.*" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyCompactorWorker" AfterTargets="Build">
|
||||
<Copy SourceFiles="@(CompactorWorkerFiles)" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace LightlessSync.PlayerData.Factories
|
||||
{
|
||||
public enum AnimationValidationMode
|
||||
{
|
||||
Unsafe = 0,
|
||||
Safe = 1,
|
||||
Safest = 2,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Services.ModelDecimation;
|
||||
using LightlessSync.Services.TextureCompression;
|
||||
using LightlessSync.WebAPI.Files;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -16,7 +17,9 @@ public class FileDownloadManagerFactory
|
||||
private readonly FileCompactor _fileCompactor;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly TextureDownscaleService _textureDownscaleService;
|
||||
private readonly ModelDecimationService _modelDecimationService;
|
||||
private readonly TextureMetadataHelper _textureMetadataHelper;
|
||||
private readonly FileDownloadDeduplicator _downloadDeduplicator;
|
||||
|
||||
public FileDownloadManagerFactory(
|
||||
ILoggerFactory loggerFactory,
|
||||
@@ -26,7 +29,9 @@ public class FileDownloadManagerFactory
|
||||
FileCompactor fileCompactor,
|
||||
LightlessConfigService configService,
|
||||
TextureDownscaleService textureDownscaleService,
|
||||
TextureMetadataHelper textureMetadataHelper)
|
||||
ModelDecimationService modelDecimationService,
|
||||
TextureMetadataHelper textureMetadataHelper,
|
||||
FileDownloadDeduplicator downloadDeduplicator)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_lightlessMediator = lightlessMediator;
|
||||
@@ -35,7 +40,9 @@ public class FileDownloadManagerFactory
|
||||
_fileCompactor = fileCompactor;
|
||||
_configService = configService;
|
||||
_textureDownscaleService = textureDownscaleService;
|
||||
_modelDecimationService = modelDecimationService;
|
||||
_textureMetadataHelper = textureMetadataHelper;
|
||||
_downloadDeduplicator = downloadDeduplicator;
|
||||
}
|
||||
|
||||
public FileDownloadManager Create()
|
||||
@@ -48,6 +55,8 @@ public class FileDownloadManagerFactory
|
||||
_fileCompactor,
|
||||
_configService,
|
||||
_textureDownscaleService,
|
||||
_textureMetadataHelper);
|
||||
_modelDecimationService,
|
||||
_textureMetadataHelper,
|
||||
_downloadDeduplicator);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||
using Dalamud.Utility;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.Interop.Ipc;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.PlayerData.Data;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace LightlessSync.PlayerData.Factories;
|
||||
|
||||
@@ -18,13 +24,34 @@ public class PlayerDataFactory
|
||||
private readonly IpcManager _ipcManager;
|
||||
private readonly ILogger<PlayerDataFactory> _logger;
|
||||
private readonly PerformanceCollectorService _performanceCollector;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly XivDataAnalyzer _modelAnalyzer;
|
||||
private readonly LightlessMediator _lightlessMediator;
|
||||
private readonly TransientResourceManager _transientResourceManager;
|
||||
private static readonly SemaphoreSlim _papParseLimiter = new(1, 1);
|
||||
|
||||
public PlayerDataFactory(ILogger<PlayerDataFactory> logger, DalamudUtilService dalamudUtil, IpcManager ipcManager,
|
||||
TransientResourceManager transientResourceManager, FileCacheManager fileReplacementFactory,
|
||||
PerformanceCollectorService performanceCollector, XivDataAnalyzer modelAnalyzer, LightlessMediator lightlessMediator)
|
||||
// Transient resolved entries threshold
|
||||
private const int _maxTransientResolvedEntries = 1000;
|
||||
|
||||
// Character build caches
|
||||
private readonly TaskRegistry<nint> _characterBuildInflight = new();
|
||||
private readonly ConcurrentDictionary<nint, CacheEntry> _characterBuildCache = new();
|
||||
|
||||
// Time out thresholds
|
||||
private static readonly TimeSpan _characterCacheTtl = TimeSpan.FromMilliseconds(750);
|
||||
private static readonly TimeSpan _softReturnIfBusyAfter = TimeSpan.FromMilliseconds(250);
|
||||
private static readonly TimeSpan _hardBuildTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
public PlayerDataFactory(
|
||||
ILogger<PlayerDataFactory> logger,
|
||||
DalamudUtilService dalamudUtil,
|
||||
IpcManager ipcManager,
|
||||
TransientResourceManager transientResourceManager,
|
||||
FileCacheManager fileReplacementFactory,
|
||||
PerformanceCollectorService performanceCollector,
|
||||
XivDataAnalyzer modelAnalyzer,
|
||||
LightlessMediator lightlessMediator,
|
||||
LightlessConfigService configService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dalamudUtil = dalamudUtil;
|
||||
@@ -34,15 +61,15 @@ public class PlayerDataFactory
|
||||
_performanceCollector = performanceCollector;
|
||||
_modelAnalyzer = modelAnalyzer;
|
||||
_lightlessMediator = lightlessMediator;
|
||||
_configService = configService;
|
||||
_logger.LogTrace("Creating {this}", nameof(PlayerDataFactory));
|
||||
}
|
||||
private sealed record CacheEntry(CharacterDataFragment Fragment, DateTime CreatedUtc);
|
||||
|
||||
public async Task<CharacterDataFragment?> BuildCharacterData(GameObjectHandler playerRelatedObject, CancellationToken token)
|
||||
{
|
||||
if (!_ipcManager.Initialized)
|
||||
{
|
||||
throw new InvalidOperationException("Penumbra or Glamourer is not connected");
|
||||
}
|
||||
|
||||
if (playerRelatedObject == null) return null;
|
||||
|
||||
@@ -67,16 +94,17 @@ public class PlayerDataFactory
|
||||
|
||||
if (pointerIsZero)
|
||||
{
|
||||
_logger.LogTrace("Pointer was zero for {objectKind}", playerRelatedObject.ObjectKind);
|
||||
_logger.LogTrace("Pointer was zero for {objectKind}; couldn't build character", playerRelatedObject.ObjectKind);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _performanceCollector.LogPerformance(this, $"CreateCharacterData>{playerRelatedObject.ObjectKind}", async () =>
|
||||
{
|
||||
return await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false);
|
||||
}).ConfigureAwait(true);
|
||||
return await _performanceCollector.LogPerformance(
|
||||
this,
|
||||
$"CreateCharacterData>{playerRelatedObject.ObjectKind}",
|
||||
async () => await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false)
|
||||
).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -92,17 +120,17 @@ public class PlayerDataFactory
|
||||
}
|
||||
|
||||
private async Task<bool> CheckForNullDrawObject(IntPtr playerPointer)
|
||||
{
|
||||
return await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
|
||||
}
|
||||
=> await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
|
||||
|
||||
private unsafe bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
|
||||
private unsafe static bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
|
||||
{
|
||||
if (playerPointer == IntPtr.Zero)
|
||||
return true;
|
||||
|
||||
var character = (Character*)playerPointer;
|
||||
if (!IsPointerValid(playerPointer))
|
||||
return true;
|
||||
|
||||
var character = (Character*)playerPointer;
|
||||
if (character == null)
|
||||
return true;
|
||||
|
||||
@@ -110,96 +138,204 @@ public class PlayerDataFactory
|
||||
if (gameObject == null)
|
||||
return true;
|
||||
|
||||
if (!IsPointerValid((IntPtr)gameObject))
|
||||
return true;
|
||||
|
||||
return gameObject->DrawObject == null;
|
||||
}
|
||||
|
||||
private async Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
|
||||
private static bool IsPointerValid(IntPtr ptr)
|
||||
{
|
||||
if (ptr == IntPtr.Zero)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
_ = Marshal.ReadByte(ptr);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCacheFresh(CacheEntry entry)
|
||||
=> (DateTime.UtcNow - entry.CreatedUtc) <= _characterCacheTtl;
|
||||
|
||||
private Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
|
||||
=> CreateCharacterDataCoalesced(playerRelatedObject, ct);
|
||||
|
||||
private async Task<CharacterDataFragment> CreateCharacterDataCoalesced(GameObjectHandler obj, CancellationToken ct)
|
||||
{
|
||||
var key = obj.Address;
|
||||
|
||||
if (_characterBuildCache.TryGetValue(key, out CacheEntry cached) && IsCacheFresh(cached) && !_characterBuildInflight.TryGetExisting(key, out _))
|
||||
return cached.Fragment;
|
||||
|
||||
Task<CharacterDataFragment> buildTask = _characterBuildInflight.GetOrStart(key, () => BuildAndCacheAsync(obj, key));
|
||||
|
||||
if (_characterBuildCache.TryGetValue(key, out cached))
|
||||
{
|
||||
var completed = await Task.WhenAny(buildTask, Task.Delay(_softReturnIfBusyAfter, ct)).ConfigureAwait(false);
|
||||
if (completed != buildTask && (DateTime.UtcNow - cached.CreatedUtc) <= TimeSpan.FromSeconds(5))
|
||||
{
|
||||
return cached.Fragment;
|
||||
}
|
||||
}
|
||||
|
||||
return await WithCancellation(buildTask, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<CharacterDataFragment> BuildAndCacheAsync(GameObjectHandler obj, nint key)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(_hardBuildTimeout);
|
||||
CharacterDataFragment fragment = await CreateCharacterDataInternal(obj, cts.Token).ConfigureAwait(false);
|
||||
|
||||
_characterBuildCache[key] = new CacheEntry(fragment, DateTime.UtcNow);
|
||||
PruneCharacterCacheIfNeeded();
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
private void PruneCharacterCacheIfNeeded()
|
||||
{
|
||||
if (_characterBuildCache.Count < 2048) return;
|
||||
|
||||
var cutoff = DateTime.UtcNow - TimeSpan.FromSeconds(10);
|
||||
foreach (var kv in _characterBuildCache)
|
||||
{
|
||||
if (kv.Value.CreatedUtc < cutoff)
|
||||
_characterBuildCache.TryRemove(kv.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<T> WithCancellation<T>(Task<T> task, CancellationToken ct)
|
||||
=> await task.WaitAsync(ct).ConfigureAwait(false);
|
||||
|
||||
private async Task<CharacterDataFragment> CreateCharacterDataInternal(GameObjectHandler playerRelatedObject, CancellationToken ct)
|
||||
{
|
||||
var objectKind = playerRelatedObject.ObjectKind;
|
||||
CharacterDataFragment fragment = objectKind == ObjectKind.Player ? new CharacterDataFragmentPlayer() : new();
|
||||
|
||||
_logger.LogDebug("Building character data for {obj}", playerRelatedObject);
|
||||
var logDebug = _logger.IsEnabled(LogLevel.Debug);
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// wait until chara is not drawing and present so nothing spontaneously explodes
|
||||
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct).ConfigureAwait(false);
|
||||
int totalWaitTime = 10000;
|
||||
while (!await _dalamudUtil.IsObjectPresentAsync(await _dalamudUtil.CreateGameObjectAsync(playerRelatedObject.Address).ConfigureAwait(false)).ConfigureAwait(false) && totalWaitTime > 0)
|
||||
_logger.LogDebug("Building character data for {obj}", playerRelatedObject);
|
||||
|
||||
await EnsureObjectPresentAsync(playerRelatedObject, ct).ConfigureAwait(false);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var waitRecordingTask = _transientResourceManager.WaitForRecording(ct);
|
||||
|
||||
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// get all remaining paths and resolve them
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (await CheckForNullDrawObject(playerRelatedObject.Address).ConfigureAwait(false))
|
||||
throw new InvalidOperationException("DrawObject became null during build (actor despawned)");
|
||||
|
||||
Task<string> getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address);
|
||||
Task<string?> getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address);
|
||||
Task<string?>? getMoodlesData = null;
|
||||
Task<string>? getHeelsOffset = null;
|
||||
Task<string>? getHonorificTitle = null;
|
||||
|
||||
if (objectKind == ObjectKind.Player)
|
||||
{
|
||||
_logger.LogTrace("Character is null but it shouldn't be, waiting");
|
||||
await Task.Delay(50, ct).ConfigureAwait(false);
|
||||
totalWaitTime -= 50;
|
||||
getHeelsOffset = _ipcManager.Heels.GetOffsetAsync();
|
||||
getHonorificTitle = _ipcManager.Honorific.GetTitle();
|
||||
getMoodlesData = _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address);
|
||||
}
|
||||
|
||||
Guid penumbraRequestId = Guid.Empty;
|
||||
Stopwatch? penumbraSw = null;
|
||||
if (logDebug)
|
||||
{
|
||||
penumbraRequestId = Guid.NewGuid();
|
||||
penumbraSw = Stopwatch.StartNew();
|
||||
_logger.LogDebug("Penumbra GetCharacterData start {id} for {obj}", penumbraRequestId, playerRelatedObject);
|
||||
}
|
||||
|
||||
var resolvedPaths = await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false);
|
||||
|
||||
if (logDebug)
|
||||
{
|
||||
penumbraSw!.Stop();
|
||||
_logger.LogDebug("Penumbra GetCharacterData done {id} in {elapsedMs}ms (count={count})",
|
||||
penumbraRequestId,
|
||||
penumbraSw.ElapsedMilliseconds,
|
||||
resolvedPaths?.Count ?? -1);
|
||||
}
|
||||
|
||||
if (resolvedPaths == null)
|
||||
throw new InvalidOperationException("Penumbra returned null data; couldn't proceed with character");
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
DateTime start = DateTime.UtcNow;
|
||||
var staticBuildTask = Task.Run(() => BuildStaticReplacements(resolvedPaths), ct);
|
||||
|
||||
// penumbra call, it's currently broken
|
||||
Dictionary<string, HashSet<string>>? resolvedPaths;
|
||||
|
||||
resolvedPaths = (await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false));
|
||||
if (resolvedPaths == null) throw new InvalidOperationException("Penumbra returned null data");
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
fragment.FileReplacements =
|
||||
new HashSet<FileReplacement>(resolvedPaths.Select(c => new FileReplacement([.. c.Value], c.Key)), FileReplacementComparer.Instance)
|
||||
.Where(p => p.HasFileReplacement).ToHashSet();
|
||||
fragment.FileReplacements.RemoveWhere(c => c.GamePaths.Any(g => !CacheMonitor.AllowedFileExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase))));
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
fragment.FileReplacements = await staticBuildTask.ConfigureAwait(false);
|
||||
|
||||
if (logDebug)
|
||||
{
|
||||
_logger.LogDebug("== Static Replacements ==");
|
||||
foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement).OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
|
||||
foreach (var replacement in fragment.FileReplacements
|
||||
.Where(i => i.HasFileReplacement)
|
||||
.OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogDebug("=> {repl}", replacement);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
var staticReplacements = new HashSet<FileReplacement>(fragment.FileReplacements, FileReplacementComparer.Instance);
|
||||
|
||||
var transientTask = ResolveTransientReplacementsAsync(
|
||||
playerRelatedObject,
|
||||
objectKind,
|
||||
staticReplacements,
|
||||
waitRecordingTask,
|
||||
ct);
|
||||
|
||||
fragment.GlamourerString = await getGlamourerData.ConfigureAwait(false);
|
||||
_logger.LogDebug("Glamourer is now: {data}", fragment.GlamourerString);
|
||||
|
||||
var customizeScale = await getCustomizeData.ConfigureAwait(false);
|
||||
fragment.CustomizePlusScale = customizeScale ?? string.Empty;
|
||||
_logger.LogDebug("Customize is now: {data}", fragment.CustomizePlusScale);
|
||||
|
||||
if (objectKind == ObjectKind.Player)
|
||||
{
|
||||
foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
}
|
||||
}
|
||||
CharacterDataFragmentPlayer? playerFragment = fragment as CharacterDataFragmentPlayer ?? throw new InvalidOperationException("Failed to cast CharacterDataFragment to Player variant");
|
||||
|
||||
await _transientResourceManager.WaitForRecording(ct).ConfigureAwait(false);
|
||||
playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations();
|
||||
playerFragment.HonorificData = await getHonorificTitle!.ConfigureAwait(false);
|
||||
_logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData);
|
||||
|
||||
// if it's pet then it's summoner, if it's summoner we actually want to keep all filereplacements alive at all times
|
||||
// or we get into redraw city for every change and nothing works properly
|
||||
if (objectKind == ObjectKind.Pet)
|
||||
{
|
||||
foreach (var item in fragment.FileReplacements.Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths))
|
||||
{
|
||||
if (_transientResourceManager.AddTransientResource(objectKind, item))
|
||||
{
|
||||
_logger.LogDebug("Marking static {item} for Pet as transient", item);
|
||||
}
|
||||
}
|
||||
playerFragment.PetNamesData = _ipcManager.PetNames.GetLocalNames();
|
||||
_logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);
|
||||
|
||||
_logger.LogTrace("Clearing {count} Static Replacements for Pet", fragment.FileReplacements.Count);
|
||||
fragment.FileReplacements.Clear();
|
||||
playerFragment.HeelsData = await getHeelsOffset!.ConfigureAwait(false);
|
||||
_logger.LogDebug("Heels is now: {heels}", playerFragment!.HeelsData);
|
||||
|
||||
playerFragment.MoodlesData = (await getMoodlesData!.ConfigureAwait(false)) ?? string.Empty;
|
||||
_logger.LogDebug("Moodles is now: {moodles}", playerFragment!.MoodlesData);
|
||||
}
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
_logger.LogDebug("Handling transient update for {obj}", playerRelatedObject);
|
||||
|
||||
// remove all potentially gathered paths from the transient resource manager that are resolved through static resolving
|
||||
_transientResourceManager.ClearTransientPaths(objectKind, fragment.FileReplacements.SelectMany(c => c.GamePaths).ToList());
|
||||
|
||||
// get all remaining paths and resolve them
|
||||
var transientPaths = ManageSemiTransientData(objectKind);
|
||||
var resolvedTransientPaths = await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
|
||||
var (resolvedTransientPaths, clearedForPet) = await transientTask.ConfigureAwait(false);
|
||||
if (clearedForPet != null)
|
||||
fragment.FileReplacements.Clear();
|
||||
|
||||
if (logDebug)
|
||||
{
|
||||
_logger.LogDebug("== Transient Replacements ==");
|
||||
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)).OrderBy(f => f.ResolvedPath, StringComparer.Ordinal))
|
||||
foreach (var replacement in resolvedTransientPaths
|
||||
.Select(c => new FileReplacement([.. c.Value], c.Key))
|
||||
.OrderBy(f => f.ResolvedPath, StringComparer.Ordinal))
|
||||
{
|
||||
_logger.LogDebug("=> {repl}", replacement);
|
||||
fragment.FileReplacements.Add(replacement);
|
||||
@@ -208,85 +344,64 @@ public class PlayerDataFactory
|
||||
else
|
||||
{
|
||||
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)))
|
||||
{
|
||||
fragment.FileReplacements.Add(replacement);
|
||||
}
|
||||
}
|
||||
|
||||
// clean up all semi transient resources that don't have any file replacement (aka null resolve)
|
||||
_transientResourceManager.CleanUpSemiTransientResources(objectKind, [.. fragment.FileReplacements]);
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// make sure we only return data that actually has file replacements
|
||||
fragment.FileReplacements = new HashSet<FileReplacement>(fragment.FileReplacements.Where(v => v.HasFileReplacement).OrderBy(v => v.ResolvedPath, StringComparer.Ordinal), FileReplacementComparer.Instance);
|
||||
|
||||
// gather up data from ipc
|
||||
Task<string> getHeelsOffset = _ipcManager.Heels.GetOffsetAsync();
|
||||
Task<string> getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address);
|
||||
Task<string?> getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address);
|
||||
Task<string> getHonorificTitle = _ipcManager.Honorific.GetTitle();
|
||||
fragment.GlamourerString = await getGlamourerData.ConfigureAwait(false);
|
||||
_logger.LogDebug("Glamourer is now: {data}", fragment.GlamourerString);
|
||||
var customizeScale = await getCustomizeData.ConfigureAwait(false);
|
||||
fragment.CustomizePlusScale = customizeScale ?? string.Empty;
|
||||
_logger.LogDebug("Customize is now: {data}", fragment.CustomizePlusScale);
|
||||
|
||||
if (objectKind == ObjectKind.Player)
|
||||
{
|
||||
var playerFragment = (fragment as CharacterDataFragmentPlayer)!;
|
||||
playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations();
|
||||
|
||||
playerFragment!.HonorificData = await getHonorificTitle.ConfigureAwait(false);
|
||||
_logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData);
|
||||
|
||||
playerFragment!.HeelsData = await getHeelsOffset.ConfigureAwait(false);
|
||||
_logger.LogDebug("Heels is now: {heels}", playerFragment!.HeelsData);
|
||||
|
||||
playerFragment!.MoodlesData = await _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address).ConfigureAwait(false) ?? string.Empty;
|
||||
_logger.LogDebug("Moodles is now: {moodles}", playerFragment!.MoodlesData);
|
||||
|
||||
playerFragment!.PetNamesData = _ipcManager.PetNames.GetLocalNames();
|
||||
_logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);
|
||||
}
|
||||
fragment.FileReplacements = new HashSet<FileReplacement>(
|
||||
fragment.FileReplacements
|
||||
.Where(v => v.HasFileReplacement)
|
||||
.OrderBy(v => v.ResolvedPath, StringComparer.Ordinal),
|
||||
FileReplacementComparer.Instance);
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var toCompute = fragment.FileReplacements.Where(f => !f.IsFileSwap).ToArray();
|
||||
_logger.LogDebug("Getting Hashes for {amount} Files", toCompute.Length);
|
||||
var computedPaths = _fileCacheManager.GetFileCachesByPaths(toCompute.Select(c => c.ResolvedPath).ToArray());
|
||||
foreach (var file in toCompute)
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty;
|
||||
}
|
||||
var computedPaths = _fileCacheManager.GetFileCachesByPaths([.. toCompute.Select(c => c.ResolvedPath)]);
|
||||
foreach (var file in toCompute)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty;
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
var removed = fragment.FileReplacements.RemoveWhere(f => !f.IsFileSwap && string.IsNullOrEmpty(f.Hash));
|
||||
if (removed > 0)
|
||||
{
|
||||
_logger.LogDebug("Removed {amount} of invalid files", removed);
|
||||
}
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
Dictionary<string, List<ushort>>? boneIndices = null;
|
||||
var hasPapFiles = false;
|
||||
if (objectKind == ObjectKind.Player)
|
||||
{
|
||||
hasPapFiles = fragment.FileReplacements.Any(f =>
|
||||
!f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase));
|
||||
if (hasPapFiles)
|
||||
{
|
||||
boneIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (objectKind == ObjectKind.Player)
|
||||
{
|
||||
hasPapFiles = fragment.FileReplacements.Any(f =>
|
||||
!f.IsFileSwap && f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
if (hasPapFiles)
|
||||
{
|
||||
boneIndices = await _dalamudUtil
|
||||
.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
#if DEBUG
|
||||
if (hasPapFiles && boneIndices != null)
|
||||
_modelAnalyzer.DumpLocalSkeletonIndices(playerRelatedObject);
|
||||
#endif
|
||||
|
||||
if (hasPapFiles)
|
||||
{
|
||||
await VerifyPlayerAnimationBones(boneIndices, (fragment as CharacterDataFragmentPlayer)!, ct).ConfigureAwait(false);
|
||||
await VerifyPlayerAnimationBones(boneIndices, (CharacterDataFragmentPlayer)fragment, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException e)
|
||||
@@ -300,171 +415,320 @@ public class PlayerDataFactory
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Building character data for {obj} took {time}ms", objectKind, TimeSpan.FromTicks(DateTime.UtcNow.Ticks - start.Ticks).TotalMilliseconds);
|
||||
_logger.LogInformation("Building character data for {obj} took {time}ms",
|
||||
objectKind, sw.Elapsed.TotalMilliseconds);
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
private async Task VerifyPlayerAnimationBones(Dictionary<string, List<ushort>>? boneIndices, CharacterDataFragmentPlayer fragment, CancellationToken ct)
|
||||
private async Task EnsureObjectPresentAsync(GameObjectHandler handler, CancellationToken ct)
|
||||
{
|
||||
if (boneIndices == null) return;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
foreach (var kvp in boneIndices)
|
||||
{
|
||||
_logger.LogDebug("Found {skellyname} ({idx} bone indices) on player: {bones}", kvp.Key, kvp.Value.Any() ? kvp.Value.Max() : 0, string.Join(',', kvp.Value));
|
||||
}
|
||||
}
|
||||
|
||||
var maxPlayerBoneIndex = boneIndices.SelectMany(kvp => kvp.Value).DefaultIfEmpty().Max();
|
||||
if (maxPlayerBoneIndex <= 0) return;
|
||||
|
||||
int noValidationFailed = 0;
|
||||
foreach (var file in fragment.FileReplacements.Where(f => !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)).ToList())
|
||||
var remaining = 10000;
|
||||
while (remaining > 0)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var skeletonIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetBoneIndicesFromPap(file.Hash)).ConfigureAwait(false);
|
||||
bool validationFailed = false;
|
||||
if (skeletonIndices != null)
|
||||
var obj = await _dalamudUtil.CreateGameObjectAsync(handler.Address).ConfigureAwait(false);
|
||||
if (await _dalamudUtil.IsObjectPresentAsync(obj).ConfigureAwait(false))
|
||||
return;
|
||||
|
||||
_logger.LogTrace("Character is null but it shouldn't be, waiting");
|
||||
await Task.Delay(50, ct).ConfigureAwait(false);
|
||||
remaining -= 50;
|
||||
}
|
||||
}
|
||||
|
||||
private static HashSet<FileReplacement> BuildStaticReplacements(Dictionary<string, HashSet<string>> resolvedPaths)
|
||||
{
|
||||
var set = new HashSet<FileReplacement>(FileReplacementComparer.Instance);
|
||||
|
||||
foreach (var kvp in resolvedPaths)
|
||||
{
|
||||
var fr = new FileReplacement([.. kvp.Value], kvp.Key);
|
||||
if (!fr.HasFileReplacement) continue;
|
||||
|
||||
var allAllowed = fr.GamePaths.All(g =>
|
||||
CacheMonitor.AllowedFileExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
if (!allAllowed) continue;
|
||||
|
||||
set.Add(fr);
|
||||
}
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
private async Task<(IReadOnlyDictionary<string, string[]> ResolvedPaths, HashSet<FileReplacement>? ClearedReplacements)>
|
||||
ResolveTransientReplacementsAsync(
|
||||
GameObjectHandler obj,
|
||||
ObjectKind objectKind,
|
||||
HashSet<FileReplacement> staticReplacements,
|
||||
Task waitRecordingTask,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await waitRecordingTask.ConfigureAwait(false);
|
||||
|
||||
HashSet<FileReplacement>? clearedReplacements = null;
|
||||
|
||||
if (objectKind == ObjectKind.Pet)
|
||||
{
|
||||
foreach (var item in staticReplacements.Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths))
|
||||
{
|
||||
// 105 is the maximum vanilla skellington spoopy bone index
|
||||
if (skeletonIndices.All(k => k.Value.Max() <= 105))
|
||||
{
|
||||
_logger.LogTrace("All indices of {path} are <= 105, ignoring", file.ResolvedPath);
|
||||
continue;
|
||||
}
|
||||
if (_transientResourceManager.AddTransientResource(objectKind, item))
|
||||
_logger.LogDebug("Marking static {item} for Pet as transient", item);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Verifying bone indices for {path}, found {x} skeletons", file.ResolvedPath, skeletonIndices.Count);
|
||||
_logger.LogTrace("Clearing {count} Static Replacements for Pet", staticReplacements.Count);
|
||||
clearedReplacements = staticReplacements;
|
||||
}
|
||||
|
||||
foreach (var boneCount in skeletonIndices)
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
_transientResourceManager.ClearTransientPaths(objectKind, [.. staticReplacements.SelectMany(c => c.GamePaths)]);
|
||||
|
||||
var transientPaths = ManageSemiTransientData(objectKind);
|
||||
if (transientPaths.Count == 0)
|
||||
return (new Dictionary<string, string[]>(StringComparer.Ordinal), clearedReplacements);
|
||||
|
||||
var resolved = await GetFileReplacementsFromPaths(transientPaths, new HashSet<string>(StringComparer.Ordinal))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (_maxTransientResolvedEntries > 0 && resolved.Count > _maxTransientResolvedEntries)
|
||||
{
|
||||
_logger.LogWarning("Transient entries ({resolved}) are above the threshold {max}; Please consider disable some mods (VFX have heavy load) to reduce transient load",
|
||||
resolved.Count,
|
||||
_maxTransientResolvedEntries);
|
||||
}
|
||||
|
||||
return (resolved, clearedReplacements);
|
||||
}
|
||||
|
||||
|
||||
private async Task VerifyPlayerAnimationBones(
|
||||
Dictionary<string, List<ushort>>? playerBoneIndices,
|
||||
CharacterDataFragmentPlayer fragment,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var mode = _configService.Current.AnimationValidationMode;
|
||||
var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift;
|
||||
var allownNightIndex = _configService.Current.AnimationAllowNeighborIndexTolerance;
|
||||
|
||||
if (mode == AnimationValidationMode.Unsafe)
|
||||
return;
|
||||
|
||||
if (playerBoneIndices == null || playerBoneIndices.Count == 0)
|
||||
return;
|
||||
|
||||
var localBoneSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var (rawLocalKey, indices) in playerBoneIndices)
|
||||
{
|
||||
if (indices is not { Count: > 0 })
|
||||
continue;
|
||||
|
||||
var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawLocalKey);
|
||||
if (string.IsNullOrEmpty(key))
|
||||
continue;
|
||||
|
||||
if (!localBoneSets.TryGetValue(key, out var set))
|
||||
localBoneSets[key] = set = [];
|
||||
|
||||
foreach (var idx in indices)
|
||||
set.Add(idx);
|
||||
}
|
||||
|
||||
if (localBoneSets.Count == 0)
|
||||
return;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("SEND local buckets: {b}",
|
||||
string.Join(", ", localBoneSets.Keys.Order(StringComparer.Ordinal)));
|
||||
|
||||
foreach (var kvp in localBoneSets.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var min = kvp.Value.Count > 0 ? kvp.Value.Min() : 0;
|
||||
var max = kvp.Value.Count > 0 ? kvp.Value.Max() : 0;
|
||||
_logger.LogDebug("Local bucket {bucket}: count={count} min={min} max={max}",
|
||||
kvp.Key, kvp.Value.Count, min, max);
|
||||
}
|
||||
}
|
||||
|
||||
var papGroups = fragment.FileReplacements
|
||||
.Where(f => !f.IsFileSwap
|
||||
&& !string.IsNullOrEmpty(f.Hash)
|
||||
&& f.GamePaths is { Count: > 0 }
|
||||
&& f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
|
||||
.GroupBy(f => f.Hash!, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
int noValidationFailed = 0;
|
||||
|
||||
foreach (var g in papGroups)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
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;
|
||||
|
||||
await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
|
||||
var papPath = cacheEntity?.ResolvedFilepath;
|
||||
|
||||
if (!string.IsNullOrEmpty(papPath) && File.Exists(papPath))
|
||||
{
|
||||
var maxAnimationIndex = boneCount.Value.DefaultIfEmpty().Max();
|
||||
if (maxAnimationIndex > maxPlayerBoneIndex)
|
||||
var havokBytes = await Task.Run(() => XivDataAnalyzer.ReadHavokBytesFromPap(papPath), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (havokBytes is { Length: > 8 })
|
||||
{
|
||||
_logger.LogWarning("Found more bone indices on the animation {path} skeleton {skl} (max indice {idx}) than on any player related skeleton (max indice {idx2})",
|
||||
file.ResolvedPath, boneCount.Key, maxAnimationIndex, maxPlayerBoneIndex);
|
||||
validationFailed = true;
|
||||
break;
|
||||
papIndices = await _dalamudUtil.RunOnFrameworkThread(
|
||||
() => _modelAnalyzer.ParseHavokBytesOnFrameworkThread(havokBytes, hash, persistToConfig: false))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (validationFailed)
|
||||
finally
|
||||
{
|
||||
noValidationFailed++;
|
||||
_logger.LogDebug("Removing {file} from sent file replacements and transient data", file.ResolvedPath);
|
||||
fragment.FileReplacements.Remove(file);
|
||||
foreach (var gamePath in file.GamePaths)
|
||||
_papParseLimiter.Release();
|
||||
}
|
||||
|
||||
if (papIndices == null || papIndices.Count == 0)
|
||||
continue;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
try
|
||||
{
|
||||
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gamePath);
|
||||
var papBuckets = papIndices
|
||||
.Where(kvp => kvp.Value is { Count: > 0 })
|
||||
.Select(kvp => new
|
||||
{
|
||||
Raw = kvp.Key,
|
||||
Key = XivDataAnalyzer.CanonicalizeSkeletonKey(kvp.Key),
|
||||
Indices = kvp.Value
|
||||
})
|
||||
.Where(x => x.Indices is { Count: > 0 })
|
||||
.GroupBy(x => string.IsNullOrEmpty(x.Key) ? x.Raw : x.Key!, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(grp =>
|
||||
{
|
||||
var all = grp.SelectMany(v => v.Indices).ToList();
|
||||
var min = all.Count > 0 ? all.Min() : 0;
|
||||
var max = all.Count > 0 ? all.Max() : 0;
|
||||
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}",
|
||||
hash,
|
||||
string.Join(" | ", papBuckets));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error logging PAP bucket details for hash={hash}", hash);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
noValidationFailed++;
|
||||
|
||||
_logger.LogWarning(
|
||||
"Animation PAP is not compatible with local skeletons; dropping mappings for {papPath}. Reason: {reason}",
|
||||
papPathSummary,
|
||||
reason);
|
||||
|
||||
var removedGamePaths = fragment.FileReplacements
|
||||
.Where(fr => !fr.IsFileSwap
|
||||
&& string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
|
||||
&& fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
|
||||
.SelectMany(fr => fr.GamePaths.Where(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
fragment.FileReplacements.RemoveWhere(fr =>
|
||||
!fr.IsFileSwap
|
||||
&& string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
|
||||
&& fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
foreach (var gp in removedGamePaths)
|
||||
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gp);
|
||||
}
|
||||
|
||||
if (noValidationFailed > 0)
|
||||
{
|
||||
_lightlessMediator.Publish(new NotificationMessage("Invalid Skeleton Setup",
|
||||
$"Your client is attempting to send {noValidationFailed} animation files with invalid bone data. Those animation files have been removed from your sent data. " +
|
||||
$"Verify that you are using the correct skeleton for those animation files (Check /xllog for more information).",
|
||||
NotificationType.Warning, TimeSpan.FromSeconds(10)));
|
||||
_lightlessMediator.Publish(new NotificationMessage(
|
||||
"Invalid Skeleton Setup",
|
||||
$"Your client is attempting to send {noValidationFailed} animation files that don't match your current skeleton validation mode ({mode}). " +
|
||||
"Please adjust your skeleton/mods or change the validation mode if this is unexpected. " +
|
||||
"Those animation files have been removed from your sent (player) data. (Check /xllog for details).",
|
||||
NotificationType.Warning,
|
||||
TimeSpan.FromSeconds(10)));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(GameObjectHandler handler, HashSet<string> forwardResolve, HashSet<string> reverseResolve)
|
||||
|
||||
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(
|
||||
HashSet<string> forwardResolve,
|
||||
HashSet<string> reverseResolve)
|
||||
{
|
||||
var forwardPaths = forwardResolve.ToArray();
|
||||
var reversePaths = reverseResolve.ToArray();
|
||||
Dictionary<string, List<string>> resolvedPaths = new(StringComparer.Ordinal);
|
||||
if (handler.ObjectKind != ObjectKind.Player)
|
||||
if (forwardPaths.Length == 0 && reversePaths.Length == 0)
|
||||
{
|
||||
var (objectIndex, forwardResolved, reverseResolved) = await _dalamudUtil.RunOnFrameworkThread(() =>
|
||||
{
|
||||
var idx = handler.GetGameObject()?.ObjectIndex;
|
||||
if (!idx.HasValue)
|
||||
{
|
||||
return ((int?)null, Array.Empty<string>(), Array.Empty<string[]>());
|
||||
}
|
||||
|
||||
var resolvedForward = new string[forwardPaths.Length];
|
||||
for (int i = 0; i < forwardPaths.Length; i++)
|
||||
{
|
||||
resolvedForward[i] = _ipcManager.Penumbra.ResolveGameObjectPath(forwardPaths[i], idx.Value);
|
||||
}
|
||||
|
||||
var resolvedReverse = new string[reversePaths.Length][];
|
||||
for (int i = 0; i < reversePaths.Length; i++)
|
||||
{
|
||||
resolvedReverse[i] = _ipcManager.Penumbra.ReverseResolveGameObjectPath(reversePaths[i], idx.Value);
|
||||
}
|
||||
|
||||
return (idx, resolvedForward, resolvedReverse);
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (objectIndex.HasValue)
|
||||
{
|
||||
for (int i = 0; i < forwardPaths.Length; i++)
|
||||
{
|
||||
var filePath = forwardResolved[i]?.ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolvedPaths.TryGetValue(filePath, out var list))
|
||||
{
|
||||
list.Add(forwardPaths[i].ToLowerInvariant());
|
||||
}
|
||||
else
|
||||
{
|
||||
resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < reversePaths.Length; i++)
|
||||
{
|
||||
var filePath = reversePaths[i].ToLowerInvariant();
|
||||
if (resolvedPaths.TryGetValue(filePath, out var list))
|
||||
{
|
||||
list.AddRange(reverseResolved[i].Select(c => c.ToLowerInvariant()));
|
||||
}
|
||||
else
|
||||
{
|
||||
resolvedPaths[filePath] = new List<string>(reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
|
||||
}
|
||||
return new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase).AsReadOnly();
|
||||
}
|
||||
|
||||
var forwardPathsLower = forwardPaths.Length == 0 ? [] : forwardPaths.Select(p => p.ToLowerInvariant()).ToArray();
|
||||
var reversePathsLower = reversePaths.Length == 0 ? [] : reversePaths.Select(p => p.ToLowerInvariant()).ToArray();
|
||||
|
||||
Dictionary<string, List<string>> resolvedPaths = new(forwardPaths.Length + reversePaths.Length, StringComparer.Ordinal);
|
||||
var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false);
|
||||
|
||||
for (int i = 0; i < forwardPaths.Length; i++)
|
||||
{
|
||||
var filePath = forward[i].ToLowerInvariant();
|
||||
if (resolvedPaths.TryGetValue(filePath, out var list))
|
||||
{
|
||||
list.Add(forwardPaths[i].ToLowerInvariant());
|
||||
}
|
||||
else
|
||||
{
|
||||
resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < reversePaths.Length; i++)
|
||||
{
|
||||
var filePath = reversePaths[i].ToLowerInvariant();
|
||||
var filePath = reversePathsLower[i];
|
||||
var reverseResolvedLower = new string[reverse[i].Length];
|
||||
for (var j = 0; j < reverseResolvedLower.Length; j++)
|
||||
{
|
||||
reverseResolvedLower[j] = reverse[i][j].ToLowerInvariant();
|
||||
}
|
||||
if (resolvedPaths.TryGetValue(filePath, out var list))
|
||||
{
|
||||
list.AddRange(reverse[i].Select(c => c.ToLowerInvariant()));
|
||||
}
|
||||
else
|
||||
{
|
||||
resolvedPaths[filePath] = new List<string>(reverse[i].Select(c => c.ToLowerInvariant()).ToList());
|
||||
}
|
||||
resolvedPaths[filePath] = [.. reverse[i].Select(c => c.ToLowerInvariant()).ToList()];
|
||||
}
|
||||
|
||||
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
|
||||
@@ -475,11 +739,29 @@ public class PlayerDataFactory
|
||||
_transientResourceManager.PersistTransientResources(objectKind);
|
||||
|
||||
HashSet<string> pathsToResolve = new(StringComparer.Ordinal);
|
||||
foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind).Where(path => !string.IsNullOrEmpty(path)))
|
||||
|
||||
int scanned = 0, skippedEmpty = 0, skippedVfx = 0;
|
||||
|
||||
foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind))
|
||||
{
|
||||
scanned++;
|
||||
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
skippedEmpty++;
|
||||
continue;
|
||||
}
|
||||
|
||||
pathsToResolve.Add(path);
|
||||
}
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"ManageSemiTransientData({kind}): scanned={scanned}, added={added}, skippedEmpty={skippedEmpty}, skippedVfx={skippedVfx}",
|
||||
objectKind, scanned, pathsToResolve.Count, skippedEmpty, skippedVfx);
|
||||
}
|
||||
|
||||
return pathsToResolve;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,16 +113,16 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
||||
public async Task ActOnFrameworkAfterEnsureNoDrawAsync(Action<Dalamud.Game.ClientState.Objects.Types.ICharacter> act, CancellationToken token)
|
||||
{
|
||||
while (await _dalamudUtil.RunOnFrameworkThread(() =>
|
||||
{
|
||||
EnsureLatestObjectState();
|
||||
if (CurrentDrawCondition != DrawCondition.None) return true;
|
||||
var gameObj = _dalamudUtil.CreateGameObject(Address);
|
||||
if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter chara)
|
||||
{
|
||||
act.Invoke(chara);
|
||||
}
|
||||
return false;
|
||||
}).ConfigureAwait(false))
|
||||
{
|
||||
EnsureLatestObjectState();
|
||||
if (CurrentDrawCondition != DrawCondition.None) return true;
|
||||
var gameObj = _dalamudUtil.CreateGameObject(Address);
|
||||
if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter chara)
|
||||
{
|
||||
act.Invoke(chara);
|
||||
}
|
||||
return false;
|
||||
}).ConfigureAwait(false))
|
||||
{
|
||||
await Task.Delay(250, token).ConfigureAwait(false);
|
||||
}
|
||||
@@ -169,37 +169,36 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
||||
return $"{owned}/{ObjectKind}:{Name} ({Address:X},{DrawObjectAddress:X})";
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
private void CheckAndUpdateObject() => CheckAndUpdateObject(allowPublish: true);
|
||||
|
||||
Mediator.Publish(new GameObjectHandlerDestroyedMessage(this, _isOwnedObject));
|
||||
}
|
||||
|
||||
private unsafe void CheckAndUpdateObject()
|
||||
private unsafe void CheckAndUpdateObject(bool allowPublish)
|
||||
{
|
||||
var prevAddr = Address;
|
||||
var prevDrawObj = DrawObjectAddress;
|
||||
string? nameString = null;
|
||||
|
||||
Address = _getAddress();
|
||||
|
||||
if (Address != IntPtr.Zero)
|
||||
{
|
||||
var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address;
|
||||
var drawObjAddr = (IntPtr)gameObject->DrawObject;
|
||||
DrawObjectAddress = drawObjAddr;
|
||||
DrawObjectAddress = (IntPtr)gameObject->DrawObject;
|
||||
EntityId = gameObject->EntityId;
|
||||
CurrentDrawCondition = DrawCondition.None;
|
||||
|
||||
var chara = (Character*)Address;
|
||||
nameString = chara->GameObject.NameString;
|
||||
if (!string.IsNullOrEmpty(nameString) && !string.Equals(nameString, Name, StringComparison.Ordinal))
|
||||
Name = nameString;
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawObjectAddress = IntPtr.Zero;
|
||||
EntityId = uint.MaxValue;
|
||||
CurrentDrawCondition = DrawCondition.DrawObjectZero;
|
||||
}
|
||||
|
||||
CurrentDrawCondition = IsBeingDrawnUnsafe();
|
||||
|
||||
if (_haltProcessing) return;
|
||||
if (_haltProcessing || !allowPublish) return;
|
||||
|
||||
bool drawObjDiff = DrawObjectAddress != prevDrawObj;
|
||||
bool addrDiff = Address != prevAddr;
|
||||
@@ -207,16 +206,18 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
||||
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;
|
||||
}
|
||||
var drawObj = (DrawObject*)DrawObjectAddress;
|
||||
var objType = drawObj->Object.GetObjectType();
|
||||
var isHuman = objType == ObjectType.CharacterBase
|
||||
&& ((CharacterBase*)drawObj)->GetModelType() == CharacterBase.ModelType.Human;
|
||||
|
||||
nameString ??= ((Character*)Address)->GameObject.NameString;
|
||||
var nameChange = !string.Equals(nameString, Name, StringComparison.Ordinal);
|
||||
if (nameChange) Name = nameString;
|
||||
|
||||
bool equipDiff = false;
|
||||
|
||||
if (((DrawObject*)DrawObjectAddress)->Object.GetObjectType() == ObjectType.CharacterBase
|
||||
&& ((CharacterBase*)DrawObjectAddress)->GetModelType() == CharacterBase.ModelType.Human)
|
||||
if (isHuman)
|
||||
{
|
||||
var classJob = chara->CharacterData.ClassJob;
|
||||
if (classJob != _classJob)
|
||||
@@ -226,7 +227,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
||||
Mediator.Publish(new ClassJobChangedMessage(this));
|
||||
}
|
||||
|
||||
equipDiff = CompareAndUpdateEquipByteData((byte*)&((Human*)DrawObjectAddress)->Head);
|
||||
equipDiff = CompareAndUpdateEquipByteData((byte*)&((Human*)drawObj)->Head);
|
||||
|
||||
ref var mh = ref chara->DrawData.Weapon(WeaponSlot.MainHand);
|
||||
ref var oh = ref chara->DrawData.Weapon(WeaponSlot.OffHand);
|
||||
@@ -251,12 +252,11 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
||||
|
||||
bool customizeDiff = false;
|
||||
|
||||
if (((DrawObject*)DrawObjectAddress)->Object.GetObjectType() == ObjectType.CharacterBase
|
||||
&& ((CharacterBase*)DrawObjectAddress)->GetModelType() == CharacterBase.ModelType.Human)
|
||||
if (isHuman)
|
||||
{
|
||||
var gender = ((Human*)DrawObjectAddress)->Customize.Sex;
|
||||
var raceId = ((Human*)DrawObjectAddress)->Customize.Race;
|
||||
var tribeId = ((Human*)DrawObjectAddress)->Customize.Tribe;
|
||||
var gender = ((Human*)drawObj)->Customize.Sex;
|
||||
var raceId = ((Human*)drawObj)->Customize.Race;
|
||||
var tribeId = ((Human*)drawObj)->Customize.Tribe;
|
||||
|
||||
if (_isOwnedObject && ObjectKind == ObjectKind.Player
|
||||
&& (gender != Gender || raceId != RaceId || tribeId != TribeId))
|
||||
@@ -267,7 +267,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
||||
TribeId = tribeId;
|
||||
}
|
||||
|
||||
customizeDiff = CompareAndUpdateCustomizeData(((Human*)DrawObjectAddress)->Customize.Data);
|
||||
customizeDiff = CompareAndUpdateCustomizeData(((Human*)drawObj)->Customize.Data);
|
||||
if (customizeDiff)
|
||||
Logger.LogTrace("Checking [{this}] customize data as human from draw obj, result: {diff}", this, customizeDiff);
|
||||
}
|
||||
@@ -356,12 +356,10 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
||||
|
||||
private void FrameworkUpdate()
|
||||
{
|
||||
if (!_delayedZoningTask?.IsCompleted ?? false) return;
|
||||
|
||||
try
|
||||
{
|
||||
_performanceCollector.LogPerformance(this, $"CheckAndUpdateObject>{(_isOwnedObject ? "Self" : "Other")}+{ObjectKind}/{(string.IsNullOrEmpty(Name) ? "Unk" : Name)}"
|
||||
+ $"+{Address.ToString("X")}", CheckAndUpdateObject);
|
||||
var zoningDelayActive = !(_delayedZoningTask?.IsCompleted ?? true);
|
||||
_performanceCollector.LogPerformance(this, $"CheckAndUpdateObject>{(_isOwnedObject ? "Self" : "Other")}+{ObjectKind}/{(string.IsNullOrEmpty(Name) ? "Unk" : Name)}", () => CheckAndUpdateObject(allowPublish: !zoningDelayActive));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -462,6 +460,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
||||
Logger.LogDebug("[{this}] Delay after zoning complete", this);
|
||||
_zoningCts.Dispose();
|
||||
}
|
||||
});
|
||||
}, _zoningCts.Token);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,42 @@
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data;
|
||||
|
||||
namespace LightlessSync.PlayerData.Pairs;
|
||||
namespace LightlessSync.PlayerData.Pairs;
|
||||
|
||||
/// <summary>
|
||||
/// orchestrates the lifecycle of a paired character
|
||||
/// </summary>
|
||||
public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject
|
||||
{
|
||||
new string Ident { get; }
|
||||
bool Initialized { get; }
|
||||
bool IsVisible { get; }
|
||||
bool ScheduledForDeletion { get; set; }
|
||||
CharacterData? LastReceivedCharacterData { get; }
|
||||
long LastAppliedDataBytes { get; }
|
||||
new string? PlayerName { get; }
|
||||
string PlayerNameHash { get; }
|
||||
uint PlayerCharacterId { get; }
|
||||
DateTime? LastDataReceivedAt { get; }
|
||||
DateTime? LastApplyAttemptAt { get; }
|
||||
DateTime? LastSuccessfulApplyAt { get; }
|
||||
string? LastFailureReason { get; }
|
||||
IReadOnlyList<string> LastBlockingConditions { get; }
|
||||
bool IsApplying { get; }
|
||||
bool IsDownloading { get; }
|
||||
int PendingDownloadCount { get; }
|
||||
int ForbiddenDownloadCount { get; }
|
||||
bool PendingModReapply { get; }
|
||||
bool ModApplyDeferred { get; }
|
||||
int MissingCriticalMods { get; }
|
||||
int MissingNonCriticalMods { get; }
|
||||
int MissingForbiddenMods { get; }
|
||||
DateTime? InvisibleSinceUtc { get; }
|
||||
DateTime? VisibilityEvictionDueAtUtc { get; }
|
||||
/// <summary>
|
||||
/// orchestrates the lifecycle of a paired character
|
||||
/// </summary>
|
||||
public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject
|
||||
{
|
||||
new string Ident { get; }
|
||||
bool Initialized { get; }
|
||||
bool IsVisible { get; }
|
||||
bool ScheduledForDeletion { get; set; }
|
||||
CharacterData? LastReceivedCharacterData { get; }
|
||||
long LastAppliedDataBytes { get; }
|
||||
new string? PlayerName { get; }
|
||||
string PlayerNameHash { get; }
|
||||
uint PlayerCharacterId { get; }
|
||||
DateTime? LastDataReceivedAt { get; }
|
||||
DateTime? LastApplyAttemptAt { get; }
|
||||
DateTime? LastSuccessfulApplyAt { get; }
|
||||
string? LastFailureReason { get; }
|
||||
IReadOnlyList<string> LastBlockingConditions { get; }
|
||||
bool IsApplying { get; }
|
||||
bool IsDownloading { get; }
|
||||
int PendingDownloadCount { get; }
|
||||
int ForbiddenDownloadCount { get; }
|
||||
bool PendingModReapply { get; }
|
||||
bool ModApplyDeferred { get; }
|
||||
int MissingCriticalMods { get; }
|
||||
int MissingNonCriticalMods { get; }
|
||||
int MissingForbiddenMods { get; }
|
||||
|
||||
void Initialize();
|
||||
void ApplyData(CharacterData data);
|
||||
void ApplyLastReceivedData(bool forced = false);
|
||||
bool FetchPerformanceMetricsFromCache();
|
||||
void LoadCachedCharacterData(CharacterData data);
|
||||
void SetUploading(bool uploading);
|
||||
void SetPaused(bool paused);
|
||||
}
|
||||
void ApplyData(CharacterData data);
|
||||
void ApplyLastReceivedData(bool forced = false);
|
||||
Task EnsurePerformanceMetricsAsync(CancellationToken cancellationToken);
|
||||
bool FetchPerformanceMetricsFromCache();
|
||||
void LoadCachedCharacterData(CharacterData data);
|
||||
void SetUploading(bool uploading);
|
||||
void SetPaused(bool paused);
|
||||
}
|
||||
|
||||
@@ -16,4 +16,5 @@ public interface IPairPerformanceSubject
|
||||
long LastAppliedApproximateVRAMBytes { get; set; }
|
||||
long LastAppliedApproximateEffectiveVRAMBytes { get; set; }
|
||||
long LastAppliedDataTris { get; set; }
|
||||
long LastAppliedApproximateEffectiveTris { get; set; }
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ public class Pair
|
||||
public string? PlayerName => TryGetHandler()?.PlayerName ?? UserPair.User.AliasOrUID;
|
||||
public long LastAppliedDataBytes => TryGetHandler()?.LastAppliedDataBytes ?? -1;
|
||||
public long LastAppliedDataTris => TryGetHandler()?.LastAppliedDataTris ?? -1;
|
||||
public long LastAppliedApproximateEffectiveTris => TryGetHandler()?.LastAppliedApproximateEffectiveTris ?? -1;
|
||||
public long LastAppliedApproximateVRAMBytes => TryGetHandler()?.LastAppliedApproximateVRAMBytes ?? -1;
|
||||
public long LastAppliedApproximateEffectiveVRAMBytes => TryGetHandler()?.LastAppliedApproximateEffectiveVRAMBytes ?? -1;
|
||||
public string Ident => TryGetHandler()?.Ident ?? TryGetConnection()?.Ident ?? string.Empty;
|
||||
@@ -216,12 +217,6 @@ public class Pair
|
||||
if (handler is null)
|
||||
return PairDebugInfo.Empty;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var dueAt = handler.VisibilityEvictionDueAtUtc;
|
||||
var remainingSeconds = dueAt.HasValue
|
||||
? Math.Max(0, (dueAt.Value - now).TotalSeconds)
|
||||
: (double?)null;
|
||||
|
||||
return new PairDebugInfo(
|
||||
true,
|
||||
handler.Initialized,
|
||||
@@ -230,9 +225,6 @@ public class Pair
|
||||
handler.LastDataReceivedAt,
|
||||
handler.LastApplyAttemptAt,
|
||||
handler.LastSuccessfulApplyAt,
|
||||
handler.InvisibleSinceUtc,
|
||||
handler.VisibilityEvictionDueAtUtc,
|
||||
remainingSeconds,
|
||||
handler.LastFailureReason,
|
||||
handler.LastBlockingConditions,
|
||||
handler.IsApplying,
|
||||
|
||||
@@ -125,6 +125,7 @@ public sealed partial class PairCoordinator
|
||||
}
|
||||
}
|
||||
|
||||
_mediator.Publish(new PairOnlineMessage(new PairUniqueIdentifier(dto.User.UID)));
|
||||
PublishPairDataChanged();
|
||||
}
|
||||
|
||||
@@ -136,7 +137,7 @@ public sealed partial class PairCoordinator
|
||||
_pendingCharacterData.TryRemove(user.UID, out _);
|
||||
if (registrationResult.Value.CharacterIdent is not null)
|
||||
{
|
||||
_ = _handlerRegistry.DeregisterOfflinePair(registrationResult.Value);
|
||||
_ = _handlerRegistry.DeregisterOfflinePair(registrationResult.Value, forceDisposal: true);
|
||||
}
|
||||
|
||||
_mediator.Publish(new ClearProfileUserDataMessage(user));
|
||||
|
||||
@@ -8,9 +8,6 @@ public sealed record PairDebugInfo(
|
||||
DateTime? LastDataReceivedAt,
|
||||
DateTime? LastApplyAttemptAt,
|
||||
DateTime? LastSuccessfulApplyAt,
|
||||
DateTime? InvisibleSinceUtc,
|
||||
DateTime? VisibilityEvictionDueAtUtc,
|
||||
double? VisibilityEvictionRemainingSeconds,
|
||||
string? LastFailureReason,
|
||||
IReadOnlyList<string> BlockingConditions,
|
||||
bool IsApplying,
|
||||
@@ -32,9 +29,6 @@ public sealed record PairDebugInfo(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
Array.Empty<string>(),
|
||||
false,
|
||||
false,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,15 @@
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.Interop.Ipc;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.PlayerData.Factories;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.ActorTracking;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Services.ModelDecimation;
|
||||
using LightlessSync.Services.PairProcessing;
|
||||
using LightlessSync.Services.ServerConfiguration;
|
||||
using LightlessSync.Services.TextureCompression;
|
||||
using Dalamud.Plugin.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -25,13 +28,18 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IHostApplicationLifetime _lifetime;
|
||||
private readonly FileCacheManager _fileCacheManager;
|
||||
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
|
||||
private readonly PlayerPerformanceService _playerPerformanceService;
|
||||
private readonly PairProcessingLimiter _pairProcessingLimiter;
|
||||
private readonly ServerConfigurationManager _serverConfigManager;
|
||||
private readonly TextureDownscaleService _textureDownscaleService;
|
||||
private readonly ModelDecimationService _modelDecimationService;
|
||||
private readonly PairStateCache _pairStateCache;
|
||||
private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache;
|
||||
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly XivDataAnalyzer _modelAnalyzer;
|
||||
private readonly IFramework _framework;
|
||||
|
||||
public PairHandlerAdapterFactory(
|
||||
ILoggerFactory loggerFactory,
|
||||
@@ -42,15 +50,20 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
||||
FileDownloadManagerFactory fileDownloadManagerFactory,
|
||||
PluginWarningNotificationService pluginWarningNotificationManager,
|
||||
IServiceProvider serviceProvider,
|
||||
IFramework framework,
|
||||
IHostApplicationLifetime lifetime,
|
||||
FileCacheManager fileCacheManager,
|
||||
PlayerPerformanceConfigService playerPerformanceConfigService,
|
||||
PlayerPerformanceService playerPerformanceService,
|
||||
PairProcessingLimiter pairProcessingLimiter,
|
||||
ServerConfigurationManager serverConfigManager,
|
||||
TextureDownscaleService textureDownscaleService,
|
||||
ModelDecimationService modelDecimationService,
|
||||
PairStateCache pairStateCache,
|
||||
PairPerformanceMetricsCache pairPerformanceMetricsCache,
|
||||
PenumbraTempCollectionJanitor tempCollectionJanitor)
|
||||
PenumbraTempCollectionJanitor tempCollectionJanitor,
|
||||
XivDataAnalyzer modelAnalyzer,
|
||||
LightlessConfigService configService)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_mediator = mediator;
|
||||
@@ -60,15 +73,20 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
||||
_fileDownloadManagerFactory = fileDownloadManagerFactory;
|
||||
_pluginWarningNotificationManager = pluginWarningNotificationManager;
|
||||
_serviceProvider = serviceProvider;
|
||||
_framework = framework;
|
||||
_lifetime = lifetime;
|
||||
_fileCacheManager = fileCacheManager;
|
||||
_playerPerformanceConfigService = playerPerformanceConfigService;
|
||||
_playerPerformanceService = playerPerformanceService;
|
||||
_pairProcessingLimiter = pairProcessingLimiter;
|
||||
_serverConfigManager = serverConfigManager;
|
||||
_textureDownscaleService = textureDownscaleService;
|
||||
_modelDecimationService = modelDecimationService;
|
||||
_pairStateCache = pairStateCache;
|
||||
_pairPerformanceMetricsCache = pairPerformanceMetricsCache;
|
||||
_tempCollectionJanitor = tempCollectionJanitor;
|
||||
_modelAnalyzer = modelAnalyzer;
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
public IPairHandlerAdapter Create(string ident)
|
||||
@@ -86,15 +104,20 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
||||
downloadManager,
|
||||
_pluginWarningNotificationManager,
|
||||
dalamudUtilService,
|
||||
_framework,
|
||||
actorObjectService,
|
||||
_lifetime,
|
||||
_fileCacheManager,
|
||||
_playerPerformanceConfigService,
|
||||
_playerPerformanceService,
|
||||
_pairProcessingLimiter,
|
||||
_serverConfigManager,
|
||||
_textureDownscaleService,
|
||||
_modelDecimationService,
|
||||
_pairStateCache,
|
||||
_pairPerformanceMetricsCache,
|
||||
_tempCollectionJanitor);
|
||||
_tempCollectionJanitor,
|
||||
_modelAnalyzer,
|
||||
_configService);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ public sealed class PairHandlerRegistry : IDisposable
|
||||
}
|
||||
|
||||
if (handler.LastReceivedCharacterData is not null &&
|
||||
(handler.LastAppliedApproximateVRAMBytes < 0 || handler.LastAppliedDataTris < 0))
|
||||
(handler.LastAppliedApproximateVRAMBytes < 0 || handler.LastAppliedDataTris < 0 || handler.LastAppliedApproximateEffectiveTris < 0))
|
||||
{
|
||||
handler.ApplyLastReceivedData(forced: true);
|
||||
}
|
||||
@@ -136,6 +136,7 @@ public sealed class PairHandlerRegistry : IDisposable
|
||||
if (TryFinalizeHandlerRemoval(handler))
|
||||
{
|
||||
handler.Dispose();
|
||||
_pairStateCache.Clear(registration.CharacterIdent);
|
||||
}
|
||||
}
|
||||
else if (shouldScheduleRemoval && handler is not null)
|
||||
@@ -356,6 +357,7 @@ public sealed class PairHandlerRegistry : IDisposable
|
||||
finally
|
||||
{
|
||||
_pairPerformanceMetricsCache.Clear(handler.Ident);
|
||||
_pairStateCache.Clear(handler.Ident);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,6 +379,7 @@ public sealed class PairHandlerRegistry : IDisposable
|
||||
{
|
||||
handler.Dispose();
|
||||
_pairPerformanceMetricsCache.Clear(handler.Ident);
|
||||
_pairStateCache.Clear(handler.Ident);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +404,7 @@ public sealed class PairHandlerRegistry : IDisposable
|
||||
if (TryFinalizeHandlerRemoval(handler))
|
||||
{
|
||||
handler.Dispose();
|
||||
_pairStateCache.Clear(handler.Ident);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -258,7 +258,8 @@ public sealed class PairLedger : DisposableMediatorSubscriberBase
|
||||
|
||||
if (handler.LastAppliedApproximateVRAMBytes >= 0
|
||||
&& handler.LastAppliedDataTris >= 0
|
||||
&& handler.LastAppliedApproximateEffectiveVRAMBytes >= 0)
|
||||
&& handler.LastAppliedApproximateEffectiveVRAMBytes >= 0
|
||||
&& handler.LastAppliedApproximateEffectiveTris >= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -270,7 +271,20 @@ public sealed class PairLedger : DisposableMediatorSubscriberBase
|
||||
|
||||
try
|
||||
{
|
||||
handler.ApplyLastReceivedData(forced: true);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await handler.EnsurePerformanceMetricsAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to ensure performance metrics for {Ident}", handler.Ident);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -160,8 +160,9 @@ public sealed class PairManager
|
||||
return PairOperationResult<PairRegistration>.Fail($"Pair {user.UID} not found.");
|
||||
}
|
||||
|
||||
var ident = connection.Ident;
|
||||
connection.SetOffline();
|
||||
return PairOperationResult<PairRegistration>.Ok(new PairRegistration(new PairUniqueIdentifier(user.UID), connection.Ident));
|
||||
return PairOperationResult<PairRegistration>.Ok(new PairRegistration(new PairUniqueIdentifier(user.UID), ident));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,6 +531,7 @@ public sealed class PairManager
|
||||
return null;
|
||||
}
|
||||
|
||||
var ident = connection.Ident;
|
||||
if (connection.IsOnline)
|
||||
{
|
||||
connection.SetOffline();
|
||||
@@ -542,7 +544,7 @@ public sealed class PairManager
|
||||
shell.Users.Remove(userId);
|
||||
}
|
||||
|
||||
return new PairRegistration(new PairUniqueIdentifier(userId), connection.Ident);
|
||||
return new PairRegistration(new PairUniqueIdentifier(userId), ident);
|
||||
}
|
||||
|
||||
public static PairConnection CreateFromFullData(UserFullPairDto dto)
|
||||
|
||||
@@ -76,6 +76,7 @@ public sealed class PairConnection
|
||||
public void SetOffline()
|
||||
{
|
||||
IsOnline = false;
|
||||
Ident = null;
|
||||
}
|
||||
|
||||
public void UpdatePermissions(UserPermissions own, UserPermissions other)
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace LightlessSync.PlayerData.Pairs;
|
||||
public readonly record struct PairPerformanceMetrics(
|
||||
long TriangleCount,
|
||||
long ApproximateVramBytes,
|
||||
long ApproximateEffectiveVramBytes);
|
||||
long ApproximateEffectiveVramBytes,
|
||||
long ApproximateEffectiveTris);
|
||||
|
||||
/// <summary>
|
||||
/// caches performance metrics keyed by pair ident
|
||||
|
||||
@@ -50,6 +50,7 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
|
||||
});
|
||||
|
||||
Mediator.Subscribe<ConnectedMessage>(this, (_) => PushToAllVisibleUsers());
|
||||
Mediator.Subscribe<PairOnlineMessage>(this, (msg) => HandlePairOnline(msg.PairIdent));
|
||||
Mediator.Subscribe<DisconnectedMessage>(this, (_) =>
|
||||
{
|
||||
_fileTransferManager.CancelUpload();
|
||||
@@ -111,6 +112,20 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
|
||||
_ = PushCharacterDataAsync(forced);
|
||||
}
|
||||
|
||||
private void HandlePairOnline(PairUniqueIdentifier pairIdent)
|
||||
{
|
||||
if (!_apiController.IsConnected || !_pairLedger.IsPairVisible(pairIdent))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_pairLedger.GetHandler(pairIdent)?.UserData is { } user)
|
||||
{
|
||||
_usersToPushDataTo.Add(user);
|
||||
PushCharacterData(forced: true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PushCharacterDataAsync(bool forced = false)
|
||||
{
|
||||
await _pushLock.WaitAsync(_runtimeCts.Token).ConfigureAwait(false);
|
||||
@@ -152,5 +167,6 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
|
||||
private List<UserData> GetVisibleUsers() => [.. _pairLedger.GetVisiblePairs().Select(connection => connection.User)];
|
||||
private List<UserData> GetVisibleUsers()
|
||||
=> [.. _pairLedger.GetVisiblePairs().Where(connection => connection.IsOnline).Select(connection => connection.User)];
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ using System.Reflection;
|
||||
using OtterTex;
|
||||
using LightlessSync.Services.LightFinder;
|
||||
using LightlessSync.Services.PairProcessing;
|
||||
using LightlessSync.Services.ModelDecimation;
|
||||
using LightlessSync.UI.Models;
|
||||
|
||||
namespace LightlessSync;
|
||||
@@ -105,6 +106,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
services.AddSingleton(new WindowSystem("LightlessSync"));
|
||||
services.AddSingleton<FileDialogManager>();
|
||||
services.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", string.Empty, useEmbedded: true));
|
||||
services.AddSingleton(framework);
|
||||
services.AddSingleton(gameGui);
|
||||
services.AddSingleton(gameInteropProvider);
|
||||
services.AddSingleton(addonLifecycle);
|
||||
@@ -125,13 +127,17 @@ public sealed class Plugin : IDalamudPlugin
|
||||
services.AddSingleton<LightlessProfileManager>();
|
||||
services.AddSingleton<TextureCompressionService>();
|
||||
services.AddSingleton<TextureDownscaleService>();
|
||||
services.AddSingleton<ModelDecimationService>();
|
||||
services.AddSingleton<GameObjectHandlerFactory>();
|
||||
services.AddSingleton<FileDownloadDeduplicator>();
|
||||
services.AddSingleton<FileDownloadManagerFactory>();
|
||||
services.AddSingleton<PairProcessingLimiter>();
|
||||
services.AddSingleton<XivDataAnalyzer>();
|
||||
services.AddSingleton<CharacterAnalyzer>();
|
||||
services.AddSingleton<TokenProvider>();
|
||||
services.AddSingleton<PluginWarningNotificationService>();
|
||||
services.AddSingleton<ICompactorContext, PluginCompactorContext>();
|
||||
services.AddSingleton<ICompactionExecutor, ExternalCompactionExecutor>();
|
||||
services.AddSingleton<FileCompactor>();
|
||||
services.AddSingleton<TagHandler>();
|
||||
services.AddSingleton<PairRequestService>();
|
||||
@@ -140,6 +146,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
services.AddSingleton<IdDisplayHandler>();
|
||||
services.AddSingleton<PlayerPerformanceService>();
|
||||
services.AddSingleton<PenumbraTempCollectionJanitor>();
|
||||
services.AddSingleton<LocationShareService>();
|
||||
|
||||
services.AddSingleton<TextureMetadataHelper>(sp =>
|
||||
new TextureMetadataHelper(sp.GetRequiredService<ILogger<TextureMetadataHelper>>(), gameData));
|
||||
@@ -176,7 +183,8 @@ public sealed class Plugin : IDalamudPlugin
|
||||
|
||||
services.AddSingleton(sp => new BlockedCharacterHandler(
|
||||
sp.GetRequiredService<ILogger<BlockedCharacterHandler>>(),
|
||||
gameInteropProvider));
|
||||
gameInteropProvider,
|
||||
objectTable));
|
||||
|
||||
services.AddSingleton(sp => new IpcProvider(
|
||||
sp.GetRequiredService<ILogger<IpcProvider>>(),
|
||||
@@ -326,8 +334,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
pluginInterface,
|
||||
sp.GetRequiredService<DalamudUtilService>(),
|
||||
sp.GetRequiredService<LightlessMediator>(),
|
||||
sp.GetRequiredService<RedrawManager>(),
|
||||
sp.GetRequiredService<ActorObjectService>()));
|
||||
sp.GetRequiredService<RedrawManager>()));
|
||||
|
||||
services.AddSingleton(sp => new IpcCallerGlamourer(
|
||||
sp.GetRequiredService<ILogger<IpcCallerGlamourer>>(),
|
||||
@@ -372,6 +379,11 @@ public sealed class Plugin : IDalamudPlugin
|
||||
sp.GetRequiredService<DalamudUtilService>(),
|
||||
sp.GetRequiredService<LightlessMediator>()));
|
||||
|
||||
services.AddSingleton(sp => new IpcCallerLifestream(
|
||||
pluginInterface,
|
||||
sp.GetRequiredService<LightlessMediator>(),
|
||||
sp.GetRequiredService<ILogger<IpcCallerLifestream>>()));
|
||||
|
||||
services.AddSingleton(sp => new IpcManager(
|
||||
sp.GetRequiredService<ILogger<IpcManager>>(),
|
||||
sp.GetRequiredService<LightlessMediator>(),
|
||||
@@ -382,7 +394,9 @@ public sealed class Plugin : IDalamudPlugin
|
||||
sp.GetRequiredService<IpcCallerHonorific>(),
|
||||
sp.GetRequiredService<IpcCallerMoodles>(),
|
||||
sp.GetRequiredService<IpcCallerPetNames>(),
|
||||
sp.GetRequiredService<IpcCallerBrio>()));
|
||||
sp.GetRequiredService<IpcCallerBrio>(),
|
||||
sp.GetRequiredService<IpcCallerLifestream>()
|
||||
));
|
||||
|
||||
// Notifications / HTTP
|
||||
services.AddSingleton(sp => new NotificationService(
|
||||
@@ -480,19 +494,11 @@ public sealed class Plugin : IDalamudPlugin
|
||||
sp.GetRequiredService<UiSharedService>(),
|
||||
sp.GetRequiredService<ApiController>(),
|
||||
sp.GetRequiredService<LightFinderScannerService>(),
|
||||
sp.GetRequiredService<LightFinderPlateHandler>()));
|
||||
|
||||
services.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>(sp => new SyncshellFinderUI(
|
||||
sp.GetRequiredService<ILogger<SyncshellFinderUI>>(),
|
||||
sp.GetRequiredService<LightlessMediator>(),
|
||||
sp.GetRequiredService<PerformanceCollectorService>(),
|
||||
sp.GetRequiredService<LightFinderService>(),
|
||||
sp.GetRequiredService<UiSharedService>(),
|
||||
sp.GetRequiredService<ApiController>(),
|
||||
sp.GetRequiredService<LightFinderScannerService>(),
|
||||
sp.GetRequiredService<PairUiService>(),
|
||||
sp.GetRequiredService<DalamudUtilService>(),
|
||||
sp.GetRequiredService<LightlessProfileManager>()));
|
||||
sp.GetRequiredService<LightlessProfileManager>(),
|
||||
sp.GetRequiredService<ActorObjectService>(),
|
||||
sp.GetRequiredService<LightFinderPlateHandler>()));
|
||||
|
||||
services.AddScoped<IPopupHandler, BanUserPopupHandler>();
|
||||
services.AddScoped<IPopupHandler, CensusPopupHandler>();
|
||||
@@ -512,6 +518,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
sp.GetRequiredService<ILogger<UiService>>(),
|
||||
pluginInterface.UiBuilder,
|
||||
sp.GetRequiredService<LightlessConfigService>(),
|
||||
sp.GetRequiredService<DalamudUtilService>(),
|
||||
sp.GetRequiredService<WindowSystem>(),
|
||||
sp.GetServices<WindowMediatorSubscriberBase>(),
|
||||
sp.GetRequiredService<UiFactory>(),
|
||||
@@ -578,7 +585,6 @@ public sealed class Plugin : IDalamudPlugin
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_host.StopAsync().GetAwaiter().GetResult();
|
||||
_host.Dispose();
|
||||
_host.StopAsync().ContinueWith(_ => _host.Dispose()).Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -6,6 +6,7 @@ using FFXIVClientStructs.Interop;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Object;
|
||||
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -16,7 +17,7 @@ using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
||||
|
||||
namespace LightlessSync.Services.ActorTracking;
|
||||
|
||||
public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorSubscriber
|
||||
{
|
||||
public readonly record struct ActorDescriptor(
|
||||
string Name,
|
||||
@@ -36,6 +37,8 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
private readonly IClientState _clientState;
|
||||
private readonly ICondition _condition;
|
||||
private readonly LightlessMediator _mediator;
|
||||
private readonly object _playerRelatedHandlerLock = new();
|
||||
private readonly HashSet<GameObjectHandler> _playerRelatedHandlers = [];
|
||||
|
||||
private readonly ConcurrentDictionary<nint, ActorDescriptor> _activePlayers = new();
|
||||
private readonly ConcurrentDictionary<nint, ActorDescriptor> _gposePlayers = new();
|
||||
@@ -71,6 +74,26 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
_clientState = clientState;
|
||||
_condition = condition;
|
||||
_mediator = mediator;
|
||||
|
||||
_mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
|
||||
{
|
||||
if (!msg.OwnedObject) return;
|
||||
lock (_playerRelatedHandlerLock)
|
||||
{
|
||||
_playerRelatedHandlers.Add(msg.GameObjectHandler);
|
||||
}
|
||||
RefreshTrackedActors(force: true);
|
||||
});
|
||||
_mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, (msg) =>
|
||||
{
|
||||
if (!msg.OwnedObject) return;
|
||||
lock (_playerRelatedHandlerLock)
|
||||
{
|
||||
_playerRelatedHandlers.Remove(msg.GameObjectHandler);
|
||||
}
|
||||
RefreshTrackedActors(force: true);
|
||||
});
|
||||
_mediator.Subscribe<DalamudLogoutMessage>(this, _ => ClearTrackingState());
|
||||
}
|
||||
|
||||
private bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51];
|
||||
@@ -84,6 +107,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
public IReadOnlyList<ActorDescriptor> PlayerDescriptors => Snapshot.PlayerDescriptors;
|
||||
public IReadOnlyList<ActorDescriptor> OwnedDescriptors => Snapshot.OwnedDescriptors;
|
||||
public IReadOnlyList<ActorDescriptor> GposeDescriptors => CurrentGposeSnapshot.GposeDescriptors;
|
||||
public LightlessMediator Mediator => _mediator;
|
||||
|
||||
public bool TryGetActorByHash(string hash, out ActorDescriptor descriptor) => _actorsByHash.TryGetValue(hash, out descriptor);
|
||||
public bool TryGetValidatedActorByHash(string hash, out ActorDescriptor descriptor)
|
||||
@@ -213,18 +237,25 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task WaitForFullyLoadedAsync(nint address, CancellationToken cancellationToken = default)
|
||||
public async Task<bool> WaitForFullyLoadedAsync(nint address, CancellationToken cancellationToken = default, int timeOutMs = 30000)
|
||||
{
|
||||
if (address == nint.Zero)
|
||||
throw new ArgumentException("Address cannot be zero.", nameof(address));
|
||||
|
||||
var timeoutAt = timeOutMs > 0 ? Environment.TickCount64 + timeOutMs : long.MaxValue;
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var isLoaded = await _framework.RunOnFrameworkThread(() => IsObjectFullyLoaded(address)).ConfigureAwait(false);
|
||||
if (!IsZoning && isLoaded)
|
||||
return;
|
||||
var loadState = await _framework.RunOnFrameworkThread(() => GetObjectLoadState(address)).ConfigureAwait(false);
|
||||
if (!loadState.IsValid)
|
||||
return false;
|
||||
|
||||
if (!IsZoning && loadState.IsLoaded)
|
||||
return true;
|
||||
|
||||
if (Environment.TickCount64 >= timeoutAt)
|
||||
return false;
|
||||
|
||||
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -312,13 +343,8 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
DisposeHooks();
|
||||
_activePlayers.Clear();
|
||||
_gposePlayers.Clear();
|
||||
_actorsByHash.Clear();
|
||||
_actorsByName.Clear();
|
||||
_pendingHashResolutions.Clear();
|
||||
Volatile.Write(ref _snapshot, ActorSnapshot.Empty);
|
||||
Volatile.Write(ref _gposeSnapshot, GposeSnapshot.Empty);
|
||||
ClearTrackingState();
|
||||
_mediator.UnsubscribeAll(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -493,7 +519,9 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
if (objectKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
|
||||
{
|
||||
var expectedMinionOrMount = GetMinionOrMountAddress(localPlayerAddress, localEntityId);
|
||||
if (expectedMinionOrMount != nint.Zero && (nint)gameObject == expectedMinionOrMount)
|
||||
if (expectedMinionOrMount != nint.Zero
|
||||
&& (nint)gameObject == expectedMinionOrMount
|
||||
&& IsPlayerRelatedOwnedAddress(expectedMinionOrMount, LightlessObjectKind.MinionOrMount))
|
||||
{
|
||||
var resolvedOwner = ownerId != 0 ? ownerId : localEntityId;
|
||||
return (LightlessObjectKind.MinionOrMount, resolvedOwner);
|
||||
@@ -507,51 +535,55 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
return (null, ownerId);
|
||||
|
||||
var expectedPet = GetPetAddress(localPlayerAddress, localEntityId);
|
||||
if (expectedPet != nint.Zero && (nint)gameObject == expectedPet)
|
||||
if (expectedPet != nint.Zero
|
||||
&& (nint)gameObject == expectedPet
|
||||
&& IsPlayerRelatedOwnedAddress(expectedPet, LightlessObjectKind.Pet))
|
||||
return (LightlessObjectKind.Pet, ownerId);
|
||||
|
||||
var expectedCompanion = GetCompanionAddress(localPlayerAddress, localEntityId);
|
||||
if (expectedCompanion != nint.Zero && (nint)gameObject == expectedCompanion)
|
||||
if (expectedCompanion != nint.Zero
|
||||
&& (nint)gameObject == expectedCompanion
|
||||
&& IsPlayerRelatedOwnedAddress(expectedCompanion, LightlessObjectKind.Companion))
|
||||
return (LightlessObjectKind.Companion, ownerId);
|
||||
|
||||
return (null, ownerId);
|
||||
}
|
||||
|
||||
private bool IsPlayerRelatedOwnedAddress(nint address, LightlessObjectKind expectedKind)
|
||||
{
|
||||
if (address == nint.Zero)
|
||||
return false;
|
||||
|
||||
lock (_playerRelatedHandlerLock)
|
||||
{
|
||||
foreach (var handler in _playerRelatedHandlers)
|
||||
{
|
||||
if (handler.Address == address && handler.ObjectKind == expectedKind)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private unsafe nint GetMinionOrMountAddress(nint localPlayerAddress, uint ownerEntityId)
|
||||
{
|
||||
if (localPlayerAddress == nint.Zero)
|
||||
return nint.Zero;
|
||||
|
||||
if (ownerEntityId == 0)
|
||||
return nint.Zero;
|
||||
|
||||
var playerObject = (GameObject*)localPlayerAddress;
|
||||
var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1);
|
||||
if (candidateAddress != nint.Zero)
|
||||
{
|
||||
var candidate = (GameObject*)candidateAddress;
|
||||
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
|
||||
if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
|
||||
{
|
||||
if (ownerEntityId == 0 || ResolveOwnerId(candidate) == ownerEntityId)
|
||||
return candidateAddress;
|
||||
}
|
||||
}
|
||||
if (candidateAddress == nint.Zero)
|
||||
return nint.Zero;
|
||||
|
||||
if (ownerEntityId == 0)
|
||||
return candidateAddress;
|
||||
|
||||
foreach (var obj in _objectTable)
|
||||
{
|
||||
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
|
||||
continue;
|
||||
|
||||
if (obj.ObjectKind is not (DalamudObjectKind.MountType or DalamudObjectKind.Companion))
|
||||
continue;
|
||||
|
||||
var candidate = (GameObject*)obj.Address;
|
||||
if (ResolveOwnerId(candidate) == ownerEntityId)
|
||||
return obj.Address;
|
||||
}
|
||||
|
||||
return candidateAddress;
|
||||
var candidate = (GameObject*)candidateAddress;
|
||||
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
|
||||
return candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion
|
||||
? candidateAddress
|
||||
: nint.Zero;
|
||||
}
|
||||
|
||||
private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId)
|
||||
@@ -571,22 +603,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var obj in _objectTable)
|
||||
{
|
||||
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
|
||||
continue;
|
||||
|
||||
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
|
||||
continue;
|
||||
|
||||
var candidate = (GameObject*)obj.Address;
|
||||
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
|
||||
continue;
|
||||
|
||||
if (ResolveOwnerId(candidate) == ownerEntityId)
|
||||
return obj.Address;
|
||||
}
|
||||
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
@@ -606,23 +622,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var obj in _objectTable)
|
||||
{
|
||||
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
|
||||
continue;
|
||||
|
||||
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
|
||||
continue;
|
||||
|
||||
var candidate = (GameObject*)obj.Address;
|
||||
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Buddy)
|
||||
continue;
|
||||
|
||||
if (ResolveOwnerId(candidate) == ownerEntityId)
|
||||
return obj.Address;
|
||||
}
|
||||
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
@@ -1019,9 +1018,26 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearTrackingState()
|
||||
{
|
||||
_activePlayers.Clear();
|
||||
_gposePlayers.Clear();
|
||||
_actorsByHash.Clear();
|
||||
_actorsByName.Clear();
|
||||
_pendingHashResolutions.Clear();
|
||||
lock (_playerRelatedHandlerLock)
|
||||
{
|
||||
_playerRelatedHandlers.Clear();
|
||||
}
|
||||
Volatile.Write(ref _snapshot, ActorSnapshot.Empty);
|
||||
Volatile.Write(ref _gposeSnapshot, GposeSnapshot.Empty);
|
||||
_nextRefreshAllowed = DateTime.MinValue;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeHooks();
|
||||
_mediator.UnsubscribeAll(this);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
@@ -1143,6 +1159,18 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
return results;
|
||||
}
|
||||
|
||||
private LoadState GetObjectLoadState(nint address)
|
||||
{
|
||||
if (address == nint.Zero)
|
||||
return LoadState.Invalid;
|
||||
|
||||
var obj = _objectTable.CreateObjectReference(address);
|
||||
if (obj is null || obj.Address != address)
|
||||
return LoadState.Invalid;
|
||||
|
||||
return new LoadState(true, IsObjectFullyLoaded(address));
|
||||
}
|
||||
|
||||
private static unsafe bool IsObjectFullyLoaded(nint address)
|
||||
{
|
||||
if (address == nint.Zero)
|
||||
@@ -1169,6 +1197,11 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
private readonly record struct LoadState(bool IsValid, bool IsLoaded)
|
||||
{
|
||||
public static LoadState Invalid => new(false, false);
|
||||
}
|
||||
|
||||
private sealed record OwnedObjectSnapshot(
|
||||
IReadOnlyList<nint> RenderedPlayers,
|
||||
IReadOnlyList<nint> RenderedCompanions,
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
{
|
||||
_baseAnalysisCts = _baseAnalysisCts.CancelRecreate();
|
||||
var token = _baseAnalysisCts.Token;
|
||||
_ = BaseAnalysis(msg.CharacterData, token);
|
||||
_ = Task.Run(async () => await BaseAnalysis(msg.CharacterData, token).ConfigureAwait(false), token);
|
||||
});
|
||||
_fileCacheManager = fileCacheManager;
|
||||
_xivDataAnalyzer = modelAnalyzer;
|
||||
@@ -106,7 +106,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
_baseAnalysisCts.Dispose();
|
||||
}
|
||||
|
||||
public async Task UpdateFileEntriesAsync(IEnumerable<string> filePaths, CancellationToken token)
|
||||
public async Task UpdateFileEntriesAsync(IEnumerable<string> filePaths, CancellationToken token, bool force = false)
|
||||
{
|
||||
var normalized = new HashSet<string>(
|
||||
filePaths.Where(path => !string.IsNullOrWhiteSpace(path)),
|
||||
@@ -115,6 +115,8 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = false;
|
||||
foreach (var objectEntries in LastAnalysis.Values)
|
||||
{
|
||||
foreach (var entry in objectEntries.Values)
|
||||
@@ -124,9 +126,26 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
continue;
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
await entry.ComputeSizes(_fileCacheManager, token).ConfigureAwait(false);
|
||||
await entry.ComputeSizes(_fileCacheManager, token, force).ConfigureAwait(false);
|
||||
|
||||
if (string.Equals(entry.FileType, "mdl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var sourcePath = entry.FilePaths.FirstOrDefault(path => !string.IsNullOrWhiteSpace(path));
|
||||
if (!string.IsNullOrWhiteSpace(sourcePath))
|
||||
{
|
||||
entry.UpdateTriangles(_xivDataAnalyzer.RefreshTrianglesForPath(entry.Hash, sourcePath));
|
||||
}
|
||||
}
|
||||
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated)
|
||||
{
|
||||
RecalculateSummary();
|
||||
Mediator.Publish(new CharacterDataAnalyzedMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BaseAnalysis(CharacterData charaData, CancellationToken token)
|
||||
@@ -311,6 +330,10 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
var original = new FileInfo(path).Length;
|
||||
|
||||
var compressedLen = await fileCacheManager.GetCompressedSizeAsync(Hash, token).ConfigureAwait(false);
|
||||
if (compressedLen <= 0 && !string.Equals(FileType, "tex", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
compressedLen = original;
|
||||
}
|
||||
|
||||
fileCacheManager.SetSizeInfo(Hash, original, compressedLen);
|
||||
FileCacheManager.ApplySizesToEntries(CacheEntries, original, compressedLen);
|
||||
@@ -326,6 +349,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
private Lazy<string>? _format;
|
||||
|
||||
public void RefreshFormat() => _format = CreateFormatValue();
|
||||
public void UpdateTriangles(long triangles) => Triangles = triangles;
|
||||
|
||||
private Lazy<string> CreateFormatValue()
|
||||
=> new(() =>
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
using Dalamud.Interface.Textures.TextureWraps;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.UI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
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;
|
||||
|
||||
public sealed class ChatEmoteService : IDisposable
|
||||
{
|
||||
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 HttpClient _httpClient;
|
||||
private readonly UiSharedService _uiSharedService;
|
||||
private readonly ChatConfigService _chatConfigService;
|
||||
private readonly ConcurrentDictionary<string, EmoteEntry> _emotes = new(StringComparer.Ordinal);
|
||||
private readonly SemaphoreSlim _downloadGate = new(3, 3);
|
||||
|
||||
private readonly object _loadLock = new();
|
||||
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;
|
||||
_httpClient = httpClient;
|
||||
_uiSharedService = uiSharedService;
|
||||
_chatConfigService = chatConfigService;
|
||||
}
|
||||
|
||||
public void EnsureGlobalEmotesLoaded()
|
||||
@@ -62,13 +74,17 @@ public sealed class ChatEmoteService : IDisposable
|
||||
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;
|
||||
}
|
||||
|
||||
entry.EnsureLoading(QueueEmoteDownload);
|
||||
entry.EnsureLoading(allowAnimation, QueueEmoteDownload);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -76,7 +92,7 @@ public sealed class ChatEmoteService : IDisposable
|
||||
{
|
||||
foreach (var entry in _emotes.Values)
|
||||
{
|
||||
entry.Texture?.Dispose();
|
||||
entry.Dispose();
|
||||
}
|
||||
|
||||
_downloadGate.Dispose();
|
||||
@@ -108,13 +124,13 @@ public sealed class ChatEmoteService : IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
var url = TryBuildEmoteUrl(emoteElement);
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
var source = TryBuildEmoteSource(emoteElement);
|
||||
if (source is null || (!source.Value.HasStatic && !source.Value.HasAnimation))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_emotes.TryAdd(name, new EmoteEntry(url));
|
||||
_emotes.TryAdd(name, new EmoteEntry(name, source.Value));
|
||||
}
|
||||
}
|
||||
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))
|
||||
{
|
||||
@@ -156,29 +172,38 @@ public sealed class ChatEmoteService : IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
var fileName = PickBestStaticFile(filesElement);
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
var files = ReadEmoteFiles(filesElement);
|
||||
if (files.Count == 0)
|
||||
{
|
||||
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)
|
||||
{
|
||||
string? png1x = null;
|
||||
string? webp1x = null;
|
||||
string? pngFallback = null;
|
||||
string? webpFallback = null;
|
||||
private static string BuildEmoteUrl(string baseUrl, string fileName)
|
||||
=> baseUrl.TrimEnd('/') + "/" + fileName;
|
||||
|
||||
private static List<EmoteFile> ReadEmoteFiles(JsonElement filesElement)
|
||||
{
|
||||
var files = new List<EmoteFile>();
|
||||
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))
|
||||
{
|
||||
continue;
|
||||
@@ -190,6 +215,88 @@ public sealed class ChatEmoteService : IDisposable
|
||||
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))
|
||||
{
|
||||
png1x = name;
|
||||
@@ -198,6 +305,10 @@ public sealed class ChatEmoteService : IDisposable
|
||||
{
|
||||
webp1x = name;
|
||||
}
|
||||
else if (name.Equals("1x.gif", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
gif1x = name;
|
||||
}
|
||||
else if (name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && pngFallback is null)
|
||||
{
|
||||
pngFallback = name;
|
||||
@@ -206,25 +317,80 @@ public sealed class ChatEmoteService : IDisposable
|
||||
{
|
||||
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 () =>
|
||||
{
|
||||
await _downloadGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var data = await _httpClient.GetByteArrayAsync(entry.Url).ConfigureAwait(false);
|
||||
var texture = _uiSharedService.LoadImage(data);
|
||||
entry.SetTexture(texture);
|
||||
if (allowAnimation)
|
||||
{
|
||||
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)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to load 7TV emote {Url}", entry.Url);
|
||||
_logger.LogDebug(ex, "Failed to load 7TV emote {Emote}", entry.Code);
|
||||
entry.MarkFailed();
|
||||
}
|
||||
finally
|
||||
@@ -234,21 +400,334 @@ public sealed class ChatEmoteService : IDisposable
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class EmoteEntry
|
||||
private async Task<bool> TryLoadAnimatedEmoteAsync(EmoteEntry entry)
|
||||
{
|
||||
private int _loadingState;
|
||||
|
||||
public EmoteEntry(string url)
|
||||
if (string.IsNullOrWhiteSpace(entry.AnimatedUrl))
|
||||
{
|
||||
Url = url;
|
||||
return false;
|
||||
}
|
||||
|
||||
public string Url { get; }
|
||||
public IDalamudTextureWrap? Texture { get; private set; }
|
||||
|
||||
public void EnsureLoading(Action<EmoteEntry> queueDownload)
|
||||
try
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -258,12 +737,22 @@ public sealed class ChatEmoteService : IDisposable
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -271,5 +760,11 @@ public sealed class ChatEmoteService : IDisposable
|
||||
{
|
||||
Interlocked.Exchange(ref _loadingState, 0);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_animation?.Dispose();
|
||||
_staticTexture?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,18 +8,26 @@ using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using LightlessSync.UI.Services;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LightlessSync.Services.Chat;
|
||||
|
||||
public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedService
|
||||
{
|
||||
private const int MaxMessageHistory = 150;
|
||||
private const int MaxMessageHistory = 200;
|
||||
internal const int MaxOutgoingLength = 200;
|
||||
private const int MaxUnreadCount = 999;
|
||||
private const string ZoneUnavailableMessage = "Zone chat is only available in major cities.";
|
||||
private const string ZoneChannelKey = "zone";
|
||||
private const int MaxReportReasonLength = 100;
|
||||
private const int MaxReportContextLength = 1000;
|
||||
private static readonly JsonSerializerOptions PersistedHistorySerializerOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly ApiController _apiController;
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
@@ -376,6 +384,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
LoadPersistedSyncshellHistory();
|
||||
Mediator.Subscribe<DalamudLoginMessage>(this, _ => HandleLogin());
|
||||
Mediator.Subscribe<DalamudLogoutMessage>(this, _ => HandleLogout());
|
||||
Mediator.Subscribe<ZoneSwitchEndMessage>(this, _ => ScheduleZonePresenceUpdate());
|
||||
@@ -1000,11 +1009,22 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
|
||||
private void OnChatMessageReceived(ChatMessageDto dto)
|
||||
{
|
||||
var descriptor = dto.Channel.WithNormalizedCustomKey();
|
||||
var key = descriptor.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(descriptor);
|
||||
var fromSelf = IsMessageFromSelf(dto, key);
|
||||
var message = BuildMessage(dto, fromSelf);
|
||||
ChatChannelDescriptor descriptor = dto.Channel.WithNormalizedCustomKey();
|
||||
string key = descriptor.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(descriptor);
|
||||
bool fromSelf = IsMessageFromSelf(dto, key);
|
||||
ChatMessageEntry message = BuildMessage(dto, fromSelf);
|
||||
bool mentionNotificationsEnabled = _chatConfigService.Current.EnableMentionNotifications;
|
||||
bool notifyMention = mentionNotificationsEnabled
|
||||
&& !fromSelf
|
||||
&& descriptor.Type == ChatChannelType.Group
|
||||
&& TryGetSelfMentionToken(dto.Message, out _);
|
||||
|
||||
string? mentionChannelName = null;
|
||||
string? mentionSenderName = null;
|
||||
bool publishChannelList = false;
|
||||
bool shouldPersistHistory = _chatConfigService.Current.PersistSyncshellHistory;
|
||||
List<PersistedChatMessage>? persistedMessages = null;
|
||||
string? persistedChannelKey = null;
|
||||
|
||||
using (_sync.EnterScope())
|
||||
{
|
||||
@@ -1042,6 +1062,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
state.Messages.RemoveAt(0);
|
||||
}
|
||||
|
||||
if (notifyMention)
|
||||
{
|
||||
mentionChannelName = state.DisplayName;
|
||||
mentionSenderName = message.DisplayName;
|
||||
}
|
||||
|
||||
if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal))
|
||||
{
|
||||
state.HasUnread = false;
|
||||
@@ -1058,10 +1084,29 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
}
|
||||
|
||||
MarkChannelsSnapshotDirtyLocked();
|
||||
|
||||
if (shouldPersistHistory && state.Type == ChatChannelType.Group)
|
||||
{
|
||||
persistedChannelKey = state.Key;
|
||||
persistedMessages = BuildPersistedHistoryLocked(state);
|
||||
}
|
||||
}
|
||||
|
||||
Mediator.Publish(new ChatChannelMessageAdded(key, message));
|
||||
|
||||
if (persistedMessages is not null && persistedChannelKey is not null)
|
||||
{
|
||||
PersistSyncshellHistory(persistedChannelKey, persistedMessages);
|
||||
}
|
||||
|
||||
if (notifyMention)
|
||||
{
|
||||
string channelName = mentionChannelName ?? "Syncshell";
|
||||
string senderName = mentionSenderName ?? "Someone";
|
||||
string notificationText = $"You were mentioned by {senderName} in {channelName}.";
|
||||
Mediator.Publish(new NotificationMessage("Syncshell mention", notificationText, NotificationType.Info));
|
||||
}
|
||||
|
||||
if (publishChannelList)
|
||||
{
|
||||
using (_sync.EnterScope())
|
||||
@@ -1108,6 +1153,113 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetSelfMentionToken(string message, out string matchedToken)
|
||||
{
|
||||
matchedToken = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
HashSet<string> tokens = BuildSelfMentionTokens();
|
||||
if (tokens.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryFindMentionToken(message, tokens, out matchedToken);
|
||||
}
|
||||
|
||||
private HashSet<string> BuildSelfMentionTokens()
|
||||
{
|
||||
HashSet<string> tokens = new(StringComparer.OrdinalIgnoreCase);
|
||||
string uid = _apiController.UID;
|
||||
if (IsValidMentionToken(uid))
|
||||
{
|
||||
tokens.Add(uid);
|
||||
}
|
||||
|
||||
string displayName = _apiController.DisplayName;
|
||||
if (IsValidMentionToken(displayName))
|
||||
{
|
||||
tokens.Add(displayName);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static bool IsValidMentionToken(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < value.Length; i++)
|
||||
{
|
||||
if (!IsMentionChar(value[i]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryFindMentionToken(string message, IReadOnlyCollection<string> tokens, out string matchedToken)
|
||||
{
|
||||
matchedToken = string.Empty;
|
||||
if (tokens.Count == 0 || string.IsNullOrEmpty(message))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int index = 0;
|
||||
while (index < message.Length)
|
||||
{
|
||||
if (message[index] != '@')
|
||||
{
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index > 0 && IsMentionChar(message[index - 1]))
|
||||
{
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
int start = index + 1;
|
||||
int end = start;
|
||||
while (end < message.Length && IsMentionChar(message[end]))
|
||||
{
|
||||
end++;
|
||||
}
|
||||
|
||||
if (end == start)
|
||||
{
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
string token = message.Substring(start, end - start);
|
||||
if (tokens.Contains(token))
|
||||
{
|
||||
matchedToken = token;
|
||||
return true;
|
||||
}
|
||||
|
||||
index = end;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsMentionChar(char value)
|
||||
{
|
||||
return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '\'';
|
||||
}
|
||||
|
||||
private ChatMessageEntry BuildMessage(ChatMessageDto dto, bool fromSelf)
|
||||
{
|
||||
var displayName = ResolveDisplayName(dto, fromSelf);
|
||||
@@ -1364,6 +1516,313 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void LoadPersistedSyncshellHistory()
|
||||
{
|
||||
if (!_chatConfigService.Current.PersistSyncshellHistory)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<string, string> persisted = _chatConfigService.Current.SyncshellChannelHistory;
|
||||
if (persisted.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<string> invalidKeys = new();
|
||||
foreach (KeyValuePair<string, string> entry in persisted)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.Key) || string.IsNullOrWhiteSpace(entry.Value))
|
||||
{
|
||||
invalidKeys.Add(entry.Key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryDecodePersistedHistory(entry.Value, out List<PersistedChatMessage> persistedMessages))
|
||||
{
|
||||
invalidKeys.Add(entry.Key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (persistedMessages.Count == 0)
|
||||
{
|
||||
invalidKeys.Add(entry.Key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (persistedMessages.Count > MaxMessageHistory)
|
||||
{
|
||||
int startIndex = Math.Max(0, persistedMessages.Count - MaxMessageHistory);
|
||||
persistedMessages = persistedMessages.GetRange(startIndex, persistedMessages.Count - startIndex);
|
||||
}
|
||||
|
||||
List<ChatMessageEntry> restoredMessages = new(persistedMessages.Count);
|
||||
foreach (PersistedChatMessage persistedMessage in persistedMessages)
|
||||
{
|
||||
if (!TryBuildRestoredMessage(entry.Key, persistedMessage, out ChatMessageEntry restoredMessage))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
restoredMessages.Add(restoredMessage);
|
||||
}
|
||||
|
||||
if (restoredMessages.Count == 0)
|
||||
{
|
||||
invalidKeys.Add(entry.Key);
|
||||
continue;
|
||||
}
|
||||
|
||||
using (_sync.EnterScope())
|
||||
{
|
||||
_messageHistoryCache[entry.Key] = restoredMessages;
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidKeys.Count > 0)
|
||||
{
|
||||
foreach (string key in invalidKeys)
|
||||
{
|
||||
persisted.Remove(key);
|
||||
}
|
||||
|
||||
_chatConfigService.Save();
|
||||
}
|
||||
}
|
||||
|
||||
private List<PersistedChatMessage> BuildPersistedHistoryLocked(ChatChannelState state)
|
||||
{
|
||||
int startIndex = Math.Max(0, state.Messages.Count - MaxMessageHistory);
|
||||
List<PersistedChatMessage> persistedMessages = new(state.Messages.Count - startIndex);
|
||||
for (int i = startIndex; i < state.Messages.Count; i++)
|
||||
{
|
||||
ChatMessageEntry entry = state.Messages[i];
|
||||
if (entry.Payload is not { } payload)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
persistedMessages.Add(new PersistedChatMessage(
|
||||
payload.Message,
|
||||
entry.DisplayName,
|
||||
entry.FromSelf,
|
||||
entry.ReceivedAtUtc,
|
||||
payload.SentAtUtc));
|
||||
}
|
||||
|
||||
return persistedMessages;
|
||||
}
|
||||
|
||||
private void PersistSyncshellHistory(string channelKey, List<PersistedChatMessage> persistedMessages)
|
||||
{
|
||||
if (!_chatConfigService.Current.PersistSyncshellHistory)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<string, string> persisted = _chatConfigService.Current.SyncshellChannelHistory;
|
||||
if (persistedMessages.Count == 0)
|
||||
{
|
||||
if (persisted.Remove(channelKey))
|
||||
{
|
||||
_chatConfigService.Save();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
string? base64 = EncodePersistedMessages(persistedMessages);
|
||||
if (string.IsNullOrWhiteSpace(base64))
|
||||
{
|
||||
if (persisted.Remove(channelKey))
|
||||
{
|
||||
_chatConfigService.Save();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
persisted[channelKey] = base64;
|
||||
_chatConfigService.Save();
|
||||
}
|
||||
|
||||
private static string? EncodePersistedMessages(List<PersistedChatMessage> persistedMessages)
|
||||
{
|
||||
if (persistedMessages.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(persistedMessages, PersistedHistorySerializerOptions);
|
||||
return Convert.ToBase64String(jsonBytes);
|
||||
}
|
||||
|
||||
private static bool TryDecodePersistedHistory(string base64, out List<PersistedChatMessage> persistedMessages)
|
||||
{
|
||||
persistedMessages = new List<PersistedChatMessage>();
|
||||
if (string.IsNullOrWhiteSpace(base64))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
byte[] jsonBytes = Convert.FromBase64String(base64);
|
||||
List<PersistedChatMessage>? decoded = JsonSerializer.Deserialize<List<PersistedChatMessage>>(jsonBytes, PersistedHistorySerializerOptions);
|
||||
if (decoded is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
persistedMessages = decoded;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryBuildRestoredMessage(string channelKey, PersistedChatMessage persistedMessage, out ChatMessageEntry restoredMessage)
|
||||
{
|
||||
restoredMessage = default;
|
||||
string messageText = persistedMessage.Message;
|
||||
DateTime sentAtUtc = persistedMessage.SentAtUtc;
|
||||
if (string.IsNullOrWhiteSpace(messageText) && persistedMessage.LegacyPayload is { } legacy)
|
||||
{
|
||||
messageText = legacy.Message;
|
||||
sentAtUtc = legacy.SentAtUtc;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(messageText))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ChatChannelDescriptor descriptor = BuildDescriptorFromChannelKey(channelKey);
|
||||
ChatSenderDescriptor sender = new ChatSenderDescriptor(
|
||||
ChatSenderKind.Anonymous,
|
||||
string.Empty,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false);
|
||||
|
||||
ChatMessageDto payload = new ChatMessageDto(descriptor, sender, messageText, sentAtUtc, string.Empty);
|
||||
restoredMessage = new ChatMessageEntry(payload, persistedMessage.DisplayName, persistedMessage.FromSelf, persistedMessage.ReceivedAtUtc);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ChatChannelDescriptor BuildDescriptorFromChannelKey(string channelKey)
|
||||
{
|
||||
if (string.Equals(channelKey, ZoneChannelKey, StringComparison.Ordinal))
|
||||
{
|
||||
return new ChatChannelDescriptor { Type = ChatChannelType.Zone };
|
||||
}
|
||||
|
||||
int separatorIndex = channelKey.IndexOf(':', StringComparison.Ordinal);
|
||||
if (separatorIndex <= 0 || separatorIndex >= channelKey.Length - 1)
|
||||
{
|
||||
return new ChatChannelDescriptor { Type = ChatChannelType.Group };
|
||||
}
|
||||
|
||||
string typeValue = channelKey[..separatorIndex];
|
||||
if (!int.TryParse(typeValue, out int parsedType))
|
||||
{
|
||||
return new ChatChannelDescriptor { Type = ChatChannelType.Group };
|
||||
}
|
||||
|
||||
string customKey = channelKey[(separatorIndex + 1)..];
|
||||
ChatChannelType channelType = parsedType switch
|
||||
{
|
||||
(int)ChatChannelType.Zone => ChatChannelType.Zone,
|
||||
(int)ChatChannelType.Group => ChatChannelType.Group,
|
||||
_ => ChatChannelType.Group
|
||||
};
|
||||
|
||||
return new ChatChannelDescriptor
|
||||
{
|
||||
Type = channelType,
|
||||
CustomKey = customKey
|
||||
};
|
||||
}
|
||||
|
||||
public void ClearPersistedSyncshellHistory(bool clearLoadedMessages)
|
||||
{
|
||||
bool shouldPublish = false;
|
||||
bool saveConfig = false;
|
||||
|
||||
using (_sync.EnterScope())
|
||||
{
|
||||
Dictionary<string, List<ChatMessageEntry>> cache = _messageHistoryCache;
|
||||
if (cache.Count > 0)
|
||||
{
|
||||
List<string> keysToRemove = new();
|
||||
foreach (string key in cache.Keys)
|
||||
{
|
||||
if (!string.Equals(key, ZoneChannelKey, StringComparison.Ordinal))
|
||||
{
|
||||
keysToRemove.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string key in keysToRemove)
|
||||
{
|
||||
cache.Remove(key);
|
||||
}
|
||||
|
||||
if (keysToRemove.Count > 0)
|
||||
{
|
||||
shouldPublish = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (clearLoadedMessages)
|
||||
{
|
||||
foreach (ChatChannelState state in _channels.Values)
|
||||
{
|
||||
if (state.Type != ChatChannelType.Group)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state.Messages.Count == 0 && state.UnreadCount == 0 && !state.HasUnread)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
state.Messages.Clear();
|
||||
state.HasUnread = false;
|
||||
state.UnreadCount = 0;
|
||||
_lastReadCounts[state.Key] = 0;
|
||||
shouldPublish = true;
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary<string, string> persisted = _chatConfigService.Current.SyncshellChannelHistory;
|
||||
if (persisted.Count > 0)
|
||||
{
|
||||
persisted.Clear();
|
||||
saveConfig = true;
|
||||
}
|
||||
|
||||
if (shouldPublish)
|
||||
{
|
||||
MarkChannelsSnapshotDirtyLocked();
|
||||
}
|
||||
}
|
||||
|
||||
if (saveConfig)
|
||||
{
|
||||
_chatConfigService.Save();
|
||||
}
|
||||
|
||||
if (shouldPublish)
|
||||
{
|
||||
PublishChannelListChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ChatChannelState
|
||||
{
|
||||
public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor)
|
||||
@@ -1400,4 +1859,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
bool IsOwner);
|
||||
|
||||
private readonly record struct PendingSelfMessage(string ChannelKey, string Message);
|
||||
|
||||
public sealed record PersistedChatMessage(
|
||||
string Message = "",
|
||||
string DisplayName = "",
|
||||
bool FromSelf = false,
|
||||
DateTime ReceivedAtUtc = default,
|
||||
DateTime SentAtUtc = default,
|
||||
[property: JsonPropertyName("Payload")] ChatMessageDto? LegacyPayload = null);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ using LightlessSync.UI;
|
||||
using LightlessSync.UI.Services;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using Lumina.Excel.Sheets;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -172,9 +171,8 @@ internal class ContextMenuService : IHostedService
|
||||
_logger.LogTrace("Cannot send pair request to {TargetName}@{World} while in PvP or GPose.", target.TargetName, target.TargetHomeWorld.RowId);
|
||||
return;
|
||||
}
|
||||
|
||||
var world = GetWorld(target.TargetHomeWorld.RowId);
|
||||
if (!IsWorldValid(world))
|
||||
|
||||
if (!IsWorldValid(target.TargetHomeWorld.RowId))
|
||||
{
|
||||
_logger.LogTrace("Target player {TargetName}@{World} is on an invalid world.", target.TargetName, target.TargetHomeWorld.RowId);
|
||||
return;
|
||||
@@ -226,9 +224,8 @@ internal class ContextMenuService : IHostedService
|
||||
{
|
||||
if (args.Target is not MenuTargetDefault target)
|
||||
return;
|
||||
|
||||
var world = GetWorld(target.TargetHomeWorld.RowId);
|
||||
if (!IsWorldValid(world))
|
||||
|
||||
if (!target.TargetHomeWorld.IsValid || !IsWorldValid(target.TargetHomeWorld.RowId))
|
||||
return;
|
||||
|
||||
try
|
||||
@@ -237,7 +234,7 @@ internal class ContextMenuService : IHostedService
|
||||
|
||||
if (targetData == null || targetData.Address == nint.Zero)
|
||||
{
|
||||
_logger.LogWarning("Target player {TargetName}@{World} not found in object table.", target.TargetName, world.Name);
|
||||
_logger.LogWarning("Target player {TargetName}@{World} not found in object table.", target.TargetName, target.TargetHomeWorld.Value.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -252,7 +249,7 @@ internal class ContextMenuService : IHostedService
|
||||
}
|
||||
|
||||
// Notify in chat when NotificationService is disabled
|
||||
NotifyInChat($"Pair request sent to {target.TargetName}@{world.Name}.", NotificationType.Info);
|
||||
NotifyInChat($"Pair request sent to {target.TargetName}@{target.TargetHomeWorld.Value.Name}.", NotificationType.Info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -312,37 +309,8 @@ internal class ContextMenuService : IHostedService
|
||||
p.HomeWorld.RowId == target.TargetHomeWorld.RowId);
|
||||
}
|
||||
|
||||
private World GetWorld(uint worldId)
|
||||
private bool IsWorldValid(uint worldId)
|
||||
{
|
||||
var sheet = _gameData.GetExcelSheet<World>()!;
|
||||
var luminaWorlds = sheet.Where(x =>
|
||||
{
|
||||
var dc = x.DataCenter.ValueNullable;
|
||||
var name = x.Name.ExtractText();
|
||||
var internalName = x.InternalName.ExtractText();
|
||||
|
||||
if (dc == null || dc.Value.Region == 0 || string.IsNullOrWhiteSpace(dc.Value.Name.ExtractText()))
|
||||
return false;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(internalName))
|
||||
return false;
|
||||
|
||||
if (name.Contains('-', StringComparison.Ordinal) || name.Contains('_', StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
return x.DataCenter.Value.Region != 5 || x.RowId > 3001 && x.RowId != 1200 && IsChineseJapaneseKoreanString(name);
|
||||
});
|
||||
|
||||
return luminaWorlds.FirstOrDefault(x => x.RowId == worldId);
|
||||
}
|
||||
|
||||
private static bool IsChineseJapaneseKoreanString(string text) => text.All(IsChineseJapaneseKoreanCharacter);
|
||||
|
||||
private static bool IsChineseJapaneseKoreanCharacter(char c) => c >= 0x4E00 && c <= 0x9FFF;
|
||||
|
||||
public static bool IsWorldValid(World world)
|
||||
{
|
||||
var name = world.Name.ToString();
|
||||
return !string.IsNullOrWhiteSpace(name) && char.IsUpper(name[0]);
|
||||
return _dalamudUtil.WorldData.Value.ContainsKey((ushort)worldId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using Dalamud.Game.ClientState.Conditions;
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Game.ClientState.Objects.Types;
|
||||
using Dalamud.Game.Text;
|
||||
using Dalamud.Plugin.Services;
|
||||
using Dalamud.Utility;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Control;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.UI;
|
||||
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
||||
using LightlessSync.API.Dto.CharaData;
|
||||
@@ -26,6 +28,7 @@ using System.Text;
|
||||
using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind;
|
||||
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
||||
using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
|
||||
using Map = Lumina.Excel.Sheets.Map;
|
||||
using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
@@ -57,6 +60,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
private string _lastGlobalBlockReason = string.Empty;
|
||||
private ushort _lastZone = 0;
|
||||
private ushort _lastWorldId = 0;
|
||||
private uint _lastMapId = 0;
|
||||
private bool _sentBetweenAreas = false;
|
||||
private Lazy<ulong> _cid;
|
||||
|
||||
@@ -86,7 +90,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
WorldData = new(() =>
|
||||
{
|
||||
return gameData.GetExcelSheet<Lumina.Excel.Sheets.World>(clientLanguage)!
|
||||
.Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0])))
|
||||
.Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0])
|
||||
|| w is { RowId: > 1000, UserType: 101 or 201 }))
|
||||
.ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString());
|
||||
});
|
||||
JobData = new(() =>
|
||||
@@ -222,6 +227,28 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
_ = RunOnFrameworkThread(ReleaseFocusUnsafe);
|
||||
}
|
||||
|
||||
public void TargetPlayerByAddress(nint address)
|
||||
{
|
||||
if (address == nint.Zero) return;
|
||||
if (_clientState.IsPvP) return;
|
||||
|
||||
_ = RunOnFrameworkThread(() =>
|
||||
{
|
||||
var gameObject = CreateGameObject(address);
|
||||
if (gameObject is null) return;
|
||||
|
||||
var useFocusTarget = _configService.Current.UseFocusTarget;
|
||||
if (useFocusTarget)
|
||||
{
|
||||
_targetManager.FocusTarget = gameObject;
|
||||
}
|
||||
else
|
||||
{
|
||||
_targetManager.Target = gameObject;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void FocusPairUnsafe(nint address, PairUniqueIdentifier pairIdent)
|
||||
{
|
||||
var target = CreateGameObject(address);
|
||||
@@ -397,38 +424,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
if (playerPointer == IntPtr.Zero) return IntPtr.Zero;
|
||||
|
||||
var playerAddress = playerPointer.Value;
|
||||
var ownerEntityId = ((Character*)playerAddress)->EntityId;
|
||||
var candidateAddress = _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1);
|
||||
if (ownerEntityId == 0) return candidateAddress;
|
||||
|
||||
if (playerAddress == _actorObjectService.LocalPlayerAddress)
|
||||
{
|
||||
var localOwned = _actorObjectService.LocalMinionOrMountAddress;
|
||||
if (localOwned != nint.Zero)
|
||||
{
|
||||
return localOwned;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateAddress != nint.Zero)
|
||||
{
|
||||
var candidate = (GameObject*)candidateAddress;
|
||||
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
|
||||
if ((candidateKind == DalamudObjectKind.MountType || candidateKind == DalamudObjectKind.Companion)
|
||||
&& ResolveOwnerId(candidate) == ownerEntityId)
|
||||
{
|
||||
return candidateAddress;
|
||||
}
|
||||
}
|
||||
|
||||
var ownedObject = FindOwnedObject(ownerEntityId, playerAddress, static kind =>
|
||||
kind == DalamudObjectKind.MountType || kind == DalamudObjectKind.Companion);
|
||||
if (ownedObject != nint.Zero)
|
||||
{
|
||||
return ownedObject;
|
||||
}
|
||||
|
||||
return candidateAddress;
|
||||
return _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1);
|
||||
}
|
||||
|
||||
public async Task<IntPtr> GetMinionOrMountAsync(IntPtr? playerPointer = null)
|
||||
@@ -458,7 +454,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
}
|
||||
}
|
||||
|
||||
return FindOwnedPet(ownerEntityId, ownerAddress);
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
public async Task<IntPtr> GetPetAsync(IntPtr? playerPointer = null)
|
||||
@@ -466,69 +462,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
return await RunOnFrameworkThread(() => GetPetPtr(playerPointer)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private unsafe nint FindOwnedObject(uint ownerEntityId, nint ownerAddress, Func<DalamudObjectKind, bool> matchesKind)
|
||||
{
|
||||
if (ownerEntityId == 0)
|
||||
{
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
foreach (var obj in _objectTable)
|
||||
{
|
||||
if (obj is null || obj.Address == nint.Zero || obj.Address == ownerAddress)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!matchesKind(obj.ObjectKind))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidate = (GameObject*)obj.Address;
|
||||
if (ResolveOwnerId(candidate) == ownerEntityId)
|
||||
{
|
||||
return obj.Address;
|
||||
}
|
||||
}
|
||||
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
private unsafe nint FindOwnedPet(uint ownerEntityId, nint ownerAddress)
|
||||
{
|
||||
if (ownerEntityId == 0)
|
||||
{
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
foreach (var obj in _objectTable)
|
||||
{
|
||||
if (obj is null || obj.Address == nint.Zero || obj.Address == ownerAddress)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidate = (GameObject*)obj.Address;
|
||||
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ResolveOwnerId(candidate) == ownerEntityId)
|
||||
{
|
||||
return obj.Address;
|
||||
}
|
||||
}
|
||||
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
private static unsafe bool IsPetMatch(GameObject* candidate, uint ownerEntityId)
|
||||
{
|
||||
if (candidate == null)
|
||||
@@ -627,6 +560,37 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGetHashedCIDFromAddress(nint address, out string hashedCid)
|
||||
{
|
||||
hashedCid = string.Empty;
|
||||
if (address == nint.Zero)
|
||||
return false;
|
||||
|
||||
if (_framework.IsInFrameworkUpdateThread)
|
||||
{
|
||||
return TryGetHashedCIDFromAddressInternal(address, out hashedCid);
|
||||
}
|
||||
|
||||
var result = _framework.RunOnFrameworkThread(() =>
|
||||
{
|
||||
var success = TryGetHashedCIDFromAddressInternal(address, out var resolved);
|
||||
return (success, resolved);
|
||||
}).GetAwaiter().GetResult();
|
||||
|
||||
hashedCid = result.resolved;
|
||||
return result.success;
|
||||
}
|
||||
|
||||
private bool TryGetHashedCIDFromAddressInternal(nint address, out string hashedCid)
|
||||
{
|
||||
hashedCid = string.Empty;
|
||||
var player = _objectTable.CreateObjectReference(address) as IPlayerCharacter;
|
||||
if (player == null || player.Address != address)
|
||||
return false;
|
||||
|
||||
return TryGetHashedCID(player, out hashedCid);
|
||||
}
|
||||
|
||||
public unsafe static string GetHashedCIDFromPlayerPointer(nint ptr)
|
||||
{
|
||||
return ((BattleChara*)ptr)->Character.ContentId.ToString().GetHash256();
|
||||
@@ -659,7 +623,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
|
||||
var location = new LocationInfo();
|
||||
location.ServerId = _playerState.CurrentWorld.RowId;
|
||||
//location.InstanceId = UIState.Instance()->PublicInstance.InstanceId; //TODO:Need API update first
|
||||
location.InstanceId = UIState.Instance()->PublicInstance.InstanceId;
|
||||
location.TerritoryId = _clientState.TerritoryType;
|
||||
location.MapId = _clientState.MapId;
|
||||
if (houseMan != null)
|
||||
@@ -685,20 +649,20 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
var outside = houseMan->OutdoorTerritory;
|
||||
var house = outside->HouseId;
|
||||
location.WardId = house.WardIndex + 1u;
|
||||
location.HouseId = (uint)houseMan->GetCurrentPlot() + 1;
|
||||
//location.HouseId = (uint)houseMan->GetCurrentPlot() + 1;
|
||||
location.DivisionId = houseMan->GetCurrentDivision();
|
||||
}
|
||||
//_logger.LogWarning(LocationToString(location));
|
||||
}
|
||||
return location;
|
||||
}
|
||||
|
||||
|
||||
public string LocationToString(LocationInfo location)
|
||||
{
|
||||
if (location.ServerId is 0 || location.TerritoryId is 0) return String.Empty;
|
||||
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}";
|
||||
}
|
||||
@@ -713,10 +677,10 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
str += $" - {MapData.Value[(ushort)location.MapId].MapName}";
|
||||
}
|
||||
|
||||
// if (location.InstanceId is not 0)
|
||||
// {
|
||||
// str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString();
|
||||
// }
|
||||
if (location.InstanceId is not 0)
|
||||
{
|
||||
str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString();
|
||||
}
|
||||
|
||||
if (location.WardId is not 0)
|
||||
{
|
||||
@@ -809,9 +773,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
{
|
||||
_logger.LogInformation("Starting DalamudUtilService");
|
||||
_framework.Update += FrameworkOnUpdate;
|
||||
if (IsLoggedIn)
|
||||
_clientState.Login += OnClientLogin;
|
||||
_clientState.Logout += OnClientLogout;
|
||||
|
||||
if (_clientState.IsLoggedIn)
|
||||
{
|
||||
_classJobId = _objectTable.LocalPlayer!.ClassJob.RowId;
|
||||
OnClientLogin();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Started DalamudUtilService");
|
||||
@@ -824,6 +791,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
|
||||
Mediator.UnsubscribeAll(this);
|
||||
_framework.Update -= FrameworkOnUpdate;
|
||||
_clientState.Login -= OnClientLogin;
|
||||
_clientState.Logout -= OnClientLogout;
|
||||
if (_FocusPairIdent.HasValue)
|
||||
{
|
||||
if (_framework.IsInFrameworkUpdateThread)
|
||||
@@ -838,6 +807,45 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnClientLogin()
|
||||
{
|
||||
if (IsLoggedIn)
|
||||
return;
|
||||
|
||||
_ = RunOnFrameworkThread(() =>
|
||||
{
|
||||
if (IsLoggedIn)
|
||||
return;
|
||||
|
||||
var localPlayer = _objectTable.LocalPlayer;
|
||||
IsLoggedIn = true;
|
||||
_lastZone = _clientState.TerritoryType;
|
||||
if (localPlayer != null)
|
||||
{
|
||||
_lastWorldId = (ushort)localPlayer.CurrentWorld.RowId;
|
||||
_classJobId = localPlayer.ClassJob.RowId;
|
||||
}
|
||||
|
||||
_cid = RebuildCID();
|
||||
Mediator.Publish(new DalamudLoginMessage());
|
||||
});
|
||||
}
|
||||
|
||||
private void OnClientLogout(int type, int code)
|
||||
{
|
||||
if (!IsLoggedIn)
|
||||
return;
|
||||
_ = RunOnFrameworkThread(() =>
|
||||
{
|
||||
if (!IsLoggedIn)
|
||||
return;
|
||||
|
||||
IsLoggedIn = false;
|
||||
_lastWorldId = 0;
|
||||
Mediator.Publish(new DalamudLogoutMessage());
|
||||
});
|
||||
}
|
||||
|
||||
public async Task WaitWhileCharacterIsDrawing(ILogger logger, GameObjectHandler handler, Guid redrawId, int timeOut = 5000, CancellationToken? ct = null)
|
||||
{
|
||||
if (!_clientState.IsLoggedIn) return;
|
||||
@@ -905,11 +913,11 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
public string? GetWorldNameFromPlayerAddress(nint address)
|
||||
{
|
||||
if (address == nint.Zero) return null;
|
||||
|
||||
|
||||
EnsureIsOnFramework();
|
||||
var playerCharacter = _objectTable.OfType<IPlayerCharacter>().FirstOrDefault(p => p.Address == address);
|
||||
if (playerCharacter == null) return null;
|
||||
|
||||
|
||||
var worldId = (ushort)playerCharacter.HomeWorld.RowId;
|
||||
return WorldData.Value.TryGetValue(worldId, out var worldName) ? worldName : null;
|
||||
}
|
||||
@@ -975,16 +983,39 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
|
||||
private unsafe void FrameworkOnUpdateInternal()
|
||||
{
|
||||
if ((_objectTable.LocalPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty])
|
||||
var localPlayer = _objectTable.LocalPlayer;
|
||||
if ((localPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty])
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool isNormalFrameworkUpdate = DateTime.UtcNow < _delayedFrameworkUpdateCheck.AddSeconds(1);
|
||||
var clientLoggedIn = _clientState.IsLoggedIn;
|
||||
|
||||
_performanceCollector.LogPerformance(this, $"FrameworkOnUpdateInternal+{(isNormalFrameworkUpdate ? "Regular" : "Delayed")}", () =>
|
||||
{
|
||||
IsAnythingDrawing = false;
|
||||
|
||||
if (!isNormalFrameworkUpdate)
|
||||
{
|
||||
if (_gameConfig != null
|
||||
&& _gameConfig.TryGet(Dalamud.Game.Config.SystemConfigOption.LodType_DX11, out bool lodEnabled))
|
||||
{
|
||||
IsLodEnabled = lodEnabled;
|
||||
}
|
||||
|
||||
if (IsInCombat || IsPerforming || IsInInstance)
|
||||
Mediator.Publish(new FrameworkUpdateMessage());
|
||||
|
||||
Mediator.Publish(new DelayedFrameworkUpdateMessage());
|
||||
|
||||
_delayedFrameworkUpdateCheck = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
if (!clientLoggedIn)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_performanceCollector.LogPerformance(this, $"TrackedActorsToState",
|
||||
() =>
|
||||
{
|
||||
@@ -993,36 +1024,46 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
_actorObjectService.RefreshTrackedActors();
|
||||
}
|
||||
|
||||
var playerDescriptors = _actorObjectService.PlayerDescriptors;
|
||||
for (var i = 0; i < playerDescriptors.Count; i++)
|
||||
if (_clientState.IsLoggedIn && localPlayer != null)
|
||||
{
|
||||
var actor = playerDescriptors[i];
|
||||
|
||||
var playerAddress = actor.Address;
|
||||
if (playerAddress == nint.Zero)
|
||||
continue;
|
||||
|
||||
if (actor.ObjectIndex >= 200)
|
||||
continue;
|
||||
|
||||
if (_blockedCharacterHandler.IsCharacterBlocked(playerAddress, out bool firstTime) && firstTime)
|
||||
var playerDescriptors = _actorObjectService.PlayerDescriptors;
|
||||
for (var i = 0; i < playerDescriptors.Count; i++)
|
||||
{
|
||||
_logger.LogTrace("Skipping character {addr}, blocked/muted", playerAddress.ToString("X"));
|
||||
continue;
|
||||
}
|
||||
var actor = playerDescriptors[i];
|
||||
|
||||
if (!IsAnythingDrawing)
|
||||
{
|
||||
var gameObj = (GameObject*)playerAddress;
|
||||
var currentName = gameObj != null ? gameObj->NameString ?? string.Empty : string.Empty;
|
||||
var charaName = string.IsNullOrEmpty(currentName) ? actor.Name : currentName;
|
||||
CheckCharacterForDrawing(playerAddress, charaName);
|
||||
if (IsAnythingDrawing)
|
||||
var playerAddress = actor.Address;
|
||||
if (playerAddress == nint.Zero)
|
||||
continue;
|
||||
|
||||
if (actor.ObjectIndex >= 200)
|
||||
continue;
|
||||
|
||||
var obj = _objectTable[actor.ObjectIndex];
|
||||
if (obj is not IPlayerCharacter player || player.Address != playerAddress)
|
||||
continue;
|
||||
|
||||
if (_blockedCharacterHandler.IsCharacterBlocked(playerAddress, actor.ObjectIndex, out bool firstTime) && firstTime)
|
||||
{
|
||||
_logger.LogTrace("Skipping character {addr}, blocked/muted", playerAddress.ToString("X"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsAnythingDrawing)
|
||||
{
|
||||
var charaName = player.Name.TextValue;
|
||||
if (string.IsNullOrEmpty(charaName))
|
||||
{
|
||||
charaName = actor.Name;
|
||||
}
|
||||
|
||||
CheckCharacterForDrawing(playerAddress, charaName);
|
||||
if (IsAnythingDrawing)
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1092,7 +1133,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
});
|
||||
|
||||
// Cutscene
|
||||
HandleStateTransition(() => IsInCutscene,v => IsInCutscene = v, shouldBeInCutscene, "Cutscene",
|
||||
HandleStateTransition(() => IsInCutscene, v => IsInCutscene = v, shouldBeInCutscene, "Cutscene",
|
||||
onEnter: () =>
|
||||
{
|
||||
Mediator.Publish(new CutsceneStartMessage());
|
||||
@@ -1136,7 +1177,18 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
Mediator.Publish(new ResumeScanMessage(nameof(ConditionFlag.BetweenAreas)));
|
||||
}
|
||||
|
||||
var localPlayer = _objectTable.LocalPlayer;
|
||||
//Map
|
||||
if (!_sentBetweenAreas)
|
||||
{
|
||||
var mapid = _clientState.MapId;
|
||||
if (mapid != _lastMapId)
|
||||
{
|
||||
_lastMapId = mapid;
|
||||
Mediator.Publish(new MapChangedMessage(mapid));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (localPlayer != null)
|
||||
{
|
||||
_classJobId = localPlayer.ClassJob.RowId;
|
||||
@@ -1158,39 +1210,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
Mediator.Publish(new FrameworkUpdateMessage());
|
||||
|
||||
Mediator.Publish(new PriorityFrameworkUpdateMessage());
|
||||
|
||||
if (isNormalFrameworkUpdate)
|
||||
return;
|
||||
|
||||
if (localPlayer != null && !IsLoggedIn)
|
||||
{
|
||||
_logger.LogDebug("Logged in");
|
||||
IsLoggedIn = true;
|
||||
_lastZone = _clientState.TerritoryType;
|
||||
_lastWorldId = (ushort)localPlayer.CurrentWorld.RowId;
|
||||
_cid = RebuildCID();
|
||||
Mediator.Publish(new DalamudLoginMessage());
|
||||
}
|
||||
else if (localPlayer == null && IsLoggedIn)
|
||||
{
|
||||
_logger.LogDebug("Logged out");
|
||||
IsLoggedIn = false;
|
||||
_lastWorldId = 0;
|
||||
Mediator.Publish(new DalamudLogoutMessage());
|
||||
}
|
||||
|
||||
if (_gameConfig != null
|
||||
&& _gameConfig.TryGet(Dalamud.Game.Config.SystemConfigOption.LodType_DX11, out bool lodEnabled))
|
||||
{
|
||||
IsLodEnabled = lodEnabled;
|
||||
}
|
||||
|
||||
if (IsInCombat || IsPerforming || IsInInstance)
|
||||
Mediator.Publish(new FrameworkUpdateMessage());
|
||||
|
||||
Mediator.Publish(new DelayedFrameworkUpdateMessage());
|
||||
|
||||
_delayedFrameworkUpdateCheck = DateTime.UtcNow;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Dalamud.Plugin.Services;
|
||||
using Dalamud.Plugin.Services;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.Services.ActorTracking;
|
||||
using LightlessSync.Services.Mediator;
|
||||
@@ -23,6 +23,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
private readonly HashSet<string> _syncshellCids = [];
|
||||
private volatile bool _pendingLocalBroadcast;
|
||||
private TimeSpan? _pendingLocalTtl;
|
||||
private string? _pendingLocalGid;
|
||||
|
||||
private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4);
|
||||
private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1);
|
||||
@@ -36,6 +37,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
private const int _maxQueueSize = 100;
|
||||
|
||||
private volatile bool _batchRunning = false;
|
||||
private volatile bool _disposed = false;
|
||||
|
||||
public IReadOnlyDictionary<string, BroadcastEntry> BroadcastCache => _broadcastCache;
|
||||
public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID);
|
||||
@@ -68,6 +70,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
|
||||
public void Update()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_frameCounter++;
|
||||
var lookupsThisFrame = 0;
|
||||
|
||||
@@ -78,12 +83,12 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
|
||||
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;
|
||||
|
||||
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address);
|
||||
var cid = descriptor.HashedContentId;
|
||||
var isStale = !_broadcastCache.TryGetValue(cid, out var entry) || entry.ExpiryTime <= now;
|
||||
|
||||
if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < _maxQueueSize)
|
||||
@@ -111,7 +116,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
|
||||
private async Task BatchUpdateBroadcastCacheAsync(List<string> cids)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
var results = await _broadcastService.AreUsersBroadcastingAsync(cids).ConfigureAwait(false);
|
||||
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var (cid, info) in results)
|
||||
@@ -130,6 +142,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
(_, old) => new BroadcastEntry(info.IsBroadcasting, expiry, info.GID));
|
||||
}
|
||||
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
var activeCids = _broadcastCache
|
||||
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now)
|
||||
.Select(e => e.Key)
|
||||
@@ -142,6 +157,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
|
||||
private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
if (!msg.Enabled)
|
||||
{
|
||||
_broadcastCache.Clear();
|
||||
@@ -158,6 +176,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
|
||||
_pendingLocalBroadcast = true;
|
||||
_pendingLocalTtl = msg.Ttl;
|
||||
_pendingLocalGid = msg.Gid;
|
||||
TryPrimeLocalBroadcastCache();
|
||||
}
|
||||
|
||||
@@ -173,11 +192,12 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
var expiry = DateTime.UtcNow + ttl;
|
||||
|
||||
_broadcastCache.AddOrUpdate(localCid,
|
||||
new BroadcastEntry(true, expiry, null),
|
||||
(_, old) => new BroadcastEntry(true, expiry, old.GID));
|
||||
new BroadcastEntry(true, expiry, _pendingLocalGid),
|
||||
(_, old) => new BroadcastEntry(true, expiry, _pendingLocalGid ?? old.GID));
|
||||
|
||||
_pendingLocalBroadcast = false;
|
||||
_pendingLocalTtl = null;
|
||||
_pendingLocalGid = null;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var activeCids = _broadcastCache
|
||||
@@ -187,10 +207,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
|
||||
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
|
||||
_lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids);
|
||||
UpdateSyncshellBroadcasts();
|
||||
}
|
||||
|
||||
private void UpdateSyncshellBroadcasts()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var nearbyCids = GetNearbyHashedCids(out _);
|
||||
var newSet = nearbyCids.Count == 0
|
||||
@@ -324,17 +348,35 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
_disposed = true;
|
||||
base.Dispose(disposing);
|
||||
_framework.Update -= OnFrameworkUpdate;
|
||||
if (_cleanupTask != null)
|
||||
|
||||
try
|
||||
{
|
||||
_cleanupTask?.Wait(100, _cleanupCts.Token);
|
||||
_cleanupCts.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already disposed, can be ignored :)
|
||||
}
|
||||
|
||||
_cleanupCts.Cancel();
|
||||
_cleanupCts.Dispose();
|
||||
try
|
||||
{
|
||||
_cleanupTask?.Wait(100);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Task may have already completed or been cancelled?
|
||||
}
|
||||
|
||||
_cleanupTask?.Wait(100);
|
||||
_cleanupCts.Dispose();
|
||||
try
|
||||
{
|
||||
_cleanupCts.Dispose();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already disposed, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
@@ -121,7 +121,10 @@ public class LightFinderService : IHostedService, IMediatorSubscriber
|
||||
_waitingForTtlFetch = false;
|
||||
|
||||
if (!wasEnabled || previousRemaining != validTtl)
|
||||
_mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl));
|
||||
{
|
||||
var gid = _config.Current.SyncshellFinderEnabled ? _config.Current.SelectedFinderSyncshell : null;
|
||||
_mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl, gid));
|
||||
}
|
||||
|
||||
_logger.LogInformation("Lightfinder broadcast enabled ({Context}), TTL: {TTL}", context, validTtl);
|
||||
return true;
|
||||
|
||||
137
LightlessSync/Services/LocationShareService.cs
Normal file
137
LightlessSync/Services/LocationShareService.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Dto.CharaData;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.WebAPI;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace LightlessSync.Services
|
||||
{
|
||||
public class LocationShareService : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private readonly ApiController _apiController;
|
||||
private IMemoryCache _locations = new MemoryCache(new MemoryCacheOptions());
|
||||
private IMemoryCache _sharingStatus = new MemoryCache(new MemoryCacheOptions());
|
||||
private CancellationTokenSource _resetToken = new CancellationTokenSource();
|
||||
|
||||
public LocationShareService(ILogger<LocationShareService> logger, LightlessMediator mediator, DalamudUtilService dalamudUtilService, ApiController apiController) : base(logger, mediator)
|
||||
{
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
_apiController = apiController;
|
||||
|
||||
|
||||
Mediator.Subscribe<DisconnectedMessage>(this, (msg) =>
|
||||
{
|
||||
_resetToken.Cancel();
|
||||
_resetToken.Dispose();
|
||||
_resetToken = new CancellationTokenSource();
|
||||
});
|
||||
Mediator.Subscribe<ConnectedMessage>(this, (msg) =>
|
||||
{
|
||||
_ = _apiController.UpdateLocation(new LocationDto(new UserData(_apiController.UID, apiController.DisplayName), _dalamudUtilService.GetMapData()));
|
||||
_ = RequestAllLocation();
|
||||
} );
|
||||
Mediator.Subscribe<LocationSharingMessage>(this, UpdateLocationList);
|
||||
Mediator.Subscribe<MapChangedMessage>(this,
|
||||
msg => _ = _apiController.UpdateLocation(new LocationDto(new UserData(_apiController.UID, _apiController.DisplayName), _dalamudUtilService.GetMapData())));
|
||||
}
|
||||
|
||||
private void UpdateLocationList(LocationSharingMessage msg)
|
||||
{
|
||||
if (_locations.TryGetValue(msg.User.UID, out _) && msg.LocationInfo.ServerId is 0)
|
||||
{
|
||||
_locations.Remove(msg.User.UID);
|
||||
return;
|
||||
}
|
||||
|
||||
if ( msg.LocationInfo.ServerId is not 0 && msg.ExpireAt > DateTime.UtcNow)
|
||||
{
|
||||
AddLocationInfo(msg.User.UID, msg.LocationInfo, msg.ExpireAt);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddLocationInfo(string uid, LocationInfo location, DateTimeOffset expireAt)
|
||||
{
|
||||
var options = new MemoryCacheEntryOptions()
|
||||
.SetAbsoluteExpiration(expireAt)
|
||||
.AddExpirationToken(new CancellationChangeToken(_resetToken.Token));
|
||||
_locations.Set(uid, location, options);
|
||||
}
|
||||
|
||||
private async Task RequestAllLocation()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (data, status) = await _apiController.RequestAllLocationInfo().ConfigureAwait(false);
|
||||
foreach (var dto in data)
|
||||
{
|
||||
AddLocationInfo(dto.LocationDto.User.UID, dto.LocationDto.Location, dto.ExpireAt);
|
||||
}
|
||||
|
||||
foreach (var dto in status)
|
||||
{
|
||||
AddStatus(dto.User.UID, dto.ExpireAt);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.LogError(e,"RequestAllLocation error : ");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddStatus(string uid, DateTimeOffset expireAt)
|
||||
{
|
||||
var options = new MemoryCacheEntryOptions()
|
||||
.SetAbsoluteExpiration(expireAt)
|
||||
.AddExpirationToken(new CancellationChangeToken(_resetToken.Token));
|
||||
_sharingStatus.Set(uid, expireAt, options);
|
||||
}
|
||||
|
||||
public string GetUserLocation(string uid)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_locations.TryGetValue<LocationInfo>(uid, out var location))
|
||||
{
|
||||
return _dalamudUtilService.LocationToString(location);
|
||||
}
|
||||
return String.Empty;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.LogError(e,"GetUserLocation error : ");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public DateTimeOffset GetSharingStatus(string uid)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_sharingStatus.TryGetValue<DateTimeOffset>(uid, out var expireAt))
|
||||
{
|
||||
return expireAt;
|
||||
}
|
||||
return DateTimeOffset.MinValue;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.LogError(e,"GetSharingStatus error : ");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateSharingStatus(List<string> users, DateTimeOffset expireAt)
|
||||
{
|
||||
foreach (var user in users)
|
||||
{
|
||||
AddStatus(user, expireAt);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -63,23 +63,31 @@ public sealed class LightlessMediator : IHostedService
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
while (!_loopCts.Token.IsCancellationRequested)
|
||||
try
|
||||
{
|
||||
while (!_processQueue)
|
||||
while (!_loopCts.Token.IsCancellationRequested)
|
||||
{
|
||||
while (!_processQueue)
|
||||
{
|
||||
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
|
||||
|
||||
HashSet<MessageBase> processedMessages = [];
|
||||
while (_messageQueue.TryDequeue(out var message))
|
||||
{
|
||||
if (processedMessages.Contains(message)) { continue; }
|
||||
|
||||
processedMessages.Add(message);
|
||||
|
||||
ExecuteMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
|
||||
|
||||
HashSet<MessageBase> processedMessages = [];
|
||||
while (_messageQueue.TryDequeue(out var message))
|
||||
{
|
||||
if (processedMessages.Contains(message)) { continue; }
|
||||
processedMessages.Add(message);
|
||||
|
||||
ExecuteMessage(message);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("LightlessMediator stopped");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,12 @@ public record SwitchToIntroUiMessage : MessageBase;
|
||||
public record SwitchToMainUiMessage : MessageBase;
|
||||
public record OpenSettingsUiMessage : MessageBase;
|
||||
public record OpenLightfinderSettingsMessage : MessageBase;
|
||||
public enum PerformanceSettingsSection
|
||||
{
|
||||
TextureOptimization,
|
||||
ModelOptimization,
|
||||
}
|
||||
public record OpenPerformanceSettingsMessage(PerformanceSettingsSection Section) : MessageBase;
|
||||
public record DalamudLoginMessage : MessageBase;
|
||||
public record DalamudLogoutMessage : MessageBase;
|
||||
public record ActorTrackedMessage(ActorObjectService.ActorDescriptor Descriptor) : SameThreadMessage;
|
||||
@@ -73,7 +79,7 @@ public record HubClosedMessage(Exception? Exception) : SameThreadMessage;
|
||||
public record ResumeScanMessage(string Source) : MessageBase;
|
||||
public record FileCacheInitializedMessage : MessageBase;
|
||||
public record DownloadReadyMessage(Guid RequestId) : MessageBase;
|
||||
public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase;
|
||||
public record DownloadStartedMessage(GameObjectHandler DownloadId, IReadOnlyDictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase;
|
||||
public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase;
|
||||
public record UiToggleMessage(Type UiType) : MessageBase;
|
||||
public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : MessageBase;
|
||||
@@ -104,6 +110,7 @@ public record PairUiUpdatedMessage(PairUiSnapshot Snapshot) : MessageBase;
|
||||
public record CensusUpdateMessage(byte Gender, byte RaceId, byte TribeId) : MessageBase;
|
||||
public record TargetPairMessage(Pair Pair) : MessageBase;
|
||||
public record PairFocusCharacterMessage(Pair Pair) : SameThreadMessage;
|
||||
public record PairOnlineMessage(PairUniqueIdentifier PairIdent) : MessageBase;
|
||||
public record CombatStartMessage : MessageBase;
|
||||
public record CombatEndMessage : MessageBase;
|
||||
public record PerformanceStartMessage : MessageBase;
|
||||
@@ -123,7 +130,7 @@ public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) :
|
||||
public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase;
|
||||
public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase;
|
||||
public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase;
|
||||
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : MessageBase;
|
||||
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl, string? Gid = null) : MessageBase;
|
||||
public record UserLeftSyncshell(string gid) : MessageBase;
|
||||
public record UserJoinedSyncshell(string gid) : MessageBase;
|
||||
public record SyncshellBroadcastsUpdatedMessage : MessageBase;
|
||||
@@ -135,5 +142,7 @@ public record ChatChannelsUpdated : MessageBase;
|
||||
public record ChatChannelMessageAdded(string ChannelKey, ChatMessageEntry Message) : MessageBase;
|
||||
public record GroupCollectionChangedMessage : MessageBase;
|
||||
public record OpenUserProfileMessage(UserData User) : MessageBase;
|
||||
public record LocationSharingMessage(UserData User, LocationInfo LocationInfo, DateTimeOffset ExpireAt) : MessageBase;
|
||||
public record MapChangedMessage(uint MapId) : MessageBase;
|
||||
#pragma warning restore S2094
|
||||
#pragma warning restore MA0048 // File name must match type name
|
||||
#pragma warning restore MA0048 // File name must match type name
|
||||
|
||||
3625
LightlessSync/Services/ModelDecimation/MdlDecimator.cs
Normal file
3625
LightlessSync/Services/ModelDecimation/MdlDecimator.cs
Normal file
File diff suppressed because it is too large
Load Diff
532
LightlessSync/Services/ModelDecimation/ModelDecimationService.cs
Normal file
532
LightlessSync/Services/ModelDecimation/ModelDecimationService.cs
Normal file
@@ -0,0 +1,532 @@
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.LightlessConfiguration.Configurations;
|
||||
using LightlessSync.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LightlessSync.Services.ModelDecimation;
|
||||
|
||||
public sealed class ModelDecimationService
|
||||
{
|
||||
private const int MaxConcurrentJobs = 1;
|
||||
private const double MinTargetRatio = 0.01;
|
||||
private const double MaxTargetRatio = 0.99;
|
||||
|
||||
private readonly ILogger<ModelDecimationService> _logger;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly FileCacheManager _fileCacheManager;
|
||||
private readonly PlayerPerformanceConfigService _performanceConfigService;
|
||||
private readonly XivDataStorageService _xivDataStorageService;
|
||||
private readonly SemaphoreSlim _decimationSemaphore = new(MaxConcurrentJobs);
|
||||
|
||||
private readonly TaskRegistry<string> _decimationDeduplicator = new();
|
||||
private readonly ConcurrentDictionary<string, string> _decimatedPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, byte> _failedHashes = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ModelDecimationService(
|
||||
ILogger<ModelDecimationService> logger,
|
||||
LightlessConfigService configService,
|
||||
FileCacheManager fileCacheManager,
|
||||
PlayerPerformanceConfigService performanceConfigService,
|
||||
XivDataStorageService xivDataStorageService)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_fileCacheManager = fileCacheManager;
|
||||
_performanceConfigService = performanceConfigService;
|
||||
_xivDataStorageService = xivDataStorageService;
|
||||
}
|
||||
|
||||
public void ScheduleDecimation(string hash, string filePath, string? gamePath = null)
|
||||
{
|
||||
if (!ShouldScheduleDecimation(hash, filePath, gamePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_decimatedPaths.ContainsKey(hash) || _failedHashes.ContainsKey(hash) || _decimationDeduplicator.TryGetExisting(hash, out _))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Queued model decimation for {Hash}", hash);
|
||||
|
||||
_decimationDeduplicator.GetOrStart(hash, async () =>
|
||||
{
|
||||
await _decimationSemaphore.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await DecimateInternalAsync(hash, filePath).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_failedHashes[hash] = 1;
|
||||
_logger.LogWarning(ex, "Model decimation failed for {Hash}", hash);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_decimationSemaphore.Release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void ScheduleBatchDecimation(string hash, string filePath, ModelDecimationSettings settings)
|
||||
{
|
||||
if (!ShouldScheduleBatchDecimation(hash, filePath, settings))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_decimationDeduplicator.TryGetExisting(hash, out _))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_failedHashes.TryRemove(hash, out _);
|
||||
_decimatedPaths.TryRemove(hash, out _);
|
||||
|
||||
_logger.LogInformation("Queued batch model decimation for {Hash}", hash);
|
||||
|
||||
_decimationDeduplicator.GetOrStart(hash, async () =>
|
||||
{
|
||||
await _decimationSemaphore.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await DecimateInternalAsync(hash, filePath, settings, allowExisting: false, destinationOverride: filePath, registerDecimatedPath: false).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_failedHashes[hash] = 1;
|
||||
_logger.LogWarning(ex, "Batch model decimation failed for {Hash}", hash);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_decimationSemaphore.Release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public bool ShouldScheduleDecimation(string hash, string filePath, string? gamePath = null)
|
||||
{
|
||||
var threshold = Math.Max(0, _performanceConfigService.Current.ModelDecimationTriangleThreshold);
|
||||
return IsDecimationEnabled()
|
||||
&& filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)
|
||||
&& IsDecimationAllowed(gamePath)
|
||||
&& !ShouldSkipByTriangleCache(hash, threshold);
|
||||
}
|
||||
|
||||
public string GetPreferredPath(string hash, string originalPath)
|
||||
{
|
||||
if (!IsDecimationEnabled())
|
||||
{
|
||||
return originalPath;
|
||||
}
|
||||
|
||||
if (_decimatedPaths.TryGetValue(hash, out var existing) && File.Exists(existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var resolved = GetExistingDecimatedPath(hash);
|
||||
if (!string.IsNullOrEmpty(resolved))
|
||||
{
|
||||
_decimatedPaths[hash] = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
return originalPath;
|
||||
}
|
||||
|
||||
public Task WaitForPendingJobsAsync(IEnumerable<string>? hashes, CancellationToken token)
|
||||
{
|
||||
if (hashes is null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var pending = new List<Task>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var hash in hashes)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hash) || !seen.Add(hash))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_decimationDeduplicator.TryGetExisting(hash, out var job))
|
||||
{
|
||||
pending.Add(job);
|
||||
}
|
||||
}
|
||||
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return Task.WhenAll(pending).WaitAsync(token);
|
||||
}
|
||||
|
||||
private Task DecimateInternalAsync(string hash, string sourcePath)
|
||||
{
|
||||
if (!TryGetDecimationSettings(out var settings))
|
||||
{
|
||||
_logger.LogDebug("Model decimation disabled or invalid settings for {Hash}", hash);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return DecimateInternalAsync(hash, sourcePath, settings, allowExisting: true);
|
||||
}
|
||||
|
||||
private Task DecimateInternalAsync(
|
||||
string hash,
|
||||
string sourcePath,
|
||||
ModelDecimationSettings settings,
|
||||
bool allowExisting,
|
||||
string? destinationOverride = null,
|
||||
bool registerDecimatedPath = true)
|
||||
{
|
||||
if (!File.Exists(sourcePath))
|
||||
{
|
||||
_failedHashes[hash] = 1;
|
||||
_logger.LogWarning("Cannot decimate model {Hash}; source path missing: {Path}", hash, sourcePath);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (!TryNormalizeSettings(settings, out var normalized))
|
||||
{
|
||||
_logger.LogDebug("Model decimation skipped for {Hash}; invalid settings.", hash);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Starting model decimation for {Hash} (threshold {Threshold}, ratio {Ratio:0.##}, normalize tangents {NormalizeTangents}, avoid body intersection {AvoidBodyIntersection})",
|
||||
hash,
|
||||
normalized.TriangleThreshold,
|
||||
normalized.TargetRatio,
|
||||
normalized.NormalizeTangents,
|
||||
normalized.AvoidBodyIntersection);
|
||||
|
||||
var destination = destinationOverride ?? Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl");
|
||||
var inPlace = string.Equals(destination, sourcePath, StringComparison.OrdinalIgnoreCase);
|
||||
if (!inPlace && File.Exists(destination))
|
||||
{
|
||||
if (allowExisting)
|
||||
{
|
||||
if (registerDecimatedPath)
|
||||
{
|
||||
RegisterDecimatedModel(hash, sourcePath, destination);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
TryDelete(destination);
|
||||
}
|
||||
|
||||
if (!MdlDecimator.TryDecimate(sourcePath, destination, normalized, _logger))
|
||||
{
|
||||
_failedHashes[hash] = 1;
|
||||
_logger.LogDebug("Model decimation skipped for {Hash}", hash);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (registerDecimatedPath)
|
||||
{
|
||||
RegisterDecimatedModel(hash, sourcePath, destination);
|
||||
}
|
||||
_logger.LogDebug("Decimated model {Hash} -> {Path}", hash, destination);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void RegisterDecimatedModel(string hash, string sourcePath, string destination)
|
||||
{
|
||||
_decimatedPaths[hash] = destination;
|
||||
|
||||
var performanceConfig = _performanceConfigService.Current;
|
||||
if (performanceConfig.KeepOriginalModelFiles)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(sourcePath, destination, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryReplaceCacheEntryWithDecimated(hash, sourcePath, destination))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryDelete(sourcePath);
|
||||
}
|
||||
|
||||
private bool TryReplaceCacheEntryWithDecimated(string hash, string sourcePath, string destination)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheEntry = _fileCacheManager.GetFileCacheByHash(hash);
|
||||
if (cacheEntry is null || !cacheEntry.IsCacheEntry)
|
||||
{
|
||||
return File.Exists(sourcePath) ? false : true;
|
||||
}
|
||||
|
||||
var cacheFolder = _configService.Current.CacheFolder;
|
||||
if (string.IsNullOrEmpty(cacheFolder))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!destination.StartsWith(cacheFolder, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var info = new FileInfo(destination);
|
||||
if (!info.Exists)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var relative = Path.GetRelativePath(cacheFolder, destination)
|
||||
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
var sanitizedRelative = relative.TrimStart(Path.DirectorySeparatorChar);
|
||||
var prefixed = Path.Combine(FileCacheManager.CachePrefix, sanitizedRelative);
|
||||
|
||||
var replacement = new FileCacheEntity(
|
||||
hash,
|
||||
prefixed,
|
||||
info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture),
|
||||
info.Length,
|
||||
cacheEntry.CompressedSize);
|
||||
replacement.SetResolvedFilePath(destination);
|
||||
|
||||
if (!string.Equals(cacheEntry.PrefixedFilePath, prefixed, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_fileCacheManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath, removeDerivedFiles: false);
|
||||
}
|
||||
|
||||
_fileCacheManager.UpdateHashedFile(replacement, computeProperties: false);
|
||||
_fileCacheManager.WriteOutFullCsv();
|
||||
|
||||
_logger.LogTrace("Replaced cache entry for model {Hash} to decimated path {Path}", hash, destination);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogTrace(ex, "Failed to replace cache entry for model {Hash}", hash);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsDecimationEnabled()
|
||||
=> _performanceConfigService.Current.EnableModelDecimation;
|
||||
|
||||
private bool ShouldSkipByTriangleCache(string hash, int triangleThreshold)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hash))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_xivDataStorageService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris) || cachedTris <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var threshold = Math.Max(0, triangleThreshold);
|
||||
return threshold > 0 && cachedTris < threshold;
|
||||
}
|
||||
|
||||
private bool IsDecimationAllowed(string? gamePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(gamePath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalized = NormalizeGamePath(gamePath);
|
||||
if (normalized.Contains("/hair/", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.Contains("/chara/equipment/", StringComparison.Ordinal))
|
||||
{
|
||||
return _performanceConfigService.Current.ModelDecimationAllowClothing;
|
||||
}
|
||||
|
||||
if (normalized.Contains("/chara/accessory/", StringComparison.Ordinal))
|
||||
{
|
||||
return _performanceConfigService.Current.ModelDecimationAllowAccessories;
|
||||
}
|
||||
|
||||
if (normalized.Contains("/chara/human/", StringComparison.Ordinal))
|
||||
{
|
||||
if (normalized.Contains("/body/", StringComparison.Ordinal))
|
||||
{
|
||||
return _performanceConfigService.Current.ModelDecimationAllowBody;
|
||||
}
|
||||
|
||||
if (normalized.Contains("/face/", StringComparison.Ordinal) || normalized.Contains("/head/", StringComparison.Ordinal))
|
||||
{
|
||||
return _performanceConfigService.Current.ModelDecimationAllowFaceHead;
|
||||
}
|
||||
|
||||
if (normalized.Contains("/tail/", StringComparison.Ordinal))
|
||||
{
|
||||
return _performanceConfigService.Current.ModelDecimationAllowTail;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string NormalizeGamePath(string path)
|
||||
=> path.Replace('\\', '/').ToLowerInvariant();
|
||||
|
||||
private bool TryGetDecimationSettings(out ModelDecimationSettings settings)
|
||||
{
|
||||
settings = new ModelDecimationSettings(
|
||||
ModelDecimationDefaults.TriangleThreshold,
|
||||
ModelDecimationDefaults.TargetRatio,
|
||||
ModelDecimationDefaults.NormalizeTangents,
|
||||
ModelDecimationDefaults.AvoidBodyIntersection,
|
||||
new ModelDecimationAdvancedSettings());
|
||||
|
||||
var config = _performanceConfigService.Current;
|
||||
if (!config.EnableModelDecimation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var advanced = NormalizeAdvancedSettings(config.ModelDecimationAdvanced);
|
||||
settings = new ModelDecimationSettings(
|
||||
Math.Max(0, config.ModelDecimationTriangleThreshold),
|
||||
config.ModelDecimationTargetRatio,
|
||||
config.ModelDecimationNormalizeTangents,
|
||||
config.ModelDecimationAvoidBodyIntersection,
|
||||
advanced);
|
||||
|
||||
return TryNormalizeSettings(settings, out settings);
|
||||
}
|
||||
|
||||
private static bool TryNormalizeSettings(ModelDecimationSettings settings, out ModelDecimationSettings normalized)
|
||||
{
|
||||
var ratio = settings.TargetRatio;
|
||||
if (double.IsNaN(ratio) || double.IsInfinity(ratio))
|
||||
{
|
||||
normalized = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
ratio = Math.Clamp(ratio, MinTargetRatio, MaxTargetRatio);
|
||||
var advanced = NormalizeAdvancedSettings(settings.Advanced);
|
||||
normalized = new ModelDecimationSettings(
|
||||
Math.Max(0, settings.TriangleThreshold),
|
||||
ratio,
|
||||
settings.NormalizeTangents,
|
||||
settings.AvoidBodyIntersection,
|
||||
advanced);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ModelDecimationAdvancedSettings NormalizeAdvancedSettings(ModelDecimationAdvancedSettings? settings)
|
||||
{
|
||||
var source = settings ?? new ModelDecimationAdvancedSettings();
|
||||
return new ModelDecimationAdvancedSettings
|
||||
{
|
||||
MinComponentTriangles = Math.Clamp(source.MinComponentTriangles, 0, 1000),
|
||||
MaxCollapseEdgeLengthFactor = ClampFloat(source.MaxCollapseEdgeLengthFactor, 0.1f, 10f, ModelDecimationAdvancedSettings.DefaultMaxCollapseEdgeLengthFactor),
|
||||
NormalSimilarityThresholdDegrees = ClampFloat(source.NormalSimilarityThresholdDegrees, 0f, 180f, ModelDecimationAdvancedSettings.DefaultNormalSimilarityThresholdDegrees),
|
||||
BoneWeightSimilarityThreshold = ClampFloat(source.BoneWeightSimilarityThreshold, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBoneWeightSimilarityThreshold),
|
||||
UvSimilarityThreshold = ClampFloat(source.UvSimilarityThreshold, 0f, 1f, ModelDecimationAdvancedSettings.DefaultUvSimilarityThreshold),
|
||||
UvSeamAngleCos = ClampFloat(source.UvSeamAngleCos, -1f, 1f, ModelDecimationAdvancedSettings.DefaultUvSeamAngleCos),
|
||||
BlockUvSeamVertices = source.BlockUvSeamVertices,
|
||||
AllowBoundaryCollapses = source.AllowBoundaryCollapses,
|
||||
BodyCollisionDistanceFactor = ClampFloat(source.BodyCollisionDistanceFactor, 0f, 10f, ModelDecimationAdvancedSettings.DefaultBodyCollisionDistanceFactor),
|
||||
BodyCollisionNoOpDistanceFactor = ClampFloat(source.BodyCollisionNoOpDistanceFactor, 0f, 10f, ModelDecimationAdvancedSettings.DefaultBodyCollisionNoOpDistanceFactor),
|
||||
BodyCollisionAdaptiveRelaxFactor = ClampFloat(source.BodyCollisionAdaptiveRelaxFactor, 0f, 10f, ModelDecimationAdvancedSettings.DefaultBodyCollisionAdaptiveRelaxFactor),
|
||||
BodyCollisionAdaptiveNearRatio = ClampFloat(source.BodyCollisionAdaptiveNearRatio, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionAdaptiveNearRatio),
|
||||
BodyCollisionAdaptiveUvThreshold = ClampFloat(source.BodyCollisionAdaptiveUvThreshold, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionAdaptiveUvThreshold),
|
||||
BodyCollisionNoOpUvSeamAngleCos = ClampFloat(source.BodyCollisionNoOpUvSeamAngleCos, -1f, 1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionNoOpUvSeamAngleCos),
|
||||
BodyCollisionProtectionFactor = ClampFloat(source.BodyCollisionProtectionFactor, 0f, 10f, ModelDecimationAdvancedSettings.DefaultBodyCollisionProtectionFactor),
|
||||
BodyProxyTargetRatioMin = ClampFloat(source.BodyProxyTargetRatioMin, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBodyProxyTargetRatioMin),
|
||||
BodyCollisionProxyInflate = ClampFloat(source.BodyCollisionProxyInflate, 0f, 0.1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionProxyInflate),
|
||||
BodyCollisionPenetrationFactor = ClampFloat(source.BodyCollisionPenetrationFactor, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionPenetrationFactor),
|
||||
MinBodyCollisionDistance = ClampFloat(source.MinBodyCollisionDistance, 1e-6f, 1f, ModelDecimationAdvancedSettings.DefaultMinBodyCollisionDistance),
|
||||
MinBodyCollisionCellSize = ClampFloat(source.MinBodyCollisionCellSize, 1e-6f, 1f, ModelDecimationAdvancedSettings.DefaultMinBodyCollisionCellSize),
|
||||
};
|
||||
}
|
||||
|
||||
private static float ClampFloat(float value, float min, float max, float fallback)
|
||||
{
|
||||
if (float.IsNaN(value) || float.IsInfinity(value))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.Clamp(value, min, max);
|
||||
}
|
||||
|
||||
private bool ShouldScheduleBatchDecimation(string hash, string filePath, ModelDecimationSettings settings)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryNormalizeSettings(settings, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private string? GetExistingDecimatedPath(string hash)
|
||||
{
|
||||
var candidate = Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl");
|
||||
return File.Exists(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
private string GetDecimatedDirectory()
|
||||
{
|
||||
var directory = Path.Combine(_configService.Current.CacheFolder, "decimated");
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogTrace(ex, "Failed to create decimated directory {Directory}", directory);
|
||||
}
|
||||
}
|
||||
|
||||
return directory;
|
||||
}
|
||||
|
||||
private static void TryDelete(string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using LightlessSync.LightlessConfiguration.Configurations;
|
||||
|
||||
namespace LightlessSync.Services.ModelDecimation;
|
||||
|
||||
public readonly record struct ModelDecimationSettings(
|
||||
int TriangleThreshold,
|
||||
double TargetRatio,
|
||||
bool NormalizeTangents,
|
||||
bool AvoidBodyIntersection,
|
||||
ModelDecimationAdvancedSettings Advanced);
|
||||
@@ -1,4 +1,6 @@
|
||||
using LightlessSync.Interop.Ipc;
|
||||
using System.Linq;
|
||||
using LightlessSync.Interop.Ipc;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -10,6 +12,7 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber
|
||||
private readonly IpcManager _ipc;
|
||||
private readonly LightlessConfigService _config;
|
||||
private int _ran;
|
||||
private static readonly TimeSpan OrphanCleanupDelay = TimeSpan.FromDays(1);
|
||||
|
||||
public PenumbraTempCollectionJanitor(
|
||||
ILogger<PenumbraTempCollectionJanitor> logger,
|
||||
@@ -26,15 +29,46 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber
|
||||
public void Register(Guid id)
|
||||
{
|
||||
if (id == Guid.Empty) return;
|
||||
if (_config.Current.OrphanableTempCollections.Add(id))
|
||||
var changed = false;
|
||||
var config = _config.Current;
|
||||
if (config.OrphanableTempCollections.Add(id))
|
||||
{
|
||||
changed = true;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var existing = config.OrphanableTempCollectionEntries.FirstOrDefault(entry => entry.Id == id);
|
||||
if (existing is null)
|
||||
{
|
||||
config.OrphanableTempCollectionEntries.Add(new OrphanableTempCollectionEntry
|
||||
{
|
||||
Id = id,
|
||||
RegisteredAtUtc = now
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
else if (existing.RegisteredAtUtc == DateTime.MinValue)
|
||||
{
|
||||
existing.RegisteredAtUtc = now;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
_config.Save();
|
||||
}
|
||||
}
|
||||
|
||||
public void Unregister(Guid id)
|
||||
{
|
||||
if (id == Guid.Empty) return;
|
||||
if (_config.Current.OrphanableTempCollections.Remove(id))
|
||||
var config = _config.Current;
|
||||
var changed = config.OrphanableTempCollections.Remove(id);
|
||||
changed |= RemoveEntry(config.OrphanableTempCollectionEntries, id) > 0;
|
||||
if (changed)
|
||||
{
|
||||
_config.Save();
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupOrphansOnBoot()
|
||||
@@ -45,14 +79,33 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber
|
||||
if (!_ipc.Penumbra.APIAvailable)
|
||||
return;
|
||||
|
||||
var ids = _config.Current.OrphanableTempCollections.ToArray();
|
||||
if (ids.Length == 0)
|
||||
var config = _config.Current;
|
||||
var ids = config.OrphanableTempCollections;
|
||||
var entries = config.OrphanableTempCollectionEntries;
|
||||
if (ids.Count == 0 && entries.Count == 0)
|
||||
return;
|
||||
|
||||
var appId = Guid.NewGuid();
|
||||
Logger.LogInformation("Cleaning up {count} orphaned Lightless temp collections found in configuration", ids.Length);
|
||||
var now = DateTime.UtcNow;
|
||||
var changed = EnsureEntries(ids, entries, now);
|
||||
var cutoff = now - OrphanCleanupDelay;
|
||||
var expired = entries
|
||||
.Where(entry => entry.Id != Guid.Empty && entry.RegisteredAtUtc != DateTime.MinValue && entry.RegisteredAtUtc <= cutoff)
|
||||
.Select(entry => entry.Id)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (expired.Count == 0)
|
||||
{
|
||||
if (changed)
|
||||
{
|
||||
_config.Save();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var id in ids)
|
||||
var appId = Guid.NewGuid();
|
||||
Logger.LogInformation("Cleaning up {count} orphaned Lightless temp collections older than {delay}", expired.Count, OrphanCleanupDelay);
|
||||
|
||||
foreach (var id in expired)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -65,7 +118,70 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber
|
||||
}
|
||||
}
|
||||
|
||||
_config.Current.OrphanableTempCollections.Clear();
|
||||
foreach (var id in expired)
|
||||
{
|
||||
ids.Remove(id);
|
||||
}
|
||||
|
||||
foreach (var id in expired)
|
||||
{
|
||||
RemoveEntry(entries, id);
|
||||
}
|
||||
|
||||
_config.Save();
|
||||
}
|
||||
}
|
||||
|
||||
private static int RemoveEntry(List<OrphanableTempCollectionEntry> entries, Guid id)
|
||||
{
|
||||
var removed = 0;
|
||||
for (var i = entries.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (entries[i].Id != id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.RemoveAt(i);
|
||||
removed++;
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
private static bool EnsureEntries(HashSet<Guid> ids, List<OrphanableTempCollectionEntry> entries, DateTime now)
|
||||
{
|
||||
var changed = false;
|
||||
foreach (var id in ids)
|
||||
{
|
||||
if (id == Guid.Empty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entries.Any(entry => entry.Id == id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(new OrphanableTempCollectionEntry
|
||||
{
|
||||
Id = id,
|
||||
RegisteredAtUtc = now
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry.Id == Guid.Empty || entry.RegisteredAtUtc != DateTime.MinValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.RegisteredAtUtc = now;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,10 @@ public sealed class PerformanceCollectorService : IHostedService
|
||||
DrawSeparator(sb, longestCounterName);
|
||||
}
|
||||
|
||||
var pastEntries = limitBySeconds > 0 ? entry.Value.Where(e => e.Item1.AddMinutes(limitBySeconds / 60.0d) >= TimeOnly.FromDateTime(DateTime.Now)).ToList() : [.. entry.Value];
|
||||
var snapshot = entry.Value.Snapshot();
|
||||
var pastEntries = limitBySeconds > 0
|
||||
? snapshot.Where(e => e.Item1.AddMinutes(limitBySeconds / 60.0d) >= TimeOnly.FromDateTime(DateTime.Now)).ToList()
|
||||
: snapshot;
|
||||
|
||||
if (pastEntries.Any())
|
||||
{
|
||||
@@ -189,7 +192,11 @@ public sealed class PerformanceCollectorService : IHostedService
|
||||
{
|
||||
try
|
||||
{
|
||||
var last = entries.Value.ToList()[^1];
|
||||
if (!entries.Value.TryGetLast(out var last))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (last.Item1.AddMinutes(10) < TimeOnly.FromDateTime(DateTime.Now) && !PerformanceCounters.TryRemove(entries.Key, out _))
|
||||
{
|
||||
_logger.LogDebug("Could not remove performance counter {counter}", entries.Key);
|
||||
|
||||
@@ -4,6 +4,7 @@ using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services.Events;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Services.ModelDecimation;
|
||||
using LightlessSync.Services.TextureCompression;
|
||||
using LightlessSync.UI;
|
||||
using LightlessSync.WebAPI.Files.Models;
|
||||
@@ -18,12 +19,14 @@ public class PlayerPerformanceService
|
||||
private readonly ILogger<PlayerPerformanceService> _logger;
|
||||
private readonly LightlessMediator _mediator;
|
||||
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
|
||||
private readonly ModelDecimationService _modelDecimationService;
|
||||
private readonly TextureDownscaleService _textureDownscaleService;
|
||||
private readonly Dictionary<string, bool> _warnedForPlayers = new(StringComparer.Ordinal);
|
||||
|
||||
public PlayerPerformanceService(ILogger<PlayerPerformanceService> logger, LightlessMediator mediator,
|
||||
PlayerPerformanceConfigService playerPerformanceConfigService, FileCacheManager fileCacheManager,
|
||||
XivDataAnalyzer xivDataAnalyzer, TextureDownscaleService textureDownscaleService)
|
||||
XivDataAnalyzer xivDataAnalyzer, TextureDownscaleService textureDownscaleService,
|
||||
ModelDecimationService modelDecimationService)
|
||||
{
|
||||
_logger = logger;
|
||||
_mediator = mediator;
|
||||
@@ -31,6 +34,7 @@ public class PlayerPerformanceService
|
||||
_fileCacheManager = fileCacheManager;
|
||||
_xivDataAnalyzer = xivDataAnalyzer;
|
||||
_textureDownscaleService = textureDownscaleService;
|
||||
_modelDecimationService = modelDecimationService;
|
||||
}
|
||||
|
||||
public async Task<bool> CheckBothThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData)
|
||||
@@ -111,10 +115,12 @@ public class PlayerPerformanceService
|
||||
var config = _playerPerformanceConfigService.Current;
|
||||
|
||||
long triUsage = 0;
|
||||
long effectiveTriUsage = 0;
|
||||
|
||||
if (!charaData.FileReplacements.TryGetValue(API.Data.Enum.ObjectKind.Player, out List<FileReplacementData>? playerReplacements))
|
||||
{
|
||||
pairHandler.LastAppliedDataTris = 0;
|
||||
pairHandler.LastAppliedApproximateEffectiveTris = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -123,14 +129,40 @@ public class PlayerPerformanceService
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var skipDecimation = config.SkipModelDecimationForPreferredPairs && pairHandler.IsDirectlyPaired && pairHandler.HasStickyPermissions;
|
||||
|
||||
foreach (var hash in moddedModelHashes)
|
||||
{
|
||||
triUsage += await _xivDataAnalyzer.GetTrianglesByHash(hash).ConfigureAwait(false);
|
||||
var tris = await _xivDataAnalyzer.GetTrianglesByHash(hash).ConfigureAwait(false);
|
||||
triUsage += tris;
|
||||
|
||||
long effectiveTris = tris;
|
||||
var fileEntry = _fileCacheManager.GetFileCacheByHash(hash);
|
||||
if (fileEntry != null)
|
||||
{
|
||||
var preferredPath = fileEntry.ResolvedFilepath;
|
||||
if (!skipDecimation)
|
||||
{
|
||||
preferredPath = _modelDecimationService.GetPreferredPath(hash, fileEntry.ResolvedFilepath);
|
||||
}
|
||||
|
||||
if (!string.Equals(preferredPath, fileEntry.ResolvedFilepath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var decimatedTris = await _xivDataAnalyzer.GetEffectiveTrianglesByHash(hash, preferredPath).ConfigureAwait(false);
|
||||
if (decimatedTris > 0)
|
||||
{
|
||||
effectiveTris = decimatedTris;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
effectiveTriUsage += effectiveTris;
|
||||
}
|
||||
|
||||
pairHandler.LastAppliedDataTris = triUsage;
|
||||
pairHandler.LastAppliedApproximateEffectiveTris = effectiveTriUsage;
|
||||
|
||||
_logger.LogDebug("Calculated VRAM usage for {p}", pairHandler);
|
||||
_logger.LogDebug("Calculated triangle usage for {p}", pairHandler);
|
||||
|
||||
// no warning of any kind on ignored pairs
|
||||
if (config.UIDsToIgnore
|
||||
@@ -167,7 +199,9 @@ public class PlayerPerformanceService
|
||||
public bool ComputeAndAutoPauseOnVRAMUsageThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData, List<DownloadFileTransfer> toDownloadFiles)
|
||||
{
|
||||
var config = _playerPerformanceConfigService.Current;
|
||||
bool skipDownscale = pairHandler.IsDirectlyPaired && pairHandler.HasStickyPermissions;
|
||||
bool skipDownscale = config.SkipTextureDownscaleForPreferredPairs
|
||||
&& pairHandler.IsDirectlyPaired
|
||||
&& pairHandler.HasStickyPermissions;
|
||||
|
||||
long vramUsage = 0;
|
||||
long effectiveVramUsage = 0;
|
||||
@@ -274,4 +308,4 @@ public class PlayerPerformanceService
|
||||
|
||||
private static bool CheckForThreshold(bool thresholdEnabled, long threshold, long value, bool checkForPrefPerm, bool isPrefPerm) =>
|
||||
thresholdEnabled && threshold > 0 && threshold < value && ((checkForPrefPerm && isPrefPerm) || !isPrefPerm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using LightlessSync.Interop.Ipc;
|
||||
using LightlessSync.FileCache;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Penumbra.Api.Enums;
|
||||
using System.Globalization;
|
||||
|
||||
namespace LightlessSync.Services.TextureCompression;
|
||||
|
||||
@@ -27,7 +28,9 @@ public sealed class TextureCompressionService
|
||||
public async Task ConvertTexturesAsync(
|
||||
IReadOnlyList<TextureCompressionRequest> requests,
|
||||
IProgress<TextureConversionProgress>? progress,
|
||||
CancellationToken token)
|
||||
CancellationToken token,
|
||||
bool requestRedraw = true,
|
||||
bool includeMipMaps = true)
|
||||
{
|
||||
if (requests.Count == 0)
|
||||
{
|
||||
@@ -48,7 +51,7 @@ public sealed class TextureCompressionService
|
||||
continue;
|
||||
}
|
||||
|
||||
await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token).ConfigureAwait(false);
|
||||
await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token, requestRedraw, includeMipMaps).ConfigureAwait(false);
|
||||
|
||||
completed++;
|
||||
}
|
||||
@@ -65,14 +68,16 @@ public sealed class TextureCompressionService
|
||||
int total,
|
||||
int completedBefore,
|
||||
IProgress<TextureConversionProgress>? progress,
|
||||
CancellationToken token)
|
||||
CancellationToken token,
|
||||
bool requestRedraw,
|
||||
bool includeMipMaps)
|
||||
{
|
||||
var primaryPath = request.PrimaryFilePath;
|
||||
var displayJob = new TextureConversionJob(
|
||||
primaryPath,
|
||||
primaryPath,
|
||||
targetType,
|
||||
IncludeMipMaps: true,
|
||||
IncludeMipMaps: includeMipMaps,
|
||||
request.DuplicateFilePaths);
|
||||
|
||||
var backupPath = CreateBackupCopy(primaryPath);
|
||||
@@ -83,7 +88,7 @@ public sealed class TextureCompressionService
|
||||
try
|
||||
{
|
||||
WaitForAccess(primaryPath);
|
||||
await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { conversionJob }, null, token).ConfigureAwait(false);
|
||||
await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { conversionJob }, null, token, requestRedraw).ConfigureAwait(false);
|
||||
|
||||
if (!IsValidConversionResult(displayJob.OutputFile))
|
||||
{
|
||||
@@ -128,19 +133,46 @@ public sealed class TextureCompressionService
|
||||
var cacheEntries = _fileCacheManager.GetFileCachesByPaths(paths.ToArray());
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var hasExpectedHash = TryGetExpectedHashFromPath(path, out var expectedHash);
|
||||
if (!cacheEntries.TryGetValue(path, out var entry) || entry is null)
|
||||
{
|
||||
entry = _fileCacheManager.CreateFileEntry(path);
|
||||
if (hasExpectedHash)
|
||||
{
|
||||
entry = _fileCacheManager.CreateCacheEntryWithKnownHash(path, expectedHash);
|
||||
}
|
||||
|
||||
entry ??= _fileCacheManager.CreateFileEntry(path);
|
||||
if (entry is null)
|
||||
{
|
||||
_logger.LogWarning("Unable to locate cache entry for {Path}; skipping hash refresh", path);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (hasExpectedHash && entry.IsCacheEntry && !string.Equals(entry.Hash, expectedHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogDebug("Fixing cache hash mismatch for {Path}: {Current} -> {Expected}", path, entry.Hash, expectedHash);
|
||||
_fileCacheManager.RemoveHashedFile(entry.Hash, entry.PrefixedFilePath, removeDerivedFiles: false);
|
||||
var corrected = _fileCacheManager.CreateCacheEntryWithKnownHash(path, expectedHash);
|
||||
if (corrected is not null)
|
||||
{
|
||||
entry = corrected;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_fileCacheManager.UpdateHashedFile(entry);
|
||||
if (entry.IsCacheEntry)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
entry.Size = info.Length;
|
||||
entry.CompressedSize = null;
|
||||
entry.LastModifiedDateTicks = info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture);
|
||||
_fileCacheManager.UpdateHashedFile(entry, computeProperties: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
_fileCacheManager.UpdateHashedFile(entry);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -149,6 +181,35 @@ public sealed class TextureCompressionService
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetExpectedHashFromPath(string path, out string hash)
|
||||
{
|
||||
hash = Path.GetFileNameWithoutExtension(path);
|
||||
if (string.IsNullOrWhiteSpace(hash))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hash.Length is not (40 or 64))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < hash.Length; i++)
|
||||
{
|
||||
var c = hash[i];
|
||||
var isHex = (c >= '0' && c <= '9')
|
||||
|| (c >= 'a' && c <= 'f')
|
||||
|| (c >= 'A' && c <= 'F');
|
||||
if (!isHex)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
hash = hash.ToUpperInvariant();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static readonly string WorkingDirectory =
|
||||
Path.Combine(Path.GetTempPath(), "LightlessSync.TextureCompression");
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using OtterTex;
|
||||
using OtterImage = OtterTex.Image;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.FileCache;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Lumina.Data.Files;
|
||||
@@ -30,10 +32,12 @@ public sealed class TextureDownscaleService
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
|
||||
private readonly FileCacheManager _fileCacheManager;
|
||||
private readonly TextureCompressionService _textureCompressionService;
|
||||
|
||||
private readonly ConcurrentDictionary<string, Task> _activeJobs = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TaskRegistry<string> _downscaleDeduplicator = new();
|
||||
private readonly ConcurrentDictionary<string, string> _downscaledPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly SemaphoreSlim _downscaleSemaphore = new(4);
|
||||
private readonly SemaphoreSlim _compressionSemaphore = new(1);
|
||||
private static readonly IReadOnlyDictionary<int, TextureCompressionTarget> BlockCompressedFormatMap =
|
||||
new Dictionary<int, TextureCompressionTarget>
|
||||
{
|
||||
@@ -68,23 +72,50 @@ public sealed class TextureDownscaleService
|
||||
ILogger<TextureDownscaleService> logger,
|
||||
LightlessConfigService configService,
|
||||
PlayerPerformanceConfigService playerPerformanceConfigService,
|
||||
FileCacheManager fileCacheManager)
|
||||
FileCacheManager fileCacheManager,
|
||||
TextureCompressionService textureCompressionService)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_playerPerformanceConfigService = playerPerformanceConfigService;
|
||||
_fileCacheManager = fileCacheManager;
|
||||
_textureCompressionService = textureCompressionService;
|
||||
}
|
||||
|
||||
public void ScheduleDownscale(string hash, string filePath, TextureMapKind mapKind)
|
||||
=> ScheduleDownscale(hash, filePath, () => mapKind);
|
||||
|
||||
public void ScheduleDownscale(string hash, string filePath, Func<TextureMapKind> mapKindFactory)
|
||||
{
|
||||
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return;
|
||||
if (_activeJobs.ContainsKey(hash)) return;
|
||||
if (_downscaleDeduplicator.TryGetExisting(hash, out _)) return;
|
||||
|
||||
_activeJobs[hash] = Task.Run(async () =>
|
||||
_downscaleDeduplicator.GetOrStart(hash, async () =>
|
||||
{
|
||||
TextureMapKind mapKind;
|
||||
try
|
||||
{
|
||||
mapKind = mapKindFactory();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to determine texture map kind for {Hash}; skipping downscale", hash);
|
||||
return;
|
||||
}
|
||||
|
||||
await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false);
|
||||
}, CancellationToken.None);
|
||||
});
|
||||
}
|
||||
|
||||
public bool ShouldScheduleDownscale(string filePath)
|
||||
{
|
||||
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var performanceConfig = _playerPerformanceConfigService.Current;
|
||||
return performanceConfig.EnableNonIndexTextureMipTrim
|
||||
|| performanceConfig.EnableIndexTextureDownscale
|
||||
|| performanceConfig.EnableUncompressedTextureCompression;
|
||||
}
|
||||
|
||||
public string GetPreferredPath(string hash, string originalPath)
|
||||
@@ -121,7 +152,7 @@ public sealed class TextureDownscaleService
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_activeJobs.TryGetValue(hash, out var job))
|
||||
if (_downscaleDeduplicator.TryGetExisting(hash, out var job))
|
||||
{
|
||||
pending.Add(job);
|
||||
}
|
||||
@@ -159,10 +190,18 @@ public sealed class TextureDownscaleService
|
||||
targetMaxDimension = ResolveTargetMaxDimension();
|
||||
onlyDownscaleUncompressed = performanceConfig.OnlyDownscaleUncompressedTextures;
|
||||
|
||||
if (onlyDownscaleUncompressed && !headerInfo.HasValue)
|
||||
{
|
||||
_downscaledPaths[hash] = sourcePath;
|
||||
_logger.LogTrace("Skipping downscale for texture {Hash}; format unknown and only-uncompressed enabled.", hash);
|
||||
return;
|
||||
}
|
||||
|
||||
destination = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex");
|
||||
if (File.Exists(destination))
|
||||
{
|
||||
RegisterDownscaledTexture(hash, sourcePath, destination);
|
||||
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -173,6 +212,7 @@ public sealed class TextureDownscaleService
|
||||
if (performanceConfig.EnableNonIndexTextureMipTrim
|
||||
&& await TryDropTopMipAsync(hash, sourcePath, destination, targetMaxDimension, onlyDownscaleUncompressed, headerInfo).ConfigureAwait(false))
|
||||
{
|
||||
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -183,6 +223,7 @@ public sealed class TextureDownscaleService
|
||||
|
||||
_downscaledPaths[hash] = sourcePath;
|
||||
_logger.LogTrace("Skipping downscale for non-index texture {Hash}; no mip reduction required.", hash);
|
||||
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,6 +231,7 @@ public sealed class TextureDownscaleService
|
||||
{
|
||||
_downscaledPaths[hash] = sourcePath;
|
||||
_logger.LogTrace("Skipping downscale for index texture {Hash}; feature disabled.", hash);
|
||||
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -199,6 +241,7 @@ public sealed class TextureDownscaleService
|
||||
{
|
||||
_downscaledPaths[hash] = sourcePath;
|
||||
_logger.LogTrace("Skipping downscale for index texture {Hash}; header dimensions {Width}x{Height} within target.", hash, headerValue.Width, headerValue.Height);
|
||||
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -206,10 +249,12 @@ public sealed class TextureDownscaleService
|
||||
{
|
||||
_downscaledPaths[hash] = sourcePath;
|
||||
_logger.LogTrace("Skipping downscale for index texture {Hash}; block compressed format {Format}.", hash, headerInfo.Value.Format);
|
||||
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
using var sourceScratch = TexFileHelper.Load(sourcePath);
|
||||
var sourceFormat = sourceScratch.Meta.Format;
|
||||
using var rgbaScratch = sourceScratch.GetRGBA(out var rgbaInfo).ThrowIfError(rgbaInfo);
|
||||
|
||||
var bytesPerPixel = rgbaInfo.Meta.Format.BitsPerPixel() / 8;
|
||||
@@ -225,16 +270,39 @@ public sealed class TextureDownscaleService
|
||||
{
|
||||
_downscaledPaths[hash] = sourcePath;
|
||||
_logger.LogTrace("Skipping downscale for index texture {Hash}; already within bounds.", hash);
|
||||
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
using var resized = IndexDownscaler.Downscale(originalImage, targetSize.width, targetSize.height, BlockMultiple);
|
||||
|
||||
var canReencodeWithPenumbra = TryResolveCompressionTarget(headerInfo, sourceFormat, out var compressionTarget);
|
||||
using var resizedScratch = CreateScratchImage(resized, targetSize.width, targetSize.height);
|
||||
using var finalScratch = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm);
|
||||
if (!TryConvertForSave(resizedScratch, sourceFormat, out var finalScratch, canReencodeWithPenumbra))
|
||||
{
|
||||
if (canReencodeWithPenumbra
|
||||
&& await TryReencodeWithPenumbraAsync(hash, sourcePath, destination, resizedScratch, compressionTarget).ConfigureAwait(false))
|
||||
{
|
||||
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
TexFileHelper.Save(destination, finalScratch);
|
||||
RegisterDownscaledTexture(hash, sourcePath, destination);
|
||||
_downscaledPaths[hash] = sourcePath;
|
||||
_logger.LogTrace(
|
||||
"Skipping downscale for index texture {Hash}; failed to re-encode to {Format}.",
|
||||
hash,
|
||||
sourceFormat);
|
||||
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
using (finalScratch)
|
||||
{
|
||||
TexFileHelper.Save(destination, finalScratch);
|
||||
RegisterDownscaledTexture(hash, sourcePath, destination);
|
||||
}
|
||||
|
||||
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -254,7 +322,6 @@ public sealed class TextureDownscaleService
|
||||
finally
|
||||
{
|
||||
_downscaleSemaphore.Release();
|
||||
_activeJobs.TryRemove(hash, out _);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,6 +374,157 @@ public sealed class TextureDownscaleService
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryConvertForSave(
|
||||
ScratchImage source,
|
||||
DXGIFormat sourceFormat,
|
||||
out ScratchImage result,
|
||||
bool attemptPenumbraFallback)
|
||||
{
|
||||
var isCompressed = sourceFormat.IsCompressed();
|
||||
var targetFormat = isCompressed ? sourceFormat : DXGIFormat.B8G8R8A8UNorm;
|
||||
try
|
||||
{
|
||||
result = source.Convert(targetFormat);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var compressedFallback = attemptPenumbraFallback
|
||||
? " Attempting Penumbra re-encode."
|
||||
: " Skipping downscale.";
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to convert downscaled texture to {Format}.{Fallback}",
|
||||
targetFormat,
|
||||
isCompressed ? compressedFallback : " Falling back to B8G8R8A8.");
|
||||
if (isCompressed)
|
||||
{
|
||||
result = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
result = source.Convert(DXGIFormat.B8G8R8A8UNorm);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryResolveCompressionTarget(TexHeaderInfo? headerInfo, DXGIFormat sourceFormat, out TextureCompressionTarget target)
|
||||
{
|
||||
if (headerInfo is { } info && TryGetCompressionTarget(info.Format, out target))
|
||||
{
|
||||
return _textureCompressionService.IsTargetSelectable(target);
|
||||
}
|
||||
|
||||
if (sourceFormat.IsCompressed() && BlockCompressedFormatMap.TryGetValue((int)sourceFormat, out target))
|
||||
{
|
||||
return _textureCompressionService.IsTargetSelectable(target);
|
||||
}
|
||||
|
||||
target = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<bool> TryReencodeWithPenumbraAsync(
|
||||
string hash,
|
||||
string sourcePath,
|
||||
string destination,
|
||||
ScratchImage resizedScratch,
|
||||
TextureCompressionTarget target)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var uncompressed = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm);
|
||||
TexFileHelper.Save(destination, uncompressed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save uncompressed downscaled texture for {Hash}. Skipping downscale.", hash);
|
||||
TryDelete(destination);
|
||||
return false;
|
||||
}
|
||||
|
||||
await _compressionSemaphore.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var request = new TextureCompressionRequest(destination, Array.Empty<string>(), target);
|
||||
await _textureCompressionService
|
||||
.ConvertTexturesAsync(new[] { request }, null, CancellationToken.None, requestRedraw: false)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to re-encode downscaled texture {Hash} to {Target}. Skipping downscale.", hash, target);
|
||||
TryDelete(destination);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_compressionSemaphore.Release();
|
||||
}
|
||||
|
||||
RegisterDownscaledTexture(hash, sourcePath, destination);
|
||||
_logger.LogDebug("Downscaled texture {Hash} -> {Path} (re-encoded via Penumbra).", hash, destination);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task TryAutoCompressAsync(string hash, string texturePath, TextureMapKind mapKind, TexHeaderInfo? headerInfo)
|
||||
{
|
||||
var performanceConfig = _playerPerformanceConfigService.Current;
|
||||
if (!performanceConfig.EnableUncompressedTextureCompression)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(texturePath) || !File.Exists(texturePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var info = headerInfo ?? (TryReadTexHeader(texturePath, out var header) ? header : (TexHeaderInfo?)null);
|
||||
if (!info.HasValue)
|
||||
{
|
||||
_logger.LogTrace("Skipping auto-compress for texture {Hash}; unable to read header.", hash);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsBlockCompressedFormat(info.Value.Format))
|
||||
{
|
||||
_logger.LogTrace("Skipping auto-compress for texture {Hash}; already block-compressed.", hash);
|
||||
return;
|
||||
}
|
||||
|
||||
var suggestion = TextureMetadataHelper.GetSuggestedTarget(info.Value.Format.ToString(), mapKind, texturePath);
|
||||
if (suggestion is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var target = _textureCompressionService.NormalizeTarget(suggestion.Value.Target);
|
||||
if (!_textureCompressionService.IsTargetSelectable(target))
|
||||
{
|
||||
_logger.LogTrace("Skipping auto-compress for texture {Hash}; target {Target} not supported.", hash, target);
|
||||
return;
|
||||
}
|
||||
|
||||
await _compressionSemaphore.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var includeMipMaps = !performanceConfig.SkipUncompressedTextureCompressionMipMaps;
|
||||
var request = new TextureCompressionRequest(texturePath, Array.Empty<string>(), target);
|
||||
await _textureCompressionService
|
||||
.ConvertTexturesAsync(new[] { request }, null, CancellationToken.None, requestRedraw: false, includeMipMaps: includeMipMaps)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Auto-compress failed for texture {Hash} ({Path})", hash, texturePath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_compressionSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsIndexMap(TextureMapKind kind)
|
||||
=> kind is TextureMapKind.Mask
|
||||
or TextureMapKind.Index;
|
||||
@@ -655,7 +873,7 @@ public sealed class TextureDownscaleService
|
||||
|
||||
if (!string.Equals(cacheEntry.PrefixedFilePath, prefixed, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_fileCacheManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath);
|
||||
_fileCacheManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath, removeDerivedFiles: false);
|
||||
}
|
||||
|
||||
_fileCacheManager.UpdateHashedFile(replacement, computeProperties: false);
|
||||
|
||||
@@ -8,6 +8,7 @@ using LightlessSync.UI.Tags;
|
||||
using LightlessSync.WebAPI;
|
||||
using LightlessSync.UI.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using LightlessSync.PlayerData.Factories;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
@@ -23,6 +24,7 @@ public class UiFactory
|
||||
private readonly PerformanceCollectorService _performanceCollectorService;
|
||||
private readonly ProfileTagService _profileTagService;
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private readonly PairFactory _pairFactory;
|
||||
|
||||
public UiFactory(
|
||||
ILoggerFactory loggerFactory,
|
||||
@@ -34,7 +36,8 @@ public class UiFactory
|
||||
LightlessProfileManager lightlessProfileManager,
|
||||
PerformanceCollectorService performanceCollectorService,
|
||||
ProfileTagService profileTagService,
|
||||
DalamudUtilService dalamudUtilService)
|
||||
DalamudUtilService dalamudUtilService,
|
||||
PairFactory pairFactory)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_lightlessMediator = lightlessMediator;
|
||||
@@ -46,6 +49,7 @@ public class UiFactory
|
||||
_performanceCollectorService = performanceCollectorService;
|
||||
_profileTagService = profileTagService;
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
_pairFactory = pairFactory;
|
||||
}
|
||||
|
||||
public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto)
|
||||
@@ -58,7 +62,8 @@ public class UiFactory
|
||||
_pairUiService,
|
||||
dto,
|
||||
_performanceCollectorService,
|
||||
_lightlessProfileManager);
|
||||
_lightlessProfileManager,
|
||||
_pairFactory);
|
||||
}
|
||||
|
||||
public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair)
|
||||
|
||||
@@ -13,16 +13,20 @@ namespace LightlessSync.Services;
|
||||
public sealed class UiService : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private readonly List<WindowMediatorSubscriberBase> _createdWindows = [];
|
||||
private readonly List<WindowMediatorSubscriberBase> _registeredWindows = [];
|
||||
private readonly HashSet<WindowMediatorSubscriberBase> _uiHiddenWindows = [];
|
||||
private readonly IUiBuilder _uiBuilder;
|
||||
private readonly FileDialogManager _fileDialogManager;
|
||||
private readonly ILogger<UiService> _logger;
|
||||
private readonly LightlessConfigService _lightlessConfigService;
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private readonly WindowSystem _windowSystem;
|
||||
private readonly UiFactory _uiFactory;
|
||||
private readonly PairFactory _pairFactory;
|
||||
private bool _uiHideActive;
|
||||
|
||||
public UiService(ILogger<UiService> logger, IUiBuilder uiBuilder,
|
||||
LightlessConfigService lightlessConfigService, WindowSystem windowSystem,
|
||||
LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService, WindowSystem windowSystem,
|
||||
IEnumerable<WindowMediatorSubscriberBase> windows,
|
||||
UiFactory uiFactory, FileDialogManager fileDialogManager,
|
||||
LightlessMediator lightlessMediator, PairFactory pairFactory) : base(logger, lightlessMediator)
|
||||
@@ -31,6 +35,7 @@ public sealed class UiService : DisposableMediatorSubscriberBase
|
||||
_logger.LogTrace("Creating {type}", GetType().Name);
|
||||
_uiBuilder = uiBuilder;
|
||||
_lightlessConfigService = lightlessConfigService;
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
_windowSystem = windowSystem;
|
||||
_uiFactory = uiFactory;
|
||||
_pairFactory = pairFactory;
|
||||
@@ -43,6 +48,7 @@ public sealed class UiService : DisposableMediatorSubscriberBase
|
||||
|
||||
foreach (var window in windows)
|
||||
{
|
||||
_registeredWindows.Add(window);
|
||||
_windowSystem.AddWindow(window);
|
||||
}
|
||||
|
||||
@@ -176,6 +182,8 @@ public sealed class UiService : DisposableMediatorSubscriberBase
|
||||
{
|
||||
_windowSystem.RemoveWindow(msg.Window);
|
||||
_createdWindows.Remove(msg.Window);
|
||||
_registeredWindows.Remove(msg.Window);
|
||||
_uiHiddenWindows.Remove(msg.Window);
|
||||
msg.Window.Dispose();
|
||||
});
|
||||
}
|
||||
@@ -219,12 +227,72 @@ public sealed class UiService : DisposableMediatorSubscriberBase
|
||||
MainStyle.PushStyle();
|
||||
try
|
||||
{
|
||||
var hideOtherUi = ShouldHideOtherUi();
|
||||
UpdateUiHideState(hideOtherUi);
|
||||
_windowSystem.Draw();
|
||||
_fileDialogManager.Draw();
|
||||
if (!hideOtherUi)
|
||||
_fileDialogManager.Draw();
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainStyle.PopStyle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldHideOtherUi()
|
||||
{
|
||||
var config = _lightlessConfigService.Current;
|
||||
if (!config.ShowUiWhenUiHidden && _dalamudUtilService.IsGameUiHidden)
|
||||
return true;
|
||||
|
||||
if (!config.ShowUiInGpose && _dalamudUtilService.IsInGpose)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void UpdateUiHideState(bool hideOtherUi)
|
||||
{
|
||||
if (!hideOtherUi)
|
||||
{
|
||||
if (_uiHideActive)
|
||||
{
|
||||
foreach (var window in _uiHiddenWindows)
|
||||
{
|
||||
window.IsOpen = true;
|
||||
}
|
||||
|
||||
_uiHiddenWindows.Clear();
|
||||
_uiHideActive = false;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_uiHideActive = true;
|
||||
foreach (var window in EnumerateManagedWindows())
|
||||
{
|
||||
if (window is ZoneChatUi)
|
||||
continue;
|
||||
|
||||
if (!window.IsOpen)
|
||||
continue;
|
||||
|
||||
_uiHiddenWindows.Add(window);
|
||||
window.IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<WindowMediatorSubscriberBase> EnumerateManagedWindows()
|
||||
{
|
||||
foreach (var window in _registeredWindows)
|
||||
{
|
||||
yield return window;
|
||||
}
|
||||
|
||||
foreach (var window in _createdWindows)
|
||||
{
|
||||
yield return window;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
||||
using FFXIVClientStructs.Havok.Common.Serialize.Resource;
|
||||
using FFXIVClientStructs.Havok.Animation;
|
||||
using FFXIVClientStructs.Havok.Common.Base.Types;
|
||||
using FFXIVClientStructs.Havok.Common.Serialize.Resource;
|
||||
using FFXIVClientStructs.Havok.Common.Serialize.Util;
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.Interop.GameModel;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.PlayerData.Factories;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OtterGui.Text.EndObjects;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public sealed class XivDataAnalyzer
|
||||
public sealed partial class XivDataAnalyzer
|
||||
{
|
||||
private readonly ILogger<XivDataAnalyzer> _logger;
|
||||
private readonly FileCacheManager _fileCacheManager;
|
||||
private readonly XivDataStorageService _configService;
|
||||
private readonly List<string> _failedCalculatedTris = [];
|
||||
private readonly List<string> _failedCalculatedEffectiveTris = [];
|
||||
|
||||
public XivDataAnalyzer(ILogger<XivDataAnalyzer> logger, FileCacheManager fileCacheManager,
|
||||
XivDataStorageService configService)
|
||||
@@ -29,127 +36,435 @@ public sealed class XivDataAnalyzer
|
||||
|
||||
public unsafe Dictionary<string, List<ushort>>? GetSkeletonBoneIndices(GameObjectHandler handler)
|
||||
{
|
||||
if (handler.Address == nint.Zero) return null;
|
||||
var chara = (CharacterBase*)(((Character*)handler.Address)->GameObject.DrawObject);
|
||||
if (chara->GetModelType() != CharacterBase.ModelType.Human) return null;
|
||||
var resHandles = chara->Skeleton->SkeletonResourceHandles;
|
||||
Dictionary<string, List<ushort>> outputIndices = [];
|
||||
if (handler is null || handler.Address == nint.Zero)
|
||||
return null;
|
||||
|
||||
Dictionary<string, HashSet<ushort>> sets = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < chara->Skeleton->PartialSkeletonCount; i++)
|
||||
var drawObject = ((Character*)handler.Address)->GameObject.DrawObject;
|
||||
if (drawObject == null)
|
||||
return null;
|
||||
|
||||
var chara = (CharacterBase*)drawObject;
|
||||
if (chara->GetModelType() != CharacterBase.ModelType.Human)
|
||||
return null;
|
||||
|
||||
var skeleton = chara->Skeleton;
|
||||
if (skeleton == null)
|
||||
return null;
|
||||
|
||||
var resHandles = skeleton->SkeletonResourceHandles;
|
||||
var partialCount = skeleton->PartialSkeletonCount;
|
||||
if (partialCount <= 0)
|
||||
return null;
|
||||
|
||||
for (int i = 0; i < partialCount; i++)
|
||||
{
|
||||
var handle = *(resHandles + i);
|
||||
_logger.LogTrace("Iterating over SkeletonResourceHandle #{i}:{x}", i, ((nint)handle).ToString("X"));
|
||||
if ((nint)handle == nint.Zero) continue;
|
||||
var curBones = handle->BoneCount;
|
||||
// this is unrealistic, the filename shouldn't ever be that long
|
||||
if (handle->FileName.Length > 1024) continue;
|
||||
var skeletonName = handle->FileName.ToString();
|
||||
if (string.IsNullOrEmpty(skeletonName)) continue;
|
||||
outputIndices[skeletonName] = [];
|
||||
for (ushort boneIdx = 0; boneIdx < curBones; boneIdx++)
|
||||
if ((nint)handle == nint.Zero)
|
||||
continue;
|
||||
|
||||
if (handle->FileName.Length > 1024)
|
||||
continue;
|
||||
|
||||
var rawName = handle->FileName.ToString();
|
||||
if (string.IsNullOrWhiteSpace(rawName))
|
||||
continue;
|
||||
|
||||
var skeletonKey = CanonicalizeSkeletonKey(rawName);
|
||||
if (string.IsNullOrEmpty(skeletonKey))
|
||||
continue;
|
||||
|
||||
var boneCount = handle->BoneCount;
|
||||
if (boneCount == 0)
|
||||
continue;
|
||||
|
||||
var havokSkel = handle->HavokSkeleton;
|
||||
if ((nint)havokSkel == nint.Zero)
|
||||
continue;
|
||||
|
||||
if (!sets.TryGetValue(skeletonKey, out var set))
|
||||
{
|
||||
var boneName = handle->HavokSkeleton->Bones[boneIdx].Name.String;
|
||||
if (boneName == null) continue;
|
||||
outputIndices[skeletonName].Add((ushort)(boneIdx + 1));
|
||||
set = [];
|
||||
sets[skeletonKey] = set;
|
||||
}
|
||||
|
||||
uint maxExclusive = boneCount;
|
||||
uint ushortExclusive = (uint)ushort.MaxValue + 1u;
|
||||
if (maxExclusive > ushortExclusive)
|
||||
maxExclusive = ushortExclusive;
|
||||
|
||||
for (uint boneIdx = 0; boneIdx < maxExclusive; boneIdx++)
|
||||
{
|
||||
var name = havokSkel->Bones[boneIdx].Name.String;
|
||||
if (name == null)
|
||||
continue;
|
||||
|
||||
set.Add((ushort)boneIdx);
|
||||
}
|
||||
|
||||
_logger.LogTrace("Local skeleton raw file='{raw}', key='{key}', boneCount={count}",
|
||||
rawName, skeletonKey, boneCount);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not process skeleton data");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (outputIndices.Count != 0 && outputIndices.Values.All(u => u.Count > 0)) ? outputIndices : null;
|
||||
if (sets.Count == 0)
|
||||
return null;
|
||||
|
||||
var output = new Dictionary<string, List<ushort>>(sets.Count, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, set) in sets)
|
||||
{
|
||||
if (set.Count == 0)
|
||||
continue;
|
||||
|
||||
var list = set.ToList();
|
||||
list.Sort();
|
||||
output[key] = list;
|
||||
}
|
||||
|
||||
return (output.Count != 0 && output.Values.All(v => v.Count > 0)) ? output : null;
|
||||
}
|
||||
|
||||
public unsafe Dictionary<string, List<ushort>>? GetBoneIndicesFromPap(string hash)
|
||||
public static byte[]? ReadHavokBytesFromPap(string papPath)
|
||||
{
|
||||
if (_configService.Current.BonesDictionary.TryGetValue(hash, out var bones)) return bones;
|
||||
using var fs = File.Open(papPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var reader = new BinaryReader(fs);
|
||||
|
||||
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
|
||||
if (cacheEntity == null) return null;
|
||||
_ = reader.ReadInt32();
|
||||
_ = reader.ReadInt32();
|
||||
_ = reader.ReadInt16();
|
||||
_ = reader.ReadInt16();
|
||||
|
||||
using BinaryReader reader = new(File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read));
|
||||
var type = reader.ReadByte();
|
||||
if (type != 0) return null;
|
||||
|
||||
// most of this shit is from vfxeditor, surely nothing will change in the pap format :copium:
|
||||
reader.ReadInt32(); // ignore
|
||||
reader.ReadInt32(); // ignore
|
||||
reader.ReadInt16(); // read 2 (num animations)
|
||||
reader.ReadInt16(); // read 2 (modelid)
|
||||
var type = reader.ReadByte();// read 1 (type)
|
||||
if (type != 0) return null; // it's not human, just ignore it, whatever
|
||||
_ = reader.ReadByte();
|
||||
_ = reader.ReadInt32();
|
||||
|
||||
reader.ReadByte(); // read 1 (variant)
|
||||
reader.ReadInt32(); // ignore
|
||||
var havokPosition = reader.ReadInt32();
|
||||
var footerPosition = reader.ReadInt32();
|
||||
var havokDataSize = footerPosition - havokPosition;
|
||||
reader.BaseStream.Position = havokPosition;
|
||||
var havokData = reader.ReadBytes(havokDataSize);
|
||||
if (havokData.Length <= 8) return null; // no havok data
|
||||
|
||||
var output = new Dictionary<string, List<ushort>>(StringComparer.OrdinalIgnoreCase);
|
||||
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) + ".hkx";
|
||||
var tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath);
|
||||
if (havokPosition <= 0 || footerPosition <= havokPosition || footerPosition > fs.Length)
|
||||
return null;
|
||||
|
||||
var sizeLong = (long)footerPosition - havokPosition;
|
||||
if (sizeLong <= 8 || sizeLong > int.MaxValue)
|
||||
return null;
|
||||
|
||||
var size = (int)sizeLong;
|
||||
|
||||
fs.Position = havokPosition;
|
||||
var bytes = reader.ReadBytes(size);
|
||||
return bytes.Length > 8 ? bytes : null;
|
||||
}
|
||||
|
||||
public unsafe Dictionary<string, List<ushort>>? ParseHavokBytesOnFrameworkThread(
|
||||
byte[] havokData,
|
||||
string hash,
|
||||
bool persistToConfig)
|
||||
{
|
||||
var tempSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var tempHkxPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx");
|
||||
IntPtr pathAnsi = IntPtr.Zero;
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(tempHavokDataPath, havokData);
|
||||
File.WriteAllBytes(tempHkxPath, havokData);
|
||||
|
||||
var loadoptions = stackalloc hkSerializeUtil.LoadOptions[1];
|
||||
loadoptions->TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry();
|
||||
loadoptions->ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry();
|
||||
loadoptions->Flags = new hkFlags<hkSerializeUtil.LoadOptionBits, int>
|
||||
pathAnsi = Marshal.StringToHGlobalAnsi(tempHkxPath);
|
||||
|
||||
hkSerializeUtil.LoadOptions loadOptions = default;
|
||||
loadOptions.TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry();
|
||||
loadOptions.ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry();
|
||||
loadOptions.Flags = new hkFlags<hkSerializeUtil.LoadOptionBits, int>
|
||||
{
|
||||
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)
|
||||
{
|
||||
throw new InvalidOperationException("Resource was null after loading");
|
||||
}
|
||||
return null;
|
||||
|
||||
var rootLevelName = @"hkRootLevelContainer"u8;
|
||||
fixed (byte* n1 = rootLevelName)
|
||||
{
|
||||
var container = (hkRootLevelContainer*)resource->GetContentsPointer(n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry());
|
||||
var container = (hkRootLevelContainer*)resource->GetContentsPointer(
|
||||
n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry());
|
||||
|
||||
if (container == null) return null;
|
||||
|
||||
var animationName = @"hkaAnimationContainer"u8;
|
||||
fixed (byte* n2 = animationName)
|
||||
{
|
||||
var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null);
|
||||
if (animContainer == null) return null;
|
||||
|
||||
for (int i = 0; i < animContainer->Bindings.Length; i++)
|
||||
{
|
||||
var binding = animContainer->Bindings[i].ptr;
|
||||
if (binding == null) continue;
|
||||
|
||||
var rawSkel = binding->OriginalSkeletonName.String;
|
||||
var skeletonKey = CanonicalizeSkeletonKey(rawSkel);
|
||||
if (string.IsNullOrEmpty(skeletonKey)) continue;
|
||||
|
||||
var boneTransform = binding->TransformTrackToBoneIndices;
|
||||
string name = binding->OriginalSkeletonName.String! + "_" + i;
|
||||
output[name] = [];
|
||||
if (boneTransform.Length <= 0) continue;
|
||||
|
||||
if (!tempSets.TryGetValue(skeletonKey, out var set))
|
||||
tempSets[skeletonKey] = set = [];
|
||||
|
||||
for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++)
|
||||
{
|
||||
output[name].Add((ushort)boneTransform[boneIdx]);
|
||||
var v = boneTransform[boneIdx];
|
||||
if (v < 0) continue;
|
||||
set.Add((ushort)v);
|
||||
}
|
||||
output[name].Sort();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not load havok file in {path}", tempHavokDataPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(tempHavokDataPathAnsi);
|
||||
File.Delete(tempHavokDataPath);
|
||||
if (pathAnsi != IntPtr.Zero)
|
||||
Marshal.FreeHGlobal(pathAnsi);
|
||||
|
||||
try { if (File.Exists(tempHkxPath)) File.Delete(tempHkxPath); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (tempSets.Count == 0) return null;
|
||||
|
||||
var output = new Dictionary<string, List<ushort>>(tempSets.Count, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, set) in tempSets)
|
||||
{
|
||||
if (set.Count == 0) continue;
|
||||
var list = set.ToList();
|
||||
list.Sort();
|
||||
output[key] = list;
|
||||
}
|
||||
|
||||
if (output.Count == 0) return null;
|
||||
|
||||
_configService.Current.BonesDictionary[hash] = output;
|
||||
_configService.Save();
|
||||
if (persistToConfig) _configService.Save();
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public static string CanonicalizeSkeletonKey(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return string.Empty;
|
||||
|
||||
var s = raw.Replace('\\', '/').Trim();
|
||||
|
||||
var underscore = s.LastIndexOf('_');
|
||||
if (underscore > 0 && underscore + 1 < s.Length && char.IsDigit(s[underscore + 1]))
|
||||
s = s[..underscore];
|
||||
|
||||
if (s.StartsWith("skeleton", StringComparison.OrdinalIgnoreCase))
|
||||
return "skeleton";
|
||||
|
||||
var m = _bucketPathRegex.Match(s);
|
||||
if (m.Success)
|
||||
return m.Groups["bucket"].Value.ToLowerInvariant();
|
||||
|
||||
m = _bucketSklRegex.Match(s);
|
||||
if (m.Success)
|
||||
return m.Groups["bucket"].Value.ToLowerInvariant();
|
||||
|
||||
m = _bucketLooseRegex.Match(s);
|
||||
if (m.Success)
|
||||
return m.Groups["bucket"].Value.ToLowerInvariant();
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
public static bool ContainsIndexCompat(
|
||||
HashSet<ushort> available,
|
||||
ushort idx,
|
||||
bool papLikelyOneBased,
|
||||
bool allowOneBasedShift,
|
||||
bool allowNeighborTolerance)
|
||||
{
|
||||
Span<ushort> candidates = stackalloc ushort[2];
|
||||
int count = 0;
|
||||
|
||||
candidates[count++] = idx;
|
||||
|
||||
if (allowOneBasedShift && papLikelyOneBased && idx > 0)
|
||||
candidates[count++] = (ushort)(idx - 1);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var c = candidates[i];
|
||||
|
||||
if (available.Contains(c))
|
||||
return true;
|
||||
|
||||
if (allowNeighborTolerance)
|
||||
{
|
||||
if (c > 0 && available.Contains((ushort)(c - 1)))
|
||||
return true;
|
||||
|
||||
if (c < ushort.MaxValue && available.Contains((ushort)(c + 1)))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool IsPapCompatible(
|
||||
IReadOnlyDictionary<string, HashSet<ushort>> localBoneSets,
|
||||
IReadOnlyDictionary<string, List<ushort>> papBoneIndices,
|
||||
AnimationValidationMode mode,
|
||||
bool allowOneBasedShift,
|
||||
bool allowNeighborTolerance,
|
||||
out string reason)
|
||||
{
|
||||
reason = string.Empty;
|
||||
|
||||
if (mode == AnimationValidationMode.Unsafe)
|
||||
return true;
|
||||
|
||||
var papByBucket = new Dictionary<string, List<ushort>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
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";
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool AllIndicesOk(
|
||||
HashSet<ushort> available,
|
||||
List<ushort> indices,
|
||||
bool papLikelyOneBased,
|
||||
bool allowOneBasedShift,
|
||||
bool allowNeighborTolerance,
|
||||
out ushort missing)
|
||||
{
|
||||
foreach (var idx in indices)
|
||||
{
|
||||
if (!ContainsIndexCompat(available, idx, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance))
|
||||
{
|
||||
missing = idx;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
missing = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var (bucket, indices) in papByBucket)
|
||||
{
|
||||
if (indices.Count == 0)
|
||||
continue;
|
||||
|
||||
bool has0 = false, has1 = false;
|
||||
ushort min = ushort.MaxValue;
|
||||
foreach (var v in indices)
|
||||
{
|
||||
if (v == 0) has0 = true;
|
||||
if (v == 1) has1 = true;
|
||||
if (v < min) min = v;
|
||||
}
|
||||
bool papLikelyOneBased = allowOneBasedShift && (min == 1) && has1 && !has0;
|
||||
|
||||
if (string.Equals(bucket, "__any__", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var (lk, ls) in localBoneSets)
|
||||
{
|
||||
if (AllIndicesOk(ls, indices, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance, out _))
|
||||
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;
|
||||
}
|
||||
|
||||
public void DumpLocalSkeletonIndices(GameObjectHandler handler, string? filter = null)
|
||||
{
|
||||
var skels = GetSkeletonBoneIndices(handler);
|
||||
if (skels == null)
|
||||
{
|
||||
_logger.LogTrace("DumpLocalSkeletonIndices: local skeleton indices are null or not found");
|
||||
return;
|
||||
}
|
||||
|
||||
var keys = skels.Keys
|
||||
.Order(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
_logger.LogTrace("Local skeleton indices found ({count}): {keys}",
|
||||
keys.Length,
|
||||
string.Join(", ", keys));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
var hits = keys.Where(k =>
|
||||
k.Equals(filter, StringComparison.OrdinalIgnoreCase) ||
|
||||
k.StartsWith(filter + "_", StringComparison.OrdinalIgnoreCase) ||
|
||||
filter.StartsWith(k + "_", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains(filter, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
_logger.LogTrace("Matches found for '{filter}': {hits}",
|
||||
filter,
|
||||
hits.Length == 0 ? "<none>" : string.Join(", ", hits));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<long> GetTrianglesByHash(string hash)
|
||||
{
|
||||
if (_configService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris) && cachedTris > 0)
|
||||
@@ -162,16 +477,55 @@ public sealed class XivDataAnalyzer
|
||||
if (path == null || !path.ResolvedFilepath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase))
|
||||
return 0;
|
||||
|
||||
var filePath = path.ResolvedFilepath;
|
||||
return CalculateTrianglesFromPath(hash, path.ResolvedFilepath, _configService.Current.TriangleDictionary, _failedCalculatedTris);
|
||||
}
|
||||
|
||||
public long RefreshTrianglesForPath(string hash, string filePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filePath)
|
||||
|| !filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)
|
||||
|| !File.Exists(filePath))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
_failedCalculatedTris.RemoveAll(entry => entry.Equals(hash, StringComparison.Ordinal));
|
||||
_configService.Current.TriangleDictionary.TryRemove(hash, out _);
|
||||
return CalculateTrianglesFromPath(hash, filePath, _configService.Current.TriangleDictionary, _failedCalculatedTris);
|
||||
}
|
||||
|
||||
public async Task<long> GetEffectiveTrianglesByHash(string hash, string filePath)
|
||||
{
|
||||
if (_configService.Current.EffectiveTriangleDictionary.TryGetValue(hash, out var cachedTris) && cachedTris > 0)
|
||||
return cachedTris;
|
||||
|
||||
if (_failedCalculatedEffectiveTris.Contains(hash, StringComparer.Ordinal))
|
||||
return 0;
|
||||
|
||||
if (string.IsNullOrEmpty(filePath)
|
||||
|| !filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)
|
||||
|| !File.Exists(filePath))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return CalculateTrianglesFromPath(hash, filePath, _configService.Current.EffectiveTriangleDictionary, _failedCalculatedEffectiveTris);
|
||||
}
|
||||
|
||||
private long CalculateTrianglesFromPath(
|
||||
string hash,
|
||||
string filePath,
|
||||
ConcurrentDictionary<string, long> cache,
|
||||
List<string> failedList)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Detected Model File {path}, calculating Tris", filePath);
|
||||
var file = new MdlFile(filePath);
|
||||
if (file.LodCount <= 0)
|
||||
{
|
||||
_failedCalculatedTris.Add(hash);
|
||||
_configService.Current.TriangleDictionary[hash] = 0;
|
||||
failedList.Add(hash);
|
||||
cache[hash] = 0;
|
||||
_configService.Save();
|
||||
return 0;
|
||||
}
|
||||
@@ -195,7 +549,7 @@ public sealed class XivDataAnalyzer
|
||||
if (tris > 0)
|
||||
{
|
||||
_logger.LogDebug("TriAnalysis: {filePath} => {tris} triangles", filePath, tris);
|
||||
_configService.Current.TriangleDictionary[hash] = tris;
|
||||
cache[hash] = tris;
|
||||
_configService.Save();
|
||||
break;
|
||||
}
|
||||
@@ -205,11 +559,30 @@ public sealed class XivDataAnalyzer
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_failedCalculatedTris.Add(hash);
|
||||
_configService.Current.TriangleDictionary[hash] = 0;
|
||||
failedList.Add(hash);
|
||||
cache[hash] = 0;
|
||||
_configService.Save();
|
||||
_logger.LogWarning(e, "Could not parse file {file}", filePath);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Regexes for canonicalizing skeleton keys
|
||||
private static readonly Regex _bucketPathRegex =
|
||||
BucketRegex();
|
||||
|
||||
private static readonly Regex _bucketSklRegex =
|
||||
SklRegex();
|
||||
|
||||
private static readonly Regex _bucketLooseRegex =
|
||||
LooseBucketRegex();
|
||||
|
||||
[GeneratedRegex(@"(?i)(?:^|/)(?<bucket>c\d{4})(?:/|$)", RegexOptions.Compiled, "en-NL")]
|
||||
private static partial Regex BucketRegex();
|
||||
|
||||
[GeneratedRegex(@"(?i)\bskl_(?<bucket>c\d{4})[a-z]\d{4}\b", RegexOptions.Compiled, "en-NL")]
|
||||
private static partial Regex SklRegex();
|
||||
|
||||
[GeneratedRegex(@"(?i)(?<![a-z0-9])(?<bucket>c\d{4})(?!\d)", RegexOptions.Compiled, "en-NL")]
|
||||
private static partial Regex LooseBucketRegex();
|
||||
}
|
||||
|
||||
1325
LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/Decimate.cs
vendored
Normal file
1325
LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/Decimate.cs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
88
LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/EdgeCollapse.cs
vendored
Normal file
88
LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/EdgeCollapse.cs
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
|
||||
namespace Nanomesh
|
||||
{
|
||||
public partial class DecimateModifier
|
||||
{
|
||||
public class EdgeCollapse : IComparable<EdgeCollapse>, IEquatable<EdgeCollapse>
|
||||
{
|
||||
public int posA;
|
||||
public int posB;
|
||||
public Vector3 result;
|
||||
public double error;
|
||||
|
||||
private double _weight = -1;
|
||||
|
||||
public ref double Weight => ref _weight;
|
||||
|
||||
public void SetWeight(double weight)
|
||||
{
|
||||
_weight = weight;
|
||||
}
|
||||
|
||||
public EdgeCollapse(int posA, int posB)
|
||||
{
|
||||
this.posA = posA;
|
||||
this.posB = posB;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
return posA + posB;
|
||||
}
|
||||
}
|
||||
|
||||
public override bool Equals(object obj)
|
||||
{
|
||||
return Equals((EdgeCollapse)obj);
|
||||
}
|
||||
|
||||
public bool Equals(EdgeCollapse pc)
|
||||
{
|
||||
if (ReferenceEquals(pc, null))
|
||||
return false;
|
||||
|
||||
if (ReferenceEquals(this, pc))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return (posA == pc.posA && posB == pc.posB) || (posA == pc.posB && posB == pc.posA);
|
||||
}
|
||||
}
|
||||
|
||||
public int CompareTo(EdgeCollapse other)
|
||||
{
|
||||
return error > other.error ? 1 : error < other.error ? -1 : 0;
|
||||
}
|
||||
|
||||
public static bool operator >(EdgeCollapse x, EdgeCollapse y)
|
||||
{
|
||||
return x.error > y.error;
|
||||
}
|
||||
|
||||
public static bool operator >=(EdgeCollapse x, EdgeCollapse y)
|
||||
{
|
||||
return x.error >= y.error;
|
||||
}
|
||||
|
||||
public static bool operator <(EdgeCollapse x, EdgeCollapse y)
|
||||
{
|
||||
return x.error < y.error;
|
||||
}
|
||||
|
||||
public static bool operator <=(EdgeCollapse x, EdgeCollapse y)
|
||||
{
|
||||
return x.error <= y.error;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"<A:{posA} B:{posB} error:{error} topology:{_weight}>";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/EdgeComparer.cs
vendored
Normal file
15
LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/EdgeComparer.cs
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Nanomesh
|
||||
{
|
||||
public partial class DecimateModifier
|
||||
{
|
||||
private class EdgeComparer : IComparer<EdgeCollapse>
|
||||
{
|
||||
public int Compare(EdgeCollapse x, EdgeCollapse y)
|
||||
{
|
||||
return x.CompareTo(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/SceneDecimator.cs
vendored
Normal file
72
LightlessSync/ThirdParty/Nanomesh/Algo/Decimation/SceneDecimator.cs
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Nanomesh
|
||||
{
|
||||
public class SceneDecimator
|
||||
{
|
||||
private class ModifierAndOccurrences
|
||||
{
|
||||
public int occurrences = 1;
|
||||
public DecimateModifier modifier = new DecimateModifier();
|
||||
}
|
||||
|
||||
private Dictionary<ConnectedMesh, ModifierAndOccurrences> _modifiers;
|
||||
|
||||
public void Initialize(IEnumerable<ConnectedMesh> meshes)
|
||||
{
|
||||
_modifiers = new Dictionary<ConnectedMesh, ModifierAndOccurrences>();
|
||||
|
||||
foreach (ConnectedMesh mesh in meshes)
|
||||
{
|
||||
ModifierAndOccurrences modifier;
|
||||
if (_modifiers.ContainsKey(mesh))
|
||||
{
|
||||
modifier = _modifiers[mesh];
|
||||
modifier.occurrences++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_modifiers.Add(mesh, modifier = new ModifierAndOccurrences());
|
||||
//System.Console.WriteLine($"Faces:{mesh.FaceCount}");
|
||||
modifier.modifier.Initialize(mesh);
|
||||
}
|
||||
|
||||
_faceCount += mesh.FaceCount;
|
||||
}
|
||||
|
||||
_initalFaceCount = _faceCount;
|
||||
}
|
||||
|
||||
private int _faceCount;
|
||||
private int _initalFaceCount;
|
||||
|
||||
public void DecimateToRatio(float targetTriangleRatio)
|
||||
{
|
||||
targetTriangleRatio = MathF.Clamp(targetTriangleRatio, 0f, 1f);
|
||||
DecimateToPolycount((int)MathF.Round(targetTriangleRatio * _initalFaceCount));
|
||||
}
|
||||
|
||||
public void DecimatePolycount(int polycount)
|
||||
{
|
||||
DecimateToPolycount((int)MathF.Round(_initalFaceCount - polycount));
|
||||
}
|
||||
|
||||
public void DecimateToPolycount(int targetTriangleCount)
|
||||
{
|
||||
//System.Console.WriteLine($"Faces:{_faceCount} Target:{targetTriangleCount}");
|
||||
while (_faceCount > targetTriangleCount)
|
||||
{
|
||||
KeyValuePair<ConnectedMesh, ModifierAndOccurrences> pair = _modifiers.OrderBy(x => x.Value.modifier.GetMinimumError()).First();
|
||||
|
||||
int facesBefore = pair.Key.FaceCount;
|
||||
pair.Value.modifier.Iterate();
|
||||
|
||||
if (facesBefore == pair.Key.FaceCount)
|
||||
break; // Exit !
|
||||
|
||||
_faceCount -= (facesBefore - pair.Key.FaceCount) * pair.Value.occurrences;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
LightlessSync/ThirdParty/Nanomesh/Algo/NormalsCreator.cs
vendored
Normal file
76
LightlessSync/ThirdParty/Nanomesh/Algo/NormalsCreator.cs
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Nanomesh
|
||||
{
|
||||
public class NormalsModifier
|
||||
{
|
||||
public struct PosAndAttribute : IEquatable<PosAndAttribute>
|
||||
{
|
||||
public int position;
|
||||
public Attribute attribute;
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return position.GetHashCode() ^ (attribute.GetHashCode() << 2);
|
||||
}
|
||||
|
||||
public bool Equals(PosAndAttribute other)
|
||||
{
|
||||
return position == other.position && attribute.Equals(other.attribute);
|
||||
}
|
||||
}
|
||||
|
||||
public void Run(ConnectedMesh mesh, float smoothingAngle)
|
||||
{
|
||||
float cosineThreshold = MathF.Cos(smoothingAngle * MathF.PI / 180f);
|
||||
|
||||
int[] positionToNode = mesh.GetPositionToNode();
|
||||
|
||||
Dictionary<PosAndAttribute, int> attributeToIndex = new Dictionary<PosAndAttribute, int>();
|
||||
|
||||
for (int p = 0; p < positionToNode.Length; p++)
|
||||
{
|
||||
int nodeIndex = positionToNode[p];
|
||||
if (nodeIndex < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Debug.Assert(!mesh.nodes[nodeIndex].IsRemoved);
|
||||
|
||||
int sibling1 = nodeIndex;
|
||||
do
|
||||
{
|
||||
Vector3F sum = Vector3F.Zero;
|
||||
|
||||
Vector3F normal1 = mesh.GetFaceNormal(sibling1);
|
||||
|
||||
int sibling2 = nodeIndex;
|
||||
do
|
||||
{
|
||||
Vector3F normal2 = mesh.GetFaceNormal(sibling2);
|
||||
|
||||
float dot = Vector3F.Dot(normal1, normal2);
|
||||
|
||||
if (dot >= cosineThreshold)
|
||||
{
|
||||
// Area and angle weighting (it gives better results)
|
||||
sum += mesh.GetFaceArea(sibling2) * mesh.GetAngleRadians(sibling2) * normal2;
|
||||
}
|
||||
|
||||
} while ((sibling2 = mesh.nodes[sibling2].sibling) != nodeIndex);
|
||||
|
||||
sum = sum.Normalized;
|
||||
|
||||
|
||||
} while ((sibling1 = mesh.nodes[sibling1].sibling) != nodeIndex);
|
||||
}
|
||||
|
||||
// Assign new attributes
|
||||
|
||||
// TODO : Fix
|
||||
}
|
||||
}
|
||||
}
|
||||
17
LightlessSync/ThirdParty/Nanomesh/Algo/NormalsFixer.cs
vendored
Normal file
17
LightlessSync/ThirdParty/Nanomesh/Algo/NormalsFixer.cs
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Nanomesh
|
||||
{
|
||||
public class NormalsFixer
|
||||
{
|
||||
public void Start(ConnectedMesh mesh)
|
||||
{
|
||||
/*
|
||||
for (int i = 0; i < mesh.attributes.Length; i++)
|
||||
{
|
||||
Attribute attribute = mesh.attributes[i];
|
||||
attribute.normal = attribute.normal.Normalized;
|
||||
mesh.attributes[i] = attribute;
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
27
LightlessSync/ThirdParty/Nanomesh/Algo/Triangulate.cs
vendored
Normal file
27
LightlessSync/ThirdParty/Nanomesh/Algo/Triangulate.cs
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
|
||||
namespace Nanomesh
|
||||
{
|
||||
public class TriangulateModifier
|
||||
{
|
||||
public void Run(ConnectedMesh mesh)
|
||||
{
|
||||
for (int i = 0; i < mesh.nodes.Length; i++)
|
||||
{
|
||||
int edgeCount = 0;
|
||||
int relative = i;
|
||||
while ((relative = mesh.nodes[relative].relative) != i) // Circulate around face
|
||||
{
|
||||
edgeCount++;
|
||||
}
|
||||
|
||||
if (edgeCount > 2)
|
||||
{
|
||||
throw new Exception("Mesh has polygons of dimension 4 or greater");
|
||||
}
|
||||
}
|
||||
|
||||
// Todo : Implement
|
||||
}
|
||||
}
|
||||
}
|
||||
144
LightlessSync/ThirdParty/Nanomesh/Base/BoneWeight.cs
vendored
Normal file
144
LightlessSync/ThirdParty/Nanomesh/Base/BoneWeight.cs
vendored
Normal file
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Nanomesh
|
||||
{
|
||||
public readonly struct BoneWeight : IEquatable<BoneWeight>, IInterpolable<BoneWeight>
|
||||
{
|
||||
public readonly int index0;
|
||||
public readonly int index1;
|
||||
public readonly int index2;
|
||||
public readonly int index3;
|
||||
public readonly float weight0;
|
||||
public readonly float weight1;
|
||||
public readonly float weight2;
|
||||
public readonly float weight3;
|
||||
|
||||
public int GetIndex(int i)
|
||||
{
|
||||
switch (i)
|
||||
{
|
||||
case 0: return index0;
|
||||
case 1: return index1;
|
||||
case 2: return index2;
|
||||
case 3: return index3;
|
||||
default: return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public float GetWeight(int i)
|
||||
{
|
||||
switch (i)
|
||||
{
|
||||
case 0: return weight0;
|
||||
case 1: return weight1;
|
||||
case 2: return weight2;
|
||||
case 3: return weight3;
|
||||
default: return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public BoneWeight(int index0, int index1, int index2, int index3, float weight0, float weight1, float weight2, float weight3)
|
||||
{
|
||||
this.index0 = index0;
|
||||
this.index1 = index1;
|
||||
this.index2 = index2;
|
||||
this.index3 = index3;
|
||||
this.weight0 = weight0;
|
||||
this.weight1 = weight1;
|
||||
this.weight2 = weight2;
|
||||
this.weight3 = weight3;
|
||||
}
|
||||
|
||||
public bool Equals(BoneWeight other)
|
||||
{
|
||||
return index0 == other.index0
|
||||
&& index1 == other.index1
|
||||
&& index2 == other.index2
|
||||
&& index3 == other.index3
|
||||
&& weight0 == other.weight0
|
||||
&& weight1 == other.weight1
|
||||
&& weight2 == other.weight2
|
||||
&& weight3 == other.weight3;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
int hash = 17;
|
||||
hash = hash * 31 + index0;
|
||||
hash = hash * 31 + index1;
|
||||
hash = hash * 31 + index2;
|
||||
hash = hash * 31 + index3;
|
||||
hash = hash * 31 + weight0.GetHashCode();
|
||||
hash = hash * 31 + weight1.GetHashCode();
|
||||
hash = hash * 31 + weight2.GetHashCode();
|
||||
hash = hash * 31 + weight3.GetHashCode();
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
public unsafe BoneWeight Interpolate(BoneWeight other, double ratio)
|
||||
{
|
||||
BoneWeight boneWeightA = this;
|
||||
BoneWeight boneWeightB = other;
|
||||
|
||||
Dictionary<int, float> newBoneWeight = new Dictionary<int, float>();
|
||||
|
||||
// Map weights and indices
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
newBoneWeight.TryAdd(boneWeightA.GetIndex(i), 0);
|
||||
newBoneWeight.TryAdd(boneWeightB.GetIndex(i), 0);
|
||||
newBoneWeight[boneWeightA.GetIndex(i)] += (float)((1 - ratio) * boneWeightA.GetWeight(i));
|
||||
newBoneWeight[boneWeightB.GetIndex(i)] += (float)(ratio * boneWeightB.GetWeight(i));
|
||||
}
|
||||
|
||||
int* newIndices = stackalloc int[4];
|
||||
float* newWeights = stackalloc float[4];
|
||||
|
||||
// Order from biggest to smallest weight, and drop bones above 4th
|
||||
float totalWeight = 0;
|
||||
int k = 0;
|
||||
foreach (KeyValuePair<int, float> boneWeightN in newBoneWeight.OrderByDescending(x => x.Value))
|
||||
{
|
||||
newIndices[k] = boneWeightN.Key;
|
||||
newWeights[k] = boneWeightN.Value;
|
||||
totalWeight += boneWeightN.Value;
|
||||
if (k == 3)
|
||||
break;
|
||||
k++;
|
||||
}
|
||||
|
||||
var sumA = boneWeightA.weight0 + boneWeightA.weight1 + boneWeightA.weight2 + boneWeightA.weight3;
|
||||
var sumB = boneWeightB.weight0 + boneWeightB.weight1 + boneWeightB.weight2 + boneWeightB.weight3;
|
||||
var targetSum = (float)((1d - ratio) * sumA + ratio * sumB);
|
||||
|
||||
// Normalize and re-scale to preserve original weight sum.
|
||||
if (totalWeight > 0f)
|
||||
{
|
||||
var scale = targetSum / totalWeight;
|
||||
for (int j = 0; j < 4; j++)
|
||||
{
|
||||
newWeights[j] *= scale;
|
||||
}
|
||||
}
|
||||
|
||||
return new BoneWeight(
|
||||
newIndices[0], newIndices[1], newIndices[2], newIndices[3],
|
||||
newWeights[0], newWeights[1], newWeights[2], newWeights[3]);
|
||||
|
||||
//return new BoneWeight(
|
||||
// ratio < 0.5f ? index0 : other.index0,
|
||||
// ratio < 0.5f ? index1 : other.index1,
|
||||
// ratio < 0.5f ? index2 : other.index2,
|
||||
// ratio < 0.5f ? index3 : other.index3,
|
||||
// (float)(ratio * weight0 + (1 - ratio) * other.weight0),
|
||||
// (float)(ratio * weight1 + (1 - ratio) * other.weight1),
|
||||
// (float)(ratio * weight2 + (1 - ratio) * other.weight2),
|
||||
// (float)(ratio * weight3 + (1 - ratio) * other.weight3));
|
||||
}
|
||||
}
|
||||
}
|
||||
110
LightlessSync/ThirdParty/Nanomesh/Base/Color32.cs
vendored
Normal file
110
LightlessSync/ThirdParty/Nanomesh/Base/Color32.cs
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Nanomesh
|
||||
{
|
||||
[StructLayout(LayoutKind.Explicit)]
|
||||
public readonly struct Color32 : IEquatable<Color32>, IInterpolable<Color32>
|
||||
{
|
||||
[FieldOffset(0)]
|
||||
internal readonly int rgba;
|
||||
|
||||
[FieldOffset(0)]
|
||||
public readonly byte r;
|
||||
|
||||
[FieldOffset(1)]
|
||||
public readonly byte g;
|
||||
|
||||
[FieldOffset(2)]
|
||||
public readonly byte b;
|
||||
|
||||
[FieldOffset(3)]
|
||||
public readonly byte a;
|
||||
|
||||
public Color32(byte r, byte g, byte b, byte a)
|
||||
{
|
||||
rgba = 0;
|
||||
this.r = r;
|
||||
this.g = g;
|
||||
this.b = b;
|
||||
this.a = a;
|
||||
}
|
||||
|
||||
public Color32(float r, float g, float b, float a)
|
||||
{
|
||||
rgba = 0;
|
||||
this.r = (byte)MathF.Round(r);
|
||||
this.g = (byte)MathF.Round(g);
|
||||
this.b = (byte)MathF.Round(b);
|
||||
this.a = (byte)MathF.Round(a);
|
||||
}
|
||||
|
||||
public Color32(double r, double g, double b, double a)
|
||||
{
|
||||
rgba = 0;
|
||||
this.r = (byte)Math.Round(r);
|
||||
this.g = (byte)Math.Round(g);
|
||||
this.b = (byte)Math.Round(b);
|
||||
this.a = (byte)Math.Round(a);
|
||||
}
|
||||
|
||||
public bool Equals(Color32 other)
|
||||
{
|
||||
return other.rgba == rgba;
|
||||
}
|
||||
|
||||
public Color32 Interpolate(Color32 other, double ratio)
|
||||
{
|
||||
return ratio * this + (1 - ratio) * other;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds two colors.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static Color32 operator +(Color32 a, Color32 b) { return new Color32(a.r + b.r, a.g + b.g, a.b + b.b, a.a + b.a); }
|
||||
|
||||
/// <summary>
|
||||
/// Subtracts one color from another.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static Color32 operator -(Color32 a, Color32 b) { return new Color32(1f * a.r - b.r, a.g - b.g, a.b - b.b, a.a - b.a); }
|
||||
|
||||
/// <summary>
|
||||
/// Multiplies one color by another.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static Color32 operator *(Color32 a, Color32 b) { return new Color32(1f * a.r * b.r, 1f * a.g * b.g, 1f * a.b * b.b, 1f * a.a * b.a); }
|
||||
|
||||
/// <summary>
|
||||
/// Divides one color over another.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static Color32 operator /(Color32 a, Color32 b) { return new Color32(1f * a.r / b.r, 1f * a.g / b.g, 1f * a.b / b.b, 1f * a.a / b.a); }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Multiplies a color by a number.
|
||||
/// </summary>
|
||||
/// <param name="a"></param>
|
||||
/// <param name="d"></param>
|
||||
/// <returns></returns>
|
||||
public static Color32 operator *(Color32 a, float d) { return new Color32(d * a.r, d * a.g, d * a.b, d * a.a); }
|
||||
|
||||
public static Color32 operator *(Color32 a, double d) { return new Color32(d * a.r, d * a.g, d * a.b, d * a.a); }
|
||||
|
||||
/// <summary>
|
||||
/// Multiplies a color by a number.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static Color32 operator *(float d, Color32 a) { return new Color32(d * a.r, d * a.g, d * a.b, d * a.a); }
|
||||
|
||||
public static Color32 operator *(double d, Color32 a) { return new Color32(d * a.r, d * a.g, d * a.b, d * a.a); }
|
||||
|
||||
/// <summary>
|
||||
/// Divides a color by a number.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public static Color32 operator /(Color32 a, float d) { return new Color32(1f * a.r / d, 1f * a.g / d, 1f * a.b / d, 1f * a.a / d); }
|
||||
}
|
||||
}
|
||||
347
LightlessSync/ThirdParty/Nanomesh/Base/FfxivVertexAttribute.cs
vendored
Normal file
347
LightlessSync/ThirdParty/Nanomesh/Base/FfxivVertexAttribute.cs
vendored
Normal file
@@ -0,0 +1,347 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Nanomesh
|
||||
{
|
||||
[Flags]
|
||||
public enum FfxivAttributeFlags : uint
|
||||
{
|
||||
None = 0,
|
||||
Normal = 1u << 0,
|
||||
Tangent1 = 1u << 1,
|
||||
Tangent2 = 1u << 2,
|
||||
Color = 1u << 3,
|
||||
BoneWeights = 1u << 4,
|
||||
PositionW = 1u << 5,
|
||||
NormalW = 1u << 6,
|
||||
Uv0 = 1u << 7,
|
||||
Uv1 = 1u << 8,
|
||||
Uv2 = 1u << 9,
|
||||
Uv3 = 1u << 10,
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public readonly struct FfxivVertexAttribute : IEquatable<FfxivVertexAttribute>, IInterpolable<FfxivVertexAttribute>
|
||||
{
|
||||
public readonly Vector3F normal;
|
||||
public readonly Vector4F tangent1;
|
||||
public readonly Vector4F tangent2;
|
||||
public readonly Vector2F uv0;
|
||||
public readonly Vector2F uv1;
|
||||
public readonly Vector2F uv2;
|
||||
public readonly Vector2F uv3;
|
||||
public readonly Vector4F color;
|
||||
public readonly BoneWeight boneWeight;
|
||||
public readonly float positionW;
|
||||
public readonly float normalW;
|
||||
public readonly FfxivAttributeFlags flags;
|
||||
|
||||
public FfxivVertexAttribute(
|
||||
FfxivAttributeFlags flags,
|
||||
Vector3F normal,
|
||||
Vector4F tangent1,
|
||||
Vector4F tangent2,
|
||||
Vector2F uv0,
|
||||
Vector2F uv1,
|
||||
Vector2F uv2,
|
||||
Vector2F uv3,
|
||||
Vector4F color,
|
||||
BoneWeight boneWeight,
|
||||
float positionW,
|
||||
float normalW)
|
||||
{
|
||||
this.flags = flags;
|
||||
this.normal = normal;
|
||||
this.tangent1 = tangent1;
|
||||
this.tangent2 = tangent2;
|
||||
this.uv0 = uv0;
|
||||
this.uv1 = uv1;
|
||||
this.uv2 = uv2;
|
||||
this.uv3 = uv3;
|
||||
this.color = color;
|
||||
this.boneWeight = boneWeight;
|
||||
this.positionW = positionW;
|
||||
this.normalW = normalW;
|
||||
}
|
||||
|
||||
public FfxivVertexAttribute Interpolate(FfxivVertexAttribute other, double ratio)
|
||||
{
|
||||
var t = (float)ratio;
|
||||
var inv = 1f - t;
|
||||
var combinedFlags = flags | other.flags;
|
||||
|
||||
var normal = (combinedFlags & FfxivAttributeFlags.Normal) != 0
|
||||
? NormalizeVector3(new Vector3F(
|
||||
(this.normal.x * inv) + (other.normal.x * t),
|
||||
(this.normal.y * inv) + (other.normal.y * t),
|
||||
(this.normal.z * inv) + (other.normal.z * t)))
|
||||
: default;
|
||||
|
||||
var tangent1 = (combinedFlags & FfxivAttributeFlags.Tangent1) != 0
|
||||
? BlendTangent(this.tangent1, other.tangent1, t)
|
||||
: default;
|
||||
|
||||
var tangent2 = (combinedFlags & FfxivAttributeFlags.Tangent2) != 0
|
||||
? BlendTangent(this.tangent2, other.tangent2, t)
|
||||
: default;
|
||||
|
||||
var uv0 = (combinedFlags & FfxivAttributeFlags.Uv0) != 0
|
||||
? Vector2F.LerpUnclamped(this.uv0, other.uv0, t)
|
||||
: default;
|
||||
|
||||
var uv1 = (combinedFlags & FfxivAttributeFlags.Uv1) != 0
|
||||
? Vector2F.LerpUnclamped(this.uv1, other.uv1, t)
|
||||
: default;
|
||||
|
||||
var uv2 = (combinedFlags & FfxivAttributeFlags.Uv2) != 0
|
||||
? Vector2F.LerpUnclamped(this.uv2, other.uv2, t)
|
||||
: default;
|
||||
|
||||
var uv3 = (combinedFlags & FfxivAttributeFlags.Uv3) != 0
|
||||
? Vector2F.LerpUnclamped(this.uv3, other.uv3, t)
|
||||
: default;
|
||||
|
||||
var color = (combinedFlags & FfxivAttributeFlags.Color) != 0
|
||||
? new Vector4F(
|
||||
(this.color.x * inv) + (other.color.x * t),
|
||||
(this.color.y * inv) + (other.color.y * t),
|
||||
(this.color.z * inv) + (other.color.z * t),
|
||||
(this.color.w * inv) + (other.color.w * t))
|
||||
: default;
|
||||
|
||||
var boneWeight = (combinedFlags & FfxivAttributeFlags.BoneWeights) != 0
|
||||
? BlendBoneWeights(this.boneWeight, other.boneWeight, t)
|
||||
: default;
|
||||
|
||||
var positionW = (combinedFlags & FfxivAttributeFlags.PositionW) != 0
|
||||
? (this.positionW * inv) + (other.positionW * t)
|
||||
: 0f;
|
||||
|
||||
var normalW = (combinedFlags & FfxivAttributeFlags.NormalW) != 0
|
||||
? (this.normalW * inv) + (other.normalW * t)
|
||||
: 0f;
|
||||
|
||||
return new FfxivVertexAttribute(
|
||||
combinedFlags,
|
||||
normal,
|
||||
tangent1,
|
||||
tangent2,
|
||||
uv0,
|
||||
uv1,
|
||||
uv2,
|
||||
uv3,
|
||||
color,
|
||||
boneWeight,
|
||||
positionW,
|
||||
normalW);
|
||||
}
|
||||
|
||||
public bool Equals(FfxivVertexAttribute other)
|
||||
{
|
||||
if (flags != other.flags)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((flags & FfxivAttributeFlags.Normal) != 0 && !normal.Equals(other.normal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((flags & FfxivAttributeFlags.Tangent1) != 0 && !tangent1.Equals(other.tangent1))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((flags & FfxivAttributeFlags.Tangent2) != 0 && !tangent2.Equals(other.tangent2))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((flags & FfxivAttributeFlags.Uv0) != 0 && !uv0.Equals(other.uv0))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((flags & FfxivAttributeFlags.Uv1) != 0 && !uv1.Equals(other.uv1))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((flags & FfxivAttributeFlags.Uv2) != 0 && !uv2.Equals(other.uv2))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((flags & FfxivAttributeFlags.Uv3) != 0 && !uv3.Equals(other.uv3))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((flags & FfxivAttributeFlags.Color) != 0 && !color.Equals(other.color))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((flags & FfxivAttributeFlags.BoneWeights) != 0 && !boneWeight.Equals(other.boneWeight))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((flags & FfxivAttributeFlags.PositionW) != 0 && positionW != other.positionW)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((flags & FfxivAttributeFlags.NormalW) != 0 && normalW != other.normalW)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
=> obj is FfxivVertexAttribute other && Equals(other);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = new HashCode();
|
||||
hash.Add(normal);
|
||||
hash.Add(tangent1);
|
||||
hash.Add(tangent2);
|
||||
hash.Add(uv0);
|
||||
hash.Add(uv1);
|
||||
hash.Add(uv2);
|
||||
hash.Add(uv3);
|
||||
hash.Add(color);
|
||||
hash.Add(boneWeight);
|
||||
hash.Add(positionW);
|
||||
hash.Add(normalW);
|
||||
hash.Add(flags);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
|
||||
private static Vector3F NormalizeVector3(in Vector3F value)
|
||||
{
|
||||
var length = Vector3F.Magnitude(value);
|
||||
return length > 0f ? value / length : value;
|
||||
}
|
||||
|
||||
private static Vector4F BlendTangent(in Vector4F a, in Vector4F b, float t)
|
||||
{
|
||||
var inv = 1f - t;
|
||||
var blended = new Vector3F(
|
||||
(a.x * inv) + (b.x * t),
|
||||
(a.y * inv) + (b.y * t),
|
||||
(a.z * inv) + (b.z * t));
|
||||
blended = NormalizeVector3(blended);
|
||||
|
||||
var w = t >= 0.5f ? b.w : a.w;
|
||||
if (w != 0f)
|
||||
{
|
||||
w = w >= 0f ? 1f : -1f;
|
||||
}
|
||||
|
||||
return new Vector4F(blended.x, blended.y, blended.z, w);
|
||||
}
|
||||
|
||||
private static BoneWeight BlendBoneWeights(in BoneWeight a, in BoneWeight b, float ratio)
|
||||
{
|
||||
Span<int> indices = stackalloc int[8];
|
||||
Span<float> weights = stackalloc float[8];
|
||||
var count = 0;
|
||||
|
||||
static void AddWeight(Span<int> indices, Span<float> weights, ref int count, int index, float weight)
|
||||
{
|
||||
if (weight <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
if (indices[i] == index)
|
||||
{
|
||||
weights[i] += weight;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (count < indices.Length)
|
||||
{
|
||||
indices[count] = index;
|
||||
weights[count] = weight;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
var inv = 1f - ratio;
|
||||
var sumA = a.weight0 + a.weight1 + a.weight2 + a.weight3;
|
||||
var sumB = b.weight0 + b.weight1 + b.weight2 + b.weight3;
|
||||
var targetSum = (sumA * inv) + (sumB * ratio);
|
||||
AddWeight(indices, weights, ref count, a.index0, a.weight0 * inv);
|
||||
AddWeight(indices, weights, ref count, a.index1, a.weight1 * inv);
|
||||
AddWeight(indices, weights, ref count, a.index2, a.weight2 * inv);
|
||||
AddWeight(indices, weights, ref count, a.index3, a.weight3 * inv);
|
||||
AddWeight(indices, weights, ref count, b.index0, b.weight0 * ratio);
|
||||
AddWeight(indices, weights, ref count, b.index1, b.weight1 * ratio);
|
||||
AddWeight(indices, weights, ref count, b.index2, b.weight2 * ratio);
|
||||
AddWeight(indices, weights, ref count, b.index3, b.weight3 * ratio);
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
return a;
|
||||
}
|
||||
|
||||
Span<int> topIndices = stackalloc int[4];
|
||||
Span<float> topWeights = stackalloc float[4];
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
topIndices[i] = -1;
|
||||
topWeights[i] = 0f;
|
||||
}
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var weight = weights[i];
|
||||
var index = indices[i];
|
||||
for (var slot = 0; slot < 4; slot++)
|
||||
{
|
||||
if (weight > topWeights[slot])
|
||||
{
|
||||
for (var shift = 3; shift > slot; shift--)
|
||||
{
|
||||
topWeights[shift] = topWeights[shift - 1];
|
||||
topIndices[shift] = topIndices[shift - 1];
|
||||
}
|
||||
|
||||
topWeights[slot] = weight;
|
||||
topIndices[slot] = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sum = topWeights[0] + topWeights[1] + topWeights[2] + topWeights[3];
|
||||
if (sum > 0f)
|
||||
{
|
||||
var scale = targetSum > 0f ? targetSum / sum : 0f;
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
topWeights[i] *= scale;
|
||||
}
|
||||
}
|
||||
|
||||
return new BoneWeight(
|
||||
topIndices[0] < 0 ? 0 : topIndices[0],
|
||||
topIndices[1] < 0 ? 0 : topIndices[1],
|
||||
topIndices[2] < 0 ? 0 : topIndices[2],
|
||||
topIndices[3] < 0 ? 0 : topIndices[3],
|
||||
topWeights[0],
|
||||
topWeights[1],
|
||||
topWeights[2],
|
||||
topWeights[3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
LightlessSync/ThirdParty/Nanomesh/Base/IInterpolable.cs
vendored
Normal file
7
LightlessSync/ThirdParty/Nanomesh/Base/IInterpolable.cs
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Nanomesh
|
||||
{
|
||||
public interface IInterpolable<T>
|
||||
{
|
||||
T Interpolate(T other, double ratio);
|
||||
}
|
||||
}
|
||||
356
LightlessSync/ThirdParty/Nanomesh/Base/MathF.cs
vendored
Normal file
356
LightlessSync/ThirdParty/Nanomesh/Base/MathF.cs
vendored
Normal file
@@ -0,0 +1,356 @@
|
||||
using System;
|
||||
|
||||
namespace Nanomesh
|
||||
{
|
||||
public static partial class MathF
|
||||
{
|
||||
// Returns the sine of angle /f/ in radians.
|
||||
public static float Sin(float f) { return (float)Math.Sin(f); }
|
||||
|
||||
// Returns the cosine of angle /f/ in radians.
|
||||
public static float Cos(float f) { return (float)Math.Cos(f); }
|
||||
|
||||
// Returns the tangent of angle /f/ in radians.
|
||||
public static float Tan(float f) { return (float)Math.Tan(f); }
|
||||
|
||||
// Returns the arc-sine of /f/ - the angle in radians whose sine is /f/.
|
||||
public static float Asin(float f) { return (float)Math.Asin(f); }
|
||||
|
||||
// Returns the arc-cosine of /f/ - the angle in radians whose cosine is /f/.
|
||||
public static float Acos(float f) { return (float)Math.Acos(f); }
|
||||
|
||||
// Returns the arc-tangent of /f/ - the angle in radians whose tangent is /f/.
|
||||
public static float Atan(float f) { return (float)Math.Atan(f); }
|
||||
|
||||
// Returns the angle in radians whose ::ref::Tan is @@y/x@@.
|
||||
public static float Atan2(float y, float x) { return (float)Math.Atan2(y, x); }
|
||||
|
||||
// Returns square root of /f/.
|
||||
public static float Sqrt(float f) { return (float)Math.Sqrt(f); }
|
||||
|
||||
// Returns the absolute value of /f/.
|
||||
public static float Abs(float f) { return (float)Math.Abs(f); }
|
||||
|
||||
// Returns the absolute value of /value/.
|
||||
public static int Abs(int value) { return Math.Abs(value); }
|
||||
|
||||
/// *listonly*
|
||||
public static float Min(float a, float b) { return a < b ? a : b; }
|
||||
// Returns the smallest of two or more values.
|
||||
public static float Min(params float[] values)
|
||||
{
|
||||
int len = values.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
float m = values[0];
|
||||
for (int i = 1; i < len; i++)
|
||||
{
|
||||
if (values[i] < m)
|
||||
{
|
||||
m = values[i];
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/// *listonly*
|
||||
public static int Min(int a, int b) { return a < b ? a : b; }
|
||||
// Returns the smallest of two or more values.
|
||||
public static int Min(params int[] values)
|
||||
{
|
||||
int len = values.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int m = values[0];
|
||||
for (int i = 1; i < len; i++)
|
||||
{
|
||||
if (values[i] < m)
|
||||
{
|
||||
m = values[i];
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/// *listonly*
|
||||
public static float Max(float a, float b) { return a > b ? a : b; }
|
||||
// Returns largest of two or more values.
|
||||
public static float Max(params float[] values)
|
||||
{
|
||||
int len = values.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
float m = values[0];
|
||||
for (int i = 1; i < len; i++)
|
||||
{
|
||||
if (values[i] > m)
|
||||
{
|
||||
m = values[i];
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/// *listonly*
|
||||
public static int Max(int a, int b) { return a > b ? a : b; }
|
||||
// Returns the largest of two or more values.
|
||||
public static int Max(params int[] values)
|
||||
{
|
||||
int len = values.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int m = values[0];
|
||||
for (int i = 1; i < len; i++)
|
||||
{
|
||||
if (values[i] > m)
|
||||
{
|
||||
m = values[i];
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// Returns /f/ raised to power /p/.
|
||||
public static float Pow(float f, float p) { return (float)Math.Pow(f, p); }
|
||||
|
||||
// Returns e raised to the specified power.
|
||||
public static float Exp(float power) { return (float)Math.Exp(power); }
|
||||
|
||||
// Returns the logarithm of a specified number in a specified base.
|
||||
public static float Log(float f, float p) { return (float)Math.Log(f, p); }
|
||||
|
||||
// Returns the natural (base e) logarithm of a specified number.
|
||||
public static float Log(float f) { return (float)Math.Log(f); }
|
||||
|
||||
// Returns the base 10 logarithm of a specified number.
|
||||
public static float Log10(float f) { return (float)Math.Log10(f); }
|
||||
|
||||
// Returns the smallest integer greater to or equal to /f/.
|
||||
public static float Ceil(float f) { return (float)Math.Ceiling(f); }
|
||||
|
||||
// Returns the largest integer smaller to or equal to /f/.
|
||||
public static float Floor(float f) { return (float)Math.Floor(f); }
|
||||
|
||||
// Returns /f/ rounded to the nearest integer.
|
||||
public static float Round(float f) { return (float)Math.Round(f); }
|
||||
|
||||
// Returns the smallest integer greater to or equal to /f/.
|
||||
public static int CeilToInt(float f) { return (int)Math.Ceiling(f); }
|
||||
|
||||
// Returns the largest integer smaller to or equal to /f/.
|
||||
public static int FloorToInt(float f) { return (int)Math.Floor(f); }
|
||||
|
||||
// Returns /f/ rounded to the nearest integer.
|
||||
public static int RoundToInt(float f) { return (int)Math.Round(f); }
|
||||
|
||||
// Returns the sign of /f/.
|
||||
public static float Sign(float f) { return f >= 0F ? 1F : -1F; }
|
||||
|
||||
// The infamous ''3.14159265358979...'' value (RO).
|
||||
public const float PI = (float)Math.PI;
|
||||
|
||||
// A representation of positive infinity (RO).
|
||||
public const float Infinity = float.PositiveInfinity;
|
||||
|
||||
// A representation of negative infinity (RO).
|
||||
public const float NegativeInfinity = float.NegativeInfinity;
|
||||
|
||||
// Degrees-to-radians conversion constant (RO).
|
||||
public const float Deg2Rad = PI * 2F / 360F;
|
||||
|
||||
// Radians-to-degrees conversion constant (RO).
|
||||
public const float Rad2Deg = 1F / Deg2Rad;
|
||||
|
||||
// Clamps a value between a minimum float and maximum float value.
|
||||
public static double Clamp(double value, double min, double max)
|
||||
{
|
||||
if (value < min)
|
||||
{
|
||||
value = min;
|
||||
}
|
||||
else if (value > max)
|
||||
{
|
||||
value = max;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// Clamps a value between a minimum float and maximum float value.
|
||||
public static float Clamp(float value, float min, float max)
|
||||
{
|
||||
if (value < min)
|
||||
{
|
||||
value = min;
|
||||
}
|
||||
else if (value > max)
|
||||
{
|
||||
value = max;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// Clamps value between min and max and returns value.
|
||||
// Set the position of the transform to be that of the time
|
||||
// but never less than 1 or more than 3
|
||||
//
|
||||
public static int Clamp(int value, int min, int max)
|
||||
{
|
||||
if (value < min)
|
||||
{
|
||||
value = min;
|
||||
}
|
||||
else if (value > max)
|
||||
{
|
||||
value = max;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// Clamps value between 0 and 1 and returns value
|
||||
public static float Clamp01(float value)
|
||||
{
|
||||
if (value < 0F)
|
||||
{
|
||||
return 0F;
|
||||
}
|
||||
else if (value > 1F)
|
||||
{
|
||||
return 1F;
|
||||
}
|
||||
else
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Interpolates between /a/ and /b/ by /t/. /t/ is clamped between 0 and 1.
|
||||
public static float Lerp(float a, float b, float t)
|
||||
{
|
||||
return a + (b - a) * Clamp01(t);
|
||||
}
|
||||
|
||||
// Interpolates between /a/ and /b/ by /t/ without clamping the interpolant.
|
||||
public static float LerpUnclamped(float a, float b, float t)
|
||||
{
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
// Same as ::ref::Lerp but makes sure the values interpolate correctly when they wrap around 360 degrees.
|
||||
public static float LerpAngle(float a, float b, float t)
|
||||
{
|
||||
float delta = Repeat((b - a), 360);
|
||||
if (delta > 180)
|
||||
{
|
||||
delta -= 360;
|
||||
}
|
||||
|
||||
return a + delta * Clamp01(t);
|
||||
}
|
||||
|
||||
// Moves a value /current/ towards /target/.
|
||||
public static float MoveTowards(float current, float target, float maxDelta)
|
||||
{
|
||||
if (MathF.Abs(target - current) <= maxDelta)
|
||||
{
|
||||
return target;
|
||||
}
|
||||
|
||||
return current + MathF.Sign(target - current) * maxDelta;
|
||||
}
|
||||
|
||||
// Same as ::ref::MoveTowards but makes sure the values interpolate correctly when they wrap around 360 degrees.
|
||||
public static float MoveTowardsAngle(float current, float target, float maxDelta)
|
||||
{
|
||||
float deltaAngle = DeltaAngle(current, target);
|
||||
if (-maxDelta < deltaAngle && deltaAngle < maxDelta)
|
||||
{
|
||||
return target;
|
||||
}
|
||||
|
||||
target = current + deltaAngle;
|
||||
return MoveTowards(current, target, maxDelta);
|
||||
}
|
||||
|
||||
// Interpolates between /min/ and /max/ with smoothing at the limits.
|
||||
public static float SmoothStep(float from, float to, float t)
|
||||
{
|
||||
t = MathF.Clamp01(t);
|
||||
t = -2.0F * t * t * t + 3.0F * t * t;
|
||||
return to * t + from * (1F - t);
|
||||
}
|
||||
|
||||
//*undocumented
|
||||
public static float Gamma(float value, float absmax, float gamma)
|
||||
{
|
||||
bool negative = value < 0F;
|
||||
float absval = Abs(value);
|
||||
if (absval > absmax)
|
||||
{
|
||||
return negative ? -absval : absval;
|
||||
}
|
||||
|
||||
float result = Pow(absval / absmax, gamma) * absmax;
|
||||
return negative ? -result : result;
|
||||
}
|
||||
|
||||
// Loops the value t, so that it is never larger than length and never smaller than 0.
|
||||
public static float Repeat(float t, float length)
|
||||
{
|
||||
return Clamp(t - MathF.Floor(t / length) * length, 0.0f, length);
|
||||
}
|
||||
|
||||
// PingPongs the value t, so that it is never larger than length and never smaller than 0.
|
||||
public static float PingPong(float t, float length)
|
||||
{
|
||||
t = Repeat(t, length * 2F);
|
||||
return length - MathF.Abs(t - length);
|
||||
}
|
||||
|
||||
// Calculates the ::ref::Lerp parameter between of two values.
|
||||
public static float InverseLerp(float a, float b, float value)
|
||||
{
|
||||
if (a != b)
|
||||
{
|
||||
return Clamp01((value - a) / (b - a));
|
||||
}
|
||||
else
|
||||
{
|
||||
return 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculates the shortest difference between two given angles.
|
||||
public static float DeltaAngle(float current, float target)
|
||||
{
|
||||
float delta = MathF.Repeat((target - current), 360.0F);
|
||||
if (delta > 180.0F)
|
||||
{
|
||||
delta -= 360.0F;
|
||||
}
|
||||
|
||||
return delta;
|
||||
}
|
||||
|
||||
internal static long RandomToLong(System.Random r)
|
||||
{
|
||||
byte[] buffer = new byte[8];
|
||||
r.NextBytes(buffer);
|
||||
return (long)(System.BitConverter.ToUInt64(buffer, 0) & long.MaxValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
114
LightlessSync/ThirdParty/Nanomesh/Base/MathUtils.cs
vendored
Normal file
114
LightlessSync/ThirdParty/Nanomesh/Base/MathUtils.cs
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Nanomesh
|
||||
{
|
||||
public static class MathUtils
|
||||
{
|
||||
public const float EpsilonFloat = 1e-15f;
|
||||
public const double EpsilonDouble = 1e-40f;
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static float DivideSafe(float numerator, float denominator)
|
||||
{
|
||||
return (denominator > -EpsilonFloat && denominator < EpsilonFloat) ? 0f : numerator / denominator;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static double DivideSafe(double numerator, double denominator)
|
||||
{
|
||||
return (denominator > -EpsilonDouble && denominator < EpsilonDouble) ? 0d : numerator / denominator;
|
||||
}
|
||||
|
||||
public static void SelectMin<T>(double e1, double e2, double e3, in T v1, in T v2, in T v3, out double e, out T v)
|
||||
{
|
||||
if (e1 < e2)
|
||||
{
|
||||
if (e1 < e3)
|
||||
{
|
||||
e = e1;
|
||||
v = v1;
|
||||
}
|
||||
else
|
||||
{
|
||||
e = e3;
|
||||
v = v3;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (e2 < e3)
|
||||
{
|
||||
e = e2;
|
||||
v = v2;
|
||||
}
|
||||
else
|
||||
{
|
||||
e = e3;
|
||||
v = v3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void SelectMin<T>(double e1, double e2, double e3, double e4, in T v1, in T v2, in T v3, in T v4, out double e, out T v)
|
||||
{
|
||||
if (e1 < e2)
|
||||
{
|
||||
if (e1 < e3)
|
||||
{
|
||||
if (e1 < e4)
|
||||
{
|
||||
e = e1;
|
||||
v = v1;
|
||||
}
|
||||
else
|
||||
{
|
||||
e = e4;
|
||||
v = v4;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (e3 < e4)
|
||||
{
|
||||
e = e3;
|
||||
v = v3;
|
||||
}
|
||||
else
|
||||
{
|
||||
e = e4;
|
||||
v = v4;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (e2 < e3)
|
||||
{
|
||||
if (e2 < e4)
|
||||
{
|
||||
e = e2;
|
||||
v = v2;
|
||||
}
|
||||
else
|
||||
{
|
||||
e = e4;
|
||||
v = v4;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (e3 < e4)
|
||||
{
|
||||
e = e3;
|
||||
v = v3;
|
||||
}
|
||||
else
|
||||
{
|
||||
e = e4;
|
||||
v = v4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
LightlessSync/ThirdParty/Nanomesh/Base/Profiling.cs
vendored
Normal file
50
LightlessSync/ThirdParty/Nanomesh/Base/Profiling.cs
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Nanomesh
|
||||
{
|
||||
public static class Profiling
|
||||
{
|
||||
private static readonly Dictionary<string, Stopwatch> stopwatches = new Dictionary<string, Stopwatch>();
|
||||
|
||||
public static void Start(string key)
|
||||
{
|
||||
if (!stopwatches.ContainsKey(key))
|
||||
{
|
||||
stopwatches.Add(key, Stopwatch.StartNew());
|
||||
}
|
||||
else
|
||||
{
|
||||
stopwatches[key] = Stopwatch.StartNew();
|
||||
}
|
||||
}
|
||||
|
||||
public static string End(string key)
|
||||
{
|
||||
TimeSpan time = EndTimer(key);
|
||||
return $"{key} done in {time.ToString("mm':'ss':'fff")}";
|
||||
}
|
||||
|
||||
private static TimeSpan EndTimer(string key)
|
||||
{
|
||||
if (!stopwatches.ContainsKey(key))
|
||||
{
|
||||
return TimeSpan.MinValue;
|
||||
}
|
||||
|
||||
Stopwatch sw = stopwatches[key];
|
||||
sw.Stop();
|
||||
stopwatches.Remove(key);
|
||||
return sw.Elapsed;
|
||||
}
|
||||
|
||||
public static TimeSpan Time(Action toTime)
|
||||
{
|
||||
Stopwatch timer = Stopwatch.StartNew();
|
||||
toTime();
|
||||
timer.Stop();
|
||||
return timer.Elapsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
632
LightlessSync/ThirdParty/Nanomesh/Base/Quaternion.cs
vendored
Normal file
632
LightlessSync/ThirdParty/Nanomesh/Base/Quaternion.cs
vendored
Normal file
@@ -0,0 +1,632 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Nanomesh
|
||||
{
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public partial struct Quaternion : IEquatable<Quaternion>
|
||||
{
|
||||
private const double radToDeg = 180.0 / Math.PI;
|
||||
private const double degToRad = Math.PI / 180.0;
|
||||
|
||||
public const double kEpsilon = 1E-20; // should probably be used in the 0 tests in LookRotation or Slerp
|
||||
|
||||
public Vector3 xyz
|
||||
{
|
||||
set
|
||||
{
|
||||
x = value.x;
|
||||
y = value.y;
|
||||
z = value.z;
|
||||
}
|
||||
get => new Vector3(x, y, z);
|
||||
}
|
||||
|
||||
public double x;
|
||||
|
||||
public double y;
|
||||
|
||||
public double z;
|
||||
|
||||
public double w;
|
||||
|
||||
public double this[int index]
|
||||
{
|
||||
get
|
||||
{
|
||||
switch (index)
|
||||
{
|
||||
case 0:
|
||||
return x;
|
||||
case 1:
|
||||
return y;
|
||||
case 2:
|
||||
return z;
|
||||
case 3:
|
||||
return w;
|
||||
default:
|
||||
throw new IndexOutOfRangeException("Invalid Quaternion index: " + index + ", can use only 0,1,2,3");
|
||||
}
|
||||
}
|
||||
set
|
||||
{
|
||||
switch (index)
|
||||
{
|
||||
case 0:
|
||||
x = value;
|
||||
break;
|
||||
case 1:
|
||||
y = value;
|
||||
break;
|
||||
case 2:
|
||||
z = value;
|
||||
break;
|
||||
case 3:
|
||||
w = value;
|
||||
break;
|
||||
default:
|
||||
throw new IndexOutOfRangeException("Invalid Quaternion index: " + index + ", can use only 0,1,2,3");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// <para>The identity rotation (RO).</para>
|
||||
/// </summary>
|
||||
public static Quaternion identity => new Quaternion(0, 0, 0, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the length (magnitude) of the quaternion.
|
||||
/// </summary>
|
||||
/// <seealso cref="LengthSquared"/>
|
||||
public double Length => (double)System.Math.Sqrt(x * x + y * y + z * z + w * w);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the square of the quaternion length (magnitude).
|
||||
/// </summary>
|
||||
public double LengthSquared => x * x + y * y + z * z + w * w;
|
||||
|
||||
/// <summary>
|
||||
/// <para>Constructs new Quaternion with given x,y,z,w components.</para>
|
||||
/// </summary>
|
||||
/// <param name="x"></param>
|
||||
/// <param name="y"></param>
|
||||
/// <param name="z"></param>
|
||||
/// <param name="w"></param>
|
||||
public Quaternion(double x, double y, double z, double w)
|
||||
{
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
this.w = w;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Construct a new Quaternion from vector and w components
|
||||
/// </summary>
|
||||
/// <param name="v">The vector part</param>
|
||||
/// <param name="w">The w part</param>
|
||||
public Quaternion(Vector3 v, double w)
|
||||
{
|
||||
x = v.x;
|
||||
y = v.y;
|
||||
z = v.z;
|
||||
this.w = w;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Set x, y, z and w components of an existing Quaternion.</para>
|
||||
/// </summary>
|
||||
/// <param name="new_x"></param>
|
||||
/// <param name="new_y"></param>
|
||||
/// <param name="new_z"></param>
|
||||
/// <param name="new_w"></param>
|
||||
public void Set(double new_x, double new_y, double new_z, double new_w)
|
||||
{
|
||||
x = new_x;
|
||||
y = new_y;
|
||||
z = new_z;
|
||||
w = new_w;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scales the Quaternion to unit length.
|
||||
/// </summary>
|
||||
public static Quaternion Normalize(Quaternion q)
|
||||
{
|
||||
double mag = Math.Sqrt(Dot(q, q));
|
||||
|
||||
if (mag < kEpsilon)
|
||||
{
|
||||
return Quaternion.identity;
|
||||
}
|
||||
|
||||
return new Quaternion(q.x / mag, q.y / mag, q.z / mag, q.w / mag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scale the given quaternion to unit length
|
||||
/// </summary>
|
||||
/// <param name="q">The quaternion to normalize</param>
|
||||
/// <param name="result">The normalized quaternion</param>
|
||||
public void Normalize()
|
||||
{
|
||||
this = Normalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>The dot product between two rotations.</para>
|
||||
/// </summary>
|
||||
/// <param name="a"></param>
|
||||
/// <param name="b"></param>
|
||||
public static double Dot(Quaternion a, Quaternion b)
|
||||
{
|
||||
return a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Creates a rotation which rotates /angle/ degrees around /axis/.</para>
|
||||
/// </summary>
|
||||
/// <param name="angle"></param>
|
||||
/// <param name="axis"></param>
|
||||
public static Quaternion AngleAxis(double angle, Vector3 axis)
|
||||
{
|
||||
return Quaternion.AngleAxis(angle, ref axis);
|
||||
}
|
||||
|
||||
private static Quaternion AngleAxis(double degress, ref Vector3 axis)
|
||||
{
|
||||
if (axis.LengthSquared == 0.0)
|
||||
{
|
||||
return identity;
|
||||
}
|
||||
|
||||
Quaternion result = identity;
|
||||
double radians = degress * degToRad;
|
||||
radians *= 0.5;
|
||||
axis = axis.Normalized;
|
||||
axis = axis * Math.Sin(radians);
|
||||
result.x = axis.x;
|
||||
result.y = axis.y;
|
||||
result.z = axis.z;
|
||||
result.w = Math.Cos(radians);
|
||||
|
||||
return Normalize(result);
|
||||
}
|
||||
|
||||
public void ToAngleAxis(out double angle, out Vector3 axis)
|
||||
{
|
||||
Quaternion.ToAxisAngleRad(this, out axis, out angle);
|
||||
angle *= radToDeg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Creates a rotation which rotates from /fromDirection/ to /toDirection/.</para>
|
||||
/// </summary>
|
||||
/// <param name="fromDirection"></param>
|
||||
/// <param name="toDirection"></param>
|
||||
public static Quaternion FromToRotation(Vector3 fromDirection, Vector3 toDirection)
|
||||
{
|
||||
return RotateTowards(LookRotation(fromDirection), LookRotation(toDirection), double.MaxValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Creates a rotation which rotates from /fromDirection/ to /toDirection/.</para>
|
||||
/// </summary>
|
||||
/// <param name="fromDirection"></param>
|
||||
/// <param name="toDirection"></param>
|
||||
public void SetFromToRotation(Vector3 fromDirection, Vector3 toDirection)
|
||||
{
|
||||
this = Quaternion.FromToRotation(fromDirection, toDirection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Creates a rotation with the specified /forward/ and /upwards/ directions.</para>
|
||||
/// </summary>
|
||||
/// <param name="forward">The direction to look in.</param>
|
||||
/// <param name="upwards">The vector that defines in which direction up is.</param>
|
||||
public static Quaternion LookRotation(Vector3 forward, Vector3 upwards)
|
||||
{
|
||||
return Quaternion.LookRotation(ref forward, ref upwards);
|
||||
}
|
||||
|
||||
public static Quaternion LookRotation(Vector3 forward)
|
||||
{
|
||||
Vector3 up = new Vector3(1, 0, 0);
|
||||
return Quaternion.LookRotation(ref forward, ref up);
|
||||
}
|
||||
|
||||
private static Quaternion LookRotation(ref Vector3 forward, ref Vector3 up)
|
||||
{
|
||||
forward = Vector3.Normalize(forward);
|
||||
Vector3 right = Vector3.Normalize(Vector3.Cross(up, forward));
|
||||
up = Vector3.Cross(forward, right);
|
||||
double m00 = right.x;
|
||||
double m01 = right.y;
|
||||
double m02 = right.z;
|
||||
double m10 = up.x;
|
||||
double m11 = up.y;
|
||||
double m12 = up.z;
|
||||
double m20 = forward.x;
|
||||
double m21 = forward.y;
|
||||
double m22 = forward.z;
|
||||
|
||||
double num8 = (m00 + m11) + m22;
|
||||
Quaternion quaternion = new Quaternion();
|
||||
if (num8 > 0)
|
||||
{
|
||||
double num = Math.Sqrt(num8 + 1);
|
||||
quaternion.w = num * 0.5;
|
||||
num = 0.5 / num;
|
||||
quaternion.x = (m12 - m21) * num;
|
||||
quaternion.y = (m20 - m02) * num;
|
||||
quaternion.z = (m01 - m10) * num;
|
||||
return quaternion;
|
||||
}
|
||||
if ((m00 >= m11) && (m00 >= m22))
|
||||
{
|
||||
double num7 = Math.Sqrt(((1 + m00) - m11) - m22);
|
||||
double num4 = 0.5 / num7;
|
||||
quaternion.x = 0.5 * num7;
|
||||
quaternion.y = (m01 + m10) * num4;
|
||||
quaternion.z = (m02 + m20) * num4;
|
||||
quaternion.w = (m12 - m21) * num4;
|
||||
return quaternion;
|
||||
}
|
||||
if (m11 > m22)
|
||||
{
|
||||
double num6 = Math.Sqrt(((1 + m11) - m00) - m22);
|
||||
double num3 = 0.5 / num6;
|
||||
quaternion.x = (m10 + m01) * num3;
|
||||
quaternion.y = 0.5 * num6;
|
||||
quaternion.z = (m21 + m12) * num3;
|
||||
quaternion.w = (m20 - m02) * num3;
|
||||
return quaternion;
|
||||
}
|
||||
double num5 = Math.Sqrt(((1 + m22) - m00) - m11);
|
||||
double num2 = 0.5 / num5;
|
||||
quaternion.x = (m20 + m02) * num2;
|
||||
quaternion.y = (m21 + m12) * num2;
|
||||
quaternion.z = 0.5 * num5;
|
||||
quaternion.w = (m01 - m10) * num2;
|
||||
return quaternion;
|
||||
}
|
||||
|
||||
public void SetLookRotation(Vector3 view)
|
||||
{
|
||||
Vector3 up = new Vector3(1, 0, 0);
|
||||
SetLookRotation(view, up);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Creates a rotation with the specified /forward/ and /upwards/ directions.</para>
|
||||
/// </summary>
|
||||
/// <param name="view">The direction to look in.</param>
|
||||
/// <param name="up">The vector that defines in which direction up is.</param>
|
||||
public void SetLookRotation(Vector3 view, Vector3 up)
|
||||
{
|
||||
this = Quaternion.LookRotation(view, up);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Spherically interpolates between /a/ and /b/ by t. The parameter /t/ is clamped to the range [0, 1].</para>
|
||||
/// </summary>
|
||||
/// <param name="a"></param>
|
||||
/// <param name="b"></param>
|
||||
/// <param name="t"></param>
|
||||
public static Quaternion Slerp(Quaternion a, Quaternion b, double t)
|
||||
{
|
||||
return Quaternion.Slerp(ref a, ref b, t);
|
||||
}
|
||||
|
||||
private static Quaternion Slerp(ref Quaternion a, ref Quaternion b, double t)
|
||||
{
|
||||
if (t > 1)
|
||||
{
|
||||
t = 1;
|
||||
}
|
||||
|
||||
if (t < 0)
|
||||
{
|
||||
t = 0;
|
||||
}
|
||||
|
||||
return SlerpUnclamped(ref a, ref b, t);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Spherically interpolates between /a/ and /b/ by t. The parameter /t/ is not clamped.</para>
|
||||
/// </summary>
|
||||
/// <param name="a"></param>
|
||||
/// <param name="b"></param>
|
||||
/// <param name="t"></param>
|
||||
public static Quaternion SlerpUnclamped(Quaternion a, Quaternion b, double t)
|
||||
{
|
||||
|
||||
return Quaternion.SlerpUnclamped(ref a, ref b, t);
|
||||
}
|
||||
private static Quaternion SlerpUnclamped(ref Quaternion a, ref Quaternion b, double t)
|
||||
{
|
||||
// if either input is zero, return the other.
|
||||
if (a.LengthSquared == 0.0)
|
||||
{
|
||||
if (b.LengthSquared == 0.0)
|
||||
{
|
||||
return identity;
|
||||
}
|
||||
return b;
|
||||
}
|
||||
else if (b.LengthSquared == 0.0)
|
||||
{
|
||||
return a;
|
||||
}
|
||||
|
||||
double cosHalfAngle = a.w * b.w + Vector3.Dot(a.xyz, b.xyz);
|
||||
|
||||
if (cosHalfAngle >= 1.0 || cosHalfAngle <= -1.0)
|
||||
{
|
||||
// angle = 0.0f, so just return one input.
|
||||
return a;
|
||||
}
|
||||
else if (cosHalfAngle < 0.0)
|
||||
{
|
||||
b.xyz = -b.xyz;
|
||||
b.w = -b.w;
|
||||
cosHalfAngle = -cosHalfAngle;
|
||||
}
|
||||
|
||||
double blendA;
|
||||
double blendB;
|
||||
if (cosHalfAngle < 0.99)
|
||||
{
|
||||
// do proper slerp for big angles
|
||||
double halfAngle = Math.Acos(cosHalfAngle);
|
||||
double sinHalfAngle = Math.Sin(halfAngle);
|
||||
double oneOverSinHalfAngle = 1.0 / sinHalfAngle;
|
||||
blendA = Math.Sin(halfAngle * (1.0 - t)) * oneOverSinHalfAngle;
|
||||
blendB = Math.Sin(halfAngle * t) * oneOverSinHalfAngle;
|
||||
}
|
||||
else
|
||||
{
|
||||
// do lerp if angle is really small.
|
||||
blendA = 1.0f - t;
|
||||
blendB = t;
|
||||
}
|
||||
|
||||
Quaternion result = new Quaternion(blendA * a.xyz + blendB * b.xyz, blendA * a.w + blendB * b.w);
|
||||
if (result.LengthSquared > 0.0)
|
||||
{
|
||||
return Normalize(result);
|
||||
}
|
||||
else
|
||||
{
|
||||
return identity;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Interpolates between /a/ and /b/ by /t/ and normalizes the result afterwards. The parameter /t/ is clamped to the range [0, 1].</para>
|
||||
/// </summary>
|
||||
/// <param name="a"></param>
|
||||
/// <param name="b"></param>
|
||||
/// <param name="t"></param>
|
||||
public static Quaternion Lerp(Quaternion a, Quaternion b, double t)
|
||||
{
|
||||
if (t > 1)
|
||||
{
|
||||
t = 1;
|
||||
}
|
||||
|
||||
if (t < 0)
|
||||
{
|
||||
t = 0;
|
||||
}
|
||||
|
||||
return Slerp(ref a, ref b, t); // TODO: use lerp not slerp, "Because quaternion works in 4D. Rotation in 4D are linear" ???
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Interpolates between /a/ and /b/ by /t/ and normalizes the result afterwards. The parameter /t/ is not clamped.</para>
|
||||
/// </summary>
|
||||
/// <param name="a"></param>
|
||||
/// <param name="b"></param>
|
||||
/// <param name="t"></param>
|
||||
public static Quaternion LerpUnclamped(Quaternion a, Quaternion b, double t)
|
||||
{
|
||||
return Slerp(ref a, ref b, t);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Rotates a rotation /from/ towards /to/.</para>
|
||||
/// </summary>
|
||||
/// <param name="from"></param>
|
||||
/// <param name="to"></param>
|
||||
/// <param name="maxDegreesDelta"></param>
|
||||
public static Quaternion RotateTowards(Quaternion from, Quaternion to, double maxDegreesDelta)
|
||||
{
|
||||
double num = Quaternion.Angle(from, to);
|
||||
if (num == 0)
|
||||
{
|
||||
return to;
|
||||
}
|
||||
double t = Math.Min(1, maxDegreesDelta / num);
|
||||
return Quaternion.SlerpUnclamped(from, to, t);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Returns the Inverse of /rotation/.</para>
|
||||
/// </summary>
|
||||
/// <param name="rotation"></param>
|
||||
public static Quaternion Inverse(Quaternion rotation)
|
||||
{
|
||||
double lengthSq = rotation.LengthSquared;
|
||||
if (lengthSq != 0.0)
|
||||
{
|
||||
double i = 1.0 / lengthSq;
|
||||
return new Quaternion(rotation.xyz * -i, rotation.w * i);
|
||||
}
|
||||
return rotation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Returns a nicely formatted string of the Quaternion.</para>
|
||||
/// </summary>
|
||||
/// <param name="format"></param>
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{x}, {y}, {z}, {w}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Returns a nicely formatted string of the Quaternion.</para>
|
||||
/// </summary>
|
||||
/// <param name="format"></param>
|
||||
public string ToString(string format)
|
||||
{
|
||||
return string.Format("({0}, {1}, {2}, {3})", x.ToString(format), y.ToString(format), z.ToString(format), w.ToString(format));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Returns the angle in degrees between two rotations /a/ and /b/.</para>
|
||||
/// </summary>
|
||||
/// <param name="a"></param>
|
||||
/// <param name="b"></param>
|
||||
public static double Angle(Quaternion a, Quaternion b)
|
||||
{
|
||||
double f = Quaternion.Dot(a, b);
|
||||
return Math.Acos(Math.Min(Math.Abs(f), 1)) * 2 * radToDeg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Returns a rotation that rotates z degrees around the z axis, x degrees around the x axis, and y degrees around the y axis (in that order).</para>
|
||||
/// </summary>
|
||||
/// <param name="x"></param>
|
||||
/// <param name="y"></param>
|
||||
/// <param name="z"></param>
|
||||
public static Quaternion Euler(double x, double y, double z)
|
||||
{
|
||||
return Quaternion.FromEulerRad(new Vector3((double)x, (double)y, (double)z) * degToRad);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Returns a rotation that rotates z degrees around the z axis, x degrees around the x axis, and y degrees around the y axis (in that order).</para>
|
||||
/// </summary>
|
||||
/// <param name="euler"></param>
|
||||
public static Quaternion Euler(Vector3 euler)
|
||||
{
|
||||
return Quaternion.FromEulerRad(euler * degToRad);
|
||||
}
|
||||
|
||||
private static double NormalizeAngle(double angle)
|
||||
{
|
||||
while (angle > 360)
|
||||
{
|
||||
angle -= 360;
|
||||
}
|
||||
|
||||
while (angle < 0)
|
||||
{
|
||||
angle += 360;
|
||||
}
|
||||
|
||||
return angle;
|
||||
}
|
||||
|
||||
private static Quaternion FromEulerRad(Vector3 euler)
|
||||
{
|
||||
double yaw = euler.x;
|
||||
double pitch = euler.y;
|
||||
double roll = euler.z;
|
||||
double rollOver2 = roll * 0.5;
|
||||
double sinRollOver2 = (double)System.Math.Sin((double)rollOver2);
|
||||
double cosRollOver2 = (double)System.Math.Cos((double)rollOver2);
|
||||
double pitchOver2 = pitch * 0.5;
|
||||
double sinPitchOver2 = (double)System.Math.Sin((double)pitchOver2);
|
||||
double cosPitchOver2 = (double)System.Math.Cos((double)pitchOver2);
|
||||
double yawOver2 = yaw * 0.5;
|
||||
double sinYawOver2 = (double)System.Math.Sin((double)yawOver2);
|
||||
double cosYawOver2 = (double)System.Math.Cos((double)yawOver2);
|
||||
Quaternion result;
|
||||
result.x = cosYawOver2 * cosPitchOver2 * cosRollOver2 + sinYawOver2 * sinPitchOver2 * sinRollOver2;
|
||||
result.y = cosYawOver2 * cosPitchOver2 * sinRollOver2 - sinYawOver2 * sinPitchOver2 * cosRollOver2;
|
||||
result.z = cosYawOver2 * sinPitchOver2 * cosRollOver2 + sinYawOver2 * cosPitchOver2 * sinRollOver2;
|
||||
result.w = sinYawOver2 * cosPitchOver2 * cosRollOver2 - cosYawOver2 * sinPitchOver2 * sinRollOver2;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ToAxisAngleRad(Quaternion q, out Vector3 axis, out double angle)
|
||||
{
|
||||
if (System.Math.Abs(q.w) > 1.0)
|
||||
{
|
||||
q.Normalize();
|
||||
}
|
||||
|
||||
angle = 2.0f * (double)System.Math.Acos(q.w); // angle
|
||||
double den = (double)System.Math.Sqrt(1.0 - q.w * q.w);
|
||||
if (den > 0.0001)
|
||||
{
|
||||
axis = q.xyz / den;
|
||||
}
|
||||
else
|
||||
{
|
||||
// This occurs when the angle is zero.
|
||||
// Not a problem: just set an arbitrary normalized axis.
|
||||
axis = new Vector3(1, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return x.GetHashCode() ^ y.GetHashCode() << 2 ^ z.GetHashCode() >> 2 ^ w.GetHashCode() >> 1;
|
||||
}
|
||||
public override bool Equals(object other)
|
||||
{
|
||||
if (!(other is Quaternion))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Quaternion quaternion = (Quaternion)other;
|
||||
return x.Equals(quaternion.x) && y.Equals(quaternion.y) && z.Equals(quaternion.z) && w.Equals(quaternion.w);
|
||||
}
|
||||
|
||||
public bool Equals(Quaternion other)
|
||||
{
|
||||
return x.Equals(other.x) && y.Equals(other.y) && z.Equals(other.z) && w.Equals(other.w);
|
||||
}
|
||||
|
||||
public static Quaternion operator *(Quaternion lhs, Quaternion rhs)
|
||||
{
|
||||
return new Quaternion(lhs.w * rhs.x + lhs.x * rhs.w + lhs.y * rhs.z - lhs.z * rhs.y, lhs.w * rhs.y + lhs.y * rhs.w + lhs.z * rhs.x - lhs.x * rhs.z, lhs.w * rhs.z + lhs.z * rhs.w + lhs.x * rhs.y - lhs.y * rhs.x, lhs.w * rhs.w - lhs.x * rhs.x - lhs.y * rhs.y - lhs.z * rhs.z);
|
||||
}
|
||||
|
||||
public static Vector3 operator *(Quaternion rotation, Vector3 point)
|
||||
{
|
||||
double num = rotation.x * 2;
|
||||
double num2 = rotation.y * 2;
|
||||
double num3 = rotation.z * 2;
|
||||
double num4 = rotation.x * num;
|
||||
double num5 = rotation.y * num2;
|
||||
double num6 = rotation.z * num3;
|
||||
double num7 = rotation.x * num2;
|
||||
double num8 = rotation.x * num3;
|
||||
double num9 = rotation.y * num3;
|
||||
double num10 = rotation.w * num;
|
||||
double num11 = rotation.w * num2;
|
||||
double num12 = rotation.w * num3;
|
||||
|
||||
return new Vector3(
|
||||
(1 - (num5 + num6)) * point.x + (num7 - num12) * point.y + (num8 + num11) * point.z,
|
||||
(num7 + num12) * point.x + (1 - (num4 + num6)) * point.y + (num9 - num10) * point.z,
|
||||
(num8 - num11) * point.x + (num9 + num10) * point.y + (1 - (num4 + num5)) * point.z);
|
||||
}
|
||||
|
||||
public static bool operator ==(Quaternion lhs, Quaternion rhs)
|
||||
{
|
||||
return Quaternion.Dot(lhs, rhs) > 0.999999999;
|
||||
}
|
||||
|
||||
public static bool operator !=(Quaternion lhs, Quaternion rhs)
|
||||
{
|
||||
return Quaternion.Dot(lhs, rhs) <= 0.999999999;
|
||||
}
|
||||
}
|
||||
}
|
||||
97
LightlessSync/ThirdParty/Nanomesh/Base/SymmetricMatrix.cs
vendored
Normal file
97
LightlessSync/ThirdParty/Nanomesh/Base/SymmetricMatrix.cs
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
namespace Nanomesh
|
||||
{
|
||||
public readonly struct SymmetricMatrix
|
||||
{
|
||||
public readonly double m0, m1, m2, m3, m4, m5, m6, m7, m8, m9;
|
||||
|
||||
public SymmetricMatrix(in double m0, in double m1, in double m2, in double m3, in double m4, in double m5, in double m6, in double m7, in double m8, in double m9)
|
||||
{
|
||||
this.m0 = m0;
|
||||
this.m1 = m1;
|
||||
this.m2 = m2;
|
||||
this.m3 = m3;
|
||||
this.m4 = m4;
|
||||
this.m5 = m5;
|
||||
this.m6 = m6;
|
||||
this.m7 = m7;
|
||||
this.m8 = m8;
|
||||
this.m9 = m9;
|
||||
}
|
||||
|
||||
public SymmetricMatrix(in double a, in double b, in double c, in double d)
|
||||
{
|
||||
m0 = a * a;
|
||||
m1 = a * b;
|
||||
m2 = a * c;
|
||||
m3 = a * d;
|
||||
|
||||
m4 = b * b;
|
||||
m5 = b * c;
|
||||
m6 = b * d;
|
||||
|
||||
m7 = c * c;
|
||||
m8 = c * d;
|
||||
|
||||
m9 = d * d;
|
||||
}
|
||||
|
||||
public static SymmetricMatrix operator +(in SymmetricMatrix a, in SymmetricMatrix b)
|
||||
{
|
||||
return new SymmetricMatrix(
|
||||
a.m0 + b.m0, a.m1 + b.m1, a.m2 + b.m2, a.m3 + b.m3,
|
||||
a.m4 + b.m4, a.m5 + b.m5, a.m6 + b.m6,
|
||||
a.m7 + b.m7, a.m8 + b.m8,
|
||||
a.m9 + b.m9
|
||||
);
|
||||
}
|
||||
|
||||
public double DeterminantXYZ()
|
||||
{
|
||||
return
|
||||
m0 * m4 * m7 +
|
||||
m2 * m1 * m5 +
|
||||
m1 * m5 * m2 -
|
||||
m2 * m4 * m2 -
|
||||
m0 * m5 * m5 -
|
||||
m1 * m1 * m7;
|
||||
}
|
||||
|
||||
public double DeterminantX()
|
||||
{
|
||||
return
|
||||
m1 * m5 * m8 +
|
||||
m3 * m4 * m7 +
|
||||
m2 * m6 * m5 -
|
||||
m3 * m5 * m5 -
|
||||
m1 * m6 * m7 -
|
||||
m2 * m4 * m8;
|
||||
}
|
||||
|
||||
public double DeterminantY()
|
||||
{
|
||||
return
|
||||
m0 * m5 * m8 +
|
||||
m3 * m1 * m7 +
|
||||
m2 * m6 * m2 -
|
||||
m3 * m5 * m2 -
|
||||
m0 * m6 * m7 -
|
||||
m2 * m1 * m8;
|
||||
}
|
||||
|
||||
public double DeterminantZ()
|
||||
{
|
||||
return
|
||||
m0 * m4 * m8 +
|
||||
m3 * m1 * m5 +
|
||||
m1 * m6 * m2 -
|
||||
m3 * m4 * m2 -
|
||||
m0 * m6 * m5 -
|
||||
m1 * m1 * m8;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{m0} {m1} {m2} {m3}| {m4} {m5} {m6} | {m7} {m8} | {m9}";
|
||||
}
|
||||
}
|
||||
}
|
||||
26
LightlessSync/ThirdParty/Nanomesh/Base/TextUtils.cs
vendored
Normal file
26
LightlessSync/ThirdParty/Nanomesh/Base/TextUtils.cs
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace Nanomesh
|
||||
{
|
||||
public static class TextUtils
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static double ToDouble(this string text)
|
||||
{
|
||||
return double.Parse(text, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static float ToFloat(this string text)
|
||||
{
|
||||
return float.Parse(text, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static int ToInt(this string text)
|
||||
{
|
||||
return int.Parse(text, CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
377
LightlessSync/ThirdParty/Nanomesh/Base/Vector2.cs
vendored
Normal file
377
LightlessSync/ThirdParty/Nanomesh/Base/Vector2.cs
vendored
Normal file
@@ -0,0 +1,377 @@
|
||||
using System;
|
||||
|
||||
namespace Nanomesh
|
||||
{
|
||||
public readonly struct Vector2 : IEquatable<Vector2>, IInterpolable<Vector2>
|
||||
{
|
||||
public readonly double x;
|
||||
public readonly double y;
|
||||
|
||||
// Access the /x/ or /y/ component using [0] or [1] respectively.
|
||||
public double this[int index]
|
||||
{
|
||||
get
|
||||
{
|
||||
switch (index)
|
||||
{
|
||||
case 0: return x;
|
||||
case 1: return y;
|
||||
default:
|
||||
throw new IndexOutOfRangeException("Invalid Vector2 index!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Constructs a new vector with given x, y components.
|
||||
public Vector2(double x, double y) { this.x = x; this.y = y; }
|
||||
|
||||
// Linearly interpolates between two vectors.
|
||||
public static Vector2 Lerp(Vector2 a, Vector2 b, double t)
|
||||
{
|
||||
t = MathF.Clamp(t, 0, 1);
|
||||
return new Vector2(
|
||||
a.x + (b.x - a.x) * t,
|
||||
a.y + (b.y - a.y) * t
|
||||
);
|
||||
}
|
||||
|
||||
// Linearly interpolates between two vectors without clamping the interpolant
|
||||
public static Vector2 LerpUnclamped(Vector2 a, Vector2 b, double t)
|
||||
{
|
||||
return new Vector2(
|
||||
a.x + (b.x - a.x) * t,
|
||||
a.y + (b.y - a.y) * t
|
||||
);
|
||||
}
|
||||
|
||||
// Moves a point /current/ towards /target/.
|
||||
public static Vector2 MoveTowards(Vector2 current, Vector2 target, double maxDistanceDelta)
|
||||
{
|
||||
// avoid vector ops because current scripting backends are terrible at inlining
|
||||
double toVector_x = target.x - current.x;
|
||||
double toVector_y = target.y - current.y;
|
||||
|
||||
double sqDist = toVector_x * toVector_x + toVector_y * toVector_y;
|
||||
|
||||
if (sqDist == 0 || (maxDistanceDelta >= 0 && sqDist <= maxDistanceDelta * maxDistanceDelta))
|
||||
{
|
||||
return target;
|
||||
}
|
||||
|
||||
double dist = Math.Sqrt(sqDist);
|
||||
|
||||
return new Vector2(current.x + toVector_x / dist * maxDistanceDelta,
|
||||
current.y + toVector_y / dist * maxDistanceDelta);
|
||||
}
|
||||
|
||||
// Multiplies two vectors component-wise.
|
||||
public static Vector2 Scale(Vector2 a, Vector2 b) => new Vector2(a.x * b.x, a.y * b.y);
|
||||
|
||||
public static Vector2 Normalize(in Vector2 value)
|
||||
{
|
||||
double mag = Magnitude(in value);
|
||||
if (mag > K_EPSILON)
|
||||
{
|
||||
return value / mag;
|
||||
}
|
||||
else
|
||||
{
|
||||
return Zero;
|
||||
}
|
||||
}
|
||||
|
||||
public Vector2 Normalize() => Normalize(in this);
|
||||
|
||||
public static double SqrMagnitude(in Vector2 a) => a.x * a.x + a.y * a.y;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the squared length of this vector (RO).
|
||||
/// </summary>
|
||||
public double SqrMagnitude() => SqrMagnitude(in this);
|
||||
|
||||
public static double Magnitude(in Vector2 vector) => Math.Sqrt(SqrMagnitude(in vector));
|
||||
|
||||
public double Magnitude() => Magnitude(this);
|
||||
|
||||
// used to allow Vector2s to be used as keys in hash tables
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return x.GetHashCode() ^ (y.GetHashCode() << 2);
|
||||
}
|
||||
|
||||
// also required for being able to use Vector2s as keys in hash tables
|
||||
public override bool Equals(object other)
|
||||
{
|
||||
if (!(other is Vector2))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Equals((Vector2)other);
|
||||
}
|
||||
|
||||
|
||||
public bool Equals(Vector2 other)
|
||||
{
|
||||
return x == other.x && y == other.y;
|
||||
}
|
||||
|
||||
public static Vector2 Reflect(Vector2 inDirection, Vector2 inNormal)
|
||||
{
|
||||
double factor = -2F * Dot(inNormal, inDirection);
|
||||
return new Vector2(factor * inNormal.x + inDirection.x, factor * inNormal.y + inDirection.y);
|
||||
}
|
||||
|
||||
|
||||
public static Vector2 Perpendicular(Vector2 inDirection)
|
||||
{
|
||||
return new Vector2(-inDirection.y, inDirection.x);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the dot Product of two vectors.
|
||||
/// </summary>
|
||||
/// <param name="lhs"></param>
|
||||
/// <param name="rhs"></param>
|
||||
/// <returns></returns>
|
||||
public static double Dot(Vector2 lhs, Vector2 rhs) { return lhs.x * rhs.x + lhs.y * rhs.y; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the angle in radians between /from/ and /to/.
|
||||
/// </summary>
|
||||
/// <param name="from"></param>
|
||||
/// <param name="to"></param>
|
||||
/// <returns></returns>
|
||||
public static double AngleRadians(Vector2 from, Vector2 to)
|
||||
{
|
||||
// sqrt(a) * sqrt(b) = sqrt(a * b) -- valid for real numbers
|
||||
double denominator = Math.Sqrt(from.SqrMagnitude() * to.SqrMagnitude());
|
||||
if (denominator < K_EPSILON_NORMAL_SQRT)
|
||||
{
|
||||
return 0F;
|
||||
}
|
||||
|
||||
double dot = MathF.Clamp(Dot(from, to) / denominator, -1F, 1F);
|
||||
return Math.Acos(dot);
|
||||
}
|
||||
|
||||
public static double AngleDegrees(Vector2 from, Vector2 to)
|
||||
{
|
||||
return AngleRadians(from, to) / MathF.PI * 180f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the signed angle in degrees between /from/ and /to/. Always returns the smallest possible angle
|
||||
/// </summary>
|
||||
/// <param name="from"></param>
|
||||
/// <param name="to"></param>
|
||||
/// <returns></returns>
|
||||
public static double SignedAngle(Vector2 from, Vector2 to)
|
||||
{
|
||||
double unsigned_angle = AngleDegrees(from, to);
|
||||
double sign = Math.Sign(from.x * to.y - from.y * to.x);
|
||||
return unsigned_angle * sign;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the distance between /a/ and /b/.
|
||||
/// </summary>
|
||||
/// <param name="a"></param>
|
||||
/// <param name="b"></param>
|
||||
/// <returns></returns>
|
||||
public static double Distance(Vector2 a, Vector2 b)
|
||||
{
|
||||
double diff_x = a.x - b.x;
|
||||
double diff_y = a.y - b.y;
|
||||
return Math.Sqrt(diff_x * diff_x + diff_y * diff_y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a copy of /vector/ with its magnitude clamped to /maxLength/.
|
||||
/// </summary>
|
||||
/// <param name="vector"></param>
|
||||
/// <param name="maxLength"></param>
|
||||
/// <returns></returns>
|
||||
public static Vector2 ClampMagnitude(Vector2 vector, double maxLength)
|
||||
{
|
||||
double sqrMagnitude = vector.SqrMagnitude();
|
||||
if (sqrMagnitude > maxLength * maxLength)
|
||||
{
|
||||
double mag = Math.Sqrt(sqrMagnitude);
|
||||
|
||||
//these intermediate variables force the intermediate result to be
|
||||
//of double precision. without this, the intermediate result can be of higher
|
||||
//precision, which changes behavior.
|
||||
double normalized_x = vector.x / mag;
|
||||
double normalized_y = vector.y / mag;
|
||||
return new Vector2(normalized_x * maxLength,
|
||||
normalized_y * maxLength);
|
||||
}
|
||||
return vector;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a vector that is made from the smallest components of two vectors.
|
||||
/// </summary>
|
||||
/// <param name="lhs"></param>
|
||||
/// <param name="rhs"></param>
|
||||
/// <returns></returns>
|
||||
public static Vector2 Min(Vector2 lhs, Vector2 rhs) { return new Vector2(Math.Min(lhs.x, rhs.x), Math.Min(lhs.y, rhs.y)); }
|
||||
|
||||
/// <summary>
|
||||
/// Returns a vector that is made from the largest components of two vectors.
|
||||
/// </summary>
|
||||
/// <param name="lhs"></param>
|
||||
/// <param name="rhs"></param>
|
||||
/// <returns></returns>
|
||||
public static Vector2 Max(Vector2 lhs, Vector2 rhs) { return new Vector2(Math.Max(lhs.x, rhs.x), Math.Max(lhs.y, rhs.y)); }
|
||||
|
||||
public Vector2 Interpolate(Vector2 other, double ratio) => this * ratio + other * (1 - ratio);
|
||||
|
||||
/// <summary>
|
||||
/// Adds two vectors.
|
||||
/// </summary>
|
||||
/// <param name="a"></param>
|
||||
/// <param name="b"></param>
|
||||
/// <returns></returns>
|
||||
public static Vector2 operator +(Vector2 a, Vector2 b) { return new Vector2(a.x + b.x, a.y + b.y); }
|
||||
|
||||
/// <summary>
|
||||
/// Subtracts one vector from another.
|
||||
/// </summary>
|
||||
/// <param name="a"></param>
|
||||
/// <param name="b"></param>
|
||||
/// <returns></returns>
|
||||
public static Vector2 operator -(Vector2 a, Vector2 b) { return new Vector2(a.x - b.x, a.y - b.y); }
|
||||
|
||||
/// <summary>
|
||||
/// Multiplies one vector by another.
|
||||
/// </summary>
|
||||
/// <param name="a"></param>
|
||||
/// <param name="b"></param>
|
||||
/// <returns></returns>
|
||||
public static Vector2 operator *(Vector2 a, Vector2 b) { return new Vector2(a.x * b.x, a.y * b.y); }
|
||||
|
||||
/// <summary>
|
||||
/// Divides one vector over another.
|
||||
/// </summary>
|
||||
/// <param name="a"></param>
|
||||
/// <param name="b"></param>
|
||||
/// <returns></returns>
|
||||
public static Vector2 operator /(Vector2 a, Vector2 b) { return new Vector2(a.x / b.x, a.y / b.y); }
|
||||
|
||||
/// <summary>
|
||||
/// Negates a vector.
|
||||
/// </summary>
|
||||
/// <param name="a"></param>
|
||||
/// <returns></returns>
|
||||
public static Vector2 operator -(Vector2 a) { return new Vector2(-a.x, -a.y); }
|
||||
|
||||
/// <summary>
|
||||
/// Multiplies a vector by a number.
|
||||
/// </summary>
|
||||
/// <param name="a"></param>
|
||||
/// <param name="d"></param>
|
||||
/// <returns></returns>
|
||||
public static Vector2 operator *(Vector2 a, double d) { return new Vector2(a.x * d, a.y * d); }
|
||||
|
||||
/// <summary>
|
||||
/// Multiplies a vector by a number.
|
||||
/// </summary>
|
||||
/// <param name="d"></param>
|
||||
/// <param name="a"></param>
|
||||
/// <returns></returns>
|
||||
public static Vector2 operator *(double d, Vector2 a) { return new Vector2(a.x * d, a.y * d); }
|
||||
|
||||
/// <summary>
|
||||
/// Divides a vector by a number.
|
||||
/// </summary>
|
||||
/// <param name="a"></param>
|
||||
/// <param name="d"></param>
|
||||
/// <returns></returns>
|
||||
public static Vector2 operator /(Vector2 a, double d) { return new Vector2(a.x / d, a.y / d); }
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the vectors are equal.
|
||||
/// </summary>
|
||||
/// <param name="lhs"></param>
|
||||
/// <param name="rhs"></param>
|
||||
/// <returns></returns>
|
||||
public static bool operator ==(Vector2 lhs, Vector2 rhs)
|
||||
{
|
||||
// Returns false in the presence of NaN values.
|
||||
double diff_x = lhs.x - rhs.x;
|
||||
double diff_y = lhs.y - rhs.y;
|
||||
return (diff_x * diff_x + diff_y * diff_y) < K_EPSILON * K_EPSILON;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if vectors are different.
|
||||
/// </summary>
|
||||
/// <param name="lhs"></param>
|
||||
/// <param name="rhs"></param>
|
||||
/// <returns></returns>
|
||||
public static bool operator !=(Vector2 lhs, Vector2 rhs)
|
||||
{
|
||||
// Returns true in the presence of NaN values.
|
||||
return !(lhs == rhs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a [[Vector3]] to a Vector2.
|
||||
/// </summary>
|
||||
/// <param name="v"></param>
|
||||
public static implicit operator Vector2(Vector3F v)
|
||||
{
|
||||
return new Vector2(v.x, v.y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Vector2 to a [[Vector3]].
|
||||
/// </summary>
|
||||
/// <param name="v"></param>
|
||||
public static implicit operator Vector3(Vector2 v)
|
||||
{
|
||||
return new Vector3(v.x, v.y, 0);
|
||||
}
|
||||
|
||||
public static implicit operator Vector2F(Vector2 vec)
|
||||
{
|
||||
return new Vector2F((float)vec.x, (float)vec.y);
|
||||
}
|
||||
|
||||
public static explicit operator Vector2(Vector2F vec)
|
||||
{
|
||||
return new Vector2(vec.x, vec.y);
|
||||
}
|
||||
|
||||
public static readonly Vector2 zeroVector = new Vector2(0F, 0F);
|
||||
public static readonly Vector2 oneVector = new Vector2(1F, 1F);
|
||||
public static readonly Vector2 upVector = new Vector2(0F, 1F);
|
||||
public static readonly Vector2 downVector = new Vector2(0F, -1F);
|
||||
public static readonly Vector2 leftVector = new Vector2(-1F, 0F);
|
||||
public static readonly Vector2 rightVector = new Vector2(1F, 0F);
|
||||
public static readonly Vector2 positiveInfinityVector = new Vector2(double.PositiveInfinity, double.PositiveInfinity);
|
||||
public static readonly Vector2 negativeInfinityVector = new Vector2(double.NegativeInfinity, double.NegativeInfinity);
|
||||
|
||||
public static Vector2 Zero => zeroVector;
|
||||
|
||||
public static Vector2 One => oneVector;
|
||||
|
||||
public static Vector2 Up => upVector;
|
||||
|
||||
public static Vector2 Down => downVector;
|
||||
|
||||
public static Vector2 Left => leftVector;
|
||||
|
||||
public static Vector2 Right => rightVector;
|
||||
|
||||
public static Vector2 PositiveInfinity => positiveInfinityVector;
|
||||
|
||||
public static Vector2 NegativeInfinity => negativeInfinityVector;
|
||||
|
||||
public const double K_EPSILON = 0.00001F;
|
||||
|
||||
public const double K_EPSILON_NORMAL_SQRT = 1e-15f;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user