Compare commits
45 Commits
cake-attem
...
2.0.0-cras
| Author | SHA1 | Date | |
|---|---|---|---|
| 543ea6c865 | |||
|
|
3bbda69699 | ||
|
|
deb7f67e59 | ||
|
|
9ba45670c5 | ||
|
|
f7bb73bcd1 | ||
|
|
4c07162ee3 | ||
|
|
a4d62af73d | ||
|
|
5fba3c01e7 | ||
|
|
906dda3885 | ||
|
|
f812b6d09e | ||
|
|
89f59a98f5 | ||
|
|
e95a2c3352 | ||
|
|
a8340c3279 | ||
|
|
e25979e089 | ||
|
|
ca7375b9c3 | ||
|
|
f8752fcb4d | ||
|
|
d1c955c74f | ||
|
|
91e60694ad | ||
|
|
f37fdefddd | ||
|
|
18fa0a47b1 | ||
|
|
9f5cc9e0d1 | ||
|
|
b02db4c1e1 | ||
|
|
d6b31ed5b9 | ||
|
|
9e600bfae0 | ||
|
|
1a73d5a4d9 | ||
|
|
a933330418 | ||
|
|
ea34b18f40 | ||
|
|
67dc215e83 | ||
|
|
baf3869cec | ||
|
|
eeda5aeb66 | ||
|
|
754df95071 | ||
|
|
24fca31606 | ||
|
|
a99c1c01b0 | ||
|
|
85999fab8f | ||
|
|
70745613e1 | ||
|
|
5c8e239a7b | ||
|
|
5eed65149a | ||
|
|
1ab4e2f94b | ||
|
|
f792bc1954 | ||
|
|
ced72ab9eb | ||
|
|
6c1cc77aaa | ||
|
|
5b81caf5a8 | ||
|
|
4e03b381dc | ||
|
|
3222133aa0 | ||
|
|
0ec423e65c |
@@ -27,7 +27,6 @@ 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;
|
||||||
@@ -227,23 +226,13 @@ 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";
|
||||||
try
|
await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false);
|
||||||
{
|
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 = new FileInfo(compressedPath).Length;
|
var compressedSize = compressed.LongLength;
|
||||||
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
|
||||||
@@ -888,83 +877,6 @@ 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");
|
||||||
@@ -1148,8 +1060,6 @@ 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()
|
||||||
{
|
{
|
||||||
_ = Task.Run(() => RefreshPlayerRelatedAddressMap());
|
RefreshPlayerRelatedAddressMap();
|
||||||
|
|
||||||
lock (_cacheAdditionLock)
|
lock (_cacheAdditionLock)
|
||||||
{
|
{
|
||||||
@@ -306,64 +306,20 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
if (_lastClassJobId != _dalamudUtil.ClassJobId)
|
if (_lastClassJobId != _dalamudUtil.ClassJobId)
|
||||||
{
|
{
|
||||||
UpdateClassJobCache();
|
_lastClassJobId = _dalamudUtil.ClassJobId;
|
||||||
}
|
if (SemiTransientResources.TryGetValue(ObjectKind.Pet, out HashSet<string>? value))
|
||||||
|
|
||||||
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;
|
value?.Clear();
|
||||||
if (address != nint.Zero)
|
|
||||||
{
|
|
||||||
tempMap[address] = handler;
|
|
||||||
updatedFrameAddresses[address] = handler.ObjectKind;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
_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 _))
|
||||||
@@ -393,6 +349,26 @@ 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,7 +4,6 @@ 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,9 +155,6 @@ public class LightlessConfig : ILightlessConfiguration
|
|||||||
public bool SyncshellFinderEnabled { get; set; } = false;
|
public bool SyncshellFinderEnabled { get; set; } = false;
|
||||||
public string? SelectedFinderSyncshell { get; set; } = null;
|
public string? SelectedFinderSyncshell { get; set; } = null;
|
||||||
public string LastSeenVersion { get; set; } = string.Empty;
|
public string LastSeenVersion { get; set; } = string.Empty;
|
||||||
|
public bool EnableParticleEffects { get; set; } = true;
|
||||||
public 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace LightlessSync.PlayerData.Factories
|
|
||||||
{
|
|
||||||
public enum AnimationValidationMode
|
|
||||||
{
|
|
||||||
Unsafe = 0,
|
|
||||||
Safe = 1,
|
|
||||||
Safest = 2,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,15 +2,12 @@
|
|||||||
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;
|
||||||
|
|
||||||
@@ -21,34 +18,13 @@ 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);
|
|
||||||
|
|
||||||
// Transient resolved entries threshold
|
public PlayerDataFactory(ILogger<PlayerDataFactory> logger, DalamudUtilService dalamudUtil, IpcManager ipcManager,
|
||||||
private const int _maxTransientResolvedEntries = 1000;
|
TransientResourceManager transientResourceManager, FileCacheManager fileReplacementFactory,
|
||||||
|
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;
|
||||||
@@ -58,15 +34,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;
|
||||||
|
|
||||||
@@ -91,17 +67,16 @@ public class PlayerDataFactory
|
|||||||
|
|
||||||
if (pointerIsZero)
|
if (pointerIsZero)
|
||||||
{
|
{
|
||||||
_logger.LogTrace("Pointer was zero for {objectKind}; couldn't build character", playerRelatedObject.ObjectKind);
|
_logger.LogTrace("Pointer was zero for {objectKind}", playerRelatedObject.ObjectKind);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await _performanceCollector.LogPerformance(
|
return await _performanceCollector.LogPerformance(this, $"CreateCharacterData>{playerRelatedObject.ObjectKind}", async () =>
|
||||||
this,
|
{
|
||||||
$"CreateCharacterData>{playerRelatedObject.ObjectKind}",
|
return await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false);
|
||||||
async () => await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false)
|
}).ConfigureAwait(true);
|
||||||
).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -117,14 +92,17 @@ 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 static bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
|
private unsafe 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;
|
||||||
|
|
||||||
@@ -135,167 +113,93 @@ public class PlayerDataFactory
|
|||||||
return gameObject->DrawObject == null;
|
return gameObject->DrawObject == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsCacheFresh(CacheEntry entry)
|
private async Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
|
||||||
=> (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();
|
||||||
|
|
||||||
var logDebug = _logger.IsEnabled(LogLevel.Debug);
|
|
||||||
var sw = Stopwatch.StartNew();
|
|
||||||
|
|
||||||
_logger.LogDebug("Building character data for {obj}", playerRelatedObject);
|
_logger.LogDebug("Building character data for {obj}", playerRelatedObject);
|
||||||
|
var logDebug = _logger.IsEnabled(LogLevel.Debug);
|
||||||
|
|
||||||
await EnsureObjectPresentAsync(playerRelatedObject, ct).ConfigureAwait(false);
|
// wait until chara is not drawing and present so nothing spontaneously explodes
|
||||||
ct.ThrowIfCancellationRequested();
|
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct).ConfigureAwait(false);
|
||||||
|
int totalWaitTime = 10000;
|
||||||
var waitRecordingTask = _transientResourceManager.WaitForRecording(ct);
|
while (!await _dalamudUtil.IsObjectPresentAsync(await _dalamudUtil.CreateGameObjectAsync(playerRelatedObject.Address).ConfigureAwait(false)).ConfigureAwait(false) && totalWaitTime > 0)
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
getHeelsOffset = _ipcManager.Heels.GetOffsetAsync();
|
_logger.LogTrace("Character is null but it shouldn't be, waiting");
|
||||||
getHonorificTitle = _ipcManager.Honorific.GetTitle();
|
await Task.Delay(50, ct).ConfigureAwait(false);
|
||||||
getMoodlesData = _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address);
|
totalWaitTime -= 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
var staticBuildTask = Task.Run(() => BuildStaticReplacements(resolvedPaths), ct);
|
DateTime start = DateTime.UtcNow;
|
||||||
|
|
||||||
fragment.FileReplacements = await staticBuildTask.ConfigureAwait(false);
|
// penumbra call, it's currently broken
|
||||||
|
Dictionary<string, HashSet<string>>? resolvedPaths;
|
||||||
|
|
||||||
|
resolvedPaths = (await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false));
|
||||||
|
if (resolvedPaths == null) throw new InvalidOperationException("Penumbra returned null data");
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
fragment.FileReplacements =
|
||||||
|
new HashSet<FileReplacement>(resolvedPaths.Select(c => new FileReplacement([.. c.Value], c.Key)), FileReplacementComparer.Instance)
|
||||||
|
.Where(p => p.HasFileReplacement).ToHashSet();
|
||||||
|
fragment.FileReplacements.RemoveWhere(c => c.GamePaths.Any(g => !CacheMonitor.AllowedFileExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase))));
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
if (logDebug)
|
if (logDebug)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("== Static Replacements ==");
|
_logger.LogDebug("== Static Replacements ==");
|
||||||
foreach (var replacement in fragment.FileReplacements
|
foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement).OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
|
||||||
.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)
|
|
||||||
{
|
{
|
||||||
CharacterDataFragmentPlayer? playerFragment = fragment as CharacterDataFragmentPlayer ?? throw new InvalidOperationException("Failed to cast CharacterDataFragment to Player variant");
|
foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement))
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations();
|
await _transientResourceManager.WaitForRecording(ct).ConfigureAwait(false);
|
||||||
playerFragment.HonorificData = await getHonorificTitle!.ConfigureAwait(false);
|
|
||||||
_logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData);
|
|
||||||
|
|
||||||
playerFragment.PetNamesData = _ipcManager.PetNames.GetLocalNames();
|
// if it's pet then it's summoner, if it's summoner we actually want to keep all filereplacements alive at all times
|
||||||
_logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);
|
// or we get into redraw city for every change and nothing works properly
|
||||||
|
if (objectKind == ObjectKind.Pet)
|
||||||
|
{
|
||||||
|
foreach (var item in fragment.FileReplacements.Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths))
|
||||||
|
{
|
||||||
|
if (_transientResourceManager.AddTransientResource(objectKind, item))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Marking static {item} for Pet as transient", item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
playerFragment.HeelsData = await getHeelsOffset!.ConfigureAwait(false);
|
_logger.LogTrace("Clearing {count} Static Replacements for Pet", fragment.FileReplacements.Count);
|
||||||
_logger.LogDebug("Heels is now: {heels}", playerFragment!.HeelsData);
|
fragment.FileReplacements.Clear();
|
||||||
|
|
||||||
playerFragment.MoodlesData = (await getMoodlesData!.ConfigureAwait(false)) ?? string.Empty;
|
|
||||||
_logger.LogDebug("Moodles is now: {moodles}", playerFragment!.MoodlesData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var (resolvedTransientPaths, clearedForPet) = await transientTask.ConfigureAwait(false);
|
_logger.LogDebug("Handling transient update for {obj}", playerRelatedObject);
|
||||||
if (clearedForPet != null)
|
|
||||||
fragment.FileReplacements.Clear();
|
// remove all potentially gathered paths from the transient resource manager that are resolved through static resolving
|
||||||
|
_transientResourceManager.ClearTransientPaths(objectKind, fragment.FileReplacements.SelectMany(c => c.GamePaths).ToList());
|
||||||
|
|
||||||
|
// get all remaining paths and resolve them
|
||||||
|
var transientPaths = ManageSemiTransientData(objectKind);
|
||||||
|
var resolvedTransientPaths = await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
|
||||||
|
|
||||||
if (logDebug)
|
if (logDebug)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("== Transient Replacements ==");
|
_logger.LogDebug("== Transient Replacements ==");
|
||||||
foreach (var replacement in resolvedTransientPaths
|
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)).OrderBy(f => f.ResolvedPath, StringComparer.Ordinal))
|
||||||
.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);
|
||||||
@@ -304,64 +208,85 @@ 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]);
|
||||||
|
|
||||||
fragment.FileReplacements = new HashSet<FileReplacement>(
|
ct.ThrowIfCancellationRequested();
|
||||||
fragment.FileReplacements
|
|
||||||
.Where(v => v.HasFileReplacement)
|
// make sure we only return data that actually has file replacements
|
||||||
.OrderBy(v => v.ResolvedPath, StringComparer.Ordinal),
|
fragment.FileReplacements = new HashSet<FileReplacement>(fragment.FileReplacements.Where(v => v.HasFileReplacement).OrderBy(v => v.ResolvedPath, StringComparer.Ordinal), FileReplacementComparer.Instance);
|
||||||
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());
|
||||||
await Task.Run(() =>
|
foreach (var file in toCompute)
|
||||||
{
|
{
|
||||||
var computedPaths = _fileCacheManager.GetFileCachesByPaths([.. toCompute.Select(c => c.ResolvedPath)]);
|
ct.ThrowIfCancellationRequested();
|
||||||
foreach (var file in toCompute)
|
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty;
|
||||||
{
|
}
|
||||||
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)
|
if (objectKind == ObjectKind.Player)
|
||||||
{
|
{
|
||||||
hasPapFiles = fragment.FileReplacements.Any(f =>
|
hasPapFiles = fragment.FileReplacements.Any(f =>
|
||||||
!f.IsFileSwap && f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
|
!f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
if (hasPapFiles)
|
if (hasPapFiles)
|
||||||
{
|
{
|
||||||
boneIndices = await _dalamudUtil
|
boneIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject)).ConfigureAwait(false);
|
||||||
.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject))
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (objectKind == ObjectKind.Player)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
#if DEBUG
|
|
||||||
if (hasPapFiles && boneIndices != null)
|
|
||||||
_modelAnalyzer.DumpLocalSkeletonIndices(playerRelatedObject);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if (hasPapFiles)
|
if (hasPapFiles)
|
||||||
{
|
{
|
||||||
await VerifyPlayerAnimationBones(boneIndices, (CharacterDataFragmentPlayer)fragment, ct)
|
await VerifyPlayerAnimationBones(boneIndices, (fragment as CharacterDataFragmentPlayer)!, ct).ConfigureAwait(false);
|
||||||
.ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException e)
|
catch (OperationCanceledException e)
|
||||||
@@ -375,270 +300,105 @@ public class PlayerDataFactory
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Building character data for {obj} took {time}ms",
|
_logger.LogInformation("Building character data for {obj} took {time}ms", objectKind, TimeSpan.FromTicks(DateTime.UtcNow.Ticks - start.Ticks).TotalMilliseconds);
|
||||||
objectKind, sw.Elapsed.TotalMilliseconds);
|
|
||||||
|
|
||||||
return fragment;
|
return fragment;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EnsureObjectPresentAsync(GameObjectHandler handler, CancellationToken ct)
|
private async Task VerifyPlayerAnimationBones(Dictionary<string, List<ushort>>? boneIndices, CharacterDataFragmentPlayer fragment, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var remaining = 10000;
|
if (boneIndices == null) return;
|
||||||
while (remaining > 0)
|
|
||||||
{
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
var obj = await _dalamudUtil.CreateGameObjectAsync(handler.Address).ConfigureAwait(false);
|
|
||||||
if (await _dalamudUtil.IsObjectPresentAsync(obj).ConfigureAwait(false))
|
|
||||||
return;
|
|
||||||
|
|
||||||
_logger.LogTrace("Character is null but it shouldn't be, waiting");
|
|
||||||
await Task.Delay(50, ct).ConfigureAwait(false);
|
|
||||||
remaining -= 50;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static HashSet<FileReplacement> BuildStaticReplacements(Dictionary<string, HashSet<string>> resolvedPaths)
|
|
||||||
{
|
|
||||||
var set = new HashSet<FileReplacement>(FileReplacementComparer.Instance);
|
|
||||||
|
|
||||||
foreach (var kvp in resolvedPaths)
|
|
||||||
{
|
|
||||||
var fr = new FileReplacement([.. kvp.Value], kvp.Key);
|
|
||||||
if (!fr.HasFileReplacement) continue;
|
|
||||||
|
|
||||||
var allAllowed = fr.GamePaths.All(g =>
|
|
||||||
CacheMonitor.AllowedFileExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase)));
|
|
||||||
|
|
||||||
if (!allAllowed) continue;
|
|
||||||
|
|
||||||
set.Add(fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
return set;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<(IReadOnlyDictionary<string, string[]> ResolvedPaths, HashSet<FileReplacement>? ClearedReplacements)>
|
|
||||||
ResolveTransientReplacementsAsync(
|
|
||||||
GameObjectHandler obj,
|
|
||||||
ObjectKind objectKind,
|
|
||||||
HashSet<FileReplacement> staticReplacements,
|
|
||||||
Task waitRecordingTask,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
|
||||||
await waitRecordingTask.ConfigureAwait(false);
|
|
||||||
|
|
||||||
HashSet<FileReplacement>? clearedReplacements = null;
|
|
||||||
|
|
||||||
if (objectKind == ObjectKind.Pet)
|
|
||||||
{
|
|
||||||
foreach (var item in staticReplacements.Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths))
|
|
||||||
{
|
|
||||||
if (_transientResourceManager.AddTransientResource(objectKind, item))
|
|
||||||
_logger.LogDebug("Marking static {item} for Pet as transient", item);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogTrace("Clearing {count} Static Replacements for Pet", staticReplacements.Count);
|
|
||||||
clearedReplacements = staticReplacements;
|
|
||||||
}
|
|
||||||
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
_transientResourceManager.ClearTransientPaths(objectKind, [.. staticReplacements.SelectMany(c => c.GamePaths)]);
|
|
||||||
|
|
||||||
var transientPaths = ManageSemiTransientData(objectKind);
|
|
||||||
if (transientPaths.Count == 0)
|
|
||||||
return (new Dictionary<string, string[]>(StringComparer.Ordinal), clearedReplacements);
|
|
||||||
|
|
||||||
var resolved = await GetFileReplacementsFromPaths(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))
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("SEND local buckets: {b}",
|
foreach (var kvp in boneIndices)
|
||||||
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;
|
_logger.LogDebug("Found {skellyname} ({idx} bone indices) on player: {bones}", kvp.Key, kvp.Value.Any() ? kvp.Value.Max() : 0, string.Join(',', kvp.Value));
|
||||||
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
|
var maxPlayerBoneIndex = boneIndices.SelectMany(kvp => kvp.Value).DefaultIfEmpty().Max();
|
||||||
.Where(f => !f.IsFileSwap
|
if (maxPlayerBoneIndex <= 0) return;
|
||||||
&& !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;
|
int noValidationFailed = 0;
|
||||||
|
foreach (var file in fragment.FileReplacements.Where(f => !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)).ToList())
|
||||||
foreach (var g in papGroups)
|
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var hash = g.Key;
|
var skeletonIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetBoneIndicesFromPap(file.Hash)).ConfigureAwait(false);
|
||||||
|
bool validationFailed = false;
|
||||||
Dictionary<string, List<ushort>>? papIndices = null;
|
if (skeletonIndices != null)
|
||||||
|
|
||||||
await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
papIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash), ct)
|
// 105 is the maximum vanilla skellington spoopy bone index
|
||||||
.ConfigureAwait(false);
|
if (skeletonIndices.All(k => k.Value.Max() <= 105))
|
||||||
}
|
{
|
||||||
finally
|
_logger.LogTrace("All indices of {path} are <= 105, ignoring", file.ResolvedPath);
|
||||||
{
|
continue;
|
||||||
_papParseLimiter.Release();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (papIndices == null || papIndices.Count == 0)
|
_logger.LogDebug("Verifying bone indices for {path}, found {x} skeletons", file.ResolvedPath, skeletonIndices.Count);
|
||||||
continue;
|
|
||||||
|
|
||||||
if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105))
|
foreach (var boneCount in skeletonIndices)
|
||||||
continue;
|
{
|
||||||
|
var maxAnimationIndex = boneCount.Value.DefaultIfEmpty().Max();
|
||||||
if (_logger.IsEnabled(LogLevel.Debug))
|
if (maxAnimationIndex > maxPlayerBoneIndex)
|
||||||
{
|
|
||||||
var papBuckets = papIndices
|
|
||||||
.Select(kvp => new
|
|
||||||
{
|
{
|
||||||
Raw = kvp.Key,
|
_logger.LogWarning("Found more bone indices on the animation {path} skeleton {skl} (max indice {idx}) than on any player related skeleton (max indice {idx2})",
|
||||||
Key = XivDataAnalyzer.CanonicalizeSkeletonKey(kvp.Key),
|
file.ResolvedPath, boneCount.Key, maxAnimationIndex, maxPlayerBoneIndex);
|
||||||
Indices = kvp.Value
|
validationFailed = true;
|
||||||
})
|
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 (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason))
|
if (validationFailed)
|
||||||
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(
|
_lightlessMediator.Publish(new NotificationMessage("Invalid Skeleton Setup",
|
||||||
"Invalid Skeleton Setup",
|
$"Your client is attempting to send {noValidationFailed} animation files with invalid bone data. Those animation files have been removed from your sent data. " +
|
||||||
$"Your client is attempting to send {noValidationFailed} animation files that don't match your current skeleton validation mode ({mode}). " +
|
$"Verify that you are using the correct skeleton for those animation files (Check /xllog for more information).",
|
||||||
"Please adjust your skeleton/mods or change the validation mode if this is unexpected. " +
|
NotificationType.Warning, TimeSpan.FromSeconds(10)));
|
||||||
"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);
|
||||||
@@ -649,21 +409,31 @@ 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();
|
||||||
@@ -671,23 +441,30 @@ 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();
|
||||||
@@ -698,29 +475,11 @@ 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,7 +22,6 @@ 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;
|
||||||
|
|
||||||
@@ -47,9 +46,7 @@ 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;
|
||||||
@@ -93,10 +90,6 @@ 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;
|
||||||
@@ -191,9 +184,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
TextureDownscaleService textureDownscaleService,
|
TextureDownscaleService textureDownscaleService,
|
||||||
PairStateCache pairStateCache,
|
PairStateCache pairStateCache,
|
||||||
PairPerformanceMetricsCache performanceMetricsCache,
|
PairPerformanceMetricsCache performanceMetricsCache,
|
||||||
PenumbraTempCollectionJanitor tempCollectionJanitor,
|
PenumbraTempCollectionJanitor tempCollectionJanitor) : base(logger, mediator)
|
||||||
XivDataAnalyzer modelAnalyzer,
|
|
||||||
LightlessConfigService configService) : base(logger, mediator)
|
|
||||||
{
|
{
|
||||||
_pairManager = pairManager;
|
_pairManager = pairManager;
|
||||||
Ident = ident;
|
Ident = ident;
|
||||||
@@ -212,8 +203,6 @@ 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()
|
||||||
@@ -1680,36 +1669,11 @@ 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(
|
await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, _applicationId, penumbraCollection,
|
||||||
Logger, _applicationId, penumbraCollection,
|
moddedPaths.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)).ConfigureAwait(false);
|
||||||
withoutPap.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal))
|
_lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(moddedPaths, moddedPaths.Comparer);
|
||||||
.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))
|
||||||
{
|
{
|
||||||
@@ -1742,45 +1706,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)
|
|
||||||
{
|
|
||||||
await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
StorePerformanceMetrics(charaData);
|
|
||||||
_lastSuccessfulApplyAt = DateTime.UtcNow;
|
|
||||||
ClearFailureState();
|
|
||||||
Logger.LogDebug("[{applicationId}] Application finished", _applicationId);
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
if (LastAppliedDataTris < 0)
|
||||||
{
|
{
|
||||||
Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier());
|
await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
StorePerformanceMetrics(charaData);
|
||||||
|
_lastSuccessfulApplyAt = DateTime.UtcNow;
|
||||||
|
ClearFailureState();
|
||||||
|
Logger.LogDebug("[{applicationId}] Application finished", _applicationId);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
_forceApplyMods = true;
|
||||||
_cachedData = charaData;
|
_cachedData = charaData;
|
||||||
_pairStateCache.Store(Ident, charaData);
|
_pairStateCache.Store(Ident, charaData);
|
||||||
_forceFullReapply = true;
|
_forceFullReapply = true;
|
||||||
RecordFailure("Application cancelled", "Cancellation");
|
Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
else
|
||||||
{
|
{
|
||||||
if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException))
|
Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId);
|
||||||
{
|
_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()
|
||||||
{
|
{
|
||||||
@@ -2014,37 +1978,14 @@ 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;
|
||||||
var anyGamePath = item.GamePaths.FirstOrDefault();
|
fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, item.GamePaths[0].Split(".")[^1]);
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2354,7 +2295,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
HandleVisibilityLoss(logChange: false);
|
HandleVisibilityLoss(logChange: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryResolveDescriptorHash(ActorObjectService.ActorDescriptor descriptor, out string hashedCid)
|
private 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))
|
||||||
@@ -2367,106 +2308,6 @@ 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,6 +1,5 @@
|
|||||||
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;
|
||||||
@@ -33,8 +32,6 @@ 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,
|
||||||
@@ -53,9 +50,7 @@ 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;
|
||||||
@@ -74,8 +69,6 @@ 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)
|
||||||
@@ -102,8 +95,6 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
|||||||
_textureDownscaleService,
|
_textureDownscaleService,
|
||||||
_pairStateCache,
|
_pairStateCache,
|
||||||
_pairPerformanceMetricsCache,
|
_pairPerformanceMetricsCache,
|
||||||
_tempCollectionJanitor,
|
_tempCollectionJanitor);
|
||||||
_modelAnalyzer,
|
|
||||||
_configService);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -481,19 +481,11 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
sp.GetRequiredService<UiSharedService>(),
|
sp.GetRequiredService<UiSharedService>(),
|
||||||
sp.GetRequiredService<ApiController>(),
|
sp.GetRequiredService<ApiController>(),
|
||||||
sp.GetRequiredService<LightFinderScannerService>(),
|
sp.GetRequiredService<LightFinderScannerService>(),
|
||||||
sp.GetRequiredService<LightFinderPlateHandler>()));
|
|
||||||
|
|
||||||
services.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>(sp => new SyncshellFinderUI(
|
|
||||||
sp.GetRequiredService<ILogger<SyncshellFinderUI>>(),
|
|
||||||
sp.GetRequiredService<LightlessMediator>(),
|
|
||||||
sp.GetRequiredService<PerformanceCollectorService>(),
|
|
||||||
sp.GetRequiredService<LightFinderService>(),
|
|
||||||
sp.GetRequiredService<UiSharedService>(),
|
|
||||||
sp.GetRequiredService<ApiController>(),
|
|
||||||
sp.GetRequiredService<LightFinderScannerService>(),
|
|
||||||
sp.GetRequiredService<PairUiService>(),
|
sp.GetRequiredService<PairUiService>(),
|
||||||
sp.GetRequiredService<DalamudUtilService>(),
|
sp.GetRequiredService<DalamudUtilService>(),
|
||||||
sp.GetRequiredService<LightlessProfileManager>()));
|
sp.GetRequiredService<LightlessProfileManager>(),
|
||||||
|
sp.GetRequiredService<ActorObjectService>(),
|
||||||
|
sp.GetRequiredService<LightFinderPlateHandler>()));
|
||||||
|
|
||||||
services.AddScoped<IPopupHandler, BanUserPopupHandler>();
|
services.AddScoped<IPopupHandler, BanUserPopupHandler>();
|
||||||
services.AddScoped<IPopupHandler, CensusPopupHandler>();
|
services.AddScoped<IPopupHandler, CensusPopupHandler>();
|
||||||
@@ -579,7 +571,6 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_host.StopAsync().GetAwaiter().GetResult();
|
_host.StopAsync().ContinueWith(_ => _host.Dispose()).Wait(TimeSpan.FromSeconds(5));
|
||||||
_host.Dispose();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
|||||||
{
|
{
|
||||||
_baseAnalysisCts = _baseAnalysisCts.CancelRecreate();
|
_baseAnalysisCts = _baseAnalysisCts.CancelRecreate();
|
||||||
var token = _baseAnalysisCts.Token;
|
var token = _baseAnalysisCts.Token;
|
||||||
_ = Task.Run(async () => await BaseAnalysis(msg.CharacterData, token).ConfigureAwait(false), token);
|
_ = BaseAnalysis(msg.CharacterData, token);
|
||||||
});
|
});
|
||||||
_fileCacheManager = fileCacheManager;
|
_fileCacheManager = fileCacheManager;
|
||||||
_xivDataAnalyzer = modelAnalyzer;
|
_xivDataAnalyzer = modelAnalyzer;
|
||||||
|
|||||||
@@ -22,10 +22,8 @@ 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;
|
||||||
@@ -845,41 +843,31 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task WaitWhileCharacterIsDrawing(
|
public async Task WaitWhileCharacterIsDrawing(ILogger logger, GameObjectHandler handler, Guid redrawId, int timeOut = 5000, CancellationToken? ct = null)
|
||||||
ILogger logger,
|
|
||||||
GameObjectHandler handler,
|
|
||||||
Guid redrawId,
|
|
||||||
int timeOut = 5000,
|
|
||||||
CancellationToken? ct = null)
|
|
||||||
{
|
{
|
||||||
if (!_clientState.IsLoggedIn) return;
|
if (!_clientState.IsLoggedIn) return;
|
||||||
|
|
||||||
var token = ct ?? CancellationToken.None;
|
if (ct == null)
|
||||||
|
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;
|
||||||
|
|
||||||
await Task.Delay(initialSettle, token).ConfigureAwait(false);
|
while ((!ct.Value.IsCancellationRequested)
|
||||||
|
&& curWaitTime < timeOut
|
||||||
while (!token.IsCancellationRequested
|
&& await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false)) // 0b100000000000 is "still rendering" or something
|
||||||
&& 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);
|
||||||
await Task.Delay(tick, token).ConfigureAwait(false);
|
curWaitTime += tick;
|
||||||
|
await Task.Delay(tick, ct.Value).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogTrace("[{redrawId}] Finished drawing after {ms}ms", redrawId, sw.ElapsedMilliseconds);
|
logger.LogTrace("[{redrawId}] Finished drawing after {curWaitTime}ms", redrawId, curWaitTime);
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
catch (AccessViolationException ex)
|
catch (AccessViolationException ex)
|
||||||
{
|
{
|
||||||
@@ -931,6 +919,28 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
return WorldData.Value.TryGetValue(worldId, out var worldName) ? worldName : null;
|
return WorldData.Value.TryGetValue(worldId, out var worldName) ? worldName : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void TargetPlayerByAddress(nint address)
|
||||||
|
{
|
||||||
|
if (address == nint.Zero) return;
|
||||||
|
if (_clientState.IsPvP) return;
|
||||||
|
|
||||||
|
_ = RunOnFrameworkThread(() =>
|
||||||
|
{
|
||||||
|
var gameObject = CreateGameObject(address);
|
||||||
|
if (gameObject is null) return;
|
||||||
|
|
||||||
|
var useFocusTarget = _configService.Current.UseFocusTarget;
|
||||||
|
if (useFocusTarget)
|
||||||
|
{
|
||||||
|
_targetManager.FocusTarget = gameObject;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_targetManager.Target = gameObject;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private unsafe void CheckCharacterForDrawing(nint address, string characterName)
|
private unsafe void CheckCharacterForDrawing(nint address, string characterName)
|
||||||
{
|
{
|
||||||
var gameObj = (GameObject*)address;
|
var gameObj = (GameObject*)address;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Dalamud.Plugin.Services;
|
using Dalamud.Plugin.Services;
|
||||||
using LightlessSync.API.Dto.User;
|
using LightlessSync.API.Dto.User;
|
||||||
using LightlessSync.Services.ActorTracking;
|
using LightlessSync.Services.ActorTracking;
|
||||||
using LightlessSync.Services.Mediator;
|
using LightlessSync.Services.Mediator;
|
||||||
@@ -23,6 +23,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
|||||||
private readonly HashSet<string> _syncshellCids = [];
|
private readonly HashSet<string> _syncshellCids = [];
|
||||||
private volatile bool _pendingLocalBroadcast;
|
private volatile bool _pendingLocalBroadcast;
|
||||||
private TimeSpan? _pendingLocalTtl;
|
private TimeSpan? _pendingLocalTtl;
|
||||||
|
private string? _pendingLocalGid;
|
||||||
|
|
||||||
private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4);
|
private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4);
|
||||||
private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1);
|
private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1);
|
||||||
@@ -36,6 +37,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
|||||||
private const int _maxQueueSize = 100;
|
private const int _maxQueueSize = 100;
|
||||||
|
|
||||||
private volatile bool _batchRunning = false;
|
private volatile bool _batchRunning = false;
|
||||||
|
private volatile bool _disposed = false;
|
||||||
|
|
||||||
public IReadOnlyDictionary<string, BroadcastEntry> BroadcastCache => _broadcastCache;
|
public IReadOnlyDictionary<string, BroadcastEntry> BroadcastCache => _broadcastCache;
|
||||||
public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID);
|
public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID);
|
||||||
@@ -68,6 +70,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
public void Update()
|
public void Update()
|
||||||
{
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
_frameCounter++;
|
_frameCounter++;
|
||||||
var lookupsThisFrame = 0;
|
var lookupsThisFrame = 0;
|
||||||
|
|
||||||
@@ -111,7 +116,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
private async Task BatchUpdateBroadcastCacheAsync(List<string> cids)
|
private async Task BatchUpdateBroadcastCacheAsync(List<string> cids)
|
||||||
{
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
var results = await _broadcastService.AreUsersBroadcastingAsync(cids).ConfigureAwait(false);
|
var results = await _broadcastService.AreUsersBroadcastingAsync(cids).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
foreach (var (cid, info) in results)
|
foreach (var (cid, info) in results)
|
||||||
@@ -130,6 +142,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
|||||||
(_, old) => new BroadcastEntry(info.IsBroadcasting, expiry, info.GID));
|
(_, old) => new BroadcastEntry(info.IsBroadcasting, expiry, info.GID));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
var activeCids = _broadcastCache
|
var activeCids = _broadcastCache
|
||||||
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now)
|
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now)
|
||||||
.Select(e => e.Key)
|
.Select(e => e.Key)
|
||||||
@@ -142,6 +157,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg)
|
private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg)
|
||||||
{
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
if (!msg.Enabled)
|
if (!msg.Enabled)
|
||||||
{
|
{
|
||||||
_broadcastCache.Clear();
|
_broadcastCache.Clear();
|
||||||
@@ -158,6 +176,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
_pendingLocalBroadcast = true;
|
_pendingLocalBroadcast = true;
|
||||||
_pendingLocalTtl = msg.Ttl;
|
_pendingLocalTtl = msg.Ttl;
|
||||||
|
_pendingLocalGid = msg.Gid;
|
||||||
TryPrimeLocalBroadcastCache();
|
TryPrimeLocalBroadcastCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,11 +192,12 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
|||||||
var expiry = DateTime.UtcNow + ttl;
|
var expiry = DateTime.UtcNow + ttl;
|
||||||
|
|
||||||
_broadcastCache.AddOrUpdate(localCid,
|
_broadcastCache.AddOrUpdate(localCid,
|
||||||
new BroadcastEntry(true, expiry, null),
|
new BroadcastEntry(true, expiry, _pendingLocalGid),
|
||||||
(_, old) => new BroadcastEntry(true, expiry, old.GID));
|
(_, old) => new BroadcastEntry(true, expiry, _pendingLocalGid ?? old.GID));
|
||||||
|
|
||||||
_pendingLocalBroadcast = false;
|
_pendingLocalBroadcast = false;
|
||||||
_pendingLocalTtl = null;
|
_pendingLocalTtl = null;
|
||||||
|
_pendingLocalGid = null;
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var activeCids = _broadcastCache
|
var activeCids = _broadcastCache
|
||||||
@@ -187,10 +207,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
|
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
|
||||||
_lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids);
|
_lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids);
|
||||||
|
UpdateSyncshellBroadcasts();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateSyncshellBroadcasts()
|
private void UpdateSyncshellBroadcasts()
|
||||||
{
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var nearbyCids = GetNearbyHashedCids(out _);
|
var nearbyCids = GetNearbyHashedCids(out _);
|
||||||
var newSet = nearbyCids.Count == 0
|
var newSet = nearbyCids.Count == 0
|
||||||
@@ -324,17 +348,35 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
|
_disposed = true;
|
||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
_framework.Update -= OnFrameworkUpdate;
|
_framework.Update -= OnFrameworkUpdate;
|
||||||
if (_cleanupTask != null)
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
_cleanupTask?.Wait(100, _cleanupCts.Token);
|
_cleanupCts.Cancel();
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
// Already disposed, can be ignored :)
|
||||||
}
|
}
|
||||||
|
|
||||||
_cleanupCts.Cancel();
|
try
|
||||||
_cleanupCts.Dispose();
|
{
|
||||||
|
_cleanupTask?.Wait(100);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Task may have already completed or been cancelled?
|
||||||
|
}
|
||||||
|
|
||||||
_cleanupTask?.Wait(100);
|
try
|
||||||
_cleanupCts.Dispose();
|
{
|
||||||
|
_cleanupCts.Dispose();
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
// Already disposed, ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Dalamud.Interface;
|
using Dalamud.Interface;
|
||||||
using LightlessSync.API.Dto.Group;
|
using LightlessSync.API.Dto.Group;
|
||||||
using LightlessSync.API.Dto.User;
|
using LightlessSync.API.Dto.User;
|
||||||
using LightlessSync.LightlessConfiguration;
|
using LightlessSync.LightlessConfiguration;
|
||||||
@@ -121,7 +121,10 @@ public class LightFinderService : IHostedService, IMediatorSubscriber
|
|||||||
_waitingForTtlFetch = false;
|
_waitingForTtlFetch = false;
|
||||||
|
|
||||||
if (!wasEnabled || previousRemaining != validTtl)
|
if (!wasEnabled || previousRemaining != validTtl)
|
||||||
_mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl));
|
{
|
||||||
|
var gid = _config.Current.SyncshellFinderEnabled ? _config.Current.SelectedFinderSyncshell : null;
|
||||||
|
_mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl, gid));
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Lightfinder broadcast enabled ({Context}), TTL: {TTL}", context, validTtl);
|
_logger.LogInformation("Lightfinder broadcast enabled ({Context}), TTL: {TTL}", context, validTtl);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -63,23 +63,31 @@ public sealed class LightlessMediator : IHostedService
|
|||||||
|
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
while (!_loopCts.Token.IsCancellationRequested)
|
try
|
||||||
{
|
{
|
||||||
while (!_processQueue)
|
while (!_loopCts.Token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
|
while (!_processQueue)
|
||||||
|
{
|
||||||
|
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
|
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
HashSet<MessageBase> processedMessages = [];
|
||||||
|
while (_messageQueue.TryDequeue(out var message))
|
||||||
|
{
|
||||||
|
if (processedMessages.Contains(message)) { continue; }
|
||||||
|
|
||||||
|
processedMessages.Add(message);
|
||||||
|
|
||||||
|
ExecuteMessage(message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
HashSet<MessageBase> processedMessages = [];
|
_logger.LogInformation("LightlessMediator stopped");
|
||||||
while (_messageQueue.TryDequeue(out var message))
|
|
||||||
{
|
|
||||||
if (processedMessages.Contains(message)) { continue; }
|
|
||||||
processedMessages.Add(message);
|
|
||||||
|
|
||||||
ExecuteMessage(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) :
|
|||||||
public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase;
|
public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase;
|
||||||
public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase;
|
public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase;
|
||||||
public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase;
|
public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase;
|
||||||
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : MessageBase;
|
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl, string? Gid = null) : MessageBase;
|
||||||
public record UserLeftSyncshell(string gid) : MessageBase;
|
public record UserLeftSyncshell(string gid) : MessageBase;
|
||||||
public record UserJoinedSyncshell(string gid) : MessageBase;
|
public record UserJoinedSyncshell(string gid) : MessageBase;
|
||||||
public record SyncshellBroadcastsUpdatedMessage : MessageBase;
|
public record SyncshellBroadcastsUpdatedMessage : MessageBase;
|
||||||
|
|||||||
@@ -6,15 +6,13 @@ 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 partial class XivDataAnalyzer
|
public sealed class XivDataAnalyzer
|
||||||
{
|
{
|
||||||
private readonly ILogger<XivDataAnalyzer> _logger;
|
private readonly ILogger<XivDataAnalyzer> _logger;
|
||||||
private readonly FileCacheManager _fileCacheManager;
|
private readonly FileCacheManager _fileCacheManager;
|
||||||
@@ -31,441 +29,127 @@ public sealed partial class XivDataAnalyzer
|
|||||||
|
|
||||||
public unsafe Dictionary<string, List<ushort>>? GetSkeletonBoneIndices(GameObjectHandler handler)
|
public unsafe Dictionary<string, List<ushort>>? GetSkeletonBoneIndices(GameObjectHandler handler)
|
||||||
{
|
{
|
||||||
if (handler is null || handler.Address == nint.Zero)
|
if (handler.Address == nint.Zero) return null;
|
||||||
return null;
|
var chara = (CharacterBase*)(((Character*)handler.Address)->GameObject.DrawObject);
|
||||||
|
if (chara->GetModelType() != CharacterBase.ModelType.Human) return null;
|
||||||
Dictionary<string, HashSet<ushort>> sets = new(StringComparer.OrdinalIgnoreCase);
|
var resHandles = chara->Skeleton->SkeletonResourceHandles;
|
||||||
|
Dictionary<string, List<ushort>> outputIndices = [];
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var drawObject = ((Character*)handler.Address)->GameObject.DrawObject;
|
for (int i = 0; i < chara->Skeleton->PartialSkeletonCount; i++)
|
||||||
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);
|
||||||
if ((nint)handle == nint.Zero)
|
_logger.LogTrace("Iterating over SkeletonResourceHandle #{i}:{x}", i, ((nint)handle).ToString("X"));
|
||||||
continue;
|
if ((nint)handle == nint.Zero) continue;
|
||||||
|
var curBones = handle->BoneCount;
|
||||||
if (handle->FileName.Length > 1024)
|
// this is unrealistic, the filename shouldn't ever be that long
|
||||||
continue;
|
if (handle->FileName.Length > 1024) continue;
|
||||||
|
var skeletonName = handle->FileName.ToString();
|
||||||
var rawName = handle->FileName.ToString();
|
if (string.IsNullOrEmpty(skeletonName)) continue;
|
||||||
if (string.IsNullOrWhiteSpace(rawName))
|
outputIndices[skeletonName] = [];
|
||||||
continue;
|
for (ushort boneIdx = 0; boneIdx < curBones; boneIdx++)
|
||||||
|
|
||||||
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))
|
|
||||||
{
|
{
|
||||||
set = [];
|
var boneName = handle->HavokSkeleton->Bones[boneIdx].Name.String;
|
||||||
sets[skeletonKey] = set;
|
if (boneName == null) continue;
|
||||||
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sets.Count == 0)
|
return (outputIndices.Count != 0 && outputIndices.Values.All(u => u.Count > 0)) ? outputIndices : null;
|
||||||
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, bool persistToConfig = true)
|
public unsafe Dictionary<string, List<ushort>>? GetBoneIndicesFromPap(string hash)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(hash))
|
if (_configService.Current.BonesDictionary.TryGetValue(hash, out var bones)) return bones;
|
||||||
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 || string.IsNullOrEmpty(cacheEntity.ResolvedFilepath) || !File.Exists(cacheEntity.ResolvedFilepath))
|
if (cacheEntity == null) return null;
|
||||||
return null;
|
|
||||||
|
|
||||||
using var fs = File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
using BinaryReader reader = new(File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read));
|
||||||
using var reader = new BinaryReader(fs);
|
|
||||||
|
|
||||||
// PAP header (mostly from vfxeditor)
|
// most of this shit is from vfxeditor, surely nothing will change in the pap format :copium:
|
||||||
_ = reader.ReadInt32(); // ignore
|
reader.ReadInt32(); // ignore
|
||||||
_ = reader.ReadInt32(); // ignore
|
reader.ReadInt32(); // ignore
|
||||||
_ = reader.ReadInt16(); // num animations
|
reader.ReadInt16(); // read 2 (num animations)
|
||||||
_ = reader.ReadInt16(); // modelid
|
reader.ReadInt16(); // read 2 (modelid)
|
||||||
|
var type = reader.ReadByte();// read 1 (type)
|
||||||
var type = reader.ReadByte(); // type
|
if (type != 0) return null; // it's not human, just ignore it, whatever
|
||||||
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)
|
if (havokData.Length <= 8) return null; // no havok data
|
||||||
return null;
|
|
||||||
|
|
||||||
var tempSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
|
var output = new Dictionary<string, List<ushort>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) + ".hkx";
|
||||||
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx");
|
var tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath);
|
||||||
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)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Havok resource was null after loading from {path}", tempHavokDataPath);
|
throw new InvalidOperationException("Resource was null after loading");
|
||||||
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;
|
||||||
if (boneTransform.Length <= 0)
|
string name = binding->OriginalSkeletonName.String! + "_" + i;
|
||||||
continue;
|
output[name] = [];
|
||||||
|
|
||||||
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++)
|
||||||
{
|
{
|
||||||
var v = boneTransform[boneIdx];
|
output[name].Add((ushort)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
|
||||||
{
|
{
|
||||||
if (tempHavokDataPathAnsi != IntPtr.Zero)
|
Marshal.FreeHGlobal(tempHavokDataPathAnsi);
|
||||||
Marshal.FreeHGlobal(tempHavokDataPathAnsi);
|
File.Delete(tempHavokDataPath);
|
||||||
|
|
||||||
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)
|
||||||
@@ -528,23 +212,4 @@ public sealed partial 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();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,44 +34,65 @@ namespace LightlessSync.UI;
|
|||||||
|
|
||||||
public class CompactUi : WindowMediatorSubscriberBase
|
public class CompactUi : WindowMediatorSubscriberBase
|
||||||
{
|
{
|
||||||
private readonly CharacterAnalyzer _characterAnalyzer;
|
#region Constants
|
||||||
|
|
||||||
|
private const float ConnectButtonHighlightThickness = 14f;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Services
|
||||||
|
|
||||||
private readonly ApiController _apiController;
|
private readonly ApiController _apiController;
|
||||||
|
private readonly CharacterAnalyzer _characterAnalyzer;
|
||||||
|
private readonly DalamudUtilService _dalamudUtilService;
|
||||||
|
private readonly DrawEntityFactory _drawEntityFactory;
|
||||||
|
private readonly FileUploadManager _fileTransferManager;
|
||||||
|
private readonly IpcManager _ipcManager;
|
||||||
|
private readonly LightFinderService _broadcastService;
|
||||||
private readonly LightlessConfigService _configService;
|
private readonly LightlessConfigService _configService;
|
||||||
private readonly LightlessMediator _lightlessMediator;
|
private readonly LightlessMediator _lightlessMediator;
|
||||||
private readonly PairLedger _pairLedger;
|
private readonly PairLedger _pairLedger;
|
||||||
private readonly ConcurrentDictionary<GameObjectHandler, Dictionary<string, FileDownloadStatus>> _currentDownloads = new();
|
|
||||||
private readonly DrawEntityFactory _drawEntityFactory;
|
|
||||||
private readonly FileUploadManager _fileTransferManager;
|
|
||||||
private readonly PlayerPerformanceConfigService _playerPerformanceConfig;
|
|
||||||
private readonly PairUiService _pairUiService;
|
private readonly PairUiService _pairUiService;
|
||||||
private readonly SelectTagForPairUi _selectTagForPairUi;
|
private readonly PlayerPerformanceConfigService _playerPerformanceConfig;
|
||||||
private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi;
|
|
||||||
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
|
|
||||||
private readonly RenameSyncshellTagUi _renameSyncshellTagUi;
|
|
||||||
private readonly SelectPairForTagUi _selectPairsForGroupUi;
|
|
||||||
private readonly RenamePairTagUi _renamePairTagUi;
|
|
||||||
private readonly IpcManager _ipcManager;
|
|
||||||
private readonly ServerConfigurationManager _serverManager;
|
private readonly ServerConfigurationManager _serverManager;
|
||||||
private readonly TopTabMenu _tabMenu;
|
|
||||||
private readonly TagHandler _tagHandler;
|
private readonly TagHandler _tagHandler;
|
||||||
private readonly UiSharedService _uiSharedService;
|
private readonly UiSharedService _uiSharedService;
|
||||||
private readonly LightFinderService _broadcastService;
|
|
||||||
private readonly DalamudUtilService _dalamudUtilService;
|
#endregion
|
||||||
|
|
||||||
|
#region UI Components
|
||||||
|
|
||||||
|
private readonly AnimatedHeader _animatedHeader = new();
|
||||||
|
private readonly RenamePairTagUi _renamePairTagUi;
|
||||||
|
private readonly RenameSyncshellTagUi _renameSyncshellTagUi;
|
||||||
|
private readonly SelectPairForTagUi _selectPairsForGroupUi;
|
||||||
|
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
|
||||||
|
private readonly SelectTagForPairUi _selectTagForPairUi;
|
||||||
|
private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi;
|
||||||
|
private readonly SeluneBrush _seluneBrush = new();
|
||||||
|
private readonly TopTabMenu _tabMenu;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region State
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<GameObjectHandler, Dictionary<string, FileDownloadStatus>> _currentDownloads = new();
|
||||||
private List<IDrawFolder> _drawFolders;
|
private List<IDrawFolder> _drawFolders;
|
||||||
|
private Pair? _focusedPair;
|
||||||
private Pair? _lastAddedUser;
|
private Pair? _lastAddedUser;
|
||||||
private string _lastAddedUserComment = string.Empty;
|
private string _lastAddedUserComment = string.Empty;
|
||||||
private Vector2 _lastPosition = Vector2.One;
|
private Vector2 _lastPosition = Vector2.One;
|
||||||
private Vector2 _lastSize = Vector2.One;
|
private Vector2 _lastSize = Vector2.One;
|
||||||
|
private int _pendingFocusFrame = -1;
|
||||||
|
private Pair? _pendingFocusPair;
|
||||||
private bool _showModalForUserAddition;
|
private bool _showModalForUserAddition;
|
||||||
private float _transferPartHeight;
|
private float _transferPartHeight;
|
||||||
private bool _wasOpen;
|
private bool _wasOpen;
|
||||||
private float _windowContentWidth;
|
private float _windowContentWidth;
|
||||||
private readonly SeluneBrush _seluneBrush = new();
|
|
||||||
private const float _connectButtonHighlightThickness = 14f;
|
#endregion
|
||||||
private Pair? _focusedPair;
|
|
||||||
private Pair? _pendingFocusPair;
|
#region Constructor
|
||||||
private int _pendingFocusFrame = -1;
|
|
||||||
|
|
||||||
public CompactUi(
|
public CompactUi(
|
||||||
ILogger<CompactUi> logger,
|
ILogger<CompactUi> logger,
|
||||||
@@ -127,6 +148,11 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
.Apply();
|
.Apply();
|
||||||
|
|
||||||
_drawFolders = [.. DrawFolders];
|
_drawFolders = [.. DrawFolders];
|
||||||
|
|
||||||
|
_animatedHeader.Height = 120f;
|
||||||
|
_animatedHeader.EnableBottomGradient = true;
|
||||||
|
_animatedHeader.GradientHeight = 250f;
|
||||||
|
_animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects;
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
string dev = "Dev Build";
|
string dev = "Dev Build";
|
||||||
@@ -150,9 +176,14 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
_lightlessMediator = mediator;
|
_lightlessMediator = mediator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Lifecycle
|
||||||
|
|
||||||
public override void OnClose()
|
public override void OnClose()
|
||||||
{
|
{
|
||||||
ForceReleaseFocus();
|
ForceReleaseFocus();
|
||||||
|
_animatedHeader.ClearParticles();
|
||||||
base.OnClose();
|
base.OnClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +195,13 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize);
|
using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize);
|
||||||
|
|
||||||
_windowContentWidth = UiSharedService.GetWindowContentRegionWidth();
|
_windowContentWidth = UiSharedService.GetWindowContentRegionWidth();
|
||||||
|
|
||||||
|
// Draw animated header background (just the gradient/particles, content drawn by existing methods)
|
||||||
|
var startCursorY = ImGui.GetCursorPosY();
|
||||||
|
_animatedHeader.Draw(_windowContentWidth, (_, _) => { });
|
||||||
|
// Reset cursor to draw content on top of the header background
|
||||||
|
ImGui.SetCursorPosY(startCursorY);
|
||||||
|
|
||||||
if (!_apiController.IsCurrentVersion)
|
if (!_apiController.IsCurrentVersion)
|
||||||
{
|
{
|
||||||
var ver = _apiController.CurrentClientVersion;
|
var ver = _apiController.CurrentClientVersion;
|
||||||
@@ -209,17 +247,11 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
using (ImRaii.PushId("header")) DrawUIDHeader();
|
using (ImRaii.PushId("header")) DrawUIDHeader();
|
||||||
_uiSharedService.RoundedSeparator(UIColors.Get("LightlessPurple"), 2.5f, 1f, 12f);
|
|
||||||
using (ImRaii.PushId("serverstatus"))
|
|
||||||
{
|
|
||||||
DrawServerStatus();
|
|
||||||
}
|
|
||||||
selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
|
selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
|
||||||
var style = ImGui.GetStyle();
|
var style = ImGui.GetStyle();
|
||||||
var contentMinY = windowPos.Y + ImGui.GetWindowContentRegionMin().Y;
|
var contentMinY = windowPos.Y + ImGui.GetWindowContentRegionMin().Y;
|
||||||
var gradientInset = 4f * ImGuiHelpers.GlobalScale;
|
var gradientInset = 4f * ImGuiHelpers.GlobalScale;
|
||||||
var gradientTop = MathF.Max(contentMinY, ImGui.GetCursorScreenPos().Y - style.ItemSpacing.Y + gradientInset);
|
var gradientTop = MathF.Max(contentMinY, ImGui.GetCursorScreenPos().Y - style.ItemSpacing.Y + gradientInset);
|
||||||
ImGui.Separator();
|
|
||||||
|
|
||||||
if (_apiController.ServerState is ServerState.Connected)
|
if (_apiController.ServerState is ServerState.Connected)
|
||||||
{
|
{
|
||||||
@@ -227,7 +259,6 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw(pairSnapshot);
|
using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw(pairSnapshot);
|
||||||
using (ImRaii.PushId("pairlist")) DrawPairs();
|
using (ImRaii.PushId("pairlist")) DrawPairs();
|
||||||
ImGui.Separator();
|
|
||||||
var transfersTop = ImGui.GetCursorScreenPos().Y;
|
var transfersTop = ImGui.GetCursorScreenPos().Y;
|
||||||
var gradientBottom = MathF.Max(gradientTop, transfersTop - style.ItemSpacing.Y - gradientInset);
|
var gradientBottom = MathF.Max(gradientTop, transfersTop - style.ItemSpacing.Y - gradientInset);
|
||||||
selune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime);
|
selune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime);
|
||||||
@@ -290,6 +321,10 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Content Drawing
|
||||||
|
|
||||||
private void DrawPairs()
|
private void DrawPairs()
|
||||||
{
|
{
|
||||||
float ySize = Math.Abs(_transferPartHeight) < 0.0001f
|
float ySize = Math.Abs(_transferPartHeight) < 0.0001f
|
||||||
@@ -308,95 +343,6 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
ImGui.EndChild();
|
ImGui.EndChild();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawServerStatus()
|
|
||||||
{
|
|
||||||
var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Link);
|
|
||||||
var userCount = _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture);
|
|
||||||
var userSize = ImGui.CalcTextSize(userCount);
|
|
||||||
var textSize = ImGui.CalcTextSize("Users Online");
|
|
||||||
#if DEBUG
|
|
||||||
string shardConnection = $"Shard: {_apiController.ServerInfo.ShardName}";
|
|
||||||
#else
|
|
||||||
string shardConnection = string.Equals(_apiController.ServerInfo.ShardName, "Main", StringComparison.OrdinalIgnoreCase) ? string.Empty : $"Shard: {_apiController.ServerInfo.ShardName}";
|
|
||||||
#endif
|
|
||||||
var shardTextSize = ImGui.CalcTextSize(shardConnection);
|
|
||||||
var printShard = !string.IsNullOrEmpty(_apiController.ServerInfo.ShardName) && shardConnection != string.Empty;
|
|
||||||
|
|
||||||
if (_apiController.ServerState is ServerState.Connected)
|
|
||||||
{
|
|
||||||
ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth()) / 2 - (userSize.X + textSize.X) / 2 - ImGui.GetStyle().ItemSpacing.X / 2);
|
|
||||||
if (!printShard) ImGui.AlignTextToFramePadding();
|
|
||||||
ImGui.TextColored(UIColors.Get("LightlessPurple"), userCount);
|
|
||||||
ImGui.SameLine();
|
|
||||||
if (!printShard) ImGui.AlignTextToFramePadding();
|
|
||||||
ImGui.TextUnformatted("Users Online");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ImGui.AlignTextToFramePadding();
|
|
||||||
ImGui.TextColored(UIColors.Get("DimRed"), "Not connected to any server");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (printShard)
|
|
||||||
{
|
|
||||||
ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ImGui.GetStyle().ItemSpacing.Y);
|
|
||||||
ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth()) / 2 - shardTextSize.X / 2);
|
|
||||||
ImGui.TextUnformatted(shardConnection);
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.SameLine();
|
|
||||||
if (printShard)
|
|
||||||
{
|
|
||||||
ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ((userSize.Y + textSize.Y) / 2 + shardTextSize.Y) / 2 - ImGui.GetStyle().ItemSpacing.Y + buttonSize.Y / 2);
|
|
||||||
}
|
|
||||||
bool isConnectingOrConnected = _apiController.ServerState is ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting;
|
|
||||||
var color = UiSharedService.GetBoolColor(!isConnectingOrConnected);
|
|
||||||
var connectedIcon = isConnectingOrConnected ? FontAwesomeIcon.Unlink : FontAwesomeIcon.Link;
|
|
||||||
|
|
||||||
ImGui.SameLine(ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() - buttonSize.X);
|
|
||||||
if (printShard)
|
|
||||||
{
|
|
||||||
ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ((userSize.Y + textSize.Y) / 2 + shardTextSize.Y) / 2 - ImGui.GetStyle().ItemSpacing.Y + buttonSize.Y / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_apiController.ServerState is not (ServerState.Reconnecting or ServerState.Disconnecting))
|
|
||||||
{
|
|
||||||
using (ImRaii.PushColor(ImGuiCol.Text, color))
|
|
||||||
{
|
|
||||||
if (_uiSharedService.IconButton(connectedIcon))
|
|
||||||
{
|
|
||||||
if (isConnectingOrConnected && !_serverManager.CurrentServer.FullPause)
|
|
||||||
{
|
|
||||||
_serverManager.CurrentServer.FullPause = true;
|
|
||||||
_serverManager.Save();
|
|
||||||
}
|
|
||||||
else if (!isConnectingOrConnected && _serverManager.CurrentServer.FullPause)
|
|
||||||
{
|
|
||||||
_serverManager.CurrentServer.FullPause = false;
|
|
||||||
_serverManager.Save();
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = _apiController.CreateConnectionsAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
|
|
||||||
{
|
|
||||||
Selune.RegisterHighlight(
|
|
||||||
ImGui.GetItemRectMin(),
|
|
||||||
ImGui.GetItemRectMax(),
|
|
||||||
SeluneHighlightMode.Both,
|
|
||||||
borderOnly: true,
|
|
||||||
borderThicknessOverride: _connectButtonHighlightThickness,
|
|
||||||
exactSize: true,
|
|
||||||
clipToElement: true,
|
|
||||||
roundingOverride: ImGui.GetStyle().FrameRounding);
|
|
||||||
}
|
|
||||||
|
|
||||||
UiSharedService.AttachToolTip(isConnectingOrConnected ? "Disconnect from " + _serverManager.CurrentServer.ServerName : "Connect to " + _serverManager.CurrentServer.ServerName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawTransfers()
|
private void DrawTransfers()
|
||||||
{
|
{
|
||||||
var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot();
|
var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot();
|
||||||
@@ -492,11 +438,9 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes);
|
return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Auto)]
|
#endregion
|
||||||
private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes)
|
|
||||||
{
|
#region Header Drawing
|
||||||
public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawUIDHeader()
|
private void DrawUIDHeader()
|
||||||
{
|
{
|
||||||
@@ -532,21 +476,52 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
using (_uiSharedService.IconFont.Push())
|
using (_uiSharedService.IconFont.Push())
|
||||||
iconSize = ImGui.CalcTextSize(FontAwesomeIcon.PersonCirclePlus.ToIconString());
|
iconSize = ImGui.CalcTextSize(FontAwesomeIcon.PersonCirclePlus.ToIconString());
|
||||||
|
|
||||||
float contentWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X;
|
float uidStartX = 25f;
|
||||||
float uidStartX = (contentWidth - uidTextSize.X) / 2f;
|
|
||||||
float cursorY = ImGui.GetCursorPosY();
|
float cursorY = ImGui.GetCursorPosY();
|
||||||
|
|
||||||
|
ImGui.SetCursorPosY(cursorY);
|
||||||
|
ImGui.SetCursorPosX(uidStartX);
|
||||||
|
|
||||||
|
bool headerItemClicked;
|
||||||
|
using (_uiSharedService.UidFont.Push())
|
||||||
|
{
|
||||||
|
if (useVanityColors)
|
||||||
|
{
|
||||||
|
var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor);
|
||||||
|
var cursorPos = ImGui.GetCursorScreenPos();
|
||||||
|
var targetFontSize = ImGui.GetFontSize();
|
||||||
|
var font = ImGui.GetFont();
|
||||||
|
SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, targetFontSize ,font , "uid-header");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ImGui.TextColored(uidColor, uidText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the actual rendered text rect for proper icon alignment
|
||||||
|
var uidTextRect = ImGui.GetItemRectMax() - ImGui.GetItemRectMin();
|
||||||
|
var uidTextRectMin = ImGui.GetItemRectMin();
|
||||||
|
var uidTextHovered = ImGui.IsItemHovered();
|
||||||
|
headerItemClicked = ImGui.IsItemClicked();
|
||||||
|
|
||||||
|
// Track position for icons next to UID text
|
||||||
|
// Use uidTextSize.Y (actual font height) for vertical centering, not hitbox height
|
||||||
|
float nextIconX = uidTextRectMin.X + uidTextRect.X + 10f;
|
||||||
|
float iconYOffset = (uidTextSize.Y - iconSize.Y) * 0.5f;
|
||||||
|
float textVerticalOffset = (uidTextRect.Y - uidTextSize.Y) * 0.5f;
|
||||||
|
var buttonSize = new Vector2(iconSize.X, uidTextSize.Y);
|
||||||
|
|
||||||
if (_configService.Current.BroadcastEnabled && _apiController.IsConnected)
|
if (_configService.Current.BroadcastEnabled && _apiController.IsConnected)
|
||||||
{
|
{
|
||||||
float iconYOffset = (uidTextSize.Y - iconSize.Y) * 0.5f;
|
ImGui.SetCursorScreenPos(new Vector2(nextIconX, uidTextRectMin.Y + textVerticalOffset));
|
||||||
var buttonSize = new Vector2(iconSize.X, uidTextSize.Y);
|
|
||||||
|
|
||||||
ImGui.SetCursorPos(new Vector2(ImGui.GetStyle().ItemSpacing.X + 5f, cursorY));
|
|
||||||
ImGui.InvisibleButton("BroadcastIcon", buttonSize);
|
ImGui.InvisibleButton("BroadcastIcon", buttonSize);
|
||||||
|
|
||||||
var iconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset);
|
var iconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset);
|
||||||
using (_uiSharedService.IconFont.Push())
|
using (_uiSharedService.IconFont.Push())
|
||||||
ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.PersonCirclePlus.ToIconString());
|
ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.Wifi.ToIconString());
|
||||||
|
|
||||||
|
nextIconX = ImGui.GetItemRectMax().X + 6f;
|
||||||
|
|
||||||
|
|
||||||
if (ImGui.IsItemHovered())
|
if (ImGui.IsItemHovered())
|
||||||
@@ -618,50 +593,8 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
if (ImGui.IsItemClicked())
|
if (ImGui.IsItemClicked())
|
||||||
_lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
|
_lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui.SetCursorPosY(cursorY);
|
// Warning threshold icon (next to lightfinder or UID text)
|
||||||
ImGui.SetCursorPosX(uidStartX);
|
|
||||||
|
|
||||||
bool headerItemClicked;
|
|
||||||
using (_uiSharedService.UidFont.Push())
|
|
||||||
{
|
|
||||||
if (useVanityColors)
|
|
||||||
{
|
|
||||||
var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor);
|
|
||||||
var cursorPos = ImGui.GetCursorScreenPos();
|
|
||||||
var targetFontSize = ImGui.GetFontSize();
|
|
||||||
var font = ImGui.GetFont();
|
|
||||||
SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, targetFontSize ,font , "uid-header");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ImGui.TextColored(uidColor, uidText);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ImGui.IsItemHovered())
|
|
||||||
{
|
|
||||||
var padding = new Vector2(35f * ImGuiHelpers.GlobalScale);
|
|
||||||
Selune.RegisterHighlight(
|
|
||||||
ImGui.GetItemRectMin() - padding,
|
|
||||||
ImGui.GetItemRectMax() + padding,
|
|
||||||
SeluneHighlightMode.Point,
|
|
||||||
exactSize: true,
|
|
||||||
clipToElement: true,
|
|
||||||
clipPadding: padding,
|
|
||||||
highlightColorOverride: vanityGlowColor,
|
|
||||||
highlightAlphaOverride: 0.05f);
|
|
||||||
}
|
|
||||||
|
|
||||||
headerItemClicked = ImGui.IsItemClicked();
|
|
||||||
|
|
||||||
if (headerItemClicked)
|
|
||||||
{
|
|
||||||
ImGui.SetClipboardText(uidText);
|
|
||||||
}
|
|
||||||
|
|
||||||
UiSharedService.AttachToolTip("Click to copy");
|
|
||||||
|
|
||||||
if (_apiController.ServerState is ServerState.Connected && analysisSummary.HasData)
|
if (_apiController.ServerState is ServerState.Connected && analysisSummary.HasData)
|
||||||
{
|
{
|
||||||
var objectSummary = analysisSummary.Objects.Values.FirstOrDefault(summary => summary.HasEntries);
|
var objectSummary = analysisSummary.Objects.Values.FirstOrDefault(summary => summary.HasEntries);
|
||||||
@@ -675,24 +608,30 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds)
|
if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds)
|
||||||
{
|
{
|
||||||
ImGui.SameLine();
|
ImGui.SetCursorScreenPos(new Vector2(nextIconX, uidTextRectMin.Y + textVerticalOffset));
|
||||||
ImGui.SetCursorPosY(cursorY + 15f);
|
|
||||||
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow"));
|
ImGui.InvisibleButton("WarningThresholdIcon", buttonSize);
|
||||||
|
var warningIconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset);
|
||||||
|
using (_uiSharedService.IconFont.Push())
|
||||||
|
ImGui.GetWindowDrawList().AddText(warningIconPos, ImGui.GetColorU32(UIColors.Get("LightlessYellow")), FontAwesomeIcon.ExclamationTriangle.ToIconString());
|
||||||
|
|
||||||
string warningMessage = "";
|
if (ImGui.IsItemHovered())
|
||||||
if (isOverTriHold)
|
|
||||||
{
|
{
|
||||||
warningMessage += $"You exceed your own triangles threshold by " +
|
string warningMessage = "";
|
||||||
$"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles.";
|
if (isOverTriHold)
|
||||||
warningMessage += Environment.NewLine;
|
{
|
||||||
|
warningMessage += $"You exceed your own triangles threshold by " +
|
||||||
|
$"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles.";
|
||||||
|
warningMessage += Environment.NewLine;
|
||||||
|
}
|
||||||
|
if (isOverVRAMUsage)
|
||||||
|
{
|
||||||
|
warningMessage += $"You exceed your own VRAM threshold by " +
|
||||||
|
$"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}.";
|
||||||
|
}
|
||||||
|
UiSharedService.AttachToolTip(warningMessage);
|
||||||
}
|
}
|
||||||
if (isOverVRAMUsage)
|
|
||||||
{
|
|
||||||
warningMessage += $"You exceed your own VRAM threshold by " +
|
|
||||||
$"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}.";
|
|
||||||
}
|
|
||||||
UiSharedService.AttachToolTip(warningMessage);
|
|
||||||
if (ImGui.IsItemClicked())
|
if (ImGui.IsItemClicked())
|
||||||
{
|
{
|
||||||
_lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi)));
|
_lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi)));
|
||||||
@@ -701,6 +640,34 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (uidTextHovered)
|
||||||
|
{
|
||||||
|
var padding = new Vector2(35f * ImGuiHelpers.GlobalScale);
|
||||||
|
Selune.RegisterHighlight(
|
||||||
|
uidTextRectMin - padding,
|
||||||
|
uidTextRectMin + uidTextRect + padding,
|
||||||
|
SeluneHighlightMode.Point,
|
||||||
|
exactSize: true,
|
||||||
|
clipToElement: true,
|
||||||
|
clipPadding: padding,
|
||||||
|
highlightColorOverride: vanityGlowColor,
|
||||||
|
highlightAlphaOverride: 0.05f);
|
||||||
|
|
||||||
|
ImGui.SetTooltip("Click to copy");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headerItemClicked)
|
||||||
|
{
|
||||||
|
ImGui.SetClipboardText(uidText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect/Disconnect button next to big UID (use screen pos to avoid affecting layout)
|
||||||
|
DrawConnectButton(uidTextRectMin.Y + textVerticalOffset, uidTextSize.Y);
|
||||||
|
|
||||||
|
// Add spacing below the big UID
|
||||||
|
ImGuiHelpers.ScaledDummy(5f);
|
||||||
|
|
||||||
if (_apiController.ServerState is ServerState.Connected)
|
if (_apiController.ServerState is ServerState.Connected)
|
||||||
{
|
{
|
||||||
if (headerItemClicked)
|
if (headerItemClicked)
|
||||||
@@ -708,10 +675,12 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
ImGui.SetClipboardText(_apiController.DisplayName);
|
ImGui.SetClipboardText(_apiController.DisplayName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.Equals(_apiController.DisplayName, _apiController.UID, StringComparison.Ordinal))
|
// Only show smaller UID line if DisplayName differs from UID (custom vanity name)
|
||||||
|
bool hasCustomName = !string.Equals(_apiController.DisplayName, _apiController.UID, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (hasCustomName)
|
||||||
{
|
{
|
||||||
var origTextSize = ImGui.CalcTextSize(_apiController.UID);
|
ImGui.SetCursorPosX(uidStartX);
|
||||||
ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X) / 2 - (origTextSize.X / 2));
|
|
||||||
|
|
||||||
if (useVanityColors)
|
if (useVanityColors)
|
||||||
{
|
{
|
||||||
@@ -746,14 +715,88 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
ImGui.SetClipboardText(_apiController.UID);
|
ImGui.SetClipboardText(_apiController.UID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Users Online on same line as smaller UID (with separator)
|
||||||
|
ImGui.SameLine();
|
||||||
|
ImGui.AlignTextToFramePadding();
|
||||||
|
ImGui.TextUnformatted("|");
|
||||||
|
ImGui.SameLine();
|
||||||
|
ImGui.TextColored(UIColors.Get("LightlessGreen"), _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture));
|
||||||
|
ImGui.SameLine();
|
||||||
|
ImGui.TextUnformatted("Users Online");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No custom name - just show Users Online aligned to uidStartX
|
||||||
|
ImGui.SetCursorPosX(uidStartX);
|
||||||
|
ImGui.TextColored(UIColors.Get("LightlessGreen"), _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture));
|
||||||
|
ImGui.SameLine();
|
||||||
|
ImGui.TextUnformatted("Users Online");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
ImGui.SetCursorPosX(uidStartX);
|
||||||
UiSharedService.ColorTextWrapped(_apiController.ServerState.GetServerError(_apiController.AuthFailureMessage), uidColor);
|
UiSharedService.ColorTextWrapped(_apiController.ServerState.GetServerError(_apiController.AuthFailureMessage), uidColor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DrawConnectButton(float screenY, float textHeight)
|
||||||
|
{
|
||||||
|
var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Link);
|
||||||
|
bool isConnectingOrConnected = _apiController.ServerState is ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting;
|
||||||
|
var color = UiSharedService.GetBoolColor(!isConnectingOrConnected);
|
||||||
|
var connectedIcon = isConnectingOrConnected ? FontAwesomeIcon.Unlink : FontAwesomeIcon.Link;
|
||||||
|
|
||||||
|
// Position on right side, vertically centered with text
|
||||||
|
if (_apiController.ServerState is not (ServerState.Reconnecting or ServerState.Disconnecting))
|
||||||
|
{
|
||||||
|
var windowPos = ImGui.GetWindowPos();
|
||||||
|
var screenX = windowPos.X + UiSharedService.GetWindowContentRegionWidth() - buttonSize.X - 13f;
|
||||||
|
var yOffset = (textHeight - buttonSize.Y) * 0.5f;
|
||||||
|
ImGui.SetCursorScreenPos(new Vector2(screenX, screenY + yOffset));
|
||||||
|
|
||||||
|
using (ImRaii.PushColor(ImGuiCol.Text, color))
|
||||||
|
using (ImRaii.PushColor(ImGuiCol.Button, ImGui.ColorConvertFloat4ToU32(new(0, 0, 0, 0))))
|
||||||
|
{
|
||||||
|
if (_uiSharedService.IconButton(connectedIcon, buttonSize.Y))
|
||||||
|
{
|
||||||
|
if (isConnectingOrConnected && !_serverManager.CurrentServer.FullPause)
|
||||||
|
{
|
||||||
|
_serverManager.CurrentServer.FullPause = true;
|
||||||
|
_serverManager.Save();
|
||||||
|
}
|
||||||
|
else if (!isConnectingOrConnected && _serverManager.CurrentServer.FullPause)
|
||||||
|
{
|
||||||
|
_serverManager.CurrentServer.FullPause = false;
|
||||||
|
_serverManager.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = _apiController.CreateConnectionsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
|
||||||
|
{
|
||||||
|
Selune.RegisterHighlight(
|
||||||
|
ImGui.GetItemRectMin(),
|
||||||
|
ImGui.GetItemRectMax(),
|
||||||
|
SeluneHighlightMode.Both,
|
||||||
|
borderOnly: true,
|
||||||
|
borderThicknessOverride: ConnectButtonHighlightThickness,
|
||||||
|
exactSize: true,
|
||||||
|
clipToElement: true,
|
||||||
|
roundingOverride: ImGui.GetStyle().FrameRounding);
|
||||||
|
}
|
||||||
|
|
||||||
|
UiSharedService.AttachToolTip(isConnectingOrConnected ? "Disconnect from " + _serverManager.CurrentServer.ServerName : "Connect to " + _serverManager.CurrentServer.ServerName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Folder Building
|
||||||
|
|
||||||
private IEnumerable<IDrawFolder> DrawFolders
|
private IEnumerable<IDrawFolder> DrawFolders
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -889,6 +932,10 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Filtering & Sorting
|
||||||
|
|
||||||
private static bool PassesFilter(PairUiEntry entry, string filter)
|
private static bool PassesFilter(PairUiEntry entry, string filter)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(filter)) return true;
|
if (string.IsNullOrEmpty(filter)) return true;
|
||||||
@@ -1032,10 +1079,11 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
return SortGroupEntries(entries, group);
|
return SortGroupEntries(entries, group);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UiSharedService_GposeEnd()
|
#endregion
|
||||||
{
|
|
||||||
IsOpen = _wasOpen;
|
#region GPose Handlers
|
||||||
}
|
|
||||||
|
private void UiSharedService_GposeEnd() => IsOpen = _wasOpen;
|
||||||
|
|
||||||
private void UiSharedService_GposeStart()
|
private void UiSharedService_GposeStart()
|
||||||
{
|
{
|
||||||
@@ -1043,6 +1091,10 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
IsOpen = false;
|
IsOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Focus Tracking
|
||||||
|
|
||||||
private void RegisterFocusCharacter(Pair pair)
|
private void RegisterFocusCharacter(Pair pair)
|
||||||
{
|
{
|
||||||
_pendingFocusPair = pair;
|
_pendingFocusPair = pair;
|
||||||
@@ -1088,4 +1140,16 @@ public class CompactUi : WindowMediatorSubscriberBase
|
|||||||
_pendingFocusPair = null;
|
_pendingFocusPair = null;
|
||||||
_pendingFocusFrame = -1;
|
_pendingFocusFrame = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Helper Types
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Auto)]
|
||||||
|
private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes)
|
||||||
|
{
|
||||||
|
public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ 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;
|
||||||
@@ -68,10 +66,6 @@ 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) =>
|
||||||
@@ -173,7 +167,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
|||||||
List<KeyValuePair<GameObjectHandler, Dictionary<string, FileDownloadStatus>>> transfers;
|
List<KeyValuePair<GameObjectHandler, Dictionary<string, FileDownloadStatus>>> transfers;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
transfers = [.. _currentDownloads];
|
transfers = _currentDownloads.ToList();
|
||||||
}
|
}
|
||||||
catch (ArgumentException)
|
catch (ArgumentException)
|
||||||
{
|
{
|
||||||
@@ -441,13 +435,9 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
|||||||
var handler = transfer.Key;
|
var handler = transfer.Key;
|
||||||
var statuses = transfer.Value.Values;
|
var statuses = transfer.Value.Values;
|
||||||
|
|
||||||
var (playerTotalFiles, playerTotalBytes) = _downloadInitialTotals.TryGetValue(handler, out var totals)
|
var playerTotalFiles = statuses.Sum(s => s.TotalFiles);
|
||||||
? totals
|
var playerTransferredFiles = statuses.Sum(s => s.TransferredFiles);
|
||||||
: (statuses.Sum(s => s.TotalFiles), statuses.Sum(s => s.TotalBytes));
|
var playerTotalBytes = 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;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@ 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;
|
||||||
@@ -41,7 +40,6 @@ 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;
|
||||||
|
|
||||||
@@ -71,6 +69,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
private readonly UiSharedService _uiShared;
|
private readonly UiSharedService _uiShared;
|
||||||
private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress;
|
private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress;
|
||||||
private readonly NameplateService _nameplateService;
|
private readonly NameplateService _nameplateService;
|
||||||
|
private readonly AnimatedHeader _animatedHeader = new();
|
||||||
|
|
||||||
private (int, int, FileCacheEntity) _currentProgress;
|
private (int, int, FileCacheEntity) _currentProgress;
|
||||||
private bool _deleteAccountPopupModalShown = false;
|
private bool _deleteAccountPopupModalShown = false;
|
||||||
private bool _deleteFilesPopupModalShown = false;
|
private bool _deleteFilesPopupModalShown = false;
|
||||||
@@ -107,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 =
|
private readonly string[] _generalTreeNavOrder = new[]
|
||||||
[
|
{
|
||||||
"Import & Export",
|
"Import & Export",
|
||||||
"Popup & Auto Fill",
|
"Popup & Auto Fill",
|
||||||
"Behavior",
|
"Behavior",
|
||||||
@@ -118,8 +118,7 @@ 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",
|
||||||
@@ -208,7 +207,10 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
_nameplateService = nameplateService;
|
_nameplateService = nameplateService;
|
||||||
_actorObjectService = actorObjectService;
|
_actorObjectService = actorObjectService;
|
||||||
_validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v);
|
_validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v);
|
||||||
|
_animatedHeader.Height = 120f;
|
||||||
|
_animatedHeader.EnableBottomGradient = true;
|
||||||
|
_animatedHeader.GradientHeight = 250f;
|
||||||
|
_animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects;
|
||||||
WindowBuilder.For(this)
|
WindowBuilder.For(this)
|
||||||
.AllowPinning(true)
|
.AllowPinning(true)
|
||||||
.AllowClickthrough(false)
|
.AllowClickthrough(false)
|
||||||
@@ -244,6 +246,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
public override void OnClose()
|
public override void OnClose()
|
||||||
{
|
{
|
||||||
|
_animatedHeader.ClearParticles();
|
||||||
_uiShared.EditTrackerPosition = false;
|
_uiShared.EditTrackerPosition = false;
|
||||||
_uidToAddForIgnore = string.Empty;
|
_uidToAddForIgnore = string.Empty;
|
||||||
_secretKeysConversionCts = _secretKeysConversionCts.CancelRecreate();
|
_secretKeysConversionCts = _secretKeysConversionCts.CancelRecreate();
|
||||||
@@ -258,8 +261,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
protected override void DrawInternal()
|
protected override void DrawInternal()
|
||||||
{
|
{
|
||||||
|
_animatedHeader.Draw(ImGui.GetContentRegionAvail().X, (_, _) => { });
|
||||||
_ = _uiShared.DrawOtherPluginState();
|
_ = _uiShared.DrawOtherPluginState();
|
||||||
|
|
||||||
DrawSettingsContent();
|
DrawSettingsContent();
|
||||||
}
|
}
|
||||||
private static Vector3 PackedColorToVector3(uint color)
|
private static Vector3 PackedColorToVector3(uint color)
|
||||||
@@ -1144,7 +1147,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 = [];
|
List<string> speedTestResults = new();
|
||||||
foreach (var server in servers)
|
foreach (var server in servers)
|
||||||
{
|
{
|
||||||
HttpResponseMessage? result = null;
|
HttpResponseMessage? result = null;
|
||||||
@@ -1928,25 +1931,14 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
using (ImRaii.PushIndent(20f))
|
using (ImRaii.PushIndent(20f))
|
||||||
{
|
{
|
||||||
if (_validationTask.IsCompletedSuccessfully)
|
if (_validationTask.IsCompleted)
|
||||||
{
|
{
|
||||||
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)
|
||||||
@@ -2103,7 +2095,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
ImGui.Separator();
|
ImGui.Separator();
|
||||||
var openPopupOnAddition = _configService.Current.OpenPopupOnAdd;
|
var openPopupOnAddition = _configService.Current.OpenPopupOnAdd;
|
||||||
|
|
||||||
using (var popupTree = BeginGeneralTree("Popup & Auto Fill", UIColors.Get("LightlessPurple")))
|
using (var popupTree = BeginGeneralTree("Popup & Auto Fill", UIColors.Get("LightlessPurple")))
|
||||||
{
|
{
|
||||||
if (popupTree.Visible)
|
if (popupTree.Visible)
|
||||||
@@ -2160,11 +2152,20 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
var groupInVisible = _configService.Current.ShowSyncshellUsersInVisible;
|
var groupInVisible = _configService.Current.ShowSyncshellUsersInVisible;
|
||||||
var syncshellOfflineSeparate = _configService.Current.ShowSyncshellOfflineUsersSeparately;
|
var syncshellOfflineSeparate = _configService.Current.ShowSyncshellOfflineUsersSeparately;
|
||||||
var greenVisiblePair = _configService.Current.ShowVisiblePairsGreenEye;
|
var greenVisiblePair = _configService.Current.ShowVisiblePairsGreenEye;
|
||||||
|
var enableParticleEffects = _configService.Current.EnableParticleEffects;
|
||||||
|
|
||||||
using (var behaviorTree = BeginGeneralTree("Behavior", UIColors.Get("LightlessPurple")))
|
using (var behaviorTree = BeginGeneralTree("Behavior", UIColors.Get("LightlessPurple")))
|
||||||
{
|
{
|
||||||
if (behaviorTree.Visible)
|
if (behaviorTree.Visible)
|
||||||
{
|
{
|
||||||
|
if (ImGui.Checkbox("Enable Particle Effects", ref enableParticleEffects))
|
||||||
|
{
|
||||||
|
_configService.Current.EnableParticleEffects = enableParticleEffects;
|
||||||
|
_configService.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiShared.DrawHelpText("This will enable particle effects in the UI.");
|
||||||
|
|
||||||
if (ImGui.Checkbox("Enable Game Right Click Menu Entries", ref enableRightClickMenu))
|
if (ImGui.Checkbox("Enable Game Right Click Menu Entries", ref enableRightClickMenu))
|
||||||
{
|
{
|
||||||
_configService.Current.EnableRightClickMenus = enableRightClickMenu;
|
_configService.Current.EnableRightClickMenus = enableRightClickMenu;
|
||||||
@@ -2873,16 +2874,21 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
var colorNames = new[]
|
var colorNames = new[]
|
||||||
{
|
{
|
||||||
("LightlessPurple", "Primary Purple", "Section titles and dividers"),
|
("LightlessPurple", "Primary Purple", "Section titles and dividers"),
|
||||||
("LightlessPurpleActive", "Primary Purple (Active)", "Active tabs and hover highlights"),
|
("LightlessPurpleActive", "Primary Purple (Active)", "Active tabs and hover highlights"),
|
||||||
("LightlessPurpleDefault", "Primary Purple (Inactive)", "Inactive tabs and default dividers"),
|
("LightlessPurpleDefault", "Primary Purple (Inactive)", "Inactive tabs and default dividers"),
|
||||||
("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"),
|
("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"),
|
||||||
("LightlessGreen", "Success Green", "Join buttons and success messages"),
|
("LightlessGreen", "Success Green", "Join buttons and success messages"),
|
||||||
("LightlessYellow", "Warning Yellow", "Warning colors"),
|
("LightlessYellow", "Warning Yellow", "Warning colors"),
|
||||||
("LightlessOrange", "Performance Orange", "Performance notifications and warnings"),
|
("LightlessOrange", "Performance Orange", "Performance notifications and warnings"),
|
||||||
("PairBlue", "Syncshell Blue", "Syncshell headers, toggle highlights, and moderator actions"),
|
("PairBlue", "Syncshell Blue", "Syncshell headers, toggle highlights, and moderator actions"),
|
||||||
("DimRed", "Error Red", "Error and offline colors")
|
("DimRed", "Error Red", "Error and offline colors"),
|
||||||
};
|
("HeaderGradientTop", "Header Gradient (Top)", "Top color of the animated header background"),
|
||||||
|
("HeaderGradientBottom", "Header Gradient (Bottom)", "Bottom color of the animated header background"),
|
||||||
|
("HeaderStaticStar", "Header Stars", "Tint color for the static background stars in the header"),
|
||||||
|
("HeaderShootingStar", "Header Shooting Star", "Tint color for the shooting star effect"),
|
||||||
|
};
|
||||||
|
|
||||||
if (ImGui.BeginTable("##ColorTable", 3,
|
if (ImGui.BeginTable("##ColorTable", 3,
|
||||||
ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit))
|
ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit))
|
||||||
{
|
{
|
||||||
@@ -3088,102 +3094,10 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3273,7 +3187,6 @@ 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;
|
||||||
@@ -3581,7 +3494,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(selector: static value => value.ToString()).ToArray();
|
var optionLabels = dimensionOptions.Select(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)
|
||||||
|
|||||||
@@ -43,10 +43,23 @@ public class AnimatedHeader
|
|||||||
private const float _extendedParticleHeight = 40f;
|
private const float _extendedParticleHeight = 40f;
|
||||||
|
|
||||||
public float Height { get; set; } = 150f;
|
public float Height { get; set; } = 150f;
|
||||||
|
|
||||||
|
// Color keys for theming
|
||||||
|
public string? TopColorKey { get; set; } = "HeaderGradientTop";
|
||||||
|
public string? BottomColorKey { get; set; } = "HeaderGradientBottom";
|
||||||
|
public string? StaticStarColorKey { get; set; } = "HeaderStaticStar";
|
||||||
|
public string? ShootingStarColorKey { get; set; } = "HeaderShootingStar";
|
||||||
|
|
||||||
|
// Fallbacks if the color keys are not found
|
||||||
public Vector4 TopColor { get; set; } = new(0.08f, 0.05f, 0.15f, 1.0f);
|
public Vector4 TopColor { get; set; } = new(0.08f, 0.05f, 0.15f, 1.0f);
|
||||||
public Vector4 BottomColor { get; set; } = new(0.12f, 0.08f, 0.20f, 1.0f);
|
public Vector4 BottomColor { get; set; } = new(0.12f, 0.08f, 0.20f, 1.0f);
|
||||||
|
public Vector4 StaticStarColor { get; set; } = new(1f, 1f, 1f, 1f);
|
||||||
|
public Vector4 ShootingStarColor { get; set; } = new(0.4f, 0.8f, 1.0f, 1.0f);
|
||||||
|
|
||||||
public bool EnableParticles { get; set; } = true;
|
public bool EnableParticles { get; set; } = true;
|
||||||
public bool EnableBottomGradient { get; set; } = true;
|
public bool EnableBottomGradient { get; set; } = true;
|
||||||
|
|
||||||
|
public float GradientHeight { get; set; } = 60f;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Draws the animated header with some customizable content
|
/// Draws the animated header with some customizable content
|
||||||
@@ -146,16 +159,21 @@ public class AnimatedHeader
|
|||||||
{
|
{
|
||||||
var drawList = ImGui.GetWindowDrawList();
|
var drawList = ImGui.GetWindowDrawList();
|
||||||
|
|
||||||
|
var top = ResolveColor(TopColorKey, TopColor);
|
||||||
|
var bottom = ResolveColor(BottomColorKey, BottomColor);
|
||||||
|
|
||||||
drawList.AddRectFilledMultiColor(
|
drawList.AddRectFilledMultiColor(
|
||||||
headerStart,
|
headerStart,
|
||||||
headerEnd,
|
headerEnd,
|
||||||
ImGui.GetColorU32(TopColor),
|
ImGui.GetColorU32(top),
|
||||||
ImGui.GetColorU32(TopColor),
|
ImGui.GetColorU32(top),
|
||||||
ImGui.GetColorU32(BottomColor),
|
ImGui.GetColorU32(bottom),
|
||||||
ImGui.GetColorU32(BottomColor)
|
ImGui.GetColorU32(bottom)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Draw static background stars
|
// Draw static background stars
|
||||||
|
var starBase = ResolveColor(StaticStarColorKey, StaticStarColor);
|
||||||
|
|
||||||
var random = new Random(42);
|
var random = new Random(42);
|
||||||
for (int i = 0; i < 50; i++)
|
for (int i = 0; i < 50; i++)
|
||||||
{
|
{
|
||||||
@@ -164,23 +182,28 @@ public class AnimatedHeader
|
|||||||
(float)random.NextDouble() * (headerEnd.Y - headerStart.Y)
|
(float)random.NextDouble() * (headerEnd.Y - headerStart.Y)
|
||||||
);
|
);
|
||||||
var brightness = 0.3f + (float)random.NextDouble() * 0.4f;
|
var brightness = 0.3f + (float)random.NextDouble() * 0.4f;
|
||||||
drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(new Vector4(1f, 1f, 1f, brightness)));
|
var starColor = starBase with { W = starBase.W * brightness };
|
||||||
|
|
||||||
|
drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(starColor));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width)
|
private void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width)
|
||||||
{
|
{
|
||||||
var drawList = ImGui.GetWindowDrawList();
|
var drawList = ImGui.GetWindowDrawList();
|
||||||
var gradientHeight = 60f;
|
var gradientHeight = GradientHeight;
|
||||||
|
var bottom = ResolveColor(BottomColorKey, BottomColor);
|
||||||
|
|
||||||
for (int i = 0; i < gradientHeight; i++)
|
for (int i = 0; i < gradientHeight; i++)
|
||||||
{
|
{
|
||||||
var progress = i / gradientHeight;
|
var progress = i / gradientHeight;
|
||||||
var smoothProgress = progress * progress;
|
var smoothProgress = progress * progress;
|
||||||
var r = BottomColor.X + (0.0f - BottomColor.X) * smoothProgress;
|
|
||||||
var g = BottomColor.Y + (0.0f - BottomColor.Y) * smoothProgress;
|
var r = bottom.X + (0.0f - bottom.X) * smoothProgress;
|
||||||
var b = BottomColor.Z + (0.0f - BottomColor.Z) * smoothProgress;
|
var g = bottom.Y + (0.0f - bottom.Y) * smoothProgress;
|
||||||
|
var b = bottom.Z + (0.0f - bottom.Z) * smoothProgress;
|
||||||
var alpha = 1f - smoothProgress;
|
var alpha = 1f - smoothProgress;
|
||||||
|
|
||||||
var gradientColor = new Vector4(r, g, b, alpha);
|
var gradientColor = new Vector4(r, g, b, alpha);
|
||||||
drawList.AddLine(
|
drawList.AddLine(
|
||||||
new Vector2(headerStart.X, headerEnd.Y + i),
|
new Vector2(headerStart.X, headerEnd.Y + i),
|
||||||
@@ -308,9 +331,11 @@ public class AnimatedHeader
|
|||||||
? baseAlpha * (0.6f + 0.4f * MathF.Sin(particle.Twinkle))
|
? baseAlpha * (0.6f + 0.4f * MathF.Sin(particle.Twinkle))
|
||||||
: baseAlpha;
|
: baseAlpha;
|
||||||
|
|
||||||
|
var shootingBase = ResolveColor(ShootingStarColorKey, ShootingStarColor);
|
||||||
|
|
||||||
if (particle.Type == ParticleType.ShootingStar && particle.Trail != null && particle.Trail.Count > 1)
|
if (particle.Type == ParticleType.ShootingStar && particle.Trail != null && particle.Trail.Count > 1)
|
||||||
{
|
{
|
||||||
var cyanColor = new Vector4(0.4f, 0.8f, 1.0f, 1.0f);
|
var baseColor = shootingBase;
|
||||||
|
|
||||||
for (int t = 1; t < particle.Trail.Count; t++)
|
for (int t = 1; t < particle.Trail.Count; t++)
|
||||||
{
|
{
|
||||||
@@ -319,17 +344,18 @@ public class AnimatedHeader
|
|||||||
var trailWidth = (1f - trailProgress) * 3f + 1f;
|
var trailWidth = (1f - trailProgress) * 3f + 1f;
|
||||||
|
|
||||||
var glowAlpha = trailAlpha * 0.4f;
|
var glowAlpha = trailAlpha * 0.4f;
|
||||||
|
|
||||||
drawList.AddLine(
|
drawList.AddLine(
|
||||||
bannerStart + particle.Trail[t - 1],
|
bannerStart + particle.Trail[t - 1],
|
||||||
bannerStart + particle.Trail[t],
|
bannerStart + particle.Trail[t],
|
||||||
ImGui.GetColorU32(cyanColor with { W = glowAlpha }),
|
ImGui.GetColorU32(baseColor with { W = glowAlpha }),
|
||||||
trailWidth + 4f
|
trailWidth + 4f
|
||||||
);
|
);
|
||||||
|
|
||||||
drawList.AddLine(
|
drawList.AddLine(
|
||||||
bannerStart + particle.Trail[t - 1],
|
bannerStart + particle.Trail[t - 1],
|
||||||
bannerStart + particle.Trail[t],
|
bannerStart + particle.Trail[t],
|
||||||
ImGui.GetColorU32(cyanColor with { W = trailAlpha }),
|
ImGui.GetColorU32(baseColor with { W = trailAlpha }),
|
||||||
trailWidth
|
trailWidth
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -448,6 +474,13 @@ public class AnimatedHeader
|
|||||||
Hue = 270f
|
Hue = 270f
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
private static Vector4 ResolveColor(string? key, Vector4 fallback)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
return fallback;
|
||||||
|
|
||||||
|
return UIColors.Get(key);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clears all active particles. Useful when closing or hiding a window with an animated header.
|
/// Clears all active particles. Useful when closing or hiding a window with an animated header.
|
||||||
|
|||||||
@@ -40,9 +40,10 @@ internal static class MainStyle
|
|||||||
new("color.frameBg", "Frame Background", () => Rgba(40, 40, 40, 255), ImGuiCol.FrameBg),
|
new("color.frameBg", "Frame Background", () => Rgba(40, 40, 40, 255), ImGuiCol.FrameBg),
|
||||||
new("color.frameBgHovered", "Frame Background (Hover)", () => Rgba(50, 50, 50, 100), ImGuiCol.FrameBgHovered),
|
new("color.frameBgHovered", "Frame Background (Hover)", () => Rgba(50, 50, 50, 100), ImGuiCol.FrameBgHovered),
|
||||||
new("color.frameBgActive", "Frame Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.FrameBgActive),
|
new("color.frameBgActive", "Frame Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.FrameBgActive),
|
||||||
new("color.titleBg", "Title Background", () => Rgba(24, 24, 24, 232), ImGuiCol.TitleBg),
|
new("color.titleBg", "Title Background", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBg),
|
||||||
new("color.titleBgActive", "Title Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.TitleBgActive),
|
new("color.titleBgActive", "Title Background (Active)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgActive),
|
||||||
new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(27, 27, 27, 255), ImGuiCol.TitleBgCollapsed),
|
new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgCollapsed),
|
||||||
|
|
||||||
new("color.menuBarBg", "Menu Bar Background", () => Rgba(36, 36, 36, 255), ImGuiCol.MenuBarBg),
|
new("color.menuBarBg", "Menu Bar Background", () => Rgba(36, 36, 36, 255), ImGuiCol.MenuBarBg),
|
||||||
new("color.scrollbarBg", "Scrollbar Background", () => Rgba(0, 0, 0, 0), ImGuiCol.ScrollbarBg),
|
new("color.scrollbarBg", "Scrollbar Background", () => Rgba(0, 0, 0, 0), ImGuiCol.ScrollbarBg),
|
||||||
new("color.scrollbarGrab", "Scrollbar Grab", () => Rgba(62, 62, 62, 255), ImGuiCol.ScrollbarGrab),
|
new("color.scrollbarGrab", "Scrollbar Grab", () => Rgba(62, 62, 62, 255), ImGuiCol.ScrollbarGrab),
|
||||||
|
|||||||
@@ -1,855 +0,0 @@
|
|||||||
using Dalamud.Bindings.ImGui;
|
|
||||||
using Dalamud.Interface;
|
|
||||||
using Dalamud.Interface.Colors;
|
|
||||||
using Dalamud.Interface.Textures.TextureWraps;
|
|
||||||
using Dalamud.Interface.Utility;
|
|
||||||
using Dalamud.Interface.Utility.Raii;
|
|
||||||
using Dalamud.Plugin.Services;
|
|
||||||
using LightlessSync.API.Data;
|
|
||||||
using LightlessSync.API.Data.Enum;
|
|
||||||
using LightlessSync.API.Data.Extensions;
|
|
||||||
using LightlessSync.API.Dto;
|
|
||||||
using LightlessSync.API.Dto.Group;
|
|
||||||
using LightlessSync.Services;
|
|
||||||
using LightlessSync.Services.LightFinder;
|
|
||||||
using LightlessSync.Services.Mediator;
|
|
||||||
using LightlessSync.UI.Services;
|
|
||||||
using LightlessSync.UI.Tags;
|
|
||||||
using LightlessSync.Utils;
|
|
||||||
using LightlessSync.WebAPI;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using System.Numerics;
|
|
||||||
|
|
||||||
namespace LightlessSync.UI;
|
|
||||||
|
|
||||||
public class SyncshellFinderUI : WindowMediatorSubscriberBase
|
|
||||||
{
|
|
||||||
private readonly ApiController _apiController;
|
|
||||||
private readonly LightFinderService _broadcastService;
|
|
||||||
private readonly UiSharedService _uiSharedService;
|
|
||||||
private readonly LightFinderScannerService _broadcastScannerService;
|
|
||||||
private readonly PairUiService _pairUiService;
|
|
||||||
private readonly DalamudUtilService _dalamudUtilService;
|
|
||||||
|
|
||||||
private Vector4 _tagBackgroundColor = new(0.18f, 0.18f, 0.18f, 0.95f);
|
|
||||||
private Vector4 _tagBorderColor = new(0.35f, 0.35f, 0.35f, 0.4f);
|
|
||||||
|
|
||||||
private readonly List<SeStringUtils.SeStringSegment> _seResolvedSegments = new();
|
|
||||||
private readonly List<GroupJoinDto> _nearbySyncshells = [];
|
|
||||||
private List<GroupFullInfoDto> _currentSyncshells = [];
|
|
||||||
private int _selectedNearbyIndex = -1;
|
|
||||||
private int _syncshellPageIndex = 0;
|
|
||||||
private readonly HashSet<string> _recentlyJoined = new(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
private GroupJoinDto? _joinDto;
|
|
||||||
private GroupJoinInfoDto? _joinInfo;
|
|
||||||
private DefaultPermissionsDto _ownPermissions = null!;
|
|
||||||
private bool _useTestSyncshells = false;
|
|
||||||
|
|
||||||
private bool _compactView = false;
|
|
||||||
private readonly LightlessProfileManager _lightlessProfileManager;
|
|
||||||
|
|
||||||
public SyncshellFinderUI(
|
|
||||||
ILogger<SyncshellFinderUI> logger,
|
|
||||||
LightlessMediator mediator,
|
|
||||||
PerformanceCollectorService performanceCollectorService,
|
|
||||||
LightFinderService broadcastService,
|
|
||||||
UiSharedService uiShared,
|
|
||||||
ApiController apiController,
|
|
||||||
LightFinderScannerService broadcastScannerService,
|
|
||||||
PairUiService pairUiService,
|
|
||||||
DalamudUtilService dalamudUtilService,
|
|
||||||
LightlessProfileManager lightlessProfileManager) : base(logger, mediator, "Shellfinder###LightlessSyncshellFinderUI", performanceCollectorService)
|
|
||||||
{
|
|
||||||
_broadcastService = broadcastService;
|
|
||||||
_uiSharedService = uiShared;
|
|
||||||
_apiController = apiController;
|
|
||||||
_broadcastScannerService = broadcastScannerService;
|
|
||||||
_pairUiService = pairUiService;
|
|
||||||
_dalamudUtilService = dalamudUtilService;
|
|
||||||
_lightlessProfileManager = lightlessProfileManager;
|
|
||||||
|
|
||||||
IsOpen = false;
|
|
||||||
WindowBuilder.For(this)
|
|
||||||
.SetSizeConstraints(new Vector2(600, 400), new Vector2(600, 550))
|
|
||||||
.Apply();
|
|
||||||
|
|
||||||
Mediator.Subscribe<SyncshellBroadcastsUpdatedMessage>(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false));
|
|
||||||
Mediator.Subscribe<BroadcastStatusChangedMessage>(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false));
|
|
||||||
Mediator.Subscribe<UserLeftSyncshell>(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false));
|
|
||||||
Mediator.Subscribe<UserJoinedSyncshell>(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async void OnOpen()
|
|
||||||
{
|
|
||||||
_ownPermissions = _apiController.DefaultPermissions.DeepClone()!;
|
|
||||||
await RefreshSyncshellsAsync().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void DrawInternal()
|
|
||||||
{
|
|
||||||
ImGui.BeginGroup();
|
|
||||||
_uiSharedService.MediumText("Nearby Syncshells", UIColors.Get("LightlessPurple"));
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
if (ImGui.SmallButton("Show test syncshells"))
|
|
||||||
{
|
|
||||||
_useTestSyncshells = !_useTestSyncshells;
|
|
||||||
_ = Task.Run(async () => await RefreshSyncshellsAsync().ConfigureAwait(false));
|
|
||||||
}
|
|
||||||
ImGui.SameLine();
|
|
||||||
#endif
|
|
||||||
|
|
||||||
string checkboxLabel = "Compact view";
|
|
||||||
float availWidth = ImGui.GetContentRegionAvail().X;
|
|
||||||
float checkboxWidth = ImGui.CalcTextSize(checkboxLabel).X + ImGui.GetFrameHeight();
|
|
||||||
|
|
||||||
float rightX = ImGui.GetCursorPosX() + availWidth - checkboxWidth - 4.0f;
|
|
||||||
ImGui.SetCursorPosX(rightX);
|
|
||||||
ImGui.Checkbox(checkboxLabel, ref _compactView);
|
|
||||||
ImGui.EndGroup();
|
|
||||||
|
|
||||||
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"));
|
|
||||||
ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale));
|
|
||||||
if (_nearbySyncshells.Count == 0)
|
|
||||||
{
|
|
||||||
ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted.");
|
|
||||||
|
|
||||||
if (!_broadcastService.IsBroadcasting)
|
|
||||||
{
|
|
||||||
|
|
||||||
UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"));
|
|
||||||
|
|
||||||
ImGui.TextColored(UIColors.Get("LightlessYellow"), "Lightfinder is currently disabled, to locate nearby syncshells, Lightfinder must be active.");
|
|
||||||
ImGuiHelpers.ScaledDummy(0.5f);
|
|
||||||
|
|
||||||
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 10.0f);
|
|
||||||
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessPurple"));
|
|
||||||
|
|
||||||
if (ImGui.Button("Open Lightfinder", new Vector2(200 * ImGuiHelpers.GlobalScale, 0)))
|
|
||||||
{
|
|
||||||
Mediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.PopStyleColor();
|
|
||||||
ImGui.PopStyleVar();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts().ToList() ?? [];
|
|
||||||
_broadcastScannerService.TryGetLocalHashedCid(out var localHashedCid);
|
|
||||||
|
|
||||||
var cardData = new List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)>();
|
|
||||||
|
|
||||||
foreach (var shell in _nearbySyncshells)
|
|
||||||
{
|
|
||||||
string broadcasterName;
|
|
||||||
|
|
||||||
if (shell?.Group == null || string.IsNullOrEmpty(shell.Group.GID))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (_useTestSyncshells)
|
|
||||||
{
|
|
||||||
var displayName = !string.IsNullOrEmpty(shell.Group.Alias)
|
|
||||||
? shell.Group.Alias
|
|
||||||
: shell.Group.GID;
|
|
||||||
|
|
||||||
broadcasterName = $"{displayName} (Tester of TestWorld)";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var broadcast = broadcasts
|
|
||||||
.FirstOrDefault(b => string.Equals(b.GID, shell.Group.GID, StringComparison.Ordinal));
|
|
||||||
|
|
||||||
if (broadcast == null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var (name, address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.HashedCID);
|
|
||||||
if (string.IsNullOrEmpty(name))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(address);
|
|
||||||
broadcasterName = !string.IsNullOrEmpty(worldName)
|
|
||||||
? $"{name} ({worldName})"
|
|
||||||
: name;
|
|
||||||
|
|
||||||
var isSelfBroadcast = !string.IsNullOrEmpty(localHashedCid)
|
|
||||||
&& string.Equals(broadcast.HashedCID, localHashedCid, StringComparison.Ordinal);
|
|
||||||
|
|
||||||
cardData.Add((shell, broadcasterName, isSelfBroadcast));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
cardData.Add((shell, broadcasterName, false));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cardData.Count == 0)
|
|
||||||
{
|
|
||||||
ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_compactView)
|
|
||||||
{
|
|
||||||
DrawSyncshellGrid(cardData);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
DrawSyncshellList(cardData);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (_joinDto != null && _joinInfo != null && _joinInfo.Success)
|
|
||||||
DrawConfirmation();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawSyncshellList(List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)> listData)
|
|
||||||
{
|
|
||||||
const int shellsPerPage = 3;
|
|
||||||
var totalPages = (int)Math.Ceiling(listData.Count / (float)shellsPerPage);
|
|
||||||
if (totalPages <= 0)
|
|
||||||
totalPages = 1;
|
|
||||||
|
|
||||||
_syncshellPageIndex = Math.Clamp(_syncshellPageIndex, 0, totalPages - 1);
|
|
||||||
|
|
||||||
var firstIndex = _syncshellPageIndex * shellsPerPage;
|
|
||||||
var lastExclusive = Math.Min(firstIndex + shellsPerPage, listData.Count);
|
|
||||||
|
|
||||||
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8.0f);
|
|
||||||
ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.0f);
|
|
||||||
|
|
||||||
for (int index = firstIndex; index < lastExclusive; index++)
|
|
||||||
{
|
|
||||||
var (shell, broadcasterName, isSelfBroadcast) = listData[index];
|
|
||||||
var broadcasterLabel = string.IsNullOrEmpty(broadcasterName)
|
|
||||||
? (isSelfBroadcast ? "You" : string.Empty)
|
|
||||||
: (isSelfBroadcast ? $"{broadcasterName} (You)" : broadcasterName);
|
|
||||||
|
|
||||||
ImGui.PushID(shell.Group.GID);
|
|
||||||
float rowHeight = 74f * ImGuiHelpers.GlobalScale;
|
|
||||||
|
|
||||||
ImGui.BeginChild($"ShellRow##{shell.Group.GID}", new Vector2(-1, rowHeight), border: true);
|
|
||||||
|
|
||||||
var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID;
|
|
||||||
|
|
||||||
var style = ImGui.GetStyle();
|
|
||||||
float startX = ImGui.GetCursorPosX();
|
|
||||||
float regionW = ImGui.GetContentRegionAvail().X;
|
|
||||||
float rightTxtW = ImGui.CalcTextSize(broadcasterLabel).X;
|
|
||||||
|
|
||||||
_uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple"));
|
|
||||||
if (ImGui.IsItemHovered())
|
|
||||||
ImGui.SetTooltip("Click to open profile.");
|
|
||||||
if (ImGui.IsItemClicked())
|
|
||||||
{
|
|
||||||
Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group));
|
|
||||||
}
|
|
||||||
|
|
||||||
float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X;
|
|
||||||
ImGui.SameLine();
|
|
||||||
ImGui.SetCursorPosX(rightX);
|
|
||||||
ImGui.TextUnformatted(broadcasterLabel);
|
|
||||||
if (ImGui.IsItemHovered())
|
|
||||||
ImGui.SetTooltip("Broadcaster of the syncshell.");
|
|
||||||
|
|
||||||
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"));
|
|
||||||
|
|
||||||
var groupProfile = _lightlessProfileManager.GetLightlessGroupProfile(shell.Group);
|
|
||||||
|
|
||||||
IReadOnlyList<ProfileTagDefinition> groupTags =
|
|
||||||
groupProfile != null && groupProfile.Tags.Count > 0
|
|
||||||
? ProfileTagService.ResolveTags(groupProfile.Tags)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
var limitedTags = groupTags.Count > 3
|
|
||||||
? [.. groupTags.Take(3)]
|
|
||||||
: groupTags;
|
|
||||||
|
|
||||||
float tagScale = ImGuiHelpers.GlobalScale * 0.9f;
|
|
||||||
|
|
||||||
Vector2 rowStartLocal = ImGui.GetCursorPos();
|
|
||||||
|
|
||||||
float tagsWidth = 0f;
|
|
||||||
float tagsHeight = 0f;
|
|
||||||
|
|
||||||
if (limitedTags.Count > 0)
|
|
||||||
{
|
|
||||||
(tagsWidth, tagsHeight) = RenderProfileTagsSingleRow(limitedTags, tagScale);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ImGui.SetCursorPosX(startX);
|
|
||||||
ImGui.TextDisabled("-- No tags set --");
|
|
||||||
ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale));
|
|
||||||
}
|
|
||||||
|
|
||||||
float btnBaselineY = rowStartLocal.Y;
|
|
||||||
float joinX = rowStartLocal.X + (tagsWidth > 0 ? tagsWidth + style.ItemSpacing.X : 0f);
|
|
||||||
|
|
||||||
ImGui.SetCursorPos(new Vector2(joinX, btnBaselineY));
|
|
||||||
DrawJoinButton(shell, isSelfBroadcast);
|
|
||||||
|
|
||||||
float btnHeight = ImGui.GetFrameHeightWithSpacing();
|
|
||||||
float rowHeightUsed = MathF.Max(tagsHeight, btnHeight);
|
|
||||||
|
|
||||||
ImGui.SetCursorPos(new Vector2(
|
|
||||||
rowStartLocal.X,
|
|
||||||
rowStartLocal.Y + rowHeightUsed));
|
|
||||||
|
|
||||||
ImGui.EndChild();
|
|
||||||
ImGui.PopID();
|
|
||||||
|
|
||||||
ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale));
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.PopStyleVar(2);
|
|
||||||
|
|
||||||
DrawPagination(totalPages);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawSyncshellGrid(List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)> cardData)
|
|
||||||
{
|
|
||||||
const int shellsPerPage = 4;
|
|
||||||
var totalPages = (int)Math.Ceiling(cardData.Count / (float)shellsPerPage);
|
|
||||||
if (totalPages <= 0)
|
|
||||||
totalPages = 1;
|
|
||||||
|
|
||||||
_syncshellPageIndex = Math.Clamp(_syncshellPageIndex, 0, totalPages - 1);
|
|
||||||
|
|
||||||
var firstIndex = _syncshellPageIndex * shellsPerPage;
|
|
||||||
var lastExclusive = Math.Min(firstIndex + shellsPerPage, cardData.Count);
|
|
||||||
|
|
||||||
var avail = ImGui.GetContentRegionAvail();
|
|
||||||
var spacing = ImGui.GetStyle().ItemSpacing;
|
|
||||||
|
|
||||||
var cardWidth = (avail.X - spacing.X) / 2.0f;
|
|
||||||
var cardHeight = (avail.Y - spacing.Y - (ImGui.GetFrameHeightWithSpacing() * 2.0f)) / 2.0f;
|
|
||||||
cardHeight = MathF.Max(110f * ImGuiHelpers.GlobalScale, cardHeight);
|
|
||||||
|
|
||||||
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8.0f);
|
|
||||||
ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.0f);
|
|
||||||
|
|
||||||
for (int index = firstIndex; index < lastExclusive; index++)
|
|
||||||
{
|
|
||||||
var localIndex = index - firstIndex;
|
|
||||||
var (shell, broadcasterName, isSelfBroadcast) = cardData[index];
|
|
||||||
var broadcasterLabel = string.IsNullOrEmpty(broadcasterName)
|
|
||||||
? (isSelfBroadcast ? "You" : string.Empty)
|
|
||||||
: (isSelfBroadcast ? $"{broadcasterName} (You)" : broadcasterName);
|
|
||||||
|
|
||||||
if (localIndex % 2 != 0)
|
|
||||||
ImGui.SameLine();
|
|
||||||
|
|
||||||
ImGui.PushID(shell.Group.GID);
|
|
||||||
|
|
||||||
ImGui.BeginGroup();
|
|
||||||
_ = ImGui.BeginChild("ShellCard##" + shell.Group.GID, new Vector2(cardWidth, cardHeight), border: true);
|
|
||||||
|
|
||||||
var displayName = !string.IsNullOrEmpty(shell.Group.Alias)
|
|
||||||
? shell.Group.Alias
|
|
||||||
: shell.Group.GID;
|
|
||||||
|
|
||||||
var style = ImGui.GetStyle();
|
|
||||||
float startX = ImGui.GetCursorPosX();
|
|
||||||
float availW = ImGui.GetContentRegionAvail().X;
|
|
||||||
|
|
||||||
ImGui.BeginGroup();
|
|
||||||
|
|
||||||
_uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple"));
|
|
||||||
if (ImGui.IsItemHovered())
|
|
||||||
ImGui.SetTooltip("Click to open profile.");
|
|
||||||
if (ImGui.IsItemClicked())
|
|
||||||
{
|
|
||||||
Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group));
|
|
||||||
}
|
|
||||||
|
|
||||||
float nameRightX = ImGui.GetItemRectMax().X;
|
|
||||||
|
|
||||||
var regionMinScreen = ImGui.GetCursorScreenPos();
|
|
||||||
float regionRightX = regionMinScreen.X + availW;
|
|
||||||
|
|
||||||
float minBroadcasterX = nameRightX + style.ItemSpacing.X;
|
|
||||||
|
|
||||||
float maxBroadcasterWidth = regionRightX - minBroadcasterX;
|
|
||||||
|
|
||||||
string broadcasterToShow = broadcasterLabel;
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(broadcasterLabel) && maxBroadcasterWidth > 0f)
|
|
||||||
{
|
|
||||||
float bcFullWidth = ImGui.CalcTextSize(broadcasterLabel).X;
|
|
||||||
string toolTip;
|
|
||||||
|
|
||||||
if (bcFullWidth > maxBroadcasterWidth)
|
|
||||||
{
|
|
||||||
broadcasterToShow = TruncateTextToWidth(broadcasterLabel, maxBroadcasterWidth);
|
|
||||||
toolTip = broadcasterLabel + Environment.NewLine + Environment.NewLine + "Broadcaster of the syncshell.";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
toolTip = "Broadcaster of the syncshell.";
|
|
||||||
}
|
|
||||||
|
|
||||||
float bcWidth = ImGui.CalcTextSize(broadcasterToShow).X;
|
|
||||||
|
|
||||||
float broadX = regionRightX - bcWidth;
|
|
||||||
|
|
||||||
broadX = MathF.Max(broadX, minBroadcasterX);
|
|
||||||
|
|
||||||
ImGui.SameLine();
|
|
||||||
var curPos = ImGui.GetCursorPos();
|
|
||||||
ImGui.SetCursorPos(new Vector2(broadX - regionMinScreen.X + startX, curPos.Y + 3f * ImGuiHelpers.GlobalScale));
|
|
||||||
ImGui.TextUnformatted(broadcasterToShow);
|
|
||||||
if (ImGui.IsItemHovered())
|
|
||||||
ImGui.SetTooltip(toolTip);
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.EndGroup();
|
|
||||||
|
|
||||||
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"));
|
|
||||||
|
|
||||||
ImGui.Dummy(new Vector2(0, 6 * ImGuiHelpers.GlobalScale));
|
|
||||||
|
|
||||||
var groupProfile = _lightlessProfileManager.GetLightlessGroupProfile(shell.Group);
|
|
||||||
|
|
||||||
IReadOnlyList<ProfileTagDefinition> groupTags =
|
|
||||||
groupProfile != null && groupProfile.Tags.Count > 0
|
|
||||||
? ProfileTagService.ResolveTags(groupProfile.Tags)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
float tagScale = ImGuiHelpers.GlobalScale * 0.9f;
|
|
||||||
|
|
||||||
if (groupTags.Count > 0)
|
|
||||||
{
|
|
||||||
var limitedTags = groupTags.Count > 2
|
|
||||||
? [.. groupTags.Take(2)]
|
|
||||||
: groupTags;
|
|
||||||
|
|
||||||
ImGui.SetCursorPosX(startX);
|
|
||||||
|
|
||||||
var (_, tagsHeight) = RenderProfileTagsSingleRow(limitedTags, tagScale);
|
|
||||||
|
|
||||||
ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ImGui.SetCursorPosX(startX);
|
|
||||||
ImGui.TextDisabled("-- No tags set --");
|
|
||||||
ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale));
|
|
||||||
}
|
|
||||||
|
|
||||||
var buttonHeight = ImGui.GetFrameHeightWithSpacing();
|
|
||||||
var remainingY = ImGui.GetContentRegionAvail().Y - buttonHeight;
|
|
||||||
if (remainingY > 0)
|
|
||||||
ImGui.Dummy(new Vector2(0, remainingY));
|
|
||||||
|
|
||||||
DrawJoinButton(shell, isSelfBroadcast);
|
|
||||||
|
|
||||||
ImGui.EndChild();
|
|
||||||
ImGui.EndGroup();
|
|
||||||
|
|
||||||
ImGui.PopID();
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale));
|
|
||||||
ImGui.PopStyleVar(2);
|
|
||||||
|
|
||||||
DrawPagination(totalPages);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawPagination(int totalPages)
|
|
||||||
{
|
|
||||||
if (totalPages > 1)
|
|
||||||
{
|
|
||||||
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"));
|
|
||||||
|
|
||||||
var style = ImGui.GetStyle();
|
|
||||||
string pageLabel = $"Page {_syncshellPageIndex + 1}/{totalPages}";
|
|
||||||
|
|
||||||
float prevWidth = ImGui.CalcTextSize("<").X + style.FramePadding.X * 2;
|
|
||||||
float nextWidth = ImGui.CalcTextSize(">").X + style.FramePadding.X * 2;
|
|
||||||
float textWidth = ImGui.CalcTextSize(pageLabel).X;
|
|
||||||
|
|
||||||
float totalWidth = prevWidth + textWidth + nextWidth + style.ItemSpacing.X * 2;
|
|
||||||
|
|
||||||
float availWidth = ImGui.GetContentRegionAvail().X;
|
|
||||||
float offsetX = (availWidth - totalWidth) * 0.5f;
|
|
||||||
|
|
||||||
ImGui.SetCursorPosX(ImGui.GetCursorPosX() + offsetX);
|
|
||||||
|
|
||||||
if (ImGui.Button("<##PrevSyncshellPage") && _syncshellPageIndex > 0)
|
|
||||||
_syncshellPageIndex--;
|
|
||||||
|
|
||||||
ImGui.SameLine();
|
|
||||||
ImGui.Text(pageLabel);
|
|
||||||
|
|
||||||
ImGui.SameLine();
|
|
||||||
if (ImGui.Button(">##NextSyncshellPage") && _syncshellPageIndex < totalPages - 1)
|
|
||||||
_syncshellPageIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawJoinButton(GroupJoinDto shell, bool isSelfBroadcast)
|
|
||||||
{
|
|
||||||
const string visibleLabel = "Join";
|
|
||||||
var label = $"{visibleLabel}##{shell.Group.GID}";
|
|
||||||
|
|
||||||
var isAlreadyMember = _currentSyncshells.Exists(g => string.Equals(g.GID, shell.GID, StringComparison.Ordinal));
|
|
||||||
var isRecentlyJoined = _recentlyJoined.Contains(shell.GID);
|
|
||||||
|
|
||||||
Vector2 buttonSize;
|
|
||||||
|
|
||||||
if (!_compactView)
|
|
||||||
{
|
|
||||||
var style = ImGui.GetStyle();
|
|
||||||
var textSize = ImGui.CalcTextSize(visibleLabel);
|
|
||||||
|
|
||||||
var width = textSize.X + style.FramePadding.X * 20f;
|
|
||||||
buttonSize = new Vector2(width, 30f);
|
|
||||||
|
|
||||||
float availX = ImGui.GetContentRegionAvail().X;
|
|
||||||
float curX = ImGui.GetCursorPosX();
|
|
||||||
float newX = curX + (availX - buttonSize.X);
|
|
||||||
ImGui.SetCursorPosX(newX);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
buttonSize = new Vector2(-1, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAlreadyMember && !isRecentlyJoined && !isSelfBroadcast)
|
|
||||||
{
|
|
||||||
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen"));
|
|
||||||
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f));
|
|
||||||
ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f));
|
|
||||||
if (ImGui.Button(label, buttonSize))
|
|
||||||
{
|
|
||||||
_logger.LogInformation($"Join requested for Syncshell {shell.Group.GID} ({shell.Group.Alias})");
|
|
||||||
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var info = await _apiController.GroupJoinHashed(new GroupJoinHashedDto(
|
|
||||||
shell.Group,
|
|
||||||
shell.Password,
|
|
||||||
shell.GroupUserPreferredPermissions
|
|
||||||
)).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (info != null && info.Success)
|
|
||||||
{
|
|
||||||
_joinDto = new GroupJoinDto(shell.Group, shell.Password, shell.GroupUserPreferredPermissions);
|
|
||||||
_joinInfo = info;
|
|
||||||
_ownPermissions = _apiController.DefaultPermissions.DeepClone()!;
|
|
||||||
|
|
||||||
_logger.LogInformation($"Fetched join info for {shell.Group.GID}");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning($"Failed to join {shell.Group.GID}: info was null or unsuccessful");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, $"Join failed for {shell.Group.GID}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("DimRed"));
|
|
||||||
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("DimRed").WithAlpha(0.85f));
|
|
||||||
ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("DimRed").WithAlpha(0.75f));
|
|
||||||
|
|
||||||
using (ImRaii.Disabled())
|
|
||||||
{
|
|
||||||
ImGui.Button(label, buttonSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
UiSharedService.AttachToolTip(isSelfBroadcast
|
|
||||||
? "This is your own Syncshell."
|
|
||||||
: "Already a member or owner of this Syncshell.");
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.PopStyleColor(3);
|
|
||||||
}
|
|
||||||
|
|
||||||
private (float widthUsed, float rowHeight) RenderProfileTagsSingleRow(IReadOnlyList<ProfileTagDefinition> tags, float scale)
|
|
||||||
{
|
|
||||||
if (tags == null || tags.Count == 0)
|
|
||||||
return (0f, 0f);
|
|
||||||
|
|
||||||
var drawList = ImGui.GetWindowDrawList();
|
|
||||||
var style = ImGui.GetStyle();
|
|
||||||
var defaultTextColorU32 = ImGui.GetColorU32(ImGuiCol.Text);
|
|
||||||
|
|
||||||
var baseLocal = ImGui.GetCursorPos();
|
|
||||||
var baseScreen = ImGui.GetCursorScreenPos();
|
|
||||||
float availableWidth = ImGui.GetContentRegionAvail().X;
|
|
||||||
if (availableWidth <= 0f)
|
|
||||||
availableWidth = 1f;
|
|
||||||
|
|
||||||
float cursorLocalX = baseLocal.X;
|
|
||||||
float cursorScreenX = baseScreen.X;
|
|
||||||
float rowHeight = 0f;
|
|
||||||
|
|
||||||
for (int i = 0; i < tags.Count; i++)
|
|
||||||
{
|
|
||||||
var tag = tags[i];
|
|
||||||
if (!tag.HasContent)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var tagSize = ProfileTagRenderer.MeasureTag(tag, scale, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger);
|
|
||||||
|
|
||||||
float tagWidth = tagSize.X;
|
|
||||||
float tagHeight = tagSize.Y;
|
|
||||||
|
|
||||||
if (cursorLocalX > baseLocal.X && cursorLocalX + tagWidth > baseLocal.X + availableWidth)
|
|
||||||
break;
|
|
||||||
|
|
||||||
var tagScreenPos = new Vector2(cursorScreenX, baseScreen.Y);
|
|
||||||
ImGui.SetCursorScreenPos(tagScreenPos);
|
|
||||||
ImGui.InvisibleButton($"##profileTagInline_{i}", tagSize);
|
|
||||||
|
|
||||||
ProfileTagRenderer.RenderTag(tag, tagScreenPos, scale, drawList, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger);
|
|
||||||
|
|
||||||
cursorLocalX += tagWidth + style.ItemSpacing.X;
|
|
||||||
cursorScreenX += tagWidth + style.ItemSpacing.X;
|
|
||||||
rowHeight = MathF.Max(rowHeight, tagHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.SetCursorPos(new Vector2(baseLocal.X, baseLocal.Y + rowHeight));
|
|
||||||
|
|
||||||
float widthUsed = cursorLocalX - baseLocal.X;
|
|
||||||
return (widthUsed, rowHeight);
|
|
||||||
}
|
|
||||||
private static string TruncateTextToWidth(string text, float maxWidth)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(text))
|
|
||||||
return text;
|
|
||||||
|
|
||||||
const string ellipsis = "...";
|
|
||||||
float ellipsisWidth = ImGui.CalcTextSize(ellipsis).X;
|
|
||||||
|
|
||||||
if (maxWidth <= ellipsisWidth)
|
|
||||||
return ellipsis;
|
|
||||||
|
|
||||||
int low = 0;
|
|
||||||
int high = text.Length;
|
|
||||||
string best = ellipsis;
|
|
||||||
|
|
||||||
while (low <= high)
|
|
||||||
{
|
|
||||||
int mid = (low + high) / 2;
|
|
||||||
string candidate = string.Concat(text.AsSpan(0, mid), ellipsis);
|
|
||||||
float width = ImGui.CalcTextSize(candidate).X;
|
|
||||||
|
|
||||||
if (width <= maxWidth)
|
|
||||||
{
|
|
||||||
best = candidate;
|
|
||||||
low = mid + 1;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
high = mid - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
private IDalamudTextureWrap? GetIconWrap(uint iconId)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_uiSharedService.TryGetIcon(iconId, out var wrap) && wrap != null)
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogDebug(ex, "Failed to resolve icon {IconId} for profile tags", iconId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawConfirmation()
|
|
||||||
{
|
|
||||||
if (_joinDto != null && _joinInfo != null)
|
|
||||||
{
|
|
||||||
ImGui.Separator();
|
|
||||||
ImGui.TextUnformatted($"Join Syncshell: {_joinDto.Group.AliasOrGID} by {_joinInfo.OwnerAliasOrUID}");
|
|
||||||
ImGuiHelpers.ScaledDummy(2f);
|
|
||||||
ImGui.TextUnformatted("Suggested Syncshell Permissions:");
|
|
||||||
|
|
||||||
DrawPermissionRow("Sounds", _joinInfo.GroupPermissions.IsPreferDisableSounds(), _ownPermissions.DisableGroupSounds, v => _ownPermissions.DisableGroupSounds = v);
|
|
||||||
DrawPermissionRow("Animations", _joinInfo.GroupPermissions.IsPreferDisableAnimations(), _ownPermissions.DisableGroupAnimations, v => _ownPermissions.DisableGroupAnimations = v);
|
|
||||||
DrawPermissionRow("VFX", _joinInfo.GroupPermissions.IsPreferDisableVFX(), _ownPermissions.DisableGroupVFX, v => _ownPermissions.DisableGroupVFX = v);
|
|
||||||
|
|
||||||
ImGui.NewLine();
|
|
||||||
ImGui.NewLine();
|
|
||||||
|
|
||||||
if (_uiSharedService.IconTextButton(Dalamud.Interface.FontAwesomeIcon.Plus, $"Finalize and join {_joinDto.Group.AliasOrGID}"))
|
|
||||||
{
|
|
||||||
var finalPermissions = GroupUserPreferredPermissions.NoneSet;
|
|
||||||
finalPermissions.SetDisableSounds(_ownPermissions.DisableGroupSounds);
|
|
||||||
finalPermissions.SetDisableAnimations(_ownPermissions.DisableGroupAnimations);
|
|
||||||
finalPermissions.SetDisableVFX(_ownPermissions.DisableGroupVFX);
|
|
||||||
|
|
||||||
_ = _apiController.GroupJoinFinalize(new GroupJoinDto(_joinDto.Group, _joinDto.Password, finalPermissions));
|
|
||||||
|
|
||||||
_recentlyJoined.Add(_joinDto.Group.GID);
|
|
||||||
|
|
||||||
_joinDto = null;
|
|
||||||
_joinInfo = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawPermissionRow(string label, bool suggested, bool current, Action<bool> apply)
|
|
||||||
{
|
|
||||||
ImGui.AlignTextToFramePadding();
|
|
||||||
ImGui.TextUnformatted($"- {label}");
|
|
||||||
|
|
||||||
ImGui.SameLine(150 * ImGuiHelpers.GlobalScale);
|
|
||||||
ImGui.TextUnformatted("Current:");
|
|
||||||
ImGui.SameLine();
|
|
||||||
_uiSharedService.BooleanToColoredIcon(!current);
|
|
||||||
|
|
||||||
ImGui.SameLine(300 * ImGuiHelpers.GlobalScale);
|
|
||||||
ImGui.TextUnformatted("Suggested:");
|
|
||||||
ImGui.SameLine();
|
|
||||||
_uiSharedService.BooleanToColoredIcon(!suggested);
|
|
||||||
|
|
||||||
ImGui.SameLine(450 * ImGuiHelpers.GlobalScale);
|
|
||||||
using var id = ImRaii.PushId(label);
|
|
||||||
if (current != suggested)
|
|
||||||
{
|
|
||||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Apply"))
|
|
||||||
apply(suggested);
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.NewLine();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RefreshSyncshellsAsync(string? gid = null)
|
|
||||||
{
|
|
||||||
var syncshellBroadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts();
|
|
||||||
var snapshot = _pairUiService.GetSnapshot();
|
|
||||||
_currentSyncshells = [.. snapshot.GroupPairs.Keys];
|
|
||||||
|
|
||||||
_recentlyJoined.RemoveWhere(gid =>
|
|
||||||
_currentSyncshells.Exists(s => string.Equals(s.GID, gid, StringComparison.Ordinal)));
|
|
||||||
|
|
||||||
List<GroupJoinDto>? updatedList = [];
|
|
||||||
|
|
||||||
if (_useTestSyncshells)
|
|
||||||
{
|
|
||||||
updatedList = BuildTestSyncshells();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (syncshellBroadcasts.Count == 0)
|
|
||||||
{
|
|
||||||
ClearSyncshells();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var groups = await _apiController.GetBroadcastedGroups(syncshellBroadcasts)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
updatedList = groups?.DistinctBy(g => g.Group.GID).ToList();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to refresh broadcasted syncshells.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updatedList == null || updatedList.Count == 0)
|
|
||||||
{
|
|
||||||
ClearSyncshells();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gid != null && _recentlyJoined.Contains(gid))
|
|
||||||
{
|
|
||||||
_recentlyJoined.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
var previousGid = GetSelectedGid();
|
|
||||||
|
|
||||||
_nearbySyncshells.Clear();
|
|
||||||
_nearbySyncshells.AddRange(updatedList);
|
|
||||||
|
|
||||||
if (previousGid != null)
|
|
||||||
{
|
|
||||||
var newIndex = _nearbySyncshells.FindIndex(s =>
|
|
||||||
string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal));
|
|
||||||
|
|
||||||
if (newIndex >= 0)
|
|
||||||
{
|
|
||||||
_selectedNearbyIndex = newIndex;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ClearSelection();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<GroupJoinDto> BuildTestSyncshells()
|
|
||||||
{
|
|
||||||
var testGroup1 = new GroupData("TEST-ALPHA", "Alpha Shell");
|
|
||||||
var testGroup2 = new GroupData("TEST-BETA", "Beta Shell");
|
|
||||||
var testGroup3 = new GroupData("TEST-GAMMA", "Gamma Shell");
|
|
||||||
var testGroup4 = new GroupData("TEST-DELTA", "Delta Shell");
|
|
||||||
var testGroup5 = new GroupData("TEST-CHARLIE", "Charlie Shell");
|
|
||||||
var testGroup6 = new GroupData("TEST-OMEGA", "Omega Shell");
|
|
||||||
var testGroup7 = new GroupData("TEST-POINT", "Point Shell");
|
|
||||||
var testGroup8 = new GroupData("TEST-HOTEL", "Hotel Shell");
|
|
||||||
|
|
||||||
return
|
|
||||||
[
|
|
||||||
new(testGroup1, "", GroupUserPreferredPermissions.NoneSet),
|
|
||||||
new(testGroup2, "", GroupUserPreferredPermissions.NoneSet),
|
|
||||||
new(testGroup3, "", GroupUserPreferredPermissions.NoneSet),
|
|
||||||
new(testGroup4, "", GroupUserPreferredPermissions.NoneSet),
|
|
||||||
new(testGroup5, "", GroupUserPreferredPermissions.NoneSet),
|
|
||||||
new(testGroup6, "", GroupUserPreferredPermissions.NoneSet),
|
|
||||||
new(testGroup7, "", GroupUserPreferredPermissions.NoneSet),
|
|
||||||
new(testGroup8, "", GroupUserPreferredPermissions.NoneSet),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ClearSyncshells()
|
|
||||||
{
|
|
||||||
if (_nearbySyncshells.Count == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_nearbySyncshells.Clear();
|
|
||||||
ClearSelection();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ClearSelection()
|
|
||||||
{
|
|
||||||
_selectedNearbyIndex = -1;
|
|
||||||
_syncshellPageIndex = 0;
|
|
||||||
_joinDto = null;
|
|
||||||
_joinInfo = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? GetSelectedGid()
|
|
||||||
{
|
|
||||||
if (_selectedNearbyIndex < 0 || _selectedNearbyIndex >= _nearbySyncshells.Count)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return _nearbySyncshells[_selectedNearbyIndex].Group.GID;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -162,24 +162,32 @@ public class TopTabMenu
|
|||||||
ImGui.SameLine();
|
ImGui.SameLine();
|
||||||
using (ImRaii.PushFont(UiBuilder.IconFont))
|
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||||
{
|
{
|
||||||
var x = ImGui.GetCursorScreenPos();
|
|
||||||
if (ImGui.Button(FontAwesomeIcon.Compass.ToIconString(), buttonSize))
|
if (ImGui.Button(FontAwesomeIcon.Compass.ToIconString(), buttonSize))
|
||||||
{
|
{
|
||||||
TabSelection = TabSelection == SelectedTab.Lightfinder ? SelectedTab.None : SelectedTab.Lightfinder;
|
_lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
|
||||||
}
|
}
|
||||||
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
|
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
|
||||||
{
|
{
|
||||||
Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding);
|
Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding);
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui.SameLine();
|
|
||||||
var xAfter = ImGui.GetCursorScreenPos();
|
|
||||||
if (TabSelection == SelectedTab.Lightfinder)
|
|
||||||
drawList.AddLine(x with { Y = x.Y + buttonSize.Y + spacing.Y },
|
|
||||||
xAfter with { Y = xAfter.Y + buttonSize.Y + spacing.Y, X = xAfter.X - spacing.X },
|
|
||||||
underlineColor, 2);
|
|
||||||
}
|
}
|
||||||
UiSharedService.AttachToolTip("Lightfinder");
|
|
||||||
|
var nearbyCount = GetNearbySyncshellCount();
|
||||||
|
if (nearbyCount > 0)
|
||||||
|
{
|
||||||
|
var buttonMax = ImGui.GetItemRectMax();
|
||||||
|
var badgeRadius = 8f * ImGuiHelpers.GlobalScale;
|
||||||
|
var badgeCenter = new Vector2(buttonMax.X - badgeRadius * 1.3f, buttonMax.Y - buttonSize.Y + badgeRadius * 0.5f);
|
||||||
|
var badgeText = nearbyCount > 99 ? "99+" : nearbyCount.ToString();
|
||||||
|
var textSize = ImGui.CalcTextSize(badgeText);
|
||||||
|
|
||||||
|
drawList.AddCircleFilled(badgeCenter, badgeRadius + 1f, ImGui.GetColorU32(new Vector4(0, 0, 0, 0.6f)));
|
||||||
|
drawList.AddCircleFilled(badgeCenter, badgeRadius, ImGui.GetColorU32(UIColors.Get("LightlessPurple")));
|
||||||
|
|
||||||
|
var textPos = new Vector2(badgeCenter.X - textSize.X * 0.45f, badgeCenter.Y - textSize.Y * 0.55f);
|
||||||
|
drawList.AddText(textPos, ImGui.GetColorU32(new Vector4(1, 1, 1, 1)), badgeText);
|
||||||
|
}
|
||||||
|
UiSharedService.AttachToolTip(nearbyCount > 0 ? $"Lightfinder ({nearbyCount} nearby)" : "Open Lightfinder");
|
||||||
|
|
||||||
ImGui.SameLine();
|
ImGui.SameLine();
|
||||||
using (ImRaii.PushFont(UiBuilder.IconFont))
|
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||||
@@ -234,10 +242,7 @@ public class TopTabMenu
|
|||||||
DrawSyncshellMenu(availableWidth, spacing.X);
|
DrawSyncshellMenu(availableWidth, spacing.X);
|
||||||
DrawGlobalSyncshellButtons(availableWidth, spacing.X);
|
DrawGlobalSyncshellButtons(availableWidth, spacing.X);
|
||||||
}
|
}
|
||||||
else if (TabSelection == SelectedTab.Lightfinder)
|
|
||||||
{
|
|
||||||
DrawLightfinderMenu(availableWidth, spacing.X);
|
|
||||||
}
|
|
||||||
else if (TabSelection == SelectedTab.UserConfig)
|
else if (TabSelection == SelectedTab.UserConfig)
|
||||||
{
|
{
|
||||||
DrawUserConfig(availableWidth, spacing.X);
|
DrawUserConfig(availableWidth, spacing.X);
|
||||||
@@ -776,53 +781,22 @@ public class TopTabMenu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawLightfinderMenu(float availableWidth, float spacingX)
|
private int GetNearbySyncshellCount()
|
||||||
{
|
|
||||||
var buttonX = (availableWidth - (spacingX)) / 2f;
|
|
||||||
|
|
||||||
var lightFinderLabel = GetLightfinderFinderLabel();
|
|
||||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.PersonCirclePlus, lightFinderLabel, buttonX, center: true))
|
|
||||||
{
|
|
||||||
_lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui.SameLine();
|
|
||||||
|
|
||||||
var syncshellFinderLabel = GetSyncshellFinderLabel();
|
|
||||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Globe, syncshellFinderLabel, buttonX, center: true))
|
|
||||||
{
|
|
||||||
_lightlessMediator.Publish(new UiToggleMessage(typeof(SyncshellFinderUI)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetLightfinderFinderLabel()
|
|
||||||
{
|
|
||||||
string label = "Lightfinder";
|
|
||||||
|
|
||||||
if (_lightFinderService.IsBroadcasting)
|
|
||||||
{
|
|
||||||
var hashExclude = _dalamudUtilService.GetCID().ToString().GetHash256();
|
|
||||||
var nearbyCount = _lightFinderScannerService.GetActiveBroadcasts(hashExclude).Count;
|
|
||||||
return $"{label} ({nearbyCount})";
|
|
||||||
}
|
|
||||||
|
|
||||||
return label;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetSyncshellFinderLabel()
|
|
||||||
{
|
{
|
||||||
if (!_lightFinderService.IsBroadcasting)
|
if (!_lightFinderService.IsBroadcasting)
|
||||||
return "Syncshell Finder";
|
return 0;
|
||||||
|
|
||||||
var nearbyCount = _lightFinderScannerService
|
var myHashedCid = _dalamudUtilService.GetCID().ToString().GetHash256();
|
||||||
.GetActiveSyncshellBroadcasts(excludeLocal: true)
|
|
||||||
.Where(b => !string.IsNullOrEmpty(b.GID))
|
return _lightFinderScannerService
|
||||||
|
.GetActiveSyncshellBroadcasts()
|
||||||
|
.Where(b =>
|
||||||
|
!string.IsNullOrEmpty(b.GID) &&
|
||||||
|
!string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal))
|
||||||
.Select(b => b.GID!)
|
.Select(b => b.GID!)
|
||||||
.Distinct(StringComparer.Ordinal)
|
.Distinct(StringComparer.Ordinal)
|
||||||
.Count();
|
.Count();
|
||||||
|
|
||||||
return nearbyCount > 0 ? $"Syncshell Finder ({nearbyCount})" : "Syncshell Finder";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawUserConfig(float availableWidth, float spacingX)
|
private void DrawUserConfig(float availableWidth, float spacingX)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace LightlessSync.UI
|
|||||||
{
|
{
|
||||||
internal static class UIColors
|
internal static class UIColors
|
||||||
{
|
{
|
||||||
private static readonly Dictionary<string, string> DefaultHexColors = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly Dictionary<string, string> _defaultHexColors = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
{ "LightlessPurple", "#ad8af5" },
|
{ "LightlessPurple", "#ad8af5" },
|
||||||
{ "LightlessPurpleActive", "#be9eff" },
|
{ "LightlessPurpleActive", "#be9eff" },
|
||||||
@@ -31,6 +31,12 @@ namespace LightlessSync.UI
|
|||||||
|
|
||||||
{ "ProfileBodyGradientTop", "#2f283fff" },
|
{ "ProfileBodyGradientTop", "#2f283fff" },
|
||||||
{ "ProfileBodyGradientBottom", "#372d4d00" },
|
{ "ProfileBodyGradientBottom", "#372d4d00" },
|
||||||
|
|
||||||
|
{ "HeaderGradientTop", "#140D26FF" },
|
||||||
|
{ "HeaderGradientBottom", "#1F1433FF" },
|
||||||
|
|
||||||
|
{ "HeaderStaticStar", "#FFFFFFFF" },
|
||||||
|
{ "HeaderShootingStar", "#66CCFFFF" },
|
||||||
};
|
};
|
||||||
|
|
||||||
private static LightlessConfigService? _configService;
|
private static LightlessConfigService? _configService;
|
||||||
@@ -45,7 +51,7 @@ namespace LightlessSync.UI
|
|||||||
if (_configService?.Current.CustomUIColors.TryGetValue(name, out var customColorHex) == true)
|
if (_configService?.Current.CustomUIColors.TryGetValue(name, out var customColorHex) == true)
|
||||||
return HexToRgba(customColorHex);
|
return HexToRgba(customColorHex);
|
||||||
|
|
||||||
if (!DefaultHexColors.TryGetValue(name, out var hex))
|
if (!_defaultHexColors.TryGetValue(name, out var hex))
|
||||||
throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name));
|
throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name));
|
||||||
|
|
||||||
return HexToRgba(hex);
|
return HexToRgba(hex);
|
||||||
@@ -53,7 +59,7 @@ namespace LightlessSync.UI
|
|||||||
|
|
||||||
public static void Set(string name, Vector4 color)
|
public static void Set(string name, Vector4 color)
|
||||||
{
|
{
|
||||||
if (!DefaultHexColors.ContainsKey(name))
|
if (!_defaultHexColors.ContainsKey(name))
|
||||||
throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name));
|
throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name));
|
||||||
|
|
||||||
if (_configService != null)
|
if (_configService != null)
|
||||||
@@ -83,7 +89,7 @@ namespace LightlessSync.UI
|
|||||||
|
|
||||||
public static Vector4 GetDefault(string name)
|
public static Vector4 GetDefault(string name)
|
||||||
{
|
{
|
||||||
if (!DefaultHexColors.TryGetValue(name, out var hex))
|
if (!_defaultHexColors.TryGetValue(name, out var hex))
|
||||||
throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name));
|
throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name));
|
||||||
|
|
||||||
return HexToRgba(hex);
|
return HexToRgba(hex);
|
||||||
@@ -96,7 +102,7 @@ namespace LightlessSync.UI
|
|||||||
|
|
||||||
public static IEnumerable<string> GetColorNames()
|
public static IEnumerable<string> GetColorNames()
|
||||||
{
|
{
|
||||||
return DefaultHexColors.Keys;
|
return _defaultHexColors.Keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Vector4 HexToRgba(string hexColor)
|
public static Vector4 HexToRgba(string hexColor)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase
|
|||||||
logger.LogInformation("UpdateNotesUi constructor called");
|
logger.LogInformation("UpdateNotesUi constructor called");
|
||||||
_uiShared = uiShared;
|
_uiShared = uiShared;
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
|
_animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects;
|
||||||
|
|
||||||
RespectCloseHotkey = true;
|
RespectCloseHotkey = true;
|
||||||
ShowCloseButton = true;
|
ShowCloseButton = true;
|
||||||
@@ -48,7 +49,8 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase
|
|||||||
ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoMove;
|
ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoMove;
|
||||||
|
|
||||||
PositionCondition = ImGuiCond.Always;
|
PositionCondition = ImGuiCond.Always;
|
||||||
|
|
||||||
|
|
||||||
WindowBuilder.For(this)
|
WindowBuilder.For(this)
|
||||||
.AllowPinning(false)
|
.AllowPinning(false)
|
||||||
.AllowClickthrough(false)
|
.AllowClickthrough(false)
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
private readonly ConcurrentDictionary<ThrottledStream, byte> _activeDownloadStreams;
|
private readonly ConcurrentDictionary<ThrottledStream, byte> _activeDownloadStreams;
|
||||||
private readonly SemaphoreSlim _decompressGate =
|
private readonly SemaphoreSlim _decompressGate =
|
||||||
new(Math.Max(1, Environment.ProcessorCount / 2), Math.Max(1, Environment.ProcessorCount / 2));
|
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;
|
||||||
@@ -406,32 +404,76 @@ 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)
|
||||||
{
|
{
|
||||||
while (true)
|
bool alreadyCancelled = false;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
downloadCt.ThrowIfCancellationRequested();
|
CancellationTokenSource localTimeoutCts = new();
|
||||||
|
localTimeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||||
|
CancellationTokenSource composite = CancellationTokenSource.CreateLinkedTokenSource(downloadCt, localTimeoutCts.Token);
|
||||||
|
|
||||||
if (_orchestrator.IsDownloadReady(requestId))
|
while (!_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))
|
|
||||||
{
|
{
|
||||||
break;
|
try
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(250, downloadCt).ConfigureAwait(false);
|
localTimeoutCts.Dispose();
|
||||||
}
|
composite.Dispose();
|
||||||
|
|
||||||
_orchestrator.ClearDownloadRequest(requestId);
|
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;
|
||||||
|
}
|
||||||
|
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(
|
||||||
@@ -490,9 +532,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// sanity check length
|
||||||
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}");
|
||||||
|
|
||||||
|
// safe cast after check
|
||||||
var len = checked((int)fileLengthBytes);
|
var len = checked((int)fileLengthBytes);
|
||||||
|
|
||||||
if (!replacementLookup.TryGetValue(fileHash, out var repl))
|
if (!replacementLookup.TryGetValue(fileHash, out var repl))
|
||||||
@@ -502,39 +546,38 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decompress
|
||||||
var filePath = _fileDbManager.GetCacheFilePath(fileHash, repl.Extension);
|
var filePath = _fileDbManager.GetCacheFilePath(fileHash, repl.Extension);
|
||||||
Logger.LogTrace("{dlName}: Decompressing {file}:{len} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath);
|
Logger.LogTrace("{dlName}: Decompressing {file}:{len} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath);
|
||||||
|
|
||||||
|
// read compressed data
|
||||||
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);
|
||||||
|
|
||||||
if (len == 0)
|
if (len == 0)
|
||||||
{
|
{
|
||||||
await File.WriteAllBytesAsync(filePath, Array.Empty<byte>(), ct).ConfigureAwait(false);
|
await _fileCompactor.WriteAllBytesAsync(filePath, Array.Empty<byte>(), ct).ConfigureAwait(false);
|
||||||
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale);
|
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
MungeBuffer(compressed);
|
MungeBuffer(compressed);
|
||||||
|
|
||||||
|
// limit concurrent decompressions
|
||||||
await _decompressGate.WaitAsync(ct).ConfigureAwait(false);
|
await _decompressGate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// offload CPU-intensive decompression to threadpool to free up worker
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
await Task.Run(async () =>
|
|
||||||
{
|
|
||||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
||||||
|
|
||||||
// decompress
|
// decompress
|
||||||
var decompressed = LZ4Wrapper.Unwrap(compressed);
|
var decompressed = LZ4Wrapper.Unwrap(compressed);
|
||||||
|
|
||||||
Logger.LogTrace("{dlName}: Unwrap {fileHash} took {ms}ms (compressed {c} bytes, decompressed {d} bytes)",
|
Logger.LogTrace("{dlName}: Unwrap {fileHash} took {ms}ms (compressed {c} bytes, decompressed {d} bytes)",
|
||||||
downloadLabel, fileHash, sw.ElapsedMilliseconds, compressed.Length, decompressed?.Length ?? -1);
|
downloadLabel, fileHash, sw.ElapsedMilliseconds, compressed.Length, decompressed?.Length ?? -1);
|
||||||
|
|
||||||
// write to file without compacting during download
|
// write to file
|
||||||
await File.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false);
|
await _fileCompactor.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false);
|
||||||
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale);
|
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale);
|
||||||
}, ct).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -709,16 +752,8 @@ 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(baseWorkers + extraWorkers, 2, coreCount);
|
var workerDop = Math.Clamp(slots * 2, 2, 16);
|
||||||
|
|
||||||
// batch downloads
|
// batch downloads
|
||||||
Task batchTask = batchChunks.Length == 0
|
Task batchTask = batchChunks.Length == 0
|
||||||
@@ -734,9 +769,6 @@ 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();
|
||||||
}
|
}
|
||||||
@@ -761,6 +793,7 @@ 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
|
||||||
@@ -840,7 +873,7 @@ 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 File.WriteAllBytesAsync(finalFilename, decompressedBytes, ct).ConfigureAwait(false);
|
await _fileCompactor.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);
|
||||||
@@ -941,12 +974,14 @@ 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) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -966,10 +1001,6 @@ 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);
|
||||||
@@ -995,52 +1026,6 @@ 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;
|
||||||
|
|||||||
@@ -76,19 +76,6 @@
|
|||||||
"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, )",
|
||||||
@@ -246,14 +233,6 @@
|
|||||||
"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",
|
||||||
@@ -639,7 +618,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.1, )",
|
"Penumbra.Api": "[5.13.0, )",
|
||||||
"Penumbra.String": "[1.0.7, )"
|
"Penumbra.String": "[1.0.7, )"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user