Compare commits
31 Commits
nameplate-
...
cake-attem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4664071eb3 | ||
|
|
11099c05ff | ||
|
|
6af61451dc | ||
|
|
02d091eefa | ||
|
|
e41a7149c5 | ||
|
|
e16ddb0a1d | ||
|
|
ba26edc33c | ||
|
|
14c4c1d669 | ||
|
|
e8c157d8ac | ||
|
|
2af0b5774b | ||
|
|
bb365442cf | ||
|
|
277d368f83 | ||
|
|
3487891185 | ||
|
|
96f8d33cde | ||
|
|
a033d4d4d8 | ||
|
|
7d2a914c84 | ||
|
|
d6fe09ba8e | ||
| 7e61954541 | |||
| bbb3375661 | |||
| ed7932ab83 | |||
| 4eaaaf694c | |||
|
|
c32c89d1a8 | ||
| a8b58d05d6 | |||
| 9ea0571e82 | |||
|
|
308c220735 | ||
|
|
27d4da4615 | ||
|
|
6b49c92ef9 | ||
|
|
6d20995dbf | ||
|
|
cf495dc826 | ||
|
|
08050614da | ||
| 94f520d0e7 |
Submodule LightlessAPI updated: 56566003e0...4ecd5375e6
@@ -27,6 +27,7 @@ public sealed class FileCacheManager : IHostedService
|
|||||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, FileCacheEntity>> _fileCaches = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, FileCacheEntity>> _fileCaches = new(StringComparer.Ordinal);
|
||||||
private readonly ConcurrentDictionary<string, FileCacheEntity> _fileCachesByPrefixedPath = new(StringComparer.OrdinalIgnoreCase);
|
private readonly ConcurrentDictionary<string, FileCacheEntity> _fileCachesByPrefixedPath = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly SemaphoreSlim _getCachesByPathsSemaphore = new(1, 1);
|
private readonly SemaphoreSlim _getCachesByPathsSemaphore = new(1, 1);
|
||||||
|
private readonly SemaphoreSlim _evictSemaphore = new(1, 1);
|
||||||
private readonly Lock _fileWriteLock = new();
|
private readonly Lock _fileWriteLock = new();
|
||||||
private readonly IpcManager _ipcManager;
|
private readonly IpcManager _ipcManager;
|
||||||
private readonly ILogger<FileCacheManager> _logger;
|
private readonly ILogger<FileCacheManager> _logger;
|
||||||
@@ -226,13 +227,23 @@ public sealed class FileCacheManager : IHostedService
|
|||||||
var compressed = LZ4Wrapper.WrapHC(raw, 0, raw.Length);
|
var compressed = LZ4Wrapper.WrapHC(raw, 0, raw.Length);
|
||||||
|
|
||||||
var tmpPath = compressedPath + ".tmp";
|
var tmpPath = compressedPath + ".tmp";
|
||||||
await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false);
|
try
|
||||||
File.Move(tmpPath, compressedPath, overwrite: true);
|
{
|
||||||
|
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);
|
SetSizeInfo(hash, originalSize, compressedSize);
|
||||||
UpdateEntitiesSizes(hash, originalSize, compressedSize);
|
UpdateEntitiesSizes(hash, originalSize, compressedSize);
|
||||||
|
|
||||||
|
var maxBytes = GiBToBytes(_configService.Current.MaxLocalCacheInGiB);
|
||||||
|
await EnforceCacheLimitAsync(maxBytes, token).ConfigureAwait(false);
|
||||||
|
|
||||||
return compressed;
|
return compressed;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -877,6 +888,83 @@ public sealed class FileCacheManager : IHostedService
|
|||||||
}, token).ConfigureAwait(false);
|
}, 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)
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Starting FileCacheManager");
|
_logger.LogInformation("Starting FileCacheManager");
|
||||||
@@ -1060,6 +1148,8 @@ public sealed class FileCacheManager : IHostedService
|
|||||||
{
|
{
|
||||||
await WriteOutFullCsvAsync(cancellationToken).ConfigureAwait(false);
|
await WriteOutFullCsvAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CleanupOrphanCompressedCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Started FileCacheManager");
|
_logger.LogInformation("Started FileCacheManager");
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
private void DalamudUtil_FrameworkUpdate()
|
private void DalamudUtil_FrameworkUpdate()
|
||||||
{
|
{
|
||||||
RefreshPlayerRelatedAddressMap();
|
_ = Task.Run(() => RefreshPlayerRelatedAddressMap());
|
||||||
|
|
||||||
lock (_cacheAdditionLock)
|
lock (_cacheAdditionLock)
|
||||||
{
|
{
|
||||||
@@ -306,20 +306,64 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
if (_lastClassJobId != _dalamudUtil.ClassJobId)
|
if (_lastClassJobId != _dalamudUtil.ClassJobId)
|
||||||
{
|
{
|
||||||
_lastClassJobId = _dalamudUtil.ClassJobId;
|
UpdateClassJobCache();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>())
|
foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast<ObjectKind>())
|
||||||
{
|
{
|
||||||
if (!_cachedFrameAddresses.Any(k => k.Value == kind) && TransientResources.Remove(kind, out _))
|
if (!_cachedFrameAddresses.Any(k => k.Value == kind) && TransientResources.Remove(kind, out _))
|
||||||
@@ -349,26 +393,6 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
_semiTransientResources = null;
|
_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)
|
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
|
||||||
{
|
{
|
||||||
if (descriptor.IsInGpose)
|
if (descriptor.IsInGpose)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using LightlessSync.LightlessConfiguration.Models;
|
|||||||
using LightlessSync.UI;
|
using LightlessSync.UI;
|
||||||
using LightlessSync.UI.Models;
|
using LightlessSync.UI.Models;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using LightlessSync.PlayerData.Factories;
|
||||||
|
|
||||||
namespace LightlessSync.LightlessConfiguration.Configurations;
|
namespace LightlessSync.LightlessConfiguration.Configurations;
|
||||||
|
|
||||||
@@ -156,4 +157,8 @@ public class LightlessConfig : ILightlessConfiguration
|
|||||||
public string? SelectedFinderSyncshell { get; set; } = null;
|
public string? SelectedFinderSyncshell { get; set; } = null;
|
||||||
public string LastSeenVersion { get; set; } = string.Empty;
|
public string LastSeenVersion { get; set; } = string.Empty;
|
||||||
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
|
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
|
||||||
|
public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Safe;
|
||||||
|
public bool AnimationAllowOneBasedShift { get; set; } = true;
|
||||||
|
|
||||||
|
public bool AnimationAllowNeighborIndexTolerance { get; set; } = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors></Authors>
|
<Authors></Authors>
|
||||||
<Company></Company>
|
<Company></Company>
|
||||||
<Version>2.0.2</Version>
|
<Version>2.0.3</Version>
|
||||||
<Description></Description>
|
<Description></Description>
|
||||||
<Copyright></Copyright>
|
<Copyright></Copyright>
|
||||||
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>
|
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" 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="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||||
<PackageReference Include="Glamourer.Api" Version="2.8.0" />
|
<PackageReference Include="Glamourer.Api" Version="2.8.0" />
|
||||||
<PackageReference Include="NReco.Logging.File" Version="1.3.1" />
|
<PackageReference Include="NReco.Logging.File" Version="1.3.1" />
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace LightlessSync.PlayerData.Factories
|
||||||
|
{
|
||||||
|
public enum AnimationValidationMode
|
||||||
|
{
|
||||||
|
Unsafe = 0,
|
||||||
|
Safe = 1,
|
||||||
|
Safest = 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,15 @@
|
|||||||
using LightlessSync.API.Data.Enum;
|
using LightlessSync.API.Data.Enum;
|
||||||
using LightlessSync.FileCache;
|
using LightlessSync.FileCache;
|
||||||
using LightlessSync.Interop.Ipc;
|
using LightlessSync.Interop.Ipc;
|
||||||
|
using LightlessSync.LightlessConfiguration;
|
||||||
using LightlessSync.LightlessConfiguration.Models;
|
using LightlessSync.LightlessConfiguration.Models;
|
||||||
using LightlessSync.PlayerData.Data;
|
using LightlessSync.PlayerData.Data;
|
||||||
using LightlessSync.PlayerData.Handlers;
|
using LightlessSync.PlayerData.Handlers;
|
||||||
using LightlessSync.Services;
|
using LightlessSync.Services;
|
||||||
using LightlessSync.Services.Mediator;
|
using LightlessSync.Services.Mediator;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
namespace LightlessSync.PlayerData.Factories;
|
namespace LightlessSync.PlayerData.Factories;
|
||||||
|
|
||||||
@@ -18,13 +21,34 @@ public class PlayerDataFactory
|
|||||||
private readonly IpcManager _ipcManager;
|
private readonly IpcManager _ipcManager;
|
||||||
private readonly ILogger<PlayerDataFactory> _logger;
|
private readonly ILogger<PlayerDataFactory> _logger;
|
||||||
private readonly PerformanceCollectorService _performanceCollector;
|
private readonly PerformanceCollectorService _performanceCollector;
|
||||||
|
private readonly LightlessConfigService _configService;
|
||||||
private readonly XivDataAnalyzer _modelAnalyzer;
|
private readonly XivDataAnalyzer _modelAnalyzer;
|
||||||
private readonly LightlessMediator _lightlessMediator;
|
private readonly LightlessMediator _lightlessMediator;
|
||||||
private readonly TransientResourceManager _transientResourceManager;
|
private readonly TransientResourceManager _transientResourceManager;
|
||||||
|
private static readonly SemaphoreSlim _papParseLimiter = new(1, 1);
|
||||||
|
|
||||||
public PlayerDataFactory(ILogger<PlayerDataFactory> logger, DalamudUtilService dalamudUtil, IpcManager ipcManager,
|
// Transient resolved entries threshold
|
||||||
TransientResourceManager transientResourceManager, FileCacheManager fileReplacementFactory,
|
private const int _maxTransientResolvedEntries = 1000;
|
||||||
PerformanceCollectorService performanceCollector, XivDataAnalyzer modelAnalyzer, LightlessMediator lightlessMediator)
|
|
||||||
|
// Character build caches
|
||||||
|
private readonly ConcurrentDictionary<nint, Task<CharacterDataFragment>> _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;
|
_logger = logger;
|
||||||
_dalamudUtil = dalamudUtil;
|
_dalamudUtil = dalamudUtil;
|
||||||
@@ -34,15 +58,15 @@ public class PlayerDataFactory
|
|||||||
_performanceCollector = performanceCollector;
|
_performanceCollector = performanceCollector;
|
||||||
_modelAnalyzer = modelAnalyzer;
|
_modelAnalyzer = modelAnalyzer;
|
||||||
_lightlessMediator = lightlessMediator;
|
_lightlessMediator = lightlessMediator;
|
||||||
|
_configService = configService;
|
||||||
_logger.LogTrace("Creating {this}", nameof(PlayerDataFactory));
|
_logger.LogTrace("Creating {this}", nameof(PlayerDataFactory));
|
||||||
}
|
}
|
||||||
|
private sealed record CacheEntry(CharacterDataFragment Fragment, DateTime CreatedUtc);
|
||||||
|
|
||||||
public async Task<CharacterDataFragment?> BuildCharacterData(GameObjectHandler playerRelatedObject, CancellationToken token)
|
public async Task<CharacterDataFragment?> BuildCharacterData(GameObjectHandler playerRelatedObject, CancellationToken token)
|
||||||
{
|
{
|
||||||
if (!_ipcManager.Initialized)
|
if (!_ipcManager.Initialized)
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Penumbra or Glamourer is not connected");
|
throw new InvalidOperationException("Penumbra or Glamourer is not connected");
|
||||||
}
|
|
||||||
|
|
||||||
if (playerRelatedObject == null) return null;
|
if (playerRelatedObject == null) return null;
|
||||||
|
|
||||||
@@ -67,16 +91,17 @@ public class PlayerDataFactory
|
|||||||
|
|
||||||
if (pointerIsZero)
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await _performanceCollector.LogPerformance(this, $"CreateCharacterData>{playerRelatedObject.ObjectKind}", async () =>
|
return await _performanceCollector.LogPerformance(
|
||||||
{
|
this,
|
||||||
return await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false);
|
$"CreateCharacterData>{playerRelatedObject.ObjectKind}",
|
||||||
}).ConfigureAwait(true);
|
async () => await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false)
|
||||||
|
).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -92,17 +117,14 @@ public class PlayerDataFactory
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> CheckForNullDrawObject(IntPtr playerPointer)
|
private async Task<bool> CheckForNullDrawObject(IntPtr playerPointer)
|
||||||
{
|
=> await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
|
||||||
return await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private unsafe bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
|
private unsafe static bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
|
||||||
{
|
{
|
||||||
if (playerPointer == IntPtr.Zero)
|
if (playerPointer == IntPtr.Zero)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
var character = (Character*)playerPointer;
|
var character = (Character*)playerPointer;
|
||||||
|
|
||||||
if (character == null)
|
if (character == null)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
@@ -113,93 +135,167 @@ public class PlayerDataFactory
|
|||||||
return gameObject->DrawObject == null;
|
return gameObject->DrawObject == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
|
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 var cached) && IsCacheFresh(cached) && !_characterBuildInflight.ContainsKey(key))
|
||||||
|
return cached.Fragment;
|
||||||
|
|
||||||
|
var buildTask = _characterBuildInflight.GetOrAdd(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)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(_hardBuildTimeout);
|
||||||
|
var fragment = await CreateCharacterDataInternal(obj, cts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_characterBuildCache[key] = new CacheEntry(fragment, DateTime.UtcNow);
|
||||||
|
PruneCharacterCacheIfNeeded();
|
||||||
|
|
||||||
|
return fragment;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_characterBuildInflight.TryRemove(key, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
var objectKind = playerRelatedObject.ObjectKind;
|
||||||
CharacterDataFragment fragment = objectKind == ObjectKind.Player ? new CharacterDataFragmentPlayer() : new();
|
CharacterDataFragment fragment = objectKind == ObjectKind.Player ? new CharacterDataFragmentPlayer() : new();
|
||||||
|
|
||||||
_logger.LogDebug("Building character data for {obj}", playerRelatedObject);
|
|
||||||
var logDebug = _logger.IsEnabled(LogLevel.Debug);
|
var logDebug = _logger.IsEnabled(LogLevel.Debug);
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
// wait until chara is not drawing and present so nothing spontaneously explodes
|
_logger.LogDebug("Building character data for {obj}", playerRelatedObject);
|
||||||
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct).ConfigureAwait(false);
|
|
||||||
int totalWaitTime = 10000;
|
await EnsureObjectPresentAsync(playerRelatedObject, ct).ConfigureAwait(false);
|
||||||
while (!await _dalamudUtil.IsObjectPresentAsync(await _dalamudUtil.CreateGameObjectAsync(playerRelatedObject.Address).ConfigureAwait(false)).ConfigureAwait(false) && totalWaitTime > 0)
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var waitRecordingTask = _transientResourceManager.WaitForRecording(ct);
|
||||||
|
|
||||||
|
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
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");
|
getHeelsOffset = _ipcManager.Heels.GetOffsetAsync();
|
||||||
await Task.Delay(50, ct).ConfigureAwait(false);
|
getHonorificTitle = _ipcManager.Honorific.GetTitle();
|
||||||
totalWaitTime -= 50;
|
getMoodlesData = _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var resolvedPaths = await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false) ?? throw new InvalidOperationException("Penumbra returned null data; couldn't proceed with character");
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
DateTime start = DateTime.UtcNow;
|
var staticBuildTask = Task.Run(() => BuildStaticReplacements(resolvedPaths), ct);
|
||||||
|
|
||||||
// penumbra call, it's currently broken
|
fragment.FileReplacements = await staticBuildTask.ConfigureAwait(false);
|
||||||
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();
|
|
||||||
|
|
||||||
if (logDebug)
|
if (logDebug)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("== Static Replacements ==");
|
_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);
|
_logger.LogDebug("=> {repl}", replacement);
|
||||||
ct.ThrowIfCancellationRequested();
|
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))
|
CharacterDataFragmentPlayer? playerFragment = fragment as CharacterDataFragmentPlayer ?? throw new InvalidOperationException("Failed to cast CharacterDataFragment to Player variant");
|
||||||
{
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
playerFragment.PetNamesData = _ipcManager.PetNames.GetLocalNames();
|
||||||
// or we get into redraw city for every change and nothing works properly
|
_logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogTrace("Clearing {count} Static Replacements for Pet", fragment.FileReplacements.Count);
|
playerFragment.HeelsData = await getHeelsOffset!.ConfigureAwait(false);
|
||||||
fragment.FileReplacements.Clear();
|
_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();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
_logger.LogDebug("Handling transient update for {obj}", playerRelatedObject);
|
var (resolvedTransientPaths, clearedForPet) = await transientTask.ConfigureAwait(false);
|
||||||
|
if (clearedForPet != null)
|
||||||
// remove all potentially gathered paths from the transient resource manager that are resolved through static resolving
|
fragment.FileReplacements.Clear();
|
||||||
_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);
|
|
||||||
|
|
||||||
if (logDebug)
|
if (logDebug)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("== Transient Replacements ==");
|
_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);
|
_logger.LogDebug("=> {repl}", replacement);
|
||||||
fragment.FileReplacements.Add(replacement);
|
fragment.FileReplacements.Add(replacement);
|
||||||
@@ -208,85 +304,64 @@ public class PlayerDataFactory
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)))
|
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)))
|
||||||
{
|
|
||||||
fragment.FileReplacements.Add(replacement);
|
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]);
|
_transientResourceManager.CleanUpSemiTransientResources(objectKind, [.. fragment.FileReplacements]);
|
||||||
|
|
||||||
ct.ThrowIfCancellationRequested();
|
fragment.FileReplacements = new HashSet<FileReplacement>(
|
||||||
|
fragment.FileReplacements
|
||||||
// make sure we only return data that actually has file replacements
|
.Where(v => v.HasFileReplacement)
|
||||||
fragment.FileReplacements = new HashSet<FileReplacement>(fragment.FileReplacements.Where(v => v.HasFileReplacement).OrderBy(v => v.ResolvedPath, StringComparer.Ordinal), FileReplacementComparer.Instance);
|
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var toCompute = fragment.FileReplacements.Where(f => !f.IsFileSwap).ToArray();
|
var toCompute = fragment.FileReplacements.Where(f => !f.IsFileSwap).ToArray();
|
||||||
_logger.LogDebug("Getting Hashes for {amount} Files", toCompute.Length);
|
_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();
|
var computedPaths = _fileCacheManager.GetFileCachesByPaths([.. toCompute.Select(c => c.ResolvedPath)]);
|
||||||
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty;
|
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));
|
var removed = fragment.FileReplacements.RemoveWhere(f => !f.IsFileSwap && string.IsNullOrEmpty(f.Hash));
|
||||||
if (removed > 0)
|
if (removed > 0)
|
||||||
{
|
|
||||||
_logger.LogDebug("Removed {amount} of invalid files", removed);
|
_logger.LogDebug("Removed {amount} of invalid files", removed);
|
||||||
}
|
|
||||||
|
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
Dictionary<string, List<ushort>>? boneIndices = null;
|
Dictionary<string, List<ushort>>? boneIndices = null;
|
||||||
var hasPapFiles = false;
|
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)
|
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
|
try
|
||||||
{
|
{
|
||||||
|
#if DEBUG
|
||||||
|
if (hasPapFiles && boneIndices != null)
|
||||||
|
_modelAnalyzer.DumpLocalSkeletonIndices(playerRelatedObject);
|
||||||
|
#endif
|
||||||
|
|
||||||
if (hasPapFiles)
|
if (hasPapFiles)
|
||||||
{
|
{
|
||||||
await VerifyPlayerAnimationBones(boneIndices, (fragment as CharacterDataFragmentPlayer)!, ct).ConfigureAwait(false);
|
await VerifyPlayerAnimationBones(boneIndices, (CharacterDataFragmentPlayer)fragment, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException e)
|
catch (OperationCanceledException e)
|
||||||
@@ -300,105 +375,270 @@ 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;
|
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;
|
var remaining = 10000;
|
||||||
|
while (remaining > 0)
|
||||||
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())
|
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var skeletonIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetBoneIndicesFromPap(file.Hash)).ConfigureAwait(false);
|
var obj = await _dalamudUtil.CreateGameObjectAsync(handler.Address).ConfigureAwait(false);
|
||||||
bool validationFailed = false;
|
if (await _dalamudUtil.IsObjectPresentAsync(obj).ConfigureAwait(false))
|
||||||
if (skeletonIndices != null)
|
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 (_transientResourceManager.AddTransientResource(objectKind, item))
|
||||||
if (skeletonIndices.All(k => k.Value.Max() <= 105))
|
_logger.LogDebug("Marking static {item} for Pet as transient", item);
|
||||||
{
|
}
|
||||||
_logger.LogTrace("All indices of {path} are <= 105, ignoring", file.ResolvedPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
_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();
|
||||||
{
|
|
||||||
var maxAnimationIndex = boneCount.Value.DefaultIfEmpty().Max();
|
_transientResourceManager.ClearTransientPaths(objectKind, [.. staticReplacements.SelectMany(c => c.GamePaths)]);
|
||||||
if (maxAnimationIndex > maxPlayerBoneIndex)
|
|
||||||
|
var transientPaths = ManageSemiTransientData(objectKind);
|
||||||
|
if (transientPaths.Count == 0)
|
||||||
|
return (new Dictionary<string, string[]>(StringComparer.Ordinal), clearedReplacements);
|
||||||
|
|
||||||
|
var resolved = await GetFileReplacementsFromPaths(obj, 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;
|
||||||
|
|
||||||
|
Dictionary<string, List<ushort>>? papIndices = null;
|
||||||
|
|
||||||
|
await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
papIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash), ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_papParseLimiter.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (papIndices == null || papIndices.Count == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
|
{
|
||||||
|
var papBuckets = papIndices
|
||||||
|
.Select(kvp => new
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Found more bone indices on the animation {path} skeleton {skl} (max indice {idx}) than on any player related skeleton (max indice {idx2})",
|
Raw = kvp.Key,
|
||||||
file.ResolvedPath, boneCount.Key, maxAnimationIndex, maxPlayerBoneIndex);
|
Key = XivDataAnalyzer.CanonicalizeSkeletonKey(kvp.Key),
|
||||||
validationFailed = true;
|
Indices = kvp.Value
|
||||||
break;
|
})
|
||||||
}
|
.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));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validationFailed)
|
if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason))
|
||||||
{
|
continue;
|
||||||
noValidationFailed++;
|
|
||||||
_logger.LogDebug("Removing {file} from sent file replacements and transient data", file.ResolvedPath);
|
|
||||||
fragment.FileReplacements.Remove(file);
|
|
||||||
foreach (var gamePath in file.GamePaths)
|
|
||||||
{
|
|
||||||
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gamePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
noValidationFailed++;
|
||||||
|
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Animation PAP hash {hash} is not compatible with local skeletons; dropping all mappings for this hash. Reason: {reason}",
|
||||||
|
hash,
|
||||||
|
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)
|
if (noValidationFailed > 0)
|
||||||
{
|
{
|
||||||
_lightlessMediator.Publish(new NotificationMessage("Invalid Skeleton Setup",
|
_lightlessMediator.Publish(new NotificationMessage(
|
||||||
$"Your client is attempting to send {noValidationFailed} animation files with invalid bone data. Those animation files have been removed from your sent data. " +
|
"Invalid Skeleton Setup",
|
||||||
$"Verify that you are using the correct skeleton for those animation files (Check /xllog for more information).",
|
$"Your client is attempting to send {noValidationFailed} animation files that don't match your current skeleton validation mode ({mode}). " +
|
||||||
NotificationType.Warning, TimeSpan.FromSeconds(10)));
|
"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(
|
||||||
|
GameObjectHandler handler,
|
||||||
|
HashSet<string> forwardResolve,
|
||||||
|
HashSet<string> reverseResolve)
|
||||||
{
|
{
|
||||||
var forwardPaths = forwardResolve.ToArray();
|
var forwardPaths = forwardResolve.ToArray();
|
||||||
var reversePaths = reverseResolve.ToArray();
|
var reversePaths = reverseResolve.ToArray();
|
||||||
Dictionary<string, List<string>> resolvedPaths = new(StringComparer.Ordinal);
|
Dictionary<string, List<string>> resolvedPaths = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
if (handler.ObjectKind != ObjectKind.Player)
|
if (handler.ObjectKind != ObjectKind.Player)
|
||||||
{
|
{
|
||||||
var (objectIndex, forwardResolved, reverseResolved) = await _dalamudUtil.RunOnFrameworkThread(() =>
|
var (objectIndex, forwardResolved, reverseResolved) = await _dalamudUtil.RunOnFrameworkThread(() =>
|
||||||
{
|
{
|
||||||
var idx = handler.GetGameObject()?.ObjectIndex;
|
var idx = handler.GetGameObject()?.ObjectIndex;
|
||||||
if (!idx.HasValue)
|
if (!idx.HasValue)
|
||||||
{
|
|
||||||
return ((int?)null, Array.Empty<string>(), Array.Empty<string[]>());
|
return ((int?)null, Array.Empty<string>(), Array.Empty<string[]>());
|
||||||
}
|
|
||||||
|
|
||||||
var resolvedForward = new string[forwardPaths.Length];
|
var resolvedForward = new string[forwardPaths.Length];
|
||||||
for (int i = 0; i < forwardPaths.Length; i++)
|
for (int i = 0; i < forwardPaths.Length; i++)
|
||||||
{
|
|
||||||
resolvedForward[i] = _ipcManager.Penumbra.ResolveGameObjectPath(forwardPaths[i], idx.Value);
|
resolvedForward[i] = _ipcManager.Penumbra.ResolveGameObjectPath(forwardPaths[i], idx.Value);
|
||||||
}
|
|
||||||
|
|
||||||
var resolvedReverse = new string[reversePaths.Length][];
|
var resolvedReverse = new string[reversePaths.Length][];
|
||||||
for (int i = 0; i < reversePaths.Length; i++)
|
for (int i = 0; i < reversePaths.Length; i++)
|
||||||
{
|
|
||||||
resolvedReverse[i] = _ipcManager.Penumbra.ReverseResolveGameObjectPath(reversePaths[i], idx.Value);
|
resolvedReverse[i] = _ipcManager.Penumbra.ReverseResolveGameObjectPath(reversePaths[i], idx.Value);
|
||||||
}
|
|
||||||
|
|
||||||
return (idx, resolvedForward, resolvedReverse);
|
return (idx, resolvedForward, resolvedReverse);
|
||||||
}).ConfigureAwait(false);
|
}).ConfigureAwait(false);
|
||||||
@@ -409,31 +649,21 @@ public class PlayerDataFactory
|
|||||||
{
|
{
|
||||||
var filePath = forwardResolved[i]?.ToLowerInvariant();
|
var filePath = forwardResolved[i]?.ToLowerInvariant();
|
||||||
if (string.IsNullOrEmpty(filePath))
|
if (string.IsNullOrEmpty(filePath))
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
|
|
||||||
if (resolvedPaths.TryGetValue(filePath, out var list))
|
if (resolvedPaths.TryGetValue(filePath, out var list))
|
||||||
{
|
|
||||||
list.Add(forwardPaths[i].ToLowerInvariant());
|
list.Add(forwardPaths[i].ToLowerInvariant());
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
|
resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; i < reversePaths.Length; i++)
|
for (int i = 0; i < reversePaths.Length; i++)
|
||||||
{
|
{
|
||||||
var filePath = reversePaths[i].ToLowerInvariant();
|
var filePath = reversePaths[i].ToLowerInvariant();
|
||||||
if (resolvedPaths.TryGetValue(filePath, out var list))
|
if (resolvedPaths.TryGetValue(filePath, out var list))
|
||||||
{
|
|
||||||
list.AddRange(reverseResolved[i].Select(c => c.ToLowerInvariant()));
|
list.AddRange(reverseResolved[i].Select(c => c.ToLowerInvariant()));
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
resolvedPaths[filePath] = [.. reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList()];
|
||||||
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 resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
|
||||||
@@ -441,30 +671,23 @@ public class PlayerDataFactory
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false);
|
var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false);
|
||||||
|
|
||||||
for (int i = 0; i < forwardPaths.Length; i++)
|
for (int i = 0; i < forwardPaths.Length; i++)
|
||||||
{
|
{
|
||||||
var filePath = forward[i].ToLowerInvariant();
|
var filePath = forward[i].ToLowerInvariant();
|
||||||
if (resolvedPaths.TryGetValue(filePath, out var list))
|
if (resolvedPaths.TryGetValue(filePath, out var list))
|
||||||
{
|
|
||||||
list.Add(forwardPaths[i].ToLowerInvariant());
|
list.Add(forwardPaths[i].ToLowerInvariant());
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
|
resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; i < reversePaths.Length; i++)
|
for (int i = 0; i < reversePaths.Length; i++)
|
||||||
{
|
{
|
||||||
var filePath = reversePaths[i].ToLowerInvariant();
|
var filePath = reversePaths[i].ToLowerInvariant();
|
||||||
if (resolvedPaths.TryGetValue(filePath, out var list))
|
if (resolvedPaths.TryGetValue(filePath, out var list))
|
||||||
{
|
|
||||||
list.AddRange(reverse[i].Select(c => c.ToLowerInvariant()));
|
list.AddRange(reverse[i].Select(c => c.ToLowerInvariant()));
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
resolvedPaths[filePath] = [.. reverse[i].Select(c => c.ToLowerInvariant()).ToList()];
|
||||||
resolvedPaths[filePath] = new List<string>(reverse[i].Select(c => c.ToLowerInvariant()).ToList());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
|
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
|
||||||
@@ -475,11 +698,29 @@ public class PlayerDataFactory
|
|||||||
_transientResourceManager.PersistTransientResources(objectKind);
|
_transientResourceManager.PersistTransientResources(objectKind);
|
||||||
|
|
||||||
HashSet<string> pathsToResolve = new(StringComparer.Ordinal);
|
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);
|
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;
|
return pathsToResolve;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
||||||
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
||||||
using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer;
|
using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer;
|
||||||
|
using LightlessSync.LightlessConfiguration;
|
||||||
|
|
||||||
namespace LightlessSync.PlayerData.Pairs;
|
namespace LightlessSync.PlayerData.Pairs;
|
||||||
|
|
||||||
@@ -46,7 +47,9 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
private readonly TextureDownscaleService _textureDownscaleService;
|
private readonly TextureDownscaleService _textureDownscaleService;
|
||||||
private readonly PairStateCache _pairStateCache;
|
private readonly PairStateCache _pairStateCache;
|
||||||
private readonly PairPerformanceMetricsCache _performanceMetricsCache;
|
private readonly PairPerformanceMetricsCache _performanceMetricsCache;
|
||||||
|
private readonly XivDataAnalyzer _modelAnalyzer;
|
||||||
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
|
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
|
||||||
|
private readonly LightlessConfigService _configService;
|
||||||
private readonly PairManager _pairManager;
|
private readonly PairManager _pairManager;
|
||||||
private CancellationTokenSource? _applicationCancellationTokenSource;
|
private CancellationTokenSource? _applicationCancellationTokenSource;
|
||||||
private Guid _applicationId;
|
private Guid _applicationId;
|
||||||
@@ -90,6 +93,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
".avfx",
|
".avfx",
|
||||||
".scd"
|
".scd"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, byte> _blockedPapHashes = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly ConcurrentDictionary<string, byte> _dumpedRemoteSkeletonForHash = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private DateTime? _invisibleSinceUtc;
|
private DateTime? _invisibleSinceUtc;
|
||||||
private DateTime? _visibilityEvictionDueAtUtc;
|
private DateTime? _visibilityEvictionDueAtUtc;
|
||||||
private DateTime _nextActorLookupUtc = DateTime.MinValue;
|
private DateTime _nextActorLookupUtc = DateTime.MinValue;
|
||||||
@@ -184,7 +191,9 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
TextureDownscaleService textureDownscaleService,
|
TextureDownscaleService textureDownscaleService,
|
||||||
PairStateCache pairStateCache,
|
PairStateCache pairStateCache,
|
||||||
PairPerformanceMetricsCache performanceMetricsCache,
|
PairPerformanceMetricsCache performanceMetricsCache,
|
||||||
PenumbraTempCollectionJanitor tempCollectionJanitor) : base(logger, mediator)
|
PenumbraTempCollectionJanitor tempCollectionJanitor,
|
||||||
|
XivDataAnalyzer modelAnalyzer,
|
||||||
|
LightlessConfigService configService) : base(logger, mediator)
|
||||||
{
|
{
|
||||||
_pairManager = pairManager;
|
_pairManager = pairManager;
|
||||||
Ident = ident;
|
Ident = ident;
|
||||||
@@ -203,6 +212,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
_pairStateCache = pairStateCache;
|
_pairStateCache = pairStateCache;
|
||||||
_performanceMetricsCache = performanceMetricsCache;
|
_performanceMetricsCache = performanceMetricsCache;
|
||||||
_tempCollectionJanitor = tempCollectionJanitor;
|
_tempCollectionJanitor = tempCollectionJanitor;
|
||||||
|
_modelAnalyzer = modelAnalyzer;
|
||||||
|
_configService = configService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Initialize()
|
public void Initialize()
|
||||||
@@ -1423,7 +1434,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
private Task _visibilityGraceTask;
|
private Task _visibilityGraceTask;
|
||||||
|
|
||||||
private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData,
|
private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData,
|
||||||
bool updateModdedPaths, bool updateManip, Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths, CancellationToken downloadToken)
|
bool updateModdedPaths, bool updateManip, Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths, CancellationToken downloadToken)
|
||||||
{
|
{
|
||||||
var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false);
|
var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
@@ -1577,24 +1588,37 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
RecordFailure("Handler not available for application", "HandlerUnavailable");
|
RecordFailure("Handler not available for application", "HandlerUnavailable");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource();
|
||||||
|
|
||||||
var appToken = _applicationCancellationTokenSource?.Token;
|
if (_applicationTask != null && !_applicationTask.IsCompleted)
|
||||||
while ((!_applicationTask?.IsCompleted ?? false)
|
|
||||||
&& !downloadToken.IsCancellationRequested
|
|
||||||
&& (!appToken?.IsCancellationRequested ?? false))
|
|
||||||
{
|
{
|
||||||
Logger.LogDebug("[BASE-{appBase}] Waiting for current data application (Id: {id}) for player ({handler}) to finish", applicationBase, _applicationId, PlayerName);
|
Logger.LogDebug("[BASE-{appBase}] Cancelling current data application (Id: {id}) for player ({handler})", applicationBase, _applicationId, PlayerName);
|
||||||
await Task.Delay(250).ConfigureAwait(false);
|
|
||||||
|
var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(downloadToken, timeoutCts.Token);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _applicationTask.WaitAsync(combinedCts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Logger.LogWarning("[BASE-{appBase}] Timeout waiting for application task {id} to complete, proceeding anyway", applicationBase, _applicationId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
timeoutCts.Dispose();
|
||||||
|
combinedCts.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false))
|
if (downloadToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
_forceFullReapply = true;
|
_forceFullReapply = true;
|
||||||
RecordFailure("Application cancelled", "Cancellation");
|
RecordFailure("Application cancelled", "Cancellation");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource();
|
|
||||||
var token = _applicationCancellationTokenSource.Token;
|
var token = _applicationCancellationTokenSource.Token;
|
||||||
|
|
||||||
_applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, wantsModApply, pendingModReapply, token);
|
_applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, wantsModApply, pendingModReapply, token);
|
||||||
@@ -1656,11 +1680,36 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SplitPapMappings(moddedPaths, out var withoutPap, out var papOnly);
|
||||||
|
|
||||||
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false);
|
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false);
|
||||||
|
|
||||||
await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, _applicationId, penumbraCollection,
|
await _ipcManager.Penumbra.SetTemporaryModsAsync(
|
||||||
moddedPaths.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)).ConfigureAwait(false);
|
Logger, _applicationId, penumbraCollection,
|
||||||
_lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(moddedPaths, moddedPaths.Comparer);
|
withoutPap.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await _ipcManager.Penumbra.RedrawAsync(Logger, handlerForApply, _applicationId, token).ConfigureAwait(false);
|
||||||
|
if (handlerForApply.Address != nint.Zero)
|
||||||
|
await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var removedPap = await StripIncompatiblePapAsync(handlerForApply, charaData, papOnly, token).ConfigureAwait(false);
|
||||||
|
if (removedPap > 0)
|
||||||
|
{
|
||||||
|
Logger.LogTrace("[{applicationId}] Removed {removedPap} incompatible PAP mappings found for {handler}", _applicationId, removedPap, GetLogIdentifier());
|
||||||
|
}
|
||||||
|
|
||||||
|
var merged = new Dictionary<(string GamePath, string? Hash), string>(withoutPap, withoutPap.Comparer);
|
||||||
|
foreach (var kv in papOnly)
|
||||||
|
merged[kv.Key] = kv.Value;
|
||||||
|
|
||||||
|
await _ipcManager.Penumbra.SetTemporaryModsAsync(
|
||||||
|
Logger, _applicationId, penumbraCollection,
|
||||||
|
merged.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
_lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(merged, merged.Comparer);
|
||||||
|
|
||||||
LastAppliedDataBytes = -1;
|
LastAppliedDataBytes = -1;
|
||||||
foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists))
|
foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists))
|
||||||
{
|
{
|
||||||
@@ -1693,45 +1742,45 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
_needsCollectionRebuild = false;
|
_needsCollectionRebuild = false;
|
||||||
if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0)
|
if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0)
|
||||||
{
|
{
|
||||||
_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List<DownloadFileTransfer>());
|
_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List<DownloadFileTransfer>());
|
||||||
}
|
}
|
||||||
if (LastAppliedDataTris < 0)
|
if (LastAppliedDataTris < 0)
|
||||||
{
|
{
|
||||||
await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(false);
|
await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
StorePerformanceMetrics(charaData);
|
StorePerformanceMetrics(charaData);
|
||||||
_lastSuccessfulApplyAt = DateTime.UtcNow;
|
_lastSuccessfulApplyAt = DateTime.UtcNow;
|
||||||
ClearFailureState();
|
ClearFailureState();
|
||||||
Logger.LogDebug("[{applicationId}] Application finished", _applicationId);
|
Logger.LogDebug("[{applicationId}] Application finished", _applicationId);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
|
||||||
Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier());
|
|
||||||
_cachedData = charaData;
|
|
||||||
_pairStateCache.Store(Ident, charaData);
|
|
||||||
_forceFullReapply = true;
|
|
||||||
RecordFailure("Application cancelled", "Cancellation");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException))
|
|
||||||
{
|
{
|
||||||
IsVisible = false;
|
Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier());
|
||||||
_forceApplyMods = true;
|
|
||||||
_cachedData = charaData;
|
_cachedData = charaData;
|
||||||
_pairStateCache.Store(Ident, charaData);
|
_pairStateCache.Store(Ident, charaData);
|
||||||
_forceFullReapply = true;
|
_forceFullReapply = true;
|
||||||
Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId);
|
RecordFailure("Application cancelled", "Cancellation");
|
||||||
}
|
}
|
||||||
else
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId);
|
if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException))
|
||||||
_forceFullReapply = true;
|
{
|
||||||
|
IsVisible = false;
|
||||||
|
_forceApplyMods = true;
|
||||||
|
_cachedData = charaData;
|
||||||
|
_pairStateCache.Store(Ident, charaData);
|
||||||
|
_forceFullReapply = true;
|
||||||
|
Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId);
|
||||||
|
_forceFullReapply = true;
|
||||||
|
}
|
||||||
|
RecordFailure($"Application failed: {ex.Message}", "Exception");
|
||||||
}
|
}
|
||||||
RecordFailure($"Application failed: {ex.Message}", "Exception");
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private void FrameworkUpdate()
|
private void FrameworkUpdate()
|
||||||
{
|
{
|
||||||
@@ -1965,14 +2014,37 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
if (string.IsNullOrEmpty(new FileInfo(fileCache.ResolvedFilepath).Extension))
|
if (string.IsNullOrEmpty(new FileInfo(fileCache.ResolvedFilepath).Extension))
|
||||||
{
|
{
|
||||||
hasMigrationChanges = true;
|
hasMigrationChanges = true;
|
||||||
fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, item.GamePaths[0].Split(".")[^1]);
|
var anyGamePath = item.GamePaths.FirstOrDefault();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(anyGamePath))
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(anyGamePath);
|
||||||
|
var extNoDot = ext.StartsWith('.') ? ext[1..] : ext;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(extNoDot))
|
||||||
|
{
|
||||||
|
hasMigrationChanges = true;
|
||||||
|
fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, extNoDot);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var gamePath in item.GamePaths)
|
foreach (var gamePath in item.GamePaths)
|
||||||
{
|
{
|
||||||
|
var mode = _configService.Current.AnimationValidationMode;
|
||||||
|
|
||||||
|
if (mode != AnimationValidationMode.Unsafe
|
||||||
|
&& gamePath.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.IsNullOrEmpty(item.Hash)
|
||||||
|
&& _blockedPapHashes.ContainsKey(item.Hash))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var preferredPath = skipDownscaleForPair
|
var preferredPath = skipDownscaleForPair
|
||||||
? fileCache.ResolvedFilepath
|
? fileCache.ResolvedFilepath
|
||||||
: _textureDownscaleService.GetPreferredPath(item.Hash, fileCache.ResolvedFilepath);
|
: _textureDownscaleService.GetPreferredPath(item.Hash, fileCache.ResolvedFilepath);
|
||||||
|
|
||||||
outputDict[(gamePath, item.Hash)] = preferredPath;
|
outputDict[(gamePath, item.Hash)] = preferredPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2282,7 +2354,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
HandleVisibilityLoss(logChange: false);
|
HandleVisibilityLoss(logChange: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryResolveDescriptorHash(ActorObjectService.ActorDescriptor descriptor, out string hashedCid)
|
private static bool TryResolveDescriptorHash(ActorObjectService.ActorDescriptor descriptor, out string hashedCid)
|
||||||
{
|
{
|
||||||
hashedCid = descriptor.HashedContentId ?? string.Empty;
|
hashedCid = descriptor.HashedContentId ?? string.Empty;
|
||||||
if (!string.IsNullOrEmpty(hashedCid))
|
if (!string.IsNullOrEmpty(hashedCid))
|
||||||
@@ -2295,6 +2367,106 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
return !string.IsNullOrEmpty(hashedCid);
|
return !string.IsNullOrEmpty(hashedCid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void SplitPapMappings(
|
||||||
|
Dictionary<(string GamePath, string? Hash), string> moddedPaths,
|
||||||
|
out Dictionary<(string GamePath, string? Hash), string> withoutPap,
|
||||||
|
out Dictionary<(string GamePath, string? Hash), string> papOnly)
|
||||||
|
{
|
||||||
|
withoutPap = new(moddedPaths.Comparer);
|
||||||
|
papOnly = new(moddedPaths.Comparer);
|
||||||
|
|
||||||
|
foreach (var kv in moddedPaths)
|
||||||
|
{
|
||||||
|
var gamePath = kv.Key.GamePath;
|
||||||
|
if (gamePath.EndsWith(".pap", StringComparison.OrdinalIgnoreCase))
|
||||||
|
papOnly[kv.Key] = kv.Value;
|
||||||
|
else
|
||||||
|
withoutPap[kv.Key] = kv.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> StripIncompatiblePapAsync(
|
||||||
|
GameObjectHandler handlerForApply,
|
||||||
|
CharacterData charaData,
|
||||||
|
Dictionary<(string GamePath, string? Hash), string> papOnly,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
var mode = _configService.Current.AnimationValidationMode;
|
||||||
|
var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift;
|
||||||
|
var allownNightIndex = _configService.Current.AnimationAllowNeighborIndexTolerance;
|
||||||
|
|
||||||
|
if (mode == AnimationValidationMode.Unsafe || papOnly.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var boneIndices = await _dalamudUtil.RunOnFrameworkThread(
|
||||||
|
() => _modelAnalyzer.GetSkeletonBoneIndices(handlerForApply))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (boneIndices == null || boneIndices.Count == 0)
|
||||||
|
{
|
||||||
|
var removedCount = papOnly.Count;
|
||||||
|
papOnly.Clear();
|
||||||
|
return removedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
var localBoneSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var (rawKey, list) in boneIndices)
|
||||||
|
{
|
||||||
|
var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawKey);
|
||||||
|
if (string.IsNullOrEmpty(key)) continue;
|
||||||
|
|
||||||
|
if (!localBoneSets.TryGetValue(key, out var set))
|
||||||
|
localBoneSets[key] = set = [];
|
||||||
|
|
||||||
|
foreach (var v in list)
|
||||||
|
set.Add(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
int removed = 0;
|
||||||
|
|
||||||
|
foreach (var hash in papOnly.Keys.Select(k => k.Hash).Where(h => !string.IsNullOrEmpty(h)).Distinct(StringComparer.OrdinalIgnoreCase).ToList())
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var papIndices = await _dalamudUtil.RunOnFrameworkThread(
|
||||||
|
() => _modelAnalyzer.GetBoneIndicesFromPap(hash!))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (papIndices == null || papIndices.Count == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var keysToRemove = papOnly.Keys.Where(k => string.Equals(k.Hash, hash, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
foreach (var k in keysToRemove)
|
||||||
|
papOnly.Remove(k);
|
||||||
|
|
||||||
|
removed += keysToRemove.Count;
|
||||||
|
|
||||||
|
if (_blockedPapHashes.TryAdd(hash!, 0))
|
||||||
|
Logger.LogWarning("Blocked remote object PAP (hash {hash}) for {handler}: {reason}", hash, GetLogIdentifier(), reason);
|
||||||
|
|
||||||
|
if (charaData.FileReplacements.TryGetValue(ObjectKind.Player, out var list))
|
||||||
|
{
|
||||||
|
list.RemoveAll(r => string.Equals(r.Hash, hash, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& r.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nullHashKeys = papOnly.Keys.Where(k => string.IsNullOrEmpty(k.Hash)).ToList();
|
||||||
|
foreach (var k in nullHashKeys)
|
||||||
|
{
|
||||||
|
papOnly.Remove(k);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ApplyCustomizeAsync(nint address, string customizeData, ObjectKind kind)
|
private async Task ApplyCustomizeAsync(nint address, string customizeData, ObjectKind kind)
|
||||||
{
|
{
|
||||||
_customizeIds[kind] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false);
|
_customizeIds[kind] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using LightlessSync.FileCache;
|
using LightlessSync.FileCache;
|
||||||
using LightlessSync.Interop.Ipc;
|
using LightlessSync.Interop.Ipc;
|
||||||
|
using LightlessSync.LightlessConfiguration;
|
||||||
using LightlessSync.PlayerData.Factories;
|
using LightlessSync.PlayerData.Factories;
|
||||||
using LightlessSync.Services;
|
using LightlessSync.Services;
|
||||||
using LightlessSync.Services.ActorTracking;
|
using LightlessSync.Services.ActorTracking;
|
||||||
@@ -32,6 +33,8 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
|||||||
private readonly PairStateCache _pairStateCache;
|
private readonly PairStateCache _pairStateCache;
|
||||||
private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache;
|
private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache;
|
||||||
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
|
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
|
||||||
|
private readonly LightlessConfigService _configService;
|
||||||
|
private readonly XivDataAnalyzer _modelAnalyzer;
|
||||||
|
|
||||||
public PairHandlerAdapterFactory(
|
public PairHandlerAdapterFactory(
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
@@ -50,7 +53,9 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
|||||||
TextureDownscaleService textureDownscaleService,
|
TextureDownscaleService textureDownscaleService,
|
||||||
PairStateCache pairStateCache,
|
PairStateCache pairStateCache,
|
||||||
PairPerformanceMetricsCache pairPerformanceMetricsCache,
|
PairPerformanceMetricsCache pairPerformanceMetricsCache,
|
||||||
PenumbraTempCollectionJanitor tempCollectionJanitor)
|
PenumbraTempCollectionJanitor tempCollectionJanitor,
|
||||||
|
XivDataAnalyzer modelAnalyzer,
|
||||||
|
LightlessConfigService configService)
|
||||||
{
|
{
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
_mediator = mediator;
|
_mediator = mediator;
|
||||||
@@ -69,6 +74,8 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
|||||||
_pairStateCache = pairStateCache;
|
_pairStateCache = pairStateCache;
|
||||||
_pairPerformanceMetricsCache = pairPerformanceMetricsCache;
|
_pairPerformanceMetricsCache = pairPerformanceMetricsCache;
|
||||||
_tempCollectionJanitor = tempCollectionJanitor;
|
_tempCollectionJanitor = tempCollectionJanitor;
|
||||||
|
_modelAnalyzer = modelAnalyzer;
|
||||||
|
_configService = configService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IPairHandlerAdapter Create(string ident)
|
public IPairHandlerAdapter Create(string ident)
|
||||||
@@ -95,6 +102,8 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
|||||||
_textureDownscaleService,
|
_textureDownscaleService,
|
||||||
_pairStateCache,
|
_pairStateCache,
|
||||||
_pairPerformanceMetricsCache,
|
_pairPerformanceMetricsCache,
|
||||||
_tempCollectionJanitor);
|
_tempCollectionJanitor,
|
||||||
|
_modelAnalyzer,
|
||||||
|
_configService);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
services.AddSingleton<IdDisplayHandler>();
|
services.AddSingleton<IdDisplayHandler>();
|
||||||
services.AddSingleton<PlayerPerformanceService>();
|
services.AddSingleton<PlayerPerformanceService>();
|
||||||
services.AddSingleton<PenumbraTempCollectionJanitor>();
|
services.AddSingleton<PenumbraTempCollectionJanitor>();
|
||||||
|
services.AddSingleton<LocationShareService>();
|
||||||
|
|
||||||
services.AddSingleton<TextureMetadataHelper>(sp =>
|
services.AddSingleton<TextureMetadataHelper>(sp =>
|
||||||
new TextureMetadataHelper(sp.GetRequiredService<ILogger<TextureMetadataHelper>>(), gameData));
|
new TextureMetadataHelper(sp.GetRequiredService<ILogger<TextureMetadataHelper>>(), gameData));
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
|||||||
{
|
{
|
||||||
_baseAnalysisCts = _baseAnalysisCts.CancelRecreate();
|
_baseAnalysisCts = _baseAnalysisCts.CancelRecreate();
|
||||||
var token = _baseAnalysisCts.Token;
|
var token = _baseAnalysisCts.Token;
|
||||||
_ = BaseAnalysis(msg.CharacterData, token);
|
_ = Task.Run(async () => await BaseAnalysis(msg.CharacterData, token).ConfigureAwait(false), token);
|
||||||
});
|
});
|
||||||
_fileCacheManager = fileCacheManager;
|
_fileCacheManager = fileCacheManager;
|
||||||
_xivDataAnalyzer = modelAnalyzer;
|
_xivDataAnalyzer = modelAnalyzer;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ using LightlessSync.UI;
|
|||||||
using LightlessSync.UI.Services;
|
using LightlessSync.UI.Services;
|
||||||
using LightlessSync.Utils;
|
using LightlessSync.Utils;
|
||||||
using LightlessSync.WebAPI;
|
using LightlessSync.WebAPI;
|
||||||
using Lumina.Excel.Sheets;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
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);
|
_logger.LogTrace("Cannot send pair request to {TargetName}@{World} while in PvP or GPose.", target.TargetName, target.TargetHomeWorld.RowId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var world = GetWorld(target.TargetHomeWorld.RowId);
|
if (!IsWorldValid(target.TargetHomeWorld.RowId))
|
||||||
if (!IsWorldValid(world))
|
|
||||||
{
|
{
|
||||||
_logger.LogTrace("Target player {TargetName}@{World} is on an invalid world.", target.TargetName, target.TargetHomeWorld.RowId);
|
_logger.LogTrace("Target player {TargetName}@{World} is on an invalid world.", target.TargetName, target.TargetHomeWorld.RowId);
|
||||||
return;
|
return;
|
||||||
@@ -226,9 +224,8 @@ internal class ContextMenuService : IHostedService
|
|||||||
{
|
{
|
||||||
if (args.Target is not MenuTargetDefault target)
|
if (args.Target is not MenuTargetDefault target)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var world = GetWorld(target.TargetHomeWorld.RowId);
|
if (!target.TargetHomeWorld.IsValid || !IsWorldValid(target.TargetHomeWorld.RowId))
|
||||||
if (!IsWorldValid(world))
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -237,7 +234,7 @@ internal class ContextMenuService : IHostedService
|
|||||||
|
|
||||||
if (targetData == null || targetData.Address == nint.Zero)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +249,7 @@ internal class ContextMenuService : IHostedService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Notify in chat when NotificationService is disabled
|
// 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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -312,37 +309,8 @@ internal class ContextMenuService : IHostedService
|
|||||||
p.HomeWorld.RowId == target.TargetHomeWorld.RowId);
|
p.HomeWorld.RowId == target.TargetHomeWorld.RowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private World GetWorld(uint worldId)
|
private bool IsWorldValid(uint worldId)
|
||||||
{
|
{
|
||||||
var sheet = _gameData.GetExcelSheet<World>()!;
|
return _dalamudUtil.WorldData.Value.ContainsKey((ushort)worldId);
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
using Dalamud.Game.ClientState.Conditions;
|
using Dalamud.Game.ClientState.Conditions;
|
||||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||||
using Dalamud.Game.ClientState.Objects.Types;
|
using Dalamud.Game.ClientState.Objects.Types;
|
||||||
|
using Dalamud.Game.Text;
|
||||||
using Dalamud.Plugin.Services;
|
using Dalamud.Plugin.Services;
|
||||||
using Dalamud.Utility;
|
using Dalamud.Utility;
|
||||||
using FFXIVClientStructs.FFXIV.Client.Game;
|
using FFXIVClientStructs.FFXIV.Client.Game;
|
||||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||||
using FFXIVClientStructs.FFXIV.Client.Game.Control;
|
using FFXIVClientStructs.FFXIV.Client.Game.Control;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.Game.UI;
|
||||||
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
||||||
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
||||||
using LightlessSync.API.Dto.CharaData;
|
using LightlessSync.API.Dto.CharaData;
|
||||||
@@ -20,12 +22,15 @@ using LightlessSync.Utils;
|
|||||||
using Lumina.Excel.Sheets;
|
using Lumina.Excel.Sheets;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind;
|
using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind;
|
||||||
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
||||||
using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
|
using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
|
||||||
|
using Map = Lumina.Excel.Sheets.Map;
|
||||||
using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags;
|
using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags;
|
||||||
|
|
||||||
namespace LightlessSync.Services;
|
namespace LightlessSync.Services;
|
||||||
@@ -57,6 +62,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
private string _lastGlobalBlockReason = string.Empty;
|
private string _lastGlobalBlockReason = string.Empty;
|
||||||
private ushort _lastZone = 0;
|
private ushort _lastZone = 0;
|
||||||
private ushort _lastWorldId = 0;
|
private ushort _lastWorldId = 0;
|
||||||
|
private uint _lastMapId = 0;
|
||||||
private bool _sentBetweenAreas = false;
|
private bool _sentBetweenAreas = false;
|
||||||
private Lazy<ulong> _cid;
|
private Lazy<ulong> _cid;
|
||||||
|
|
||||||
@@ -86,7 +92,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
WorldData = new(() =>
|
WorldData = new(() =>
|
||||||
{
|
{
|
||||||
return gameData.GetExcelSheet<Lumina.Excel.Sheets.World>(clientLanguage)!
|
return gameData.GetExcelSheet<Lumina.Excel.Sheets.World>(clientLanguage)!
|
||||||
.Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0])))
|
.Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0])
|
||||||
|
|| w is { RowId: > 1000, Region: 101 or 201 }))
|
||||||
.ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString());
|
.ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString());
|
||||||
});
|
});
|
||||||
JobData = new(() =>
|
JobData = new(() =>
|
||||||
@@ -659,7 +666,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
|
|
||||||
var location = new LocationInfo();
|
var location = new LocationInfo();
|
||||||
location.ServerId = _playerState.CurrentWorld.RowId;
|
location.ServerId = _playerState.CurrentWorld.RowId;
|
||||||
//location.InstanceId = UIState.Instance()->PublicInstance.InstanceId; //TODO:Need API update first
|
location.InstanceId = UIState.Instance()->PublicInstance.InstanceId;
|
||||||
location.TerritoryId = _clientState.TerritoryType;
|
location.TerritoryId = _clientState.TerritoryType;
|
||||||
location.MapId = _clientState.MapId;
|
location.MapId = _clientState.MapId;
|
||||||
if (houseMan != null)
|
if (houseMan != null)
|
||||||
@@ -685,7 +692,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
var outside = houseMan->OutdoorTerritory;
|
var outside = houseMan->OutdoorTerritory;
|
||||||
var house = outside->HouseId;
|
var house = outside->HouseId;
|
||||||
location.WardId = house.WardIndex + 1u;
|
location.WardId = house.WardIndex + 1u;
|
||||||
location.HouseId = (uint)houseMan->GetCurrentPlot() + 1;
|
//location.HouseId = (uint)houseMan->GetCurrentPlot() + 1;
|
||||||
location.DivisionId = houseMan->GetCurrentDivision();
|
location.DivisionId = houseMan->GetCurrentDivision();
|
||||||
}
|
}
|
||||||
//_logger.LogWarning(LocationToString(location));
|
//_logger.LogWarning(LocationToString(location));
|
||||||
@@ -713,10 +720,10 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
str += $" - {MapData.Value[(ushort)location.MapId].MapName}";
|
str += $" - {MapData.Value[(ushort)location.MapId].MapName}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (location.InstanceId is not 0)
|
if (location.InstanceId is not 0)
|
||||||
// {
|
{
|
||||||
// str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString();
|
str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString();
|
||||||
// }
|
}
|
||||||
|
|
||||||
if (location.WardId is not 0)
|
if (location.WardId is not 0)
|
||||||
{
|
{
|
||||||
@@ -838,31 +845,41 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task WaitWhileCharacterIsDrawing(ILogger logger, GameObjectHandler handler, Guid redrawId, int timeOut = 5000, CancellationToken? ct = null)
|
public async Task WaitWhileCharacterIsDrawing(
|
||||||
|
ILogger logger,
|
||||||
|
GameObjectHandler handler,
|
||||||
|
Guid redrawId,
|
||||||
|
int timeOut = 5000,
|
||||||
|
CancellationToken? ct = null)
|
||||||
{
|
{
|
||||||
if (!_clientState.IsLoggedIn) return;
|
if (!_clientState.IsLoggedIn) return;
|
||||||
|
|
||||||
if (ct == null)
|
var token = ct ?? CancellationToken.None;
|
||||||
ct = CancellationToken.None;
|
|
||||||
|
const int tick = 250;
|
||||||
|
const int initialSettle = 50;
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
const int tick = 250;
|
|
||||||
int curWaitTime = 0;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
logger.LogTrace("[{redrawId}] Starting wait for {handler} to draw", redrawId, handler);
|
logger.LogTrace("[{redrawId}] Starting wait for {handler} to draw", redrawId, handler);
|
||||||
await Task.Delay(tick, ct.Value).ConfigureAwait(true);
|
|
||||||
curWaitTime += tick;
|
|
||||||
|
|
||||||
while ((!ct.Value.IsCancellationRequested)
|
await Task.Delay(initialSettle, token).ConfigureAwait(false);
|
||||||
&& curWaitTime < timeOut
|
|
||||||
&& await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false)) // 0b100000000000 is "still rendering" or something
|
while (!token.IsCancellationRequested
|
||||||
|
&& sw.ElapsedMilliseconds < timeOut
|
||||||
|
&& await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
logger.LogTrace("[{redrawId}] Waiting for {handler} to finish drawing", redrawId, handler);
|
logger.LogTrace("[{redrawId}] Waiting for {handler} to finish drawing", redrawId, handler);
|
||||||
curWaitTime += tick;
|
await Task.Delay(tick, token).ConfigureAwait(false);
|
||||||
await Task.Delay(tick, ct.Value).ConfigureAwait(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogTrace("[{redrawId}] Finished drawing after {curWaitTime}ms", redrawId, curWaitTime);
|
logger.LogTrace("[{redrawId}] Finished drawing after {ms}ms", redrawId, sw.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
catch (AccessViolationException ex)
|
catch (AccessViolationException ex)
|
||||||
{
|
{
|
||||||
@@ -1135,6 +1152,18 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
Mediator.Publish(new ZoneSwitchEndMessage());
|
Mediator.Publish(new ZoneSwitchEndMessage());
|
||||||
Mediator.Publish(new ResumeScanMessage(nameof(ConditionFlag.BetweenAreas)));
|
Mediator.Publish(new ResumeScanMessage(nameof(ConditionFlag.BetweenAreas)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Map
|
||||||
|
if (!_sentBetweenAreas)
|
||||||
|
{
|
||||||
|
var mapid = _clientState.MapId;
|
||||||
|
if (mapid != _lastMapId)
|
||||||
|
{
|
||||||
|
_lastMapId = mapid;
|
||||||
|
Mediator.Publish(new MapChangedMessage(mapid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var localPlayer = _objectTable.LocalPlayer;
|
var localPlayer = _objectTable.LocalPlayer;
|
||||||
if (localPlayer != null)
|
if (localPlayer != null)
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,5 +135,7 @@ public record ChatChannelsUpdated : MessageBase;
|
|||||||
public record ChatChannelMessageAdded(string ChannelKey, ChatMessageEntry Message) : MessageBase;
|
public record ChatChannelMessageAdded(string ChannelKey, ChatMessageEntry Message) : MessageBase;
|
||||||
public record GroupCollectionChangedMessage : MessageBase;
|
public record GroupCollectionChangedMessage : MessageBase;
|
||||||
public record OpenUserProfileMessage(UserData User) : MessageBase;
|
public record OpenUserProfileMessage(UserData User) : MessageBase;
|
||||||
|
public record LocationSharingMessage(UserData User, LocationInfo LocationInfo, DateTimeOffset ExpireAt) : MessageBase;
|
||||||
|
public record MapChangedMessage(uint MapId) : MessageBase;
|
||||||
#pragma warning restore S2094
|
#pragma warning restore S2094
|
||||||
#pragma warning restore MA0048 // File name must match type name
|
#pragma warning restore MA0048 // File name must match type name
|
||||||
@@ -105,6 +105,7 @@ public class UiFactory
|
|||||||
groupData: groupData,
|
groupData: groupData,
|
||||||
isLightfinderContext: isLightfinderContext,
|
isLightfinderContext: isLightfinderContext,
|
||||||
lightfinderCid: lightfinderCid,
|
lightfinderCid: lightfinderCid,
|
||||||
performanceCollector: _performanceCollectorService);
|
performanceCollector: _performanceCollectorService,
|
||||||
|
_apiController);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ using FFXIVClientStructs.Havok.Common.Serialize.Util;
|
|||||||
using LightlessSync.FileCache;
|
using LightlessSync.FileCache;
|
||||||
using LightlessSync.Interop.GameModel;
|
using LightlessSync.Interop.GameModel;
|
||||||
using LightlessSync.LightlessConfiguration;
|
using LightlessSync.LightlessConfiguration;
|
||||||
|
using LightlessSync.PlayerData.Factories;
|
||||||
using LightlessSync.PlayerData.Handlers;
|
using LightlessSync.PlayerData.Handlers;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace LightlessSync.Services;
|
namespace LightlessSync.Services;
|
||||||
|
|
||||||
public sealed class XivDataAnalyzer
|
public sealed partial class XivDataAnalyzer
|
||||||
{
|
{
|
||||||
private readonly ILogger<XivDataAnalyzer> _logger;
|
private readonly ILogger<XivDataAnalyzer> _logger;
|
||||||
private readonly FileCacheManager _fileCacheManager;
|
private readonly FileCacheManager _fileCacheManager;
|
||||||
@@ -29,127 +31,441 @@ public sealed class XivDataAnalyzer
|
|||||||
|
|
||||||
public unsafe Dictionary<string, List<ushort>>? GetSkeletonBoneIndices(GameObjectHandler handler)
|
public unsafe Dictionary<string, List<ushort>>? GetSkeletonBoneIndices(GameObjectHandler handler)
|
||||||
{
|
{
|
||||||
if (handler.Address == nint.Zero) return null;
|
if (handler is null || handler.Address == nint.Zero)
|
||||||
var chara = (CharacterBase*)(((Character*)handler.Address)->GameObject.DrawObject);
|
return null;
|
||||||
if (chara->GetModelType() != CharacterBase.ModelType.Human) return null;
|
|
||||||
var resHandles = chara->Skeleton->SkeletonResourceHandles;
|
Dictionary<string, HashSet<ushort>> sets = new(StringComparer.OrdinalIgnoreCase);
|
||||||
Dictionary<string, List<ushort>> outputIndices = [];
|
|
||||||
try
|
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);
|
var handle = *(resHandles + i);
|
||||||
_logger.LogTrace("Iterating over SkeletonResourceHandle #{i}:{x}", i, ((nint)handle).ToString("X"));
|
if ((nint)handle == nint.Zero)
|
||||||
if ((nint)handle == nint.Zero) continue;
|
continue;
|
||||||
var curBones = handle->BoneCount;
|
|
||||||
// this is unrealistic, the filename shouldn't ever be that long
|
if (handle->FileName.Length > 1024)
|
||||||
if (handle->FileName.Length > 1024) continue;
|
continue;
|
||||||
var skeletonName = handle->FileName.ToString();
|
|
||||||
if (string.IsNullOrEmpty(skeletonName)) continue;
|
var rawName = handle->FileName.ToString();
|
||||||
outputIndices[skeletonName] = [];
|
if (string.IsNullOrWhiteSpace(rawName))
|
||||||
for (ushort boneIdx = 0; boneIdx < curBones; boneIdx++)
|
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;
|
set = [];
|
||||||
if (boneName == null) continue;
|
sets[skeletonKey] = set;
|
||||||
outputIndices[skeletonName].Add((ushort)(boneIdx + 1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Could not process skeleton data");
|
_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 unsafe Dictionary<string, List<ushort>>? GetBoneIndicesFromPap(string hash, bool persistToConfig = true)
|
||||||
{
|
{
|
||||||
if (_configService.Current.BonesDictionary.TryGetValue(hash, out var bones)) return bones;
|
if (string.IsNullOrWhiteSpace(hash))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (_configService.Current.BonesDictionary.TryGetValue(hash, out var cached) && cached is not null)
|
||||||
|
return cached;
|
||||||
|
|
||||||
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
|
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
|
||||||
if (cacheEntity == null) return null;
|
if (cacheEntity == null || string.IsNullOrEmpty(cacheEntity.ResolvedFilepath) || !File.Exists(cacheEntity.ResolvedFilepath))
|
||||||
|
return null;
|
||||||
|
|
||||||
using BinaryReader reader = new(File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read));
|
using var fs = File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
using var reader = new BinaryReader(fs);
|
||||||
|
|
||||||
// most of this shit is from vfxeditor, surely nothing will change in the pap format :copium:
|
// PAP header (mostly from vfxeditor)
|
||||||
reader.ReadInt32(); // ignore
|
_ = reader.ReadInt32(); // ignore
|
||||||
reader.ReadInt32(); // ignore
|
_ = reader.ReadInt32(); // ignore
|
||||||
reader.ReadInt16(); // read 2 (num animations)
|
_ = reader.ReadInt16(); // num animations
|
||||||
reader.ReadInt16(); // read 2 (modelid)
|
_ = reader.ReadInt16(); // modelid
|
||||||
var type = reader.ReadByte();// read 1 (type)
|
|
||||||
if (type != 0) return null; // it's not human, just ignore it, whatever
|
var type = reader.ReadByte(); // type
|
||||||
|
if (type != 0)
|
||||||
|
return null; // not human
|
||||||
|
|
||||||
|
_ = reader.ReadByte(); // variant
|
||||||
|
_ = reader.ReadInt32(); // ignore
|
||||||
|
|
||||||
reader.ReadByte(); // read 1 (variant)
|
|
||||||
reader.ReadInt32(); // ignore
|
|
||||||
var havokPosition = reader.ReadInt32();
|
var havokPosition = reader.ReadInt32();
|
||||||
var footerPosition = reader.ReadInt32();
|
var footerPosition = reader.ReadInt32();
|
||||||
var havokDataSize = footerPosition - havokPosition;
|
|
||||||
|
// sanity checks
|
||||||
|
if (havokPosition <= 0 || footerPosition <= havokPosition || footerPosition > fs.Length)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var havokDataSizeLong = (long)footerPosition - havokPosition;
|
||||||
|
if (havokDataSizeLong <= 8 || havokDataSizeLong > int.MaxValue)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var havokDataSize = (int)havokDataSizeLong;
|
||||||
|
|
||||||
reader.BaseStream.Position = havokPosition;
|
reader.BaseStream.Position = havokPosition;
|
||||||
var havokData = reader.ReadBytes(havokDataSize);
|
var havokData = reader.ReadBytes(havokDataSize);
|
||||||
if (havokData.Length <= 8) return null; // no havok data
|
if (havokData.Length <= 8)
|
||||||
|
return null;
|
||||||
|
|
||||||
var output = new Dictionary<string, List<ushort>>(StringComparer.OrdinalIgnoreCase);
|
var tempSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
|
||||||
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) + ".hkx";
|
|
||||||
var tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath);
|
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx");
|
||||||
|
IntPtr tempHavokDataPathAnsi = IntPtr.Zero;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
File.WriteAllBytes(tempHavokDataPath, havokData);
|
File.WriteAllBytes(tempHavokDataPath, havokData);
|
||||||
|
|
||||||
|
if (!File.Exists(tempHavokDataPath))
|
||||||
|
{
|
||||||
|
_logger.LogTrace("Temporary havok file did not exist when attempting to load: {path}", tempHavokDataPath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath);
|
||||||
|
|
||||||
var loadoptions = stackalloc hkSerializeUtil.LoadOptions[1];
|
var loadoptions = stackalloc hkSerializeUtil.LoadOptions[1];
|
||||||
loadoptions->TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry();
|
loadoptions->TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry();
|
||||||
loadoptions->ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry();
|
loadoptions->ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry();
|
||||||
loadoptions->Flags = new hkFlags<hkSerializeUtil.LoadOptionBits, int>
|
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);
|
var resource = hkSerializeUtil.LoadFromFile((byte*)tempHavokDataPathAnsi, null, loadoptions);
|
||||||
if (resource == null)
|
if (resource == null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Resource was null after loading");
|
_logger.LogWarning("Havok resource was null after loading from {path}", tempHavokDataPath);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var rootLevelName = @"hkRootLevelContainer"u8;
|
var rootLevelName = @"hkRootLevelContainer"u8;
|
||||||
fixed (byte* n1 = rootLevelName)
|
fixed (byte* n1 = rootLevelName)
|
||||||
{
|
{
|
||||||
var container = (hkRootLevelContainer*)resource->GetContentsPointer(n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry());
|
var container = (hkRootLevelContainer*)resource->GetContentsPointer(n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry());
|
||||||
|
if (container == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
var animationName = @"hkaAnimationContainer"u8;
|
var animationName = @"hkaAnimationContainer"u8;
|
||||||
fixed (byte* n2 = animationName)
|
fixed (byte* n2 = animationName)
|
||||||
{
|
{
|
||||||
var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null);
|
var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null);
|
||||||
|
if (animContainer == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
for (int i = 0; i < animContainer->Bindings.Length; i++)
|
for (int i = 0; i < animContainer->Bindings.Length; i++)
|
||||||
{
|
{
|
||||||
var binding = animContainer->Bindings[i].ptr;
|
var binding = animContainer->Bindings[i].ptr;
|
||||||
|
if (binding == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var rawSkel = binding->OriginalSkeletonName.String;
|
||||||
|
var skeletonKey = CanonicalizeSkeletonKey(rawSkel);
|
||||||
|
if (string.IsNullOrEmpty(skeletonKey))
|
||||||
|
continue;
|
||||||
|
|
||||||
var boneTransform = binding->TransformTrackToBoneIndices;
|
var boneTransform = binding->TransformTrackToBoneIndices;
|
||||||
string name = binding->OriginalSkeletonName.String! + "_" + i;
|
if (boneTransform.Length <= 0)
|
||||||
output[name] = [];
|
continue;
|
||||||
|
|
||||||
|
if (!tempSets.TryGetValue(skeletonKey, out var set))
|
||||||
|
{
|
||||||
|
set = [];
|
||||||
|
tempSets[skeletonKey] = set;
|
||||||
|
}
|
||||||
|
|
||||||
for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++)
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Could not load havok file in {path}", tempHavokDataPath);
|
_logger.LogWarning(ex, "Could not load havok file in {path}", tempHavokDataPath);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
Marshal.FreeHGlobal(tempHavokDataPathAnsi);
|
if (tempHavokDataPathAnsi != IntPtr.Zero)
|
||||||
File.Delete(tempHavokDataPath);
|
Marshal.FreeHGlobal(tempHavokDataPathAnsi);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(tempHavokDataPath))
|
||||||
|
File.Delete(tempHavokDataPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogTrace(ex, "Could not delete temporary havok file: {path}", tempHavokDataPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tempSets.Count == 0)
|
||||||
|
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.Current.BonesDictionary[hash] = output;
|
||||||
_configService.Save();
|
|
||||||
|
if (persistToConfig)
|
||||||
|
_configService.Save();
|
||||||
|
|
||||||
return output;
|
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 papBuckets = papBoneIndices.Keys
|
||||||
|
.Select(CanonicalizeSkeletonKey)
|
||||||
|
.Where(k => !string.IsNullOrEmpty(k))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (papBuckets.Count == 0)
|
||||||
|
{
|
||||||
|
reason = "No skeleton bucket bindings found in the PAP";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == AnimationValidationMode.Safe)
|
||||||
|
{
|
||||||
|
if (papBuckets.Any(b => localBoneSets.ContainsKey(b)))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
reason = $"No matching skeleton bucket between PAP [{string.Join(", ", papBuckets)}] and local [{string.Join(", ", localBoneSets.Keys.Order())}].";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var bucket in papBuckets)
|
||||||
|
{
|
||||||
|
if (!localBoneSets.TryGetValue(bucket, out var available))
|
||||||
|
{
|
||||||
|
reason = $"Missing skeleton bucket '{bucket}' on local actor.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var indices = papBoneIndices
|
||||||
|
.Where(kvp => string.Equals(CanonicalizeSkeletonKey(kvp.Key), bucket, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.SelectMany(kvp => kvp.Value ?? Enumerable.Empty<ushort>())
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (indices.Count == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
foreach (var idx in indices)
|
||||||
|
{
|
||||||
|
if (!ContainsIndexCompat(available, idx, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance))
|
||||||
|
{
|
||||||
|
reason = $"No compatible local skeleton for PAP '{bucket}': missing bone index {idx}.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
public async Task<long> GetTrianglesByHash(string hash)
|
||||||
{
|
{
|
||||||
if (_configService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris) && cachedTris > 0)
|
if (_configService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris) && cachedTris > 0)
|
||||||
@@ -212,4 +528,23 @@ public sealed class XivDataAnalyzer
|
|||||||
return 0;
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public class DrawUserPair
|
|||||||
private readonly UiSharedService _uiSharedService;
|
private readonly UiSharedService _uiSharedService;
|
||||||
private readonly PlayerPerformanceConfigService _performanceConfigService;
|
private readonly PlayerPerformanceConfigService _performanceConfigService;
|
||||||
private readonly LightlessConfigService _configService;
|
private readonly LightlessConfigService _configService;
|
||||||
|
private readonly LocationShareService _locationShareService;
|
||||||
private readonly CharaDataManager _charaDataManager;
|
private readonly CharaDataManager _charaDataManager;
|
||||||
private readonly PairLedger _pairLedger;
|
private readonly PairLedger _pairLedger;
|
||||||
private float _menuWidth = -1;
|
private float _menuWidth = -1;
|
||||||
@@ -57,6 +58,7 @@ public class DrawUserPair
|
|||||||
UiSharedService uiSharedService,
|
UiSharedService uiSharedService,
|
||||||
PlayerPerformanceConfigService performanceConfigService,
|
PlayerPerformanceConfigService performanceConfigService,
|
||||||
LightlessConfigService configService,
|
LightlessConfigService configService,
|
||||||
|
LocationShareService locationShareService,
|
||||||
CharaDataManager charaDataManager,
|
CharaDataManager charaDataManager,
|
||||||
PairLedger pairLedger)
|
PairLedger pairLedger)
|
||||||
{
|
{
|
||||||
@@ -74,6 +76,7 @@ public class DrawUserPair
|
|||||||
_uiSharedService = uiSharedService;
|
_uiSharedService = uiSharedService;
|
||||||
_performanceConfigService = performanceConfigService;
|
_performanceConfigService = performanceConfigService;
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
|
_locationShareService = locationShareService;
|
||||||
_charaDataManager = charaDataManager;
|
_charaDataManager = charaDataManager;
|
||||||
_pairLedger = pairLedger;
|
_pairLedger = pairLedger;
|
||||||
}
|
}
|
||||||
@@ -216,6 +219,48 @@ public class DrawUserPair
|
|||||||
_ = _apiController.UserSetPairPermissions(new UserPermissionsDto(_pair.UserData, permissions));
|
_ = _apiController.UserSetPairPermissions(new UserPermissionsDto(_pair.UserData, permissions));
|
||||||
}
|
}
|
||||||
UiSharedService.AttachToolTip("Changes VFX sync permissions with this user." + (individual ? individualText : string.Empty));
|
UiSharedService.AttachToolTip("Changes VFX sync permissions with this user." + (individual ? individualText : string.Empty));
|
||||||
|
|
||||||
|
ImGui.SetCursorPosX(10f);
|
||||||
|
_uiSharedService.IconText(FontAwesomeIcon.Globe);
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGui.BeginMenu("Toggle Location sharing"))
|
||||||
|
{
|
||||||
|
if (ImGui.MenuItem("Share for 30 Mins"))
|
||||||
|
{
|
||||||
|
_ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.UtcNow.AddMinutes(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ImGui.MenuItem("Share for 1 Hour"))
|
||||||
|
{
|
||||||
|
_ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.UtcNow.AddHours(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ImGui.MenuItem("Share for 3 Hours"))
|
||||||
|
{
|
||||||
|
_ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.UtcNow.AddHours(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ImGui.MenuItem("Share until manually stop"))
|
||||||
|
{
|
||||||
|
_ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.Separator();
|
||||||
|
if (ImGui.MenuItem("Stop Sharing"))
|
||||||
|
{
|
||||||
|
_ = ToggleLocationSharing([_pair.UserData.UID], DateTimeOffset.MinValue);
|
||||||
|
}
|
||||||
|
ImGui.EndMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToggleLocationSharing(List<string> users, DateTimeOffset expireAt)
|
||||||
|
{
|
||||||
|
var updated = await _apiController.ToggleLocationSharing(new LocationSharingToggleDto(users, expireAt)).ConfigureAwait(false);
|
||||||
|
if (updated)
|
||||||
|
{
|
||||||
|
_locationShareService.UpdateSharingStatus(users, expireAt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawIndividualMenu()
|
private void DrawIndividualMenu()
|
||||||
@@ -574,6 +619,71 @@ public class DrawUserPair
|
|||||||
var individualVFXDisabled = (_pair.UserPair?.OwnPermissions.IsDisableVFX() ?? false) || (_pair.UserPair?.OtherPermissions.IsDisableVFX() ?? false);
|
var individualVFXDisabled = (_pair.UserPair?.OwnPermissions.IsDisableVFX() ?? false) || (_pair.UserPair?.OtherPermissions.IsDisableVFX() ?? false);
|
||||||
var individualIsSticky = _pair.UserPair!.OwnPermissions.IsSticky();
|
var individualIsSticky = _pair.UserPair!.OwnPermissions.IsSticky();
|
||||||
var individualIcon = individualIsSticky ? FontAwesomeIcon.ArrowCircleUp : FontAwesomeIcon.InfoCircle;
|
var individualIcon = individualIsSticky ? FontAwesomeIcon.ArrowCircleUp : FontAwesomeIcon.InfoCircle;
|
||||||
|
|
||||||
|
var shareLocationIcon = FontAwesomeIcon.Globe;
|
||||||
|
var location = _locationShareService.GetUserLocation(_pair.UserPair!.User.UID);
|
||||||
|
var shareLocation = !string.IsNullOrEmpty(location);
|
||||||
|
var expireAt = _locationShareService.GetSharingStatus(_pair.UserPair!.User.UID);
|
||||||
|
var shareLocationToOther = expireAt > DateTimeOffset.UtcNow;
|
||||||
|
var shareColor = shareLocation switch
|
||||||
|
{
|
||||||
|
true when shareLocationToOther => UIColors.Get("LightlessGreen"),
|
||||||
|
true when !shareLocationToOther => UIColors.Get("LightlessBlue"),
|
||||||
|
_ => UIColors.Get("LightlessYellow"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shareLocation || shareLocationToOther)
|
||||||
|
{
|
||||||
|
currentRightSide -= (_uiSharedService.GetIconSize(shareLocationIcon).X + spacingX);
|
||||||
|
ImGui.SameLine(currentRightSide);
|
||||||
|
using (ImRaii.PushColor(ImGuiCol.Text, shareColor, shareLocation || shareLocationToOther))
|
||||||
|
_uiSharedService.IconText(shareLocationIcon);
|
||||||
|
|
||||||
|
if (ImGui.IsItemHovered())
|
||||||
|
{
|
||||||
|
ImGui.BeginTooltip();
|
||||||
|
|
||||||
|
if (_pair.IsOnline)
|
||||||
|
{
|
||||||
|
if (shareLocation)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(location))
|
||||||
|
{
|
||||||
|
_uiSharedService.IconText(FontAwesomeIcon.LocationArrow);
|
||||||
|
ImGui.SameLine();
|
||||||
|
ImGui.TextUnformatted(location);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ImGui.TextUnformatted("Location info not updated, reconnect or wait for update.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ImGui.TextUnformatted("NOT Sharing location with you. o(TヘTo)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ImGui.TextUnformatted("User not online. (´・ω・`)?");
|
||||||
|
}
|
||||||
|
ImGui.Separator();
|
||||||
|
|
||||||
|
if (shareLocationToOther)
|
||||||
|
{
|
||||||
|
ImGui.TextUnformatted("Sharing your location. ヾ(•ω•`)o");
|
||||||
|
if (expireAt != DateTimeOffset.MaxValue)
|
||||||
|
{
|
||||||
|
ImGui.TextUnformatted("Expires at " + expireAt.ToLocalTime().ToString("g"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ImGui.TextUnformatted("NOT sharing your location.  ̄へ ̄");
|
||||||
|
}
|
||||||
|
ImGui.EndTooltip();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (individualAnimDisabled || individualSoundsDisabled || individualVFXDisabled || individualIsSticky)
|
if (individualAnimDisabled || individualSoundsDisabled || individualVFXDisabled || individualIsSticky)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2183,7 +2183,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
|||||||
bool toggleClicked = false;
|
bool toggleClicked = false;
|
||||||
if (showToggle)
|
if (showToggle)
|
||||||
{
|
{
|
||||||
var icon = isCollapsed ? FontAwesomeIcon.ChevronRight : FontAwesomeIcon.ChevronLeft;
|
var icon = !isCollapsed ? FontAwesomeIcon.ChevronRight : FontAwesomeIcon.ChevronLeft;
|
||||||
Vector2 iconSize;
|
Vector2 iconSize;
|
||||||
using (_uiSharedService.IconFont.Push())
|
using (_uiSharedService.IconFont.Push())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
|||||||
private readonly ConcurrentDictionary<GameObjectHandler, bool> _uploadingPlayers = new();
|
private readonly ConcurrentDictionary<GameObjectHandler, bool> _uploadingPlayers = new();
|
||||||
private readonly Dictionary<GameObjectHandler, Vector2> _smoothed = [];
|
private readonly Dictionary<GameObjectHandler, Vector2> _smoothed = [];
|
||||||
private readonly Dictionary<GameObjectHandler, DownloadSpeedTracker> _downloadSpeeds = [];
|
private readonly Dictionary<GameObjectHandler, DownloadSpeedTracker> _downloadSpeeds = [];
|
||||||
|
private readonly Dictionary<GameObjectHandler, (int TotalFiles, long TotalBytes)> _downloadInitialTotals = [];
|
||||||
|
|
||||||
|
|
||||||
private byte _transferBoxTransparency = 100;
|
private byte _transferBoxTransparency = 100;
|
||||||
private bool _notificationDismissed = true;
|
private bool _notificationDismissed = true;
|
||||||
@@ -66,6 +68,10 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
|||||||
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) =>
|
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) =>
|
||||||
{
|
{
|
||||||
_currentDownloads[msg.DownloadId] = msg.DownloadStatus;
|
_currentDownloads[msg.DownloadId] = msg.DownloadStatus;
|
||||||
|
// Capture initial totals when download starts
|
||||||
|
var totalFiles = msg.DownloadStatus.Values.Sum(s => s.TotalFiles);
|
||||||
|
var totalBytes = msg.DownloadStatus.Values.Sum(s => s.TotalBytes);
|
||||||
|
_downloadInitialTotals[msg.DownloadId] = (totalFiles, totalBytes);
|
||||||
_notificationDismissed = false;
|
_notificationDismissed = false;
|
||||||
});
|
});
|
||||||
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) =>
|
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) =>
|
||||||
@@ -167,7 +173,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
|||||||
List<KeyValuePair<GameObjectHandler, Dictionary<string, FileDownloadStatus>>> transfers;
|
List<KeyValuePair<GameObjectHandler, Dictionary<string, FileDownloadStatus>>> transfers;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
transfers = _currentDownloads.ToList();
|
transfers = [.. _currentDownloads];
|
||||||
}
|
}
|
||||||
catch (ArgumentException)
|
catch (ArgumentException)
|
||||||
{
|
{
|
||||||
@@ -435,9 +441,13 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
|||||||
var handler = transfer.Key;
|
var handler = transfer.Key;
|
||||||
var statuses = transfer.Value.Values;
|
var statuses = transfer.Value.Values;
|
||||||
|
|
||||||
var playerTotalFiles = statuses.Sum(s => s.TotalFiles);
|
var (playerTotalFiles, playerTotalBytes) = _downloadInitialTotals.TryGetValue(handler, out var totals)
|
||||||
var playerTransferredFiles = statuses.Sum(s => s.TransferredFiles);
|
? totals
|
||||||
var playerTotalBytes = statuses.Sum(s => s.TotalBytes);
|
: (statuses.Sum(s => s.TotalFiles), statuses.Sum(s => s.TotalBytes));
|
||||||
|
|
||||||
|
var playerTransferredFiles = statuses.Count(s =>
|
||||||
|
s.DownloadStatus == DownloadStatus.Decompressing ||
|
||||||
|
s.TransferredBytes >= s.TotalBytes);
|
||||||
var playerTransferredBytes = statuses.Sum(s => s.TransferredBytes);
|
var playerTransferredBytes = statuses.Sum(s => s.TransferredBytes);
|
||||||
|
|
||||||
totalFiles += playerTotalFiles;
|
totalFiles += playerTotalFiles;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class DrawEntityFactory
|
|||||||
private readonly LightlessConfigService _configService;
|
private readonly LightlessConfigService _configService;
|
||||||
private readonly UiSharedService _uiSharedService;
|
private readonly UiSharedService _uiSharedService;
|
||||||
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
|
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
|
||||||
|
private readonly LocationShareService _locationShareService;
|
||||||
private readonly CharaDataManager _charaDataManager;
|
private readonly CharaDataManager _charaDataManager;
|
||||||
private readonly SelectTagForPairUi _selectTagForPairUi;
|
private readonly SelectTagForPairUi _selectTagForPairUi;
|
||||||
private readonly RenamePairTagUi _renamePairTagUi;
|
private readonly RenamePairTagUi _renamePairTagUi;
|
||||||
@@ -53,6 +54,7 @@ public class DrawEntityFactory
|
|||||||
LightlessConfigService configService,
|
LightlessConfigService configService,
|
||||||
UiSharedService uiSharedService,
|
UiSharedService uiSharedService,
|
||||||
PlayerPerformanceConfigService playerPerformanceConfigService,
|
PlayerPerformanceConfigService playerPerformanceConfigService,
|
||||||
|
LocationShareService locationShareService,
|
||||||
CharaDataManager charaDataManager,
|
CharaDataManager charaDataManager,
|
||||||
SelectTagForSyncshellUi selectTagForSyncshellUi,
|
SelectTagForSyncshellUi selectTagForSyncshellUi,
|
||||||
RenameSyncshellTagUi renameSyncshellTagUi,
|
RenameSyncshellTagUi renameSyncshellTagUi,
|
||||||
@@ -72,6 +74,7 @@ public class DrawEntityFactory
|
|||||||
_configService = configService;
|
_configService = configService;
|
||||||
_uiSharedService = uiSharedService;
|
_uiSharedService = uiSharedService;
|
||||||
_playerPerformanceConfigService = playerPerformanceConfigService;
|
_playerPerformanceConfigService = playerPerformanceConfigService;
|
||||||
|
_locationShareService = locationShareService;
|
||||||
_charaDataManager = charaDataManager;
|
_charaDataManager = charaDataManager;
|
||||||
_selectTagForSyncshellUi = selectTagForSyncshellUi;
|
_selectTagForSyncshellUi = selectTagForSyncshellUi;
|
||||||
_renameSyncshellTagUi = renameSyncshellTagUi;
|
_renameSyncshellTagUi = renameSyncshellTagUi;
|
||||||
@@ -162,6 +165,7 @@ public class DrawEntityFactory
|
|||||||
_uiSharedService,
|
_uiSharedService,
|
||||||
_playerPerformanceConfigService,
|
_playerPerformanceConfigService,
|
||||||
_configService,
|
_configService,
|
||||||
|
_locationShareService,
|
||||||
_charaDataManager,
|
_charaDataManager,
|
||||||
_pairLedger);
|
_pairLedger);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ using LightlessSync.Interop.Ipc;
|
|||||||
using LightlessSync.LightlessConfiguration;
|
using LightlessSync.LightlessConfiguration;
|
||||||
using LightlessSync.LightlessConfiguration.Configurations;
|
using LightlessSync.LightlessConfiguration.Configurations;
|
||||||
using LightlessSync.LightlessConfiguration.Models;
|
using LightlessSync.LightlessConfiguration.Models;
|
||||||
|
using LightlessSync.PlayerData.Factories;
|
||||||
using LightlessSync.PlayerData.Handlers;
|
using LightlessSync.PlayerData.Handlers;
|
||||||
using LightlessSync.PlayerData.Pairs;
|
using LightlessSync.PlayerData.Pairs;
|
||||||
using LightlessSync.Services;
|
using LightlessSync.Services;
|
||||||
@@ -40,6 +41,7 @@ using System.Globalization;
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
@@ -84,6 +86,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
private bool _pairDiagnosticsEnabled;
|
private bool _pairDiagnosticsEnabled;
|
||||||
private string? _selectedPairDebugUid = null;
|
private string? _selectedPairDebugUid = null;
|
||||||
private string _lightfinderIconInput = string.Empty;
|
private string _lightfinderIconInput = string.Empty;
|
||||||
|
private bool _showLightfinderRendererWarning = false;
|
||||||
|
private LightfinderLabelRenderer _pendingLightfinderRenderer = LightfinderLabelRenderer.Pictomancy;
|
||||||
private bool _lightfinderIconInputInitialized = false;
|
private bool _lightfinderIconInputInitialized = false;
|
||||||
private int _lightfinderIconPresetIndex = -1;
|
private int _lightfinderIconPresetIndex = -1;
|
||||||
private static readonly LightlessConfig DefaultConfig = new();
|
private static readonly LightlessConfig DefaultConfig = new();
|
||||||
@@ -103,8 +107,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
};
|
};
|
||||||
private readonly UiSharedService.TabOption<TransferSettingsTab>[] _transferTabOptions = new UiSharedService.TabOption<TransferSettingsTab>[2];
|
private readonly UiSharedService.TabOption<TransferSettingsTab>[] _transferTabOptions = new UiSharedService.TabOption<TransferSettingsTab>[2];
|
||||||
private readonly List<UiSharedService.TabOption<ServerSettingsTab>> _serverTabOptions = new(4);
|
private readonly List<UiSharedService.TabOption<ServerSettingsTab>> _serverTabOptions = new(4);
|
||||||
private readonly string[] _generalTreeNavOrder = new[]
|
private readonly string[] _generalTreeNavOrder =
|
||||||
{
|
[
|
||||||
"Import & Export",
|
"Import & Export",
|
||||||
"Popup & Auto Fill",
|
"Popup & Auto Fill",
|
||||||
"Behavior",
|
"Behavior",
|
||||||
@@ -114,7 +118,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
"Colors",
|
"Colors",
|
||||||
"Server Info Bar",
|
"Server Info Bar",
|
||||||
"Nameplate",
|
"Nameplate",
|
||||||
};
|
"Animation & Bones"
|
||||||
|
];
|
||||||
private static readonly HashSet<string> _generalNavSeparatorAfter = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> _generalNavSeparatorAfter = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
"Popup & Auto Fill",
|
"Popup & Auto Fill",
|
||||||
@@ -1139,7 +1144,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
private async Task<List<string>?> RunSpeedTest(List<string> servers, CancellationToken token)
|
private async Task<List<string>?> RunSpeedTest(List<string> servers, CancellationToken token)
|
||||||
{
|
{
|
||||||
List<string> speedTestResults = new();
|
List<string> speedTestResults = [];
|
||||||
foreach (var server in servers)
|
foreach (var server in servers)
|
||||||
{
|
{
|
||||||
HttpResponseMessage? result = null;
|
HttpResponseMessage? result = null;
|
||||||
@@ -1923,14 +1928,25 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
using (ImRaii.PushIndent(20f))
|
using (ImRaii.PushIndent(20f))
|
||||||
{
|
{
|
||||||
if (_validationTask.IsCompleted)
|
if (_validationTask.IsCompletedSuccessfully)
|
||||||
{
|
{
|
||||||
UiSharedService.TextWrapped(
|
UiSharedService.TextWrapped(
|
||||||
$"The storage validation has completed and removed {_validationTask.Result.Count} invalid files from storage.");
|
$"The storage validation has completed and removed {_validationTask.Result.Count} invalid files from storage.");
|
||||||
}
|
}
|
||||||
|
else if (_validationTask.IsCanceled)
|
||||||
|
{
|
||||||
|
UiSharedService.ColorTextWrapped(
|
||||||
|
"Storage validation was cancelled.",
|
||||||
|
UIColors.Get("LightlessYellow"));
|
||||||
|
}
|
||||||
|
else if (_validationTask.IsFaulted)
|
||||||
|
{
|
||||||
|
UiSharedService.ColorTextWrapped(
|
||||||
|
"Storage validation failed with an error.",
|
||||||
|
UIColors.Get("DimRed"));
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
||||||
UiSharedService.TextWrapped(
|
UiSharedService.TextWrapped(
|
||||||
$"Storage validation is running: {_currentProgress.Item1}/{_currentProgress.Item2}");
|
$"Storage validation is running: {_currentProgress.Item1}/{_currentProgress.Item2}");
|
||||||
if (_currentProgress.Item3 != null)
|
if (_currentProgress.Item3 != null)
|
||||||
@@ -2372,7 +2388,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
var labelRenderer = _configService.Current.LightfinderLabelRenderer;
|
var labelRenderer = _configService.Current.LightfinderLabelRenderer;
|
||||||
var labelRendererLabel = labelRenderer switch
|
var labelRendererLabel = labelRenderer switch
|
||||||
{
|
{
|
||||||
LightfinderLabelRenderer.SignatureHook => "Native nameplate (sig hook)",
|
LightfinderLabelRenderer.SignatureHook => "Native Nameplate Rendering",
|
||||||
_ => "ImGui Overlay",
|
_ => "ImGui Overlay",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2382,18 +2398,25 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
var optionLabel = option switch
|
var optionLabel = option switch
|
||||||
{
|
{
|
||||||
LightfinderLabelRenderer.SignatureHook => "Native Nameplate (sig hook)",
|
LightfinderLabelRenderer.SignatureHook => "Native Nameplate Rendering",
|
||||||
_ => "ImGui Overlay",
|
_ => "ImGui Overlay",
|
||||||
};
|
};
|
||||||
|
|
||||||
var selected = option == labelRenderer;
|
var selected = option == labelRenderer;
|
||||||
if (ImGui.Selectable(optionLabel, selected))
|
if (ImGui.Selectable(optionLabel, selected))
|
||||||
{
|
{
|
||||||
_configService.Current.LightfinderLabelRenderer = option;
|
if (option == LightfinderLabelRenderer.SignatureHook)
|
||||||
_configService.Save();
|
{
|
||||||
_nameplateService.RequestRedraw();
|
_pendingLightfinderRenderer = option;
|
||||||
|
_showLightfinderRendererWarning = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_configService.Current.LightfinderLabelRenderer = option;
|
||||||
|
_configService.Save();
|
||||||
|
_nameplateService.RequestRedraw();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected)
|
if (selected)
|
||||||
ImGui.SetItemDefaultFocus();
|
ImGui.SetItemDefaultFocus();
|
||||||
}
|
}
|
||||||
@@ -2401,6 +2424,34 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
ImGui.EndCombo();
|
ImGui.EndCombo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_showLightfinderRendererWarning)
|
||||||
|
{
|
||||||
|
ImGui.SetNextWindowSize(new Vector2(450f, 0f), ImGuiCond.Appearing);
|
||||||
|
ImGui.OpenPopup("Nameplate Warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ImGui.BeginPopupModal("Nameplate Warning", ref _showLightfinderRendererWarning, ImGuiWindowFlags.AlwaysAutoResize))
|
||||||
|
{
|
||||||
|
ImGui.TextColored(UIColors.Get("DimRed"), "USE AT YOUR RISK!");
|
||||||
|
ImGui.Spacing();
|
||||||
|
ImGui.TextWrapped("Writing on to the native Nameplates is known to be unstable and MAY cause crashes. DO NOT REPORT THOSE CRASHES TO DALAMUD. We will also not be supporting Nameplate crashes. You have been warned.");
|
||||||
|
ImGui.Spacing();
|
||||||
|
ImGui.TextWrapped("By accepting this warning, you understand that you are using this feature at risk of crashing.");
|
||||||
|
ImGui.Spacing();
|
||||||
|
|
||||||
|
var buttonWidth = ImGui.GetContentRegionAvail().X;
|
||||||
|
if (ImGui.Button("I Understand", new Vector2(buttonWidth, 0)))
|
||||||
|
{
|
||||||
|
_configService.Current.LightfinderLabelRenderer = _pendingLightfinderRenderer;
|
||||||
|
_configService.Save();
|
||||||
|
_nameplateService.RequestRedraw();
|
||||||
|
_showLightfinderRendererWarning = false;
|
||||||
|
ImGui.CloseCurrentPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.EndPopup();
|
||||||
|
}
|
||||||
|
|
||||||
_uiShared.DrawHelpText("Choose how Lightfinder labels render: the default ImGui overlay or native nameplate nodes via signature hook.");
|
_uiShared.DrawHelpText("Choose how Lightfinder labels render: the default ImGui overlay or native nameplate nodes via signature hook.");
|
||||||
|
|
||||||
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f);
|
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f);
|
||||||
@@ -3037,10 +3088,102 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
ImGui.Separator();
|
ImGui.Separator();
|
||||||
|
ImGui.Dummy(new Vector2(10));
|
||||||
|
_uiShared.BigText("Animation");
|
||||||
|
|
||||||
|
using (var animationTree = BeginGeneralTree("Animation & Bones", UIColors.Get("LightlessPurple")))
|
||||||
|
{
|
||||||
|
if (animationTree.Visible)
|
||||||
|
{
|
||||||
|
ImGui.TextUnformatted("Animation Options");
|
||||||
|
|
||||||
|
var modes = new[]
|
||||||
|
{
|
||||||
|
AnimationValidationMode.Unsafe,
|
||||||
|
AnimationValidationMode.Safe,
|
||||||
|
AnimationValidationMode.Safest,
|
||||||
|
};
|
||||||
|
|
||||||
|
var labels = new[]
|
||||||
|
{
|
||||||
|
"Unsafe",
|
||||||
|
"Safe (Race)",
|
||||||
|
"Safest (Race + Bones)",
|
||||||
|
};
|
||||||
|
|
||||||
|
var tooltips = new[]
|
||||||
|
{
|
||||||
|
"No validation. Fastest, but may allow incompatible animations (riskier).",
|
||||||
|
"Validates skeleton race + modded skeleton check (recommended).",
|
||||||
|
"Requires matching skeleton race + bone compatibility (strictest).",
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
var currentMode = _configService.Current.AnimationValidationMode;
|
||||||
|
int selectedIndex = Array.IndexOf(modes, currentMode);
|
||||||
|
if (selectedIndex < 0) selectedIndex = 1;
|
||||||
|
|
||||||
|
ImGui.SetNextItemWidth(140 * ImGuiHelpers.GlobalScale);
|
||||||
|
|
||||||
|
bool open = ImGui.BeginCombo("Animation validation", labels[selectedIndex]);
|
||||||
|
|
||||||
|
if (ImGui.IsItemHovered())
|
||||||
|
ImGui.SetTooltip(tooltips[selectedIndex]);
|
||||||
|
|
||||||
|
if (open)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < modes.Length; i++)
|
||||||
|
{
|
||||||
|
bool isSelected = (i == selectedIndex);
|
||||||
|
|
||||||
|
if (ImGui.Selectable(labels[i], isSelected))
|
||||||
|
{
|
||||||
|
selectedIndex = i;
|
||||||
|
_configService.Current.AnimationValidationMode = modes[i];
|
||||||
|
_configService.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ImGui.IsItemHovered())
|
||||||
|
ImGui.SetTooltip(tooltips[i]);
|
||||||
|
|
||||||
|
if (isSelected)
|
||||||
|
ImGui.SetItemDefaultFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.EndCombo();
|
||||||
|
}
|
||||||
|
|
||||||
|
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
|
||||||
|
|
||||||
|
var cfg = _configService.Current;
|
||||||
|
|
||||||
|
bool oneBased = cfg.AnimationAllowOneBasedShift;
|
||||||
|
if (ImGui.Checkbox("Treat 1-based PAP indices as compatible", ref oneBased))
|
||||||
|
{
|
||||||
|
cfg.AnimationAllowOneBasedShift = oneBased;
|
||||||
|
_configService.Save();
|
||||||
|
}
|
||||||
|
if (ImGui.IsItemHovered())
|
||||||
|
ImGui.SetTooltip("Fixes off-by-one PAPs (one bone differance in bones and PAP). Can also increase crashing, toggle off if alot of crashing is happening");
|
||||||
|
|
||||||
|
bool neighbor = cfg.AnimationAllowNeighborIndexTolerance;
|
||||||
|
if (ImGui.Checkbox("Allow 1+- bone index tolerance", ref neighbor))
|
||||||
|
{
|
||||||
|
cfg.AnimationAllowNeighborIndexTolerance = neighbor;
|
||||||
|
_configService.Save();
|
||||||
|
}
|
||||||
|
if (ImGui.IsItemHovered())
|
||||||
|
ImGui.SetTooltip("Looser matching on bone matching. Can reduce false blocks happening but also reduces safety and more prone to crashing.");
|
||||||
|
|
||||||
|
ImGui.TreePop();
|
||||||
|
animationTree.MarkContentEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ImGui.EndChild();
|
ImGui.EndChild();
|
||||||
ImGui.EndGroup();
|
ImGui.EndGroup();
|
||||||
|
|
||||||
|
ImGui.Separator();
|
||||||
generalSelune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
|
generalSelune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3130,6 +3273,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
return 1f - (elapsed / GeneralTreeHighlightDuration);
|
return 1f - (elapsed / GeneralTreeHighlightDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Auto)]
|
||||||
private struct GeneralTreeScope : IDisposable
|
private struct GeneralTreeScope : IDisposable
|
||||||
{
|
{
|
||||||
private readonly bool _visible;
|
private readonly bool _visible;
|
||||||
@@ -3437,7 +3581,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
_uiShared.DrawHelpText("Controls whether Lightless reduces index textures that exceed the size limit.");
|
_uiShared.DrawHelpText("Controls whether Lightless reduces index textures that exceed the size limit.");
|
||||||
|
|
||||||
var dimensionOptions = new[] { 512, 1024, 2048, 4096 };
|
var dimensionOptions = new[] { 512, 1024, 2048, 4096 };
|
||||||
var optionLabels = dimensionOptions.Select(static value => value.ToString()).ToArray();
|
var optionLabels = dimensionOptions.Select(selector: static value => value.ToString()).ToArray();
|
||||||
var currentDimension = textureConfig.TextureDownscaleMaxDimension;
|
var currentDimension = textureConfig.TextureDownscaleMaxDimension;
|
||||||
var selectedIndex = Array.IndexOf(dimensionOptions, currentDimension);
|
var selectedIndex = Array.IndexOf(dimensionOptions, currentDimension);
|
||||||
if (selectedIndex < 0)
|
if (selectedIndex < 0)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using LightlessSync.Services.ServerConfiguration;
|
|||||||
using LightlessSync.UI.Services;
|
using LightlessSync.UI.Services;
|
||||||
using LightlessSync.UI.Tags;
|
using LightlessSync.UI.Tags;
|
||||||
using LightlessSync.Utils;
|
using LightlessSync.Utils;
|
||||||
|
using LightlessSync.WebAPI;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase
|
|||||||
private readonly PairUiService _pairUiService;
|
private readonly PairUiService _pairUiService;
|
||||||
private readonly ServerConfigurationManager _serverManager;
|
private readonly ServerConfigurationManager _serverManager;
|
||||||
private readonly ProfileTagService _profileTagService;
|
private readonly ProfileTagService _profileTagService;
|
||||||
|
private readonly ApiController _apiController;
|
||||||
private readonly UiSharedService _uiSharedService;
|
private readonly UiSharedService _uiSharedService;
|
||||||
private readonly UserData? _userData;
|
private readonly UserData? _userData;
|
||||||
private readonly GroupData? _groupData;
|
private readonly GroupData? _groupData;
|
||||||
@@ -60,7 +62,8 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase
|
|||||||
GroupData? groupData,
|
GroupData? groupData,
|
||||||
bool isLightfinderContext,
|
bool isLightfinderContext,
|
||||||
string? lightfinderCid,
|
string? lightfinderCid,
|
||||||
PerformanceCollectorService performanceCollector)
|
PerformanceCollectorService performanceCollector,
|
||||||
|
ApiController apiController)
|
||||||
: base(logger, mediator, BuildWindowTitle(
|
: base(logger, mediator, BuildWindowTitle(
|
||||||
userData,
|
userData,
|
||||||
groupData,
|
groupData,
|
||||||
@@ -94,6 +97,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase
|
|||||||
.Apply();
|
.Apply();
|
||||||
|
|
||||||
IsOpen = true;
|
IsOpen = true;
|
||||||
|
_apiController = apiController;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Pair? Pair { get; }
|
public Pair? Pair { get; }
|
||||||
@@ -248,19 +252,33 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase
|
|||||||
ResetBannerTexture();
|
ResetBannerTexture();
|
||||||
_lastBannerPicture = bannerBytes;
|
_lastBannerPicture = bannerBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
string? noteText = null;
|
string? noteText = null;
|
||||||
string statusLabel = _isLightfinderContext ? "Exploring" : "Offline";
|
|
||||||
|
var isSelfProfile = !_isLightfinderContext
|
||||||
|
&& _userData is not null
|
||||||
|
&& !string.IsNullOrEmpty(_apiController.UID)
|
||||||
|
&& string.Equals(_userData.UID, _apiController.UID, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
string statusLabel = _isLightfinderContext
|
||||||
|
? "Exploring"
|
||||||
|
: isSelfProfile ? "Online" : "Offline";
|
||||||
|
|
||||||
string? visiblePlayerName = null;
|
string? visiblePlayerName = null;
|
||||||
bool directPair = false;
|
bool directPair = false;
|
||||||
bool youPaused = false;
|
bool youPaused = false;
|
||||||
bool theyPaused = false;
|
bool theyPaused = false;
|
||||||
List<string> syncshellLines = [];
|
List<string> syncshellLines = [];
|
||||||
|
|
||||||
|
if (!_isLightfinderContext)
|
||||||
|
{
|
||||||
|
noteText = _serverManager.GetNoteForUid(_userData!.UID);
|
||||||
|
}
|
||||||
|
|
||||||
if (!_isLightfinderContext && Pair != null)
|
if (!_isLightfinderContext && Pair != null)
|
||||||
{
|
{
|
||||||
var snapshot = _pairUiService.GetSnapshot();
|
var snapshot = _pairUiService.GetSnapshot();
|
||||||
noteText = _serverManager.GetNoteForUid(Pair.UserData.UID);
|
noteText = _serverManager.GetNoteForUid(Pair.UserData.UID);
|
||||||
|
|
||||||
statusLabel = Pair.IsVisible ? "Visible" : (Pair.IsOnline ? "Online" : "Offline");
|
statusLabel = Pair.IsVisible ? "Visible" : (Pair.IsOnline ? "Online" : "Offline");
|
||||||
visiblePlayerName = Pair.IsVisible ? Pair.PlayerName : null;
|
visiblePlayerName = Pair.IsVisible ? Pair.PlayerName : null;
|
||||||
|
|
||||||
@@ -282,11 +300,15 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase
|
|||||||
var groupLabel = snapshot.GroupsByGid.TryGetValue(gid, out var groupInfo)
|
var groupLabel = snapshot.GroupsByGid.TryGetValue(gid, out var groupInfo)
|
||||||
? groupInfo.GroupAliasOrGID
|
? groupInfo.GroupAliasOrGID
|
||||||
: gid;
|
: gid;
|
||||||
|
|
||||||
var groupNote = _serverManager.GetNoteForGid(gid);
|
var groupNote = _serverManager.GetNoteForGid(gid);
|
||||||
syncshellLines.Add(string.IsNullOrEmpty(groupNote) ? groupLabel : $"{groupNote} ({groupLabel})");
|
syncshellLines.Add(string.IsNullOrEmpty(groupNote) ? groupLabel : $"{groupNote} ({groupLabel})");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSelfProfile)
|
||||||
|
statusLabel = "Online";
|
||||||
}
|
}
|
||||||
|
|
||||||
var presenceTokens = new List<PresenceToken>
|
var presenceTokens = new List<PresenceToken>
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
|||||||
var drawList = ImGui.GetWindowDrawList();
|
var drawList = ImGui.GetWindowDrawList();
|
||||||
|
|
||||||
var purple = UIColors.Get("LightlessPurple");
|
var purple = UIColors.Get("LightlessPurple");
|
||||||
var gradLeft = purple.WithAlpha(0.0f);
|
var gradLeft = purple.WithAlpha(0.0f);
|
||||||
var gradRight = purple.WithAlpha(0.85f);
|
var gradRight = purple.WithAlpha(0.85f);
|
||||||
|
|
||||||
uint colTopLeft = ImGui.ColorConvertFloat4ToU32(gradLeft);
|
uint colTopLeft = ImGui.ColorConvertFloat4ToU32(gradLeft);
|
||||||
@@ -162,7 +162,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
var subtitlePos = new Vector2(
|
var subtitlePos = new Vector2(
|
||||||
pMin.X + 12f * scale,
|
pMin.X + 12f * scale,
|
||||||
titlePos.Y + titleHeight - 2f * scale);
|
titlePos.Y + titleHeight - 2f * scale);
|
||||||
|
|
||||||
ImGui.SetCursorScreenPos(subtitlePos);
|
ImGui.SetCursorScreenPos(subtitlePos);
|
||||||
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey);
|
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey);
|
||||||
@@ -392,25 +392,27 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
|||||||
}
|
}
|
||||||
UiSharedService.AttachToolTip("When enabled, inactive non-pinned, non-moderator users will be pruned automatically on the server.");
|
UiSharedService.AttachToolTip("When enabled, inactive non-pinned, non-moderator users will be pruned automatically on the server.");
|
||||||
|
|
||||||
ImGui.SameLine();
|
|
||||||
ImGui.SetNextItemWidth(150);
|
|
||||||
|
|
||||||
using (ImRaii.Disabled(!_autoPruneEnabled))
|
|
||||||
{
|
|
||||||
_uiSharedService.DrawCombo(
|
|
||||||
"Day(s) of inactivity",
|
|
||||||
[1, 3, 7, 14, 30, 90],
|
|
||||||
days => $"{days} day(s)",
|
|
||||||
selected =>
|
|
||||||
{
|
|
||||||
_autoPruneDays = selected;
|
|
||||||
SavePruneSettings();
|
|
||||||
},
|
|
||||||
_autoPruneDays);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_autoPruneEnabled)
|
if (!_autoPruneEnabled)
|
||||||
{
|
{
|
||||||
|
ImGui.BeginDisabled();
|
||||||
|
}
|
||||||
|
ImGui.SameLine();
|
||||||
|
ImGui.SetNextItemWidth(150);
|
||||||
|
_uiSharedService.DrawCombo(
|
||||||
|
"Day(s) of inactivity (gets checked hourly)",
|
||||||
|
[0, 1, 3, 7, 14, 30, 90],
|
||||||
|
(count) => count == 0 ? "2 hours(s)" : count + " day(s)",
|
||||||
|
selected =>
|
||||||
|
{
|
||||||
|
_autoPruneDays = selected;
|
||||||
|
SavePruneSettings();
|
||||||
|
},
|
||||||
|
_autoPruneDays);
|
||||||
|
|
||||||
|
if (!_autoPruneEnabled)
|
||||||
|
{
|
||||||
|
ImGui.EndDisabled();
|
||||||
UiSharedService.ColorTextWrapped(
|
UiSharedService.ColorTextWrapped(
|
||||||
"Automatic prune is currently disabled. Enable it and choose an inactivity threshold to let the server clean up inactive users automatically.",
|
"Automatic prune is currently disabled. Enable it and choose an inactivity threshold to let the server clean up inactive users automatically.",
|
||||||
ImGuiColors.DalamudGrey);
|
ImGuiColors.DalamudGrey);
|
||||||
@@ -593,7 +595,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
|||||||
_uiSharedService.DrawCombo(
|
_uiSharedService.DrawCombo(
|
||||||
"Day(s) of inactivity",
|
"Day(s) of inactivity",
|
||||||
[0, 1, 3, 7, 14, 30, 90],
|
[0, 1, 3, 7, 14, 30, 90],
|
||||||
(count) => count == 0 ? "15 minute(s)" : count + " day(s)",
|
(count) => count == 0 ? "2 hours(s)" : count + " day(s)",
|
||||||
(selected) =>
|
(selected) =>
|
||||||
{
|
{
|
||||||
_pruneDays = selected;
|
_pruneDays = selected;
|
||||||
@@ -663,8 +665,8 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
|||||||
var style = ImGui.GetStyle();
|
var style = ImGui.GetStyle();
|
||||||
float fullW = ImGui.GetContentRegionAvail().X;
|
float fullW = ImGui.GetContentRegionAvail().X;
|
||||||
|
|
||||||
float colIdentity = fullW * 0.45f;
|
float colIdentity = fullW * 0.45f;
|
||||||
float colMeta = fullW * 0.35f;
|
float colMeta = fullW * 0.35f;
|
||||||
float colActions = fullW - colIdentity - colMeta - style.ItemSpacing.X * 2.0f;
|
float colActions = fullW - colIdentity - colMeta - style.ItemSpacing.X * 2.0f;
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
@@ -873,7 +875,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
var boolcolor = UiSharedService.GetBoolColor(pair.IsOnline);
|
var boolcolor = UiSharedService.GetBoolColor(pair.IsOnline);
|
||||||
UiSharedService.ColorText(text, boolcolor);
|
UiSharedService.ColorText(text, boolcolor);
|
||||||
|
|
||||||
if (ImGui.IsItemClicked())
|
if (ImGui.IsItemClicked())
|
||||||
ImGui.SetClipboardText(pair.UserData.AliasOrUID);
|
ImGui.SetClipboardText(pair.UserData.AliasOrUID);
|
||||||
|
|
||||||
@@ -1093,6 +1095,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale));
|
ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SavePruneSettings()
|
private void SavePruneSettings()
|
||||||
{
|
{
|
||||||
if (_autoPruneDays <= 0)
|
if (_autoPruneDays <= 0)
|
||||||
@@ -1100,8 +1103,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
|||||||
_autoPruneEnabled = false;
|
_autoPruneEnabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var enabled = _autoPruneEnabled && _autoPruneDays > 0;
|
var dto = new GroupPruneSettingsDto(Group: GroupFullInfo.Group, AutoPruneEnabled: _autoPruneEnabled, AutoPruneDays: _autoPruneDays);
|
||||||
var dto = new GroupPruneSettingsDto(Group: GroupFullInfo.Group, AutoPruneEnabled: enabled, AutoPruneDays: enabled ? _autoPruneDays : 0);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
private readonly TextureMetadataHelper _textureMetadataHelper;
|
private readonly TextureMetadataHelper _textureMetadataHelper;
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<ThrottledStream, byte> _activeDownloadStreams;
|
private readonly ConcurrentDictionary<ThrottledStream, byte> _activeDownloadStreams;
|
||||||
|
private readonly SemaphoreSlim _decompressGate =
|
||||||
|
new(Math.Max(1, Environment.ProcessorCount / 2), Math.Max(1, Environment.ProcessorCount / 2));
|
||||||
|
|
||||||
|
private readonly ConcurrentQueue<string> _deferredCompressionQueue = new();
|
||||||
|
|
||||||
private volatile bool _disableDirectDownloads;
|
private volatile bool _disableDirectDownloads;
|
||||||
private int _consecutiveDirectDownloadFailures;
|
private int _consecutiveDirectDownloadFailures;
|
||||||
@@ -402,76 +406,32 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
private async Task WaitForDownloadReady(List<DownloadFileTransfer> downloadFileTransfer, Guid requestId, CancellationToken downloadCt)
|
private async Task WaitForDownloadReady(List<DownloadFileTransfer> downloadFileTransfer, Guid requestId, CancellationToken downloadCt)
|
||||||
{
|
{
|
||||||
bool alreadyCancelled = false;
|
while (true)
|
||||||
try
|
|
||||||
{
|
{
|
||||||
CancellationTokenSource localTimeoutCts = new();
|
downloadCt.ThrowIfCancellationRequested();
|
||||||
localTimeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
|
|
||||||
CancellationTokenSource composite = CancellationTokenSource.CreateLinkedTokenSource(downloadCt, localTimeoutCts.Token);
|
|
||||||
|
|
||||||
while (!_orchestrator.IsDownloadReady(requestId))
|
if (_orchestrator.IsDownloadReady(requestId))
|
||||||
|
break;
|
||||||
|
|
||||||
|
using var resp = await _orchestrator.SendRequestAsync(
|
||||||
|
HttpMethod.Get,
|
||||||
|
LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId),
|
||||||
|
downloadFileTransfer.Select(t => t.Hash).ToList(),
|
||||||
|
downloadCt).ConfigureAwait(false);
|
||||||
|
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var body = (await resp.Content.ReadAsStringAsync(downloadCt).ConfigureAwait(false)).Trim();
|
||||||
|
if (string.Equals(body, "true", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
body.Contains("\"ready\":true", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
try
|
break;
|
||||||
{
|
|
||||||
await Task.Delay(250, composite.Token).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (TaskCanceledException)
|
|
||||||
{
|
|
||||||
if (downloadCt.IsCancellationRequested) throw;
|
|
||||||
|
|
||||||
var req = await _orchestrator.SendRequestAsync(
|
|
||||||
HttpMethod.Get,
|
|
||||||
LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId),
|
|
||||||
downloadFileTransfer.Select(c => c.Hash).ToList(),
|
|
||||||
downloadCt).ConfigureAwait(false);
|
|
||||||
|
|
||||||
req.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
localTimeoutCts.Dispose();
|
|
||||||
composite.Dispose();
|
|
||||||
|
|
||||||
localTimeoutCts = new();
|
|
||||||
localTimeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
|
|
||||||
composite = CancellationTokenSource.CreateLinkedTokenSource(downloadCt, localTimeoutCts.Token);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
localTimeoutCts.Dispose();
|
await Task.Delay(250, downloadCt).ConfigureAwait(false);
|
||||||
composite.Dispose();
|
|
||||||
|
|
||||||
Logger.LogDebug("Download {requestId} ready", requestId);
|
|
||||||
}
|
}
|
||||||
catch (TaskCanceledException)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCancelFullPath(downloadFileTransfer[0].DownloadUri, requestId))
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
alreadyCancelled = true;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
throw;
|
_orchestrator.ClearDownloadRequest(requestId);
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (downloadCt.IsCancellationRequested && !alreadyCancelled)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCancelFullPath(downloadFileTransfer[0].DownloadUri, requestId))
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_orchestrator.ClearDownloadRequest(requestId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DownloadQueuedBlockFileAsync(
|
private async Task DownloadQueuedBlockFileAsync(
|
||||||
@@ -500,6 +460,14 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RemoveStatus(string key)
|
||||||
|
{
|
||||||
|
lock (_downloadStatusLock)
|
||||||
|
{
|
||||||
|
_downloadStatus.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task DecompressBlockFileAsync(
|
private async Task DecompressBlockFileAsync(
|
||||||
string downloadStatusKey,
|
string downloadStatusKey,
|
||||||
string blockFilePath,
|
string blockFilePath,
|
||||||
@@ -525,29 +493,53 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
if (fileLengthBytes < 0 || fileLengthBytes > int.MaxValue)
|
if (fileLengthBytes < 0 || fileLengthBytes > int.MaxValue)
|
||||||
throw new InvalidDataException($"Invalid block entry length: {fileLengthBytes}");
|
throw new InvalidDataException($"Invalid block entry length: {fileLengthBytes}");
|
||||||
|
|
||||||
|
var len = checked((int)fileLengthBytes);
|
||||||
|
|
||||||
if (!replacementLookup.TryGetValue(fileHash, out var repl))
|
if (!replacementLookup.TryGetValue(fileHash, out var repl))
|
||||||
{
|
{
|
||||||
Logger.LogWarning("{dlName}: No replacement mapping for {fileHash}", downloadLabel, fileHash);
|
Logger.LogWarning("{dlName}: No replacement mapping for {fileHash}", downloadLabel, fileHash);
|
||||||
// still need to skip bytes:
|
fileBlockStream.Seek(len, SeekOrigin.Current);
|
||||||
var skip = checked((int)fileLengthBytes);
|
|
||||||
fileBlockStream.Position += skip;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var filePath = _fileDbManager.GetCacheFilePath(fileHash, repl.Extension);
|
var filePath = _fileDbManager.GetCacheFilePath(fileHash, repl.Extension);
|
||||||
|
Logger.LogTrace("{dlName}: Decompressing {file}:{len} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath);
|
||||||
|
|
||||||
Logger.LogDebug("{dlName}: Decompressing {file}:{len} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath);
|
|
||||||
|
|
||||||
var len = checked((int)fileLengthBytes);
|
|
||||||
var compressed = new byte[len];
|
var compressed = new byte[len];
|
||||||
|
|
||||||
await ReadExactlyAsync(fileBlockStream, compressed.AsMemory(0, len), ct).ConfigureAwait(false);
|
await ReadExactlyAsync(fileBlockStream, compressed.AsMemory(0, len), ct).ConfigureAwait(false);
|
||||||
|
|
||||||
MungeBuffer(compressed);
|
if (len == 0)
|
||||||
var decompressed = LZ4Wrapper.Unwrap(compressed);
|
{
|
||||||
|
await File.WriteAllBytesAsync(filePath, Array.Empty<byte>(), ct).ConfigureAwait(false);
|
||||||
|
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await _fileCompactor.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false);
|
MungeBuffer(compressed);
|
||||||
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale);
|
|
||||||
|
await _decompressGate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// offload CPU-intensive decompression to threadpool to free up worker
|
||||||
|
await Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
|
||||||
|
// decompress
|
||||||
|
var decompressed = LZ4Wrapper.Unwrap(compressed);
|
||||||
|
|
||||||
|
Logger.LogTrace("{dlName}: Unwrap {fileHash} took {ms}ms (compressed {c} bytes, decompressed {d} bytes)",
|
||||||
|
downloadLabel, fileHash, sw.ElapsedMilliseconds, compressed.Length, decompressed?.Length ?? -1);
|
||||||
|
|
||||||
|
// write to file without compacting during download
|
||||||
|
await File.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false);
|
||||||
|
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale);
|
||||||
|
}, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_decompressGate.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (EndOfStreamException)
|
catch (EndOfStreamException)
|
||||||
{
|
{
|
||||||
@@ -568,6 +560,10 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
Logger.LogError(ex, "{dlName}: Error during block file read", downloadLabel);
|
Logger.LogError(ex, "{dlName}: Error during block file read", downloadLabel);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
RemoveStatus(downloadStatusKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<DownloadFileTransfer>> InitiateDownloadList(
|
public async Task<List<DownloadFileTransfer>> InitiateDownloadList(
|
||||||
@@ -605,20 +601,16 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
.. await FilesGetSizes(hashes, ct).ConfigureAwait(false),
|
.. await FilesGetSizes(hashes, ct).ConfigureAwait(false),
|
||||||
];
|
];
|
||||||
|
|
||||||
Logger.LogDebug("Files with size 0 or less: {files}",
|
|
||||||
string.Join(", ", downloadFileInfoFromService.Where(f => f.Size <= 0).Select(f => f.Hash)));
|
|
||||||
|
|
||||||
foreach (var dto in downloadFileInfoFromService.Where(c => c.IsForbidden))
|
foreach (var dto in downloadFileInfoFromService.Where(c => c.IsForbidden))
|
||||||
{
|
{
|
||||||
if (!_orchestrator.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, dto.Hash, StringComparison.Ordinal)))
|
if (!_orchestrator.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, dto.Hash, StringComparison.Ordinal)))
|
||||||
_orchestrator.ForbiddenTransfers.Add(new DownloadFileTransfer(dto));
|
_orchestrator.ForbiddenTransfers.Add(new DownloadFileTransfer(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
CurrentDownloads = downloadFileInfoFromService
|
CurrentDownloads = [.. downloadFileInfoFromService
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.Select(d => new DownloadFileTransfer(d))
|
.Select(d => new DownloadFileTransfer(d))
|
||||||
.Where(d => d.CanBeTransferred)
|
.Where(d => d.CanBeTransferred)];
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return CurrentDownloads;
|
return CurrentDownloads;
|
||||||
}
|
}
|
||||||
@@ -717,8 +709,16 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
if (gameObjectHandler is not null)
|
if (gameObjectHandler is not null)
|
||||||
Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus));
|
Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus));
|
||||||
|
|
||||||
|
// work based on cpu count and slots
|
||||||
|
var coreCount = Environment.ProcessorCount;
|
||||||
|
var baseWorkers = Math.Min(slots, coreCount);
|
||||||
|
|
||||||
|
// only add buffer if decompression has capacity AND we have cores to spare
|
||||||
|
var availableDecompressSlots = _decompressGate.CurrentCount;
|
||||||
|
var extraWorkers = (availableDecompressSlots > 0 && coreCount >= 6) ? 2 : 0;
|
||||||
|
|
||||||
// allow some extra workers so downloads can continue while earlier items decompress.
|
// allow some extra workers so downloads can continue while earlier items decompress.
|
||||||
var workerDop = Math.Clamp(slots * 2, 2, 16);
|
var workerDop = Math.Clamp(baseWorkers + extraWorkers, 2, coreCount);
|
||||||
|
|
||||||
// batch downloads
|
// batch downloads
|
||||||
Task batchTask = batchChunks.Length == 0
|
Task batchTask = batchChunks.Length == 0
|
||||||
@@ -734,6 +734,9 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
await Task.WhenAll(batchTask, directTask).ConfigureAwait(false);
|
await Task.WhenAll(batchTask, directTask).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// process deferred compressions after all downloads complete
|
||||||
|
await ProcessDeferredCompressionsAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
Logger.LogDebug("Download end: {id}", objectName);
|
Logger.LogDebug("Download end: {id}", objectName);
|
||||||
ClearDownload();
|
ClearDownload();
|
||||||
}
|
}
|
||||||
@@ -758,7 +761,6 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// download (with slot)
|
|
||||||
var progress = CreateInlineProgress(bytes => AddTransferredBytes(statusKey, bytes));
|
var progress = CreateInlineProgress(bytes => AddTransferredBytes(statusKey, bytes));
|
||||||
|
|
||||||
// Download slot held on get
|
// Download slot held on get
|
||||||
@@ -838,11 +840,13 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
byte[] compressedBytes = await File.ReadAllBytesAsync(tempFilename, ct).ConfigureAwait(false);
|
byte[] compressedBytes = await File.ReadAllBytesAsync(tempFilename, ct).ConfigureAwait(false);
|
||||||
var decompressedBytes = LZ4Wrapper.Unwrap(compressedBytes);
|
var decompressedBytes = LZ4Wrapper.Unwrap(compressedBytes);
|
||||||
|
|
||||||
await _fileCompactor.WriteAllBytesAsync(finalFilename, decompressedBytes, ct).ConfigureAwait(false);
|
await File.WriteAllBytesAsync(finalFilename, decompressedBytes, ct).ConfigureAwait(false);
|
||||||
PersistFileToStorage(directDownload.Hash, finalFilename, repl.GamePath, skipDownscale);
|
PersistFileToStorage(directDownload.Hash, finalFilename, repl.GamePath, skipDownscale);
|
||||||
|
|
||||||
MarkTransferredFiles(directDownload.DirectDownloadUrl!, 1);
|
MarkTransferredFiles(directDownload.DirectDownloadUrl!, 1);
|
||||||
Logger.LogDebug("Finished direct download of {hash}.", directDownload.Hash);
|
Logger.LogDebug("Finished direct download of {hash}.", directDownload.Hash);
|
||||||
|
|
||||||
|
RemoveStatus(directDownload.DirectDownloadUrl!);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException ex)
|
catch (OperationCanceledException ex)
|
||||||
{
|
{
|
||||||
@@ -937,14 +941,12 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
if (!_orchestrator.IsInitialized)
|
if (!_orchestrator.IsInitialized)
|
||||||
throw new InvalidOperationException("FileTransferManager is not initialized");
|
throw new InvalidOperationException("FileTransferManager is not initialized");
|
||||||
|
|
||||||
// batch request
|
|
||||||
var response = await _orchestrator.SendRequestAsync(
|
var response = await _orchestrator.SendRequestAsync(
|
||||||
HttpMethod.Get,
|
HttpMethod.Get,
|
||||||
LightlessFiles.ServerFilesGetSizesFullPath(_orchestrator.FilesCdnUri!),
|
LightlessFiles.ServerFilesGetSizesFullPath(_orchestrator.FilesCdnUri!),
|
||||||
hashes,
|
hashes,
|
||||||
ct).ConfigureAwait(false);
|
ct).ConfigureAwait(false);
|
||||||
|
|
||||||
// ensure success
|
|
||||||
return await response.Content.ReadFromJsonAsync<List<DownloadFileDto>>(cancellationToken: ct).ConfigureAwait(false) ?? [];
|
return await response.Content.ReadFromJsonAsync<List<DownloadFileDto>>(cancellationToken: ct).ConfigureAwait(false) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,6 +966,10 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
fi.LastAccessTime = DateTime.Today;
|
fi.LastAccessTime = DateTime.Today;
|
||||||
fi.LastWriteTime = RandomDayInThePast().Invoke();
|
fi.LastWriteTime = RandomDayInThePast().Invoke();
|
||||||
|
|
||||||
|
// queue file for deferred compression instead of compressing immediately
|
||||||
|
if (_configService.Current.UseCompactor)
|
||||||
|
_deferredCompressionQueue.Enqueue(filePath);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var entry = _fileDbManager.CreateCacheEntry(filePath);
|
var entry = _fileDbManager.CreateCacheEntry(filePath);
|
||||||
@@ -989,6 +995,52 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
private static IProgress<long> CreateInlineProgress(Action<long> callback) => new InlineProgress(callback);
|
private static IProgress<long> CreateInlineProgress(Action<long> callback) => new InlineProgress(callback);
|
||||||
|
|
||||||
|
private async Task ProcessDeferredCompressionsAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_deferredCompressionQueue.IsEmpty)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var filesToCompress = new List<string>();
|
||||||
|
while (_deferredCompressionQueue.TryDequeue(out var filePath))
|
||||||
|
{
|
||||||
|
if (File.Exists(filePath))
|
||||||
|
filesToCompress.Add(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filesToCompress.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Logger.LogDebug("Starting deferred compression of {count} files", filesToCompress.Count);
|
||||||
|
|
||||||
|
var compressionWorkers = Math.Clamp(Environment.ProcessorCount / 4, 2, 4);
|
||||||
|
|
||||||
|
await Parallel.ForEachAsync(filesToCompress,
|
||||||
|
new ParallelOptions
|
||||||
|
{
|
||||||
|
MaxDegreeOfParallelism = compressionWorkers,
|
||||||
|
CancellationToken = ct
|
||||||
|
},
|
||||||
|
async (filePath, token) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Yield();
|
||||||
|
if (_configService.Current.UseCompactor && File.Exists(filePath))
|
||||||
|
{
|
||||||
|
var bytes = await File.ReadAllBytesAsync(filePath, token).ConfigureAwait(false);
|
||||||
|
await _fileCompactor.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false);
|
||||||
|
Logger.LogTrace("Compressed file: {filePath}", filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "Failed to compress file: {filePath}", filePath);
|
||||||
|
}
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
|
Logger.LogDebug("Completed deferred compression of {count} files", filesToCompress.Count);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class InlineProgress : IProgress<long>
|
private sealed class InlineProgress : IProgress<long>
|
||||||
{
|
{
|
||||||
private readonly Action<long> _callback;
|
private readonly Action<long> _callback;
|
||||||
|
|||||||
@@ -200,5 +200,21 @@ public partial class ApiController
|
|||||||
|
|
||||||
await UserPushData(new(visibleCharacters, character, censusDto)).ConfigureAwait(false);
|
await UserPushData(new(visibleCharacters, character, censusDto)).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task UpdateLocation(LocationDto locationDto, bool offline = false)
|
||||||
|
{
|
||||||
|
if (!IsConnected) return;
|
||||||
|
await _lightlessHub!.SendAsync(nameof(UpdateLocation), locationDto, offline).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
public async Task<(List<LocationWithTimeDto>, List<SharingStatusDto>)> RequestAllLocationInfo()
|
||||||
|
{
|
||||||
|
if (!IsConnected) return ([],[]);
|
||||||
|
return await _lightlessHub!.InvokeAsync<(List<LocationWithTimeDto>, List<SharingStatusDto>)>(nameof(RequestAllLocationInfo)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
public async Task<bool> ToggleLocationSharing(LocationSharingToggleDto dto)
|
||||||
|
{
|
||||||
|
if (!IsConnected) return false;
|
||||||
|
return await _lightlessHub!.InvokeAsync<bool>(nameof(ToggleLocationSharing), dto).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#pragma warning restore MA0040
|
#pragma warning restore MA0040
|
||||||
@@ -259,6 +259,13 @@ public partial class ApiController
|
|||||||
ExecuteSafely(() => Mediator.Publish(new GPoseLobbyReceiveWorldData(userData, worldData)));
|
ExecuteSafely(() => Mediator.Publish(new GPoseLobbyReceiveWorldData(userData, worldData)));
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task Client_SendLocationToClient(LocationDto locationDto, DateTimeOffset expireAt)
|
||||||
|
{
|
||||||
|
Logger.LogDebug($"{nameof(Client_SendLocationToClient)}: {locationDto.User} {expireAt}");
|
||||||
|
ExecuteSafely(() => Mediator.Publish(new LocationSharingMessage(locationDto.User, locationDto.Location, expireAt)));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public void OnDownloadReady(Action<Guid> act)
|
public void OnDownloadReady(Action<Guid> act)
|
||||||
{
|
{
|
||||||
@@ -441,6 +448,12 @@ public partial class ApiController
|
|||||||
_lightlessHub!.On(nameof(Client_GposeLobbyPushWorldData), act);
|
_lightlessHub!.On(nameof(Client_GposeLobbyPushWorldData), act);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void OnReceiveLocation(Action<LocationDto, DateTimeOffset> act)
|
||||||
|
{
|
||||||
|
if (_initialized) return;
|
||||||
|
_lightlessHub!.On(nameof(Client_SendLocationToClient), act);
|
||||||
|
}
|
||||||
|
|
||||||
private void ExecuteSafely(Action act)
|
private void ExecuteSafely(Action act)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -606,6 +606,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
|||||||
OnGposeLobbyPushCharacterData((dto) => _ = Client_GposeLobbyPushCharacterData(dto));
|
OnGposeLobbyPushCharacterData((dto) => _ = Client_GposeLobbyPushCharacterData(dto));
|
||||||
OnGposeLobbyPushPoseData((dto, data) => _ = Client_GposeLobbyPushPoseData(dto, data));
|
OnGposeLobbyPushPoseData((dto, data) => _ = Client_GposeLobbyPushPoseData(dto, data));
|
||||||
OnGposeLobbyPushWorldData((dto, data) => _ = Client_GposeLobbyPushWorldData(dto, data));
|
OnGposeLobbyPushWorldData((dto, data) => _ = Client_GposeLobbyPushWorldData(dto, data));
|
||||||
|
OnReceiveLocation((dto, time) => _ = Client_SendLocationToClient(dto, time));
|
||||||
|
|
||||||
_healthCheckTokenSource?.Cancel();
|
_healthCheckTokenSource?.Cancel();
|
||||||
_healthCheckTokenSource?.Dispose();
|
_healthCheckTokenSource?.Dispose();
|
||||||
@@ -774,5 +775,6 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
|||||||
|
|
||||||
ServerState = state;
|
ServerState = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
#pragma warning restore MA0040
|
#pragma warning restore MA0040
|
||||||
|
|||||||
@@ -76,6 +76,19 @@
|
|||||||
"Microsoft.AspNetCore.SignalR.Common": "10.0.1"
|
"Microsoft.AspNetCore.SignalR.Common": "10.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Microsoft.Extensions.Caching.Memory": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[10.0.1, )",
|
||||||
|
"resolved": "10.0.1",
|
||||||
|
"contentHash": "NxqSP0Ky4dZ5ybszdZCqs1X2C70s+dXflqhYBUh/vhcQVTIooNCXIYnLVbafoAFGZMs51d9+rHxveXs0ZC3SQQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.Caching.Abstractions": "10.0.1",
|
||||||
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1",
|
||||||
|
"Microsoft.Extensions.Logging.Abstractions": "10.0.1",
|
||||||
|
"Microsoft.Extensions.Options": "10.0.1",
|
||||||
|
"Microsoft.Extensions.Primitives": "10.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Microsoft.Extensions.Hosting": {
|
"Microsoft.Extensions.Hosting": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[10.0.1, )",
|
"requested": "[10.0.1, )",
|
||||||
@@ -233,6 +246,14 @@
|
|||||||
"Microsoft.AspNetCore.SignalR.Common": "10.0.1"
|
"Microsoft.AspNetCore.SignalR.Common": "10.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Microsoft.Extensions.Caching.Abstractions": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "10.0.1",
|
||||||
|
"contentHash": "Vb1vVAQDxHpXVdL9fpOX2BzeV7bbhzG4pAcIKRauRl0/VfkE8mq0f+fYC+gWICh3dlzTZInJ/cTeBS2MgU/XvQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.Primitives": "10.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Microsoft.Extensions.Configuration": {
|
"Microsoft.Extensions.Configuration": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "10.0.1",
|
"resolved": "10.0.1",
|
||||||
@@ -618,7 +639,7 @@
|
|||||||
"FlatSharp.Compiler": "[7.9.0, )",
|
"FlatSharp.Compiler": "[7.9.0, )",
|
||||||
"FlatSharp.Runtime": "[7.9.0, )",
|
"FlatSharp.Runtime": "[7.9.0, )",
|
||||||
"OtterGui": "[1.0.0, )",
|
"OtterGui": "[1.0.0, )",
|
||||||
"Penumbra.Api": "[5.13.0, )",
|
"Penumbra.Api": "[5.13.1, )",
|
||||||
"Penumbra.String": "[1.0.7, )"
|
"Penumbra.String": "[1.0.7, )"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user