Compare commits

..

12 Commits

36 changed files with 2189 additions and 4658 deletions

View File

@@ -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); await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false);
File.Move(tmpPath, compressedPath, overwrite: true); 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
@@ -942,83 +931,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");
@@ -1202,8 +1114,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");

View File

@@ -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)
{ {
@@ -305,45 +305,6 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
} }
if (_lastClassJobId != _dalamudUtil.ClassJobId) if (_lastClassJobId != _dalamudUtil.ClassJobId)
{
UpdateClassJobCache();
}
CleanupAbsentObjects();
}
private void RefreshPlayerRelatedAddressMap()
{
var tempMap = new ConcurrentDictionary<nint, GameObjectHandler>();
var updatedFrameAddresses = new ConcurrentDictionary<nint, ObjectKind>();
lock (_playerRelatedLock)
{
foreach (var handler in _playerRelatedPointers)
{
var address = (nint)handler.Address;
if (address != nint.Zero)
{
tempMap[address] = handler;
updatedFrameAddresses[address] = handler.ObjectKind;
}
}
}
_playerRelatedByAddress.Clear();
foreach (var kvp in tempMap)
{
_playerRelatedByAddress[kvp.Key] = kvp.Value;
}
_cachedFrameAddresses.Clear();
foreach (var kvp in updatedFrameAddresses)
{
_cachedFrameAddresses[kvp.Key] = kvp.Value;
}
}
private void UpdateClassJobCache()
{ {
_lastClassJobId = _dalamudUtil.ClassJobId; _lastClassJobId = _dalamudUtil.ClassJobId;
if (SemiTransientResources.TryGetValue(ObjectKind.Pet, out HashSet<string>? value)) if (SemiTransientResources.TryGetValue(ObjectKind.Pet, out HashSet<string>? value))
@@ -352,18 +313,13 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
} }
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData); PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
.Concat(jobSpecificData ?? [])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData); PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
SemiTransientResources[ObjectKind.Pet] = new HashSet<string>( SemiTransientResources[ObjectKind.Pet] = new HashSet<string>(
petSpecificData ?? [], petSpecificData ?? [],
StringComparer.OrdinalIgnoreCase); 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)

View File

@@ -1,11 +0,0 @@
namespace Lifestream.Enums;
public enum ResidentialAetheryteKind
{
None = -1,
Uldah = 9,
Gridania = 2,
Limsa = 8,
Foundation = 70,
Kugane = 111,
}

View File

@@ -1 +0,0 @@
global using AddressBookEntryTuple = (string Name, int World, int City, int Ward, int PropertyType, int Plot, int Apartment, bool ApartmentSubdivision, bool AliasEnabled, string Alias);

View File

@@ -1,129 +0,0 @@
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using Lifestream.Enums;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Interop.Ipc;
public sealed class IpcCallerLifestream : IpcServiceBase
{
private static readonly IpcServiceDescriptor LifestreamDescriptor = new("Lifestream", "Lifestream", new Version(0, 0, 0, 0));
private readonly ICallGateSubscriber<string, object> _executeLifestreamCommand;
private readonly ICallGateSubscriber<AddressBookEntryTuple, bool> _isHere;
private readonly ICallGateSubscriber<AddressBookEntryTuple, object> _goToHousingAddress;
private readonly ICallGateSubscriber<bool> _isBusy;
private readonly ICallGateSubscriber<object> _abort;
private readonly ICallGateSubscriber<string, bool> _changeWorld;
private readonly ICallGateSubscriber<uint, bool> _changeWorldById;
private readonly ICallGateSubscriber<string, bool> _aetheryteTeleport;
private readonly ICallGateSubscriber<uint, bool> _aetheryteTeleportById;
private readonly ICallGateSubscriber<bool> _canChangeInstance;
private readonly ICallGateSubscriber<int> _getCurrentInstance;
private readonly ICallGateSubscriber<int> _getNumberOfInstances;
private readonly ICallGateSubscriber<int, object> _changeInstance;
private readonly ICallGateSubscriber<(ResidentialAetheryteKind, int, int)> _getCurrentPlotInfo;
public IpcCallerLifestream(IDalamudPluginInterface pi, LightlessMediator lightlessMediator, ILogger<IpcCallerLifestream> logger)
: base(logger, lightlessMediator, pi, LifestreamDescriptor)
{
_executeLifestreamCommand = pi.GetIpcSubscriber<string, object>("Lifestream.ExecuteCommand");
_isHere = pi.GetIpcSubscriber<AddressBookEntryTuple, bool>("Lifestream.IsHere");
_goToHousingAddress = pi.GetIpcSubscriber<AddressBookEntryTuple, object>("Lifestream.GoToHousingAddress");
_isBusy = pi.GetIpcSubscriber<bool>("Lifestream.IsBusy");
_abort = pi.GetIpcSubscriber<object>("Lifestream.Abort");
_changeWorld = pi.GetIpcSubscriber<string, bool>("Lifestream.ChangeWorld");
_changeWorldById = pi.GetIpcSubscriber<uint, bool>("Lifestream.ChangeWorldById");
_aetheryteTeleport = pi.GetIpcSubscriber<string, bool>("Lifestream.AetheryteTeleport");
_aetheryteTeleportById = pi.GetIpcSubscriber<uint, bool>("Lifestream.AetheryteTeleportById");
_canChangeInstance = pi.GetIpcSubscriber<bool>("Lifestream.CanChangeInstance");
_getCurrentInstance = pi.GetIpcSubscriber<int>("Lifestream.GetCurrentInstance");
_getNumberOfInstances = pi.GetIpcSubscriber<int>("Lifestream.GetNumberOfInstances");
_changeInstance = pi.GetIpcSubscriber<int, object>("Lifestream.ChangeInstance");
_getCurrentPlotInfo = pi.GetIpcSubscriber<(ResidentialAetheryteKind, int, int)>("Lifestream.GetCurrentPlotInfo");
CheckAPI();
}
public void ExecuteLifestreamCommand(string command)
{
if (!APIAvailable) return;
_executeLifestreamCommand.InvokeAction(command);
}
public bool IsHere(AddressBookEntryTuple entry)
{
if (!APIAvailable) return false;
return _isHere.InvokeFunc(entry);
}
public void GoToHousingAddress(AddressBookEntryTuple entry)
{
if (!APIAvailable) return;
_goToHousingAddress.InvokeAction(entry);
}
public bool IsBusy()
{
if (!APIAvailable) return false;
return _isBusy.InvokeFunc();
}
public void Abort()
{
if (!APIAvailable) return;
_abort.InvokeAction();
}
public bool ChangeWorld(string worldName)
{
if (!APIAvailable) return false;
return _changeWorld.InvokeFunc(worldName);
}
public bool AetheryteTeleport(string aetheryteName)
{
if (!APIAvailable) return false;
return _aetheryteTeleport.InvokeFunc(aetheryteName);
}
public bool ChangeWorldById(uint worldId)
{
if (!APIAvailable) return false;
return _changeWorldById.InvokeFunc(worldId);
}
public bool AetheryteTeleportById(uint aetheryteId)
{
if (!APIAvailable) return false;
return _aetheryteTeleportById.InvokeFunc(aetheryteId);
}
public bool CanChangeInstance()
{
if (!APIAvailable) return false;
return _canChangeInstance.InvokeFunc();
}
public int GetCurrentInstance()
{
if (!APIAvailable) return -1;
return _getCurrentInstance.InvokeFunc();
}
public int GetNumberOfInstances()
{
if (!APIAvailable) return -1;
return _getNumberOfInstances.InvokeFunc();
}
public void ChangeInstance(int instanceNumber)
{
if (!APIAvailable) return;
_changeInstance.InvokeAction(instanceNumber);
}
public (ResidentialAetheryteKind, int, int)? GetCurrentPlotInfo()
{
if (!APIAvailable) return (ResidentialAetheryteKind.None, -1, -1);
return _getCurrentPlotInfo.InvokeFunc();
}
}

View File

@@ -9,8 +9,7 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
public IpcManager(ILogger<IpcManager> logger, LightlessMediator mediator, public IpcManager(ILogger<IpcManager> logger, LightlessMediator mediator,
IpcCallerPenumbra penumbraIpc, IpcCallerGlamourer glamourerIpc, IpcCallerCustomize customizeIpc, IpcCallerHeels heelsIpc, IpcCallerPenumbra penumbraIpc, IpcCallerGlamourer glamourerIpc, IpcCallerCustomize customizeIpc, IpcCallerHeels heelsIpc,
IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio, IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio) : base(logger, mediator)
IpcCallerLifestream ipcCallerLifestream) : base(logger, mediator)
{ {
CustomizePlus = customizeIpc; CustomizePlus = customizeIpc;
Heels = heelsIpc; Heels = heelsIpc;
@@ -20,7 +19,6 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
Moodles = moodlesIpc; Moodles = moodlesIpc;
PetNames = ipcCallerPetNames; PetNames = ipcCallerPetNames;
Brio = ipcCallerBrio; Brio = ipcCallerBrio;
Lifestream = ipcCallerLifestream;
_wasInitialized = Initialized; _wasInitialized = Initialized;
if (_wasInitialized) if (_wasInitialized)
@@ -49,8 +47,8 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
public IpcCallerPenumbra Penumbra { get; } public IpcCallerPenumbra Penumbra { get; }
public IpcCallerMoodles Moodles { get; } public IpcCallerMoodles Moodles { get; }
public IpcCallerPetNames PetNames { get; } public IpcCallerPetNames PetNames { get; }
public IpcCallerBrio Brio { get; } public IpcCallerBrio Brio { get; }
public IpcCallerLifestream Lifestream { get; }
private void PeriodicApiStateCheck() private void PeriodicApiStateCheck()
{ {
@@ -71,6 +69,5 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
} }
_wasInitialized = initialized; _wasInitialized = initialized;
Lifestream.CheckAPI();
} }
} }

View File

@@ -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;
@@ -157,10 +156,5 @@ 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;
} }

View File

@@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<Authors></Authors> <Authors></Authors>
<Company></Company> <Company></Company>
<Version>2.0.2.75</Version> <Version>2.0.3</Version>
<Description></Description> <Description></Description>
<Copyright></Copyright> <Copyright></Copyright>
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl> <PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>

View File

@@ -1,9 +0,0 @@
namespace LightlessSync.PlayerData.Factories
{
public enum AnimationValidationMode
{
Unsafe = 0,
Safe = 1,
Safest = 2,
}
}

View File

@@ -1,19 +1,13 @@
using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
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;
using System.Runtime.ExceptionServices;
using System.Runtime.InteropServices;
namespace LightlessSync.PlayerData.Factories; namespace LightlessSync.PlayerData.Factories;
@@ -24,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;
@@ -61,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;
@@ -94,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)
{ {
@@ -120,17 +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;
if (!IsPointerValid(playerPointer))
return true;
var character = (Character*)playerPointer; var character = (Character*)playerPointer;
if (character == null) if (character == null)
return true; return true;
@@ -138,190 +110,99 @@ public class PlayerDataFactory
if (gameObject == null) if (gameObject == null)
return true; return true;
if (!IsPointerValid((IntPtr)gameObject))
return true;
return gameObject->DrawObject == null; return gameObject->DrawObject == null;
} }
private static bool IsPointerValid(IntPtr ptr) private async Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
{
if (ptr == IntPtr.Zero)
return false;
try
{
_ = Marshal.ReadByte(ptr);
return true;
}
catch
{
return false;
}
}
private static bool IsCacheFresh(CacheEntry entry)
=> (DateTime.UtcNow - entry.CreatedUtc) <= _characterCacheTtl;
private Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
=> CreateCharacterDataCoalesced(playerRelatedObject, ct);
private async Task<CharacterDataFragment> CreateCharacterDataCoalesced(GameObjectHandler obj, CancellationToken ct)
{
var key = obj.Address;
if (_characterBuildCache.TryGetValue(key, out 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);
// get all remaining paths and resolve them
ct.ThrowIfCancellationRequested();
if (await CheckForNullDrawObject(playerRelatedObject.Address).ConfigureAwait(false))
throw new InvalidOperationException("DrawObject became null during build (actor despawned)");
Task<string> getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address);
Task<string?> getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address);
Task<string?>? getMoodlesData = null;
Task<string>? getHeelsOffset = null;
Task<string>? getHonorificTitle = null;
if (objectKind == ObjectKind.Player)
{ {
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();
var allowedExtensions = CacheMonitor.AllowedFileExtensions;
fragment.FileReplacements.RemoveWhere(c => c.GamePaths.Any(g => !allowedExtensions.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 = transientPaths.Count == 0
? new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase).AsReadOnly()
: 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);
@@ -330,64 +211,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(() =>
{
var computedPaths = _fileCacheManager.GetFileCachesByPaths([.. toCompute.Select(c => c.ResolvedPath)]);
foreach (var file in toCompute) foreach (var file in toCompute)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty; 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)
@@ -401,300 +303,80 @@ 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;
var resolvedPath = g.Select(f => f.ResolvedPath).Distinct(StringComparer.OrdinalIgnoreCase); if (skeletonIndices != null)
var papPathSummary = string.Join(", ", resolvedPath);
if (papPathSummary.IsNullOrEmpty())
papPathSummary = "<unknown pap path>";
Dictionary<string, List<ushort>>? papIndices = null;
await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
try
{ {
try // 105 is the maximum vanilla skellington spoopy bone index
if (skeletonIndices.All(k => k.Value.Max() <= 105))
{ {
papIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash, persistToConfig: false), ct) _logger.LogTrace("All indices of {path} are <= 105, ignoring", file.ResolvedPath);
.ConfigureAwait(false);
}
catch (SEHException ex)
{
_logger.LogError(ex, "SEH exception while parsing PAP file (hash={hash}, path={path}). Error code: 0x{code:X}. Skipping this animation.", hash, papPathSummary, ex.ErrorCode);
continue;
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error parsing PAP file (hash={hash}, path={path}). Skipping this animation.", hash, papPathSummary);
continue;
}
}
finally
{
_papParseLimiter.Release();
}
if (papIndices == null || papIndices.Count == 0)
continue;
bool hasValidIndices = false;
try
{
hasValidIndices = papIndices.All(k => k.Value != null && k.Value.DefaultIfEmpty().Max() <= 105);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error validating bone indices for PAP (hash={hash}, path={path}). Skipping.", hash, papPathSummary);
continue; continue;
} }
if (hasValidIndices) _logger.LogDebug("Verifying bone indices for {path}, found {x} skeletons", file.ResolvedPath, skeletonIndices.Count);
continue;
if (_logger.IsEnabled(LogLevel.Debug)) foreach (var boneCount in skeletonIndices)
{ {
try var maxAnimationIndex = boneCount.Value.DefaultIfEmpty().Max();
if (maxAnimationIndex > maxPlayerBoneIndex)
{ {
var papBuckets = papIndices _logger.LogWarning("Found more bone indices on the animation {path} skeleton {skl} (max indice {idx}) than on any player related skeleton (max indice {idx2})",
.Where(kvp => kvp.Value is { Count: > 0 }) file.ResolvedPath, boneCount.Key, maxAnimationIndex, maxPlayerBoneIndex);
.Select(kvp => new validationFailed = true;
{ break;
Raw = kvp.Key,
Key = XivDataAnalyzer.CanonicalizeSkeletonKey(kvp.Key),
Indices = kvp.Value
})
.Where(x => x.Indices is { Count: > 0 })
.GroupBy(x => string.IsNullOrEmpty(x.Key) ? x.Raw : x.Key!, StringComparer.OrdinalIgnoreCase)
.Select(grp =>
{
var all = grp.SelectMany(v => v.Indices).ToList();
var min = all.Count > 0 ? all.Min() : 0;
var max = all.Count > 0 ? all.Max() : 0;
var raws = string.Join(',', grp.Select(v => v.Raw).Distinct(StringComparer.OrdinalIgnoreCase));
return $"{grp.Key}(min={min},max={max},raw=[{raws}])";
})
.ToList();
_logger.LogDebug("SEND pap buckets for hash={hash}: {b}",
hash,
string.Join(" | ", papBuckets));
} }
catch (Exception ex)
{
_logger.LogDebug(ex, "Error logging PAP bucket details for hash={hash}", hash);
} }
} }
bool isCompatible = false; if (validationFailed)
string reason = string.Empty;
try
{ {
isCompatible = XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out reason);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error checking PAP compatibility for hash={hash}, path={path}. Treating as incompatible.", hash, papPathSummary);
reason = $"Exception during compatibility check: {ex.Message}";
isCompatible = false;
}
if (isCompatible)
continue;
noValidationFailed++; 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);
}
}
_logger.LogWarning(
"Animation PAP is not compatible with local skeletons; dropping mappings for {papPath}. Reason: {reason}",
papPathSummary,
reason);
var removedGamePaths = fragment.FileReplacements
.Where(fr => !fr.IsFileSwap
&& string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
&& fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
.SelectMany(fr => fr.GamePaths.Where(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
fragment.FileReplacements.RemoveWhere(fr =>
!fr.IsFileSwap
&& string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
&& fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
foreach (var gp in removedGamePaths)
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gp);
} }
if (noValidationFailed > 0) 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();
@@ -713,15 +395,21 @@ public class PlayerDataFactory
{ {
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);
@@ -732,10 +420,14 @@ 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(forwardPathsLower[i]);
}
else else
{ {
resolvedPaths[filePath] = [forwardPathsLower[i]]; resolvedPaths[filePath] = [forwardPathsLower[i]];
@@ -751,9 +443,13 @@ public class PlayerDataFactory
reverseResolvedLower[j] = reverseResolved[i][j].ToLowerInvariant(); reverseResolvedLower[j] = reverseResolved[i][j].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(reverseResolvedLower);
}
else else
resolvedPaths[filePath] = [.. reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList()]; {
resolvedPaths[filePath] = new List<string>(reverseResolvedLower);
}
} }
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();
@@ -761,14 +457,17 @@ 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(forwardPathsLower[i]);
}
else else
resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()]; {
resolvedPaths[filePath] = [forwardPathsLower[i]];
}
} }
for (int i = 0; i < reversePaths.Length; i++) for (int i = 0; i < reversePaths.Length; i++)
@@ -780,9 +479,13 @@ public class PlayerDataFactory
reverseResolvedLower[j] = reverse[i][j].ToLowerInvariant(); reverseResolvedLower[j] = reverse[i][j].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(reverseResolvedLower);
}
else else
resolvedPaths[filePath] = [.. reverse[i].Select(c => c.ToLowerInvariant()).ToList()]; {
resolvedPaths[filePath] = new List<string>(reverseResolvedLower);
}
} }
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();
@@ -793,29 +496,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;
} }
} }

View File

@@ -169,36 +169,37 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
return $"{owned}/{ObjectKind}:{Name} ({Address:X},{DrawObjectAddress:X})"; return $"{owned}/{ObjectKind}:{Name} ({Address:X},{DrawObjectAddress:X})";
} }
private void CheckAndUpdateObject() => CheckAndUpdateObject(allowPublish: true); protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
private unsafe void CheckAndUpdateObject(bool allowPublish) Mediator.Publish(new GameObjectHandlerDestroyedMessage(this, _isOwnedObject));
}
private unsafe void CheckAndUpdateObject()
{ {
var prevAddr = Address; var prevAddr = Address;
var prevDrawObj = DrawObjectAddress; var prevDrawObj = DrawObjectAddress;
string? nameString = null;
Address = _getAddress(); Address = _getAddress();
if (Address != IntPtr.Zero) if (Address != IntPtr.Zero)
{ {
var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address; var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address;
DrawObjectAddress = (IntPtr)gameObject->DrawObject; var drawObjAddr = (IntPtr)gameObject->DrawObject;
DrawObjectAddress = drawObjAddr;
EntityId = gameObject->EntityId; EntityId = gameObject->EntityId;
CurrentDrawCondition = DrawCondition.None;
var chara = (Character*)Address;
nameString = chara->GameObject.NameString;
if (!string.IsNullOrEmpty(nameString) && !string.Equals(nameString, Name, StringComparison.Ordinal))
Name = nameString;
} }
else else
{ {
DrawObjectAddress = IntPtr.Zero; DrawObjectAddress = IntPtr.Zero;
EntityId = uint.MaxValue; EntityId = uint.MaxValue;
CurrentDrawCondition = DrawCondition.DrawObjectZero;
} }
CurrentDrawCondition = IsBeingDrawnUnsafe(); CurrentDrawCondition = IsBeingDrawnUnsafe();
if (_haltProcessing || !allowPublish) return; if (_haltProcessing) return;
bool drawObjDiff = DrawObjectAddress != prevDrawObj; bool drawObjDiff = DrawObjectAddress != prevDrawObj;
bool addrDiff = Address != prevAddr; bool addrDiff = Address != prevAddr;
@@ -206,18 +207,16 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
if (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero) if (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero)
{ {
var chara = (Character*)Address; var chara = (Character*)Address;
var drawObj = (DrawObject*)DrawObjectAddress; var name = chara->GameObject.NameString;
var objType = drawObj->Object.GetObjectType(); bool nameChange = !string.Equals(name, Name, StringComparison.Ordinal);
var isHuman = objType == ObjectType.CharacterBase if (nameChange)
&& ((CharacterBase*)drawObj)->GetModelType() == CharacterBase.ModelType.Human; {
Name = name;
nameString ??= ((Character*)Address)->GameObject.NameString; }
var nameChange = !string.Equals(nameString, Name, StringComparison.Ordinal);
if (nameChange) Name = nameString;
bool equipDiff = false; bool equipDiff = false;
if (isHuman) if (((DrawObject*)DrawObjectAddress)->Object.GetObjectType() == ObjectType.CharacterBase
&& ((CharacterBase*)DrawObjectAddress)->GetModelType() == CharacterBase.ModelType.Human)
{ {
var classJob = chara->CharacterData.ClassJob; var classJob = chara->CharacterData.ClassJob;
if (classJob != _classJob) if (classJob != _classJob)
@@ -227,7 +226,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
Mediator.Publish(new ClassJobChangedMessage(this)); Mediator.Publish(new ClassJobChangedMessage(this));
} }
equipDiff = CompareAndUpdateEquipByteData((byte*)&((Human*)drawObj)->Head); equipDiff = CompareAndUpdateEquipByteData((byte*)&((Human*)DrawObjectAddress)->Head);
ref var mh = ref chara->DrawData.Weapon(WeaponSlot.MainHand); ref var mh = ref chara->DrawData.Weapon(WeaponSlot.MainHand);
ref var oh = ref chara->DrawData.Weapon(WeaponSlot.OffHand); ref var oh = ref chara->DrawData.Weapon(WeaponSlot.OffHand);
@@ -252,11 +251,12 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
bool customizeDiff = false; bool customizeDiff = false;
if (isHuman) if (((DrawObject*)DrawObjectAddress)->Object.GetObjectType() == ObjectType.CharacterBase
&& ((CharacterBase*)DrawObjectAddress)->GetModelType() == CharacterBase.ModelType.Human)
{ {
var gender = ((Human*)drawObj)->Customize.Sex; var gender = ((Human*)DrawObjectAddress)->Customize.Sex;
var raceId = ((Human*)drawObj)->Customize.Race; var raceId = ((Human*)DrawObjectAddress)->Customize.Race;
var tribeId = ((Human*)drawObj)->Customize.Tribe; var tribeId = ((Human*)DrawObjectAddress)->Customize.Tribe;
if (_isOwnedObject && ObjectKind == ObjectKind.Player if (_isOwnedObject && ObjectKind == ObjectKind.Player
&& (gender != Gender || raceId != RaceId || tribeId != TribeId)) && (gender != Gender || raceId != RaceId || tribeId != TribeId))
@@ -267,7 +267,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
TribeId = tribeId; TribeId = tribeId;
} }
customizeDiff = CompareAndUpdateCustomizeData(((Human*)drawObj)->Customize.Data); customizeDiff = CompareAndUpdateCustomizeData(((Human*)DrawObjectAddress)->Customize.Data);
if (customizeDiff) if (customizeDiff)
Logger.LogTrace("Checking [{this}] customize data as human from draw obj, result: {diff}", this, customizeDiff); Logger.LogTrace("Checking [{this}] customize data as human from draw obj, result: {diff}", this, customizeDiff);
} }
@@ -356,10 +356,12 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
private void FrameworkUpdate() private void FrameworkUpdate()
{ {
if (!_delayedZoningTask?.IsCompleted ?? false) return;
try try
{ {
var zoningDelayActive = !(_delayedZoningTask?.IsCompleted ?? true); _performanceCollector.LogPerformance(this, $"CheckAndUpdateObject>{(_isOwnedObject ? "Self" : "Other")}+{ObjectKind}/{(string.IsNullOrEmpty(Name) ? "Unk" : Name)}"
_performanceCollector.LogPerformance(this, $"CheckAndUpdateObject>{(_isOwnedObject ? "Self" : "Other")}+{ObjectKind}/{(string.IsNullOrEmpty(Name) ? "Unk" : Name)}", () => CheckAndUpdateObject(allowPublish: !zoningDelayActive)); + $"+{Address.ToString("X")}", CheckAndUpdateObject);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -460,6 +462,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
Logger.LogDebug("[{this}] Delay after zoning complete", this); Logger.LogDebug("[{this}] Delay after zoning complete", this);
_zoningCts.Dispose(); _zoningCts.Dispose();
} }
}, _zoningCts.Token); });
} }
} }

View File

@@ -51,9 +51,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
private readonly ModelDecimationService _modelDecimationService; private readonly ModelDecimationService _modelDecimationService;
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 readonly IFramework _framework; private readonly IFramework _framework;
private CancellationTokenSource? _applicationCancellationTokenSource; private CancellationTokenSource? _applicationCancellationTokenSource;
@@ -95,9 +93,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
private readonly Dictionary<ObjectKind, HashSet<PlayerChanges>> _pendingOwnedChanges = new(); private readonly Dictionary<ObjectKind, HashSet<PlayerChanges>> _pendingOwnedChanges = new();
private CancellationTokenSource? _ownedRetryCts; private CancellationTokenSource? _ownedRetryCts;
private Task _ownedRetryTask = Task.CompletedTask; private Task _ownedRetryTask = Task.CompletedTask;
private static readonly TimeSpan OwnedRetryInitialDelay = TimeSpan.FromSeconds(1); private static readonly TimeSpan OwnedRetryInitialDelay = TimeSpan.FromSeconds(1);
private static readonly TimeSpan OwnedRetryMaxDelay = TimeSpan.FromSeconds(10); private static readonly TimeSpan OwnedRetryMaxDelay = TimeSpan.FromSeconds(10);
private static readonly TimeSpan OwnedRetryStaleDataGrace = TimeSpan.FromMinutes(5); private static readonly TimeSpan OwnedRetryStaleDataGrace = TimeSpan.FromMinutes(5);
@@ -109,13 +104,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
".avfx", ".avfx",
".scd" ".scd"
}; };
private readonly ConcurrentDictionary<string, byte> _blockedPapHashes = new(StringComparer.OrdinalIgnoreCase);
private AnimationValidationMode _lastAnimMode = (AnimationValidationMode)(-1);
private bool _lastAllowOneBasedShift;
private bool _lastAllowNeighborTolerance;
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;
@@ -219,9 +207,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
ModelDecimationService modelDecimationService, ModelDecimationService modelDecimationService,
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;
@@ -243,8 +229,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()
@@ -2135,36 +2119,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))
{ {
@@ -2245,7 +2204,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
} }
RecordFailure($"Application failed: {ex.Message}", "Exception"); RecordFailure($"Application failed: {ex.Message}", "Exception");
} }
} }
private void FrameworkUpdate() private void FrameworkUpdate()
{ {
@@ -2460,8 +2419,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
try try
{ {
RefreshPapBlockCacheIfAnimSettingsChanged();
var replacementList = charaData.FileReplacements.SelectMany(k => k.Value.Where(v => string.IsNullOrEmpty(v.FileSwapPath))).ToList(); var replacementList = charaData.FileReplacements.SelectMany(k => k.Value.Where(v => string.IsNullOrEmpty(v.FileSwapPath))).ToList();
Parallel.ForEach(replacementList, new ParallelOptions() Parallel.ForEach(replacementList, new ParallelOptions()
{ {
@@ -2484,37 +2441,20 @@ 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; var preferredPath = fileCache.ResolvedFilepath;
if (!skipDownscaleForPair && gamePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))
if (mode != AnimationValidationMode.Unsafe
&& gamePath.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrEmpty(item.Hash)
&& _blockedPapHashes.ContainsKey(item.Hash))
{ {
continue; preferredPath = _textureDownscaleService.GetPreferredPath(item.Hash, fileCache.ResolvedFilepath);
}
else if (!skipDecimationForPair && gamePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase))
{
preferredPath = _modelDecimationService.GetPreferredPath(item.Hash, fileCache.ResolvedFilepath);
} }
var preferredPath = skipDownscaleForPair
? fileCache.ResolvedFilepath
: _textureDownscaleService.GetPreferredPath(item.Hash, fileCache.ResolvedFilepath);
outputDict[(gamePath, item.Hash)] = preferredPath; outputDict[(gamePath, item.Hash)] = preferredPath;
} }
} }
@@ -2826,7 +2766,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))
@@ -2862,128 +2802,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
} }
} }
private void RefreshPapBlockCacheIfAnimSettingsChanged()
{
var cfg = _configService.Current;
if (cfg.AnimationValidationMode != _lastAnimMode
|| cfg.AnimationAllowOneBasedShift != _lastAllowOneBasedShift
|| cfg.AnimationAllowNeighborIndexTolerance != _lastAllowNeighborTolerance)
{
_lastAnimMode = cfg.AnimationValidationMode;
_lastAllowOneBasedShift = cfg.AnimationAllowOneBasedShift;
_lastAllowNeighborTolerance = cfg.AnimationAllowNeighborIndexTolerance;
_blockedPapHashes.Clear();
_dumpedRemoteSkeletonForHash.Clear();
Logger.LogDebug("{handler}: Cleared blocked PAP cache due to animation setting change (mode={mode}, shift={shift}, neigh={neigh})",
GetLogIdentifier(), _lastAnimMode, _lastAllowOneBasedShift, _lastAllowNeighborTolerance);
}
}
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)
{
RefreshPapBlockCacheIfAnimSettingsChanged();
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);

View File

@@ -37,8 +37,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;
private readonly IFramework _framework; private readonly IFramework _framework;
public PairHandlerAdapterFactory( public PairHandlerAdapterFactory(
@@ -61,9 +59,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
ModelDecimationService modelDecimationService, ModelDecimationService modelDecimationService,
PairStateCache pairStateCache, PairStateCache pairStateCache,
PairPerformanceMetricsCache pairPerformanceMetricsCache, PairPerformanceMetricsCache pairPerformanceMetricsCache,
PenumbraTempCollectionJanitor tempCollectionJanitor, PenumbraTempCollectionJanitor tempCollectionJanitor)
XivDataAnalyzer modelAnalyzer,
LightlessConfigService configService)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_mediator = mediator; _mediator = mediator;
@@ -85,8 +81,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)
@@ -116,8 +110,6 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
_modelDecimationService, _modelDecimationService,
_pairStateCache, _pairStateCache,
_pairPerformanceMetricsCache, _pairPerformanceMetricsCache,
_tempCollectionJanitor, _tempCollectionJanitor);
_modelAnalyzer,
_configService);
} }
} }

View File

@@ -123,6 +123,7 @@ public sealed class Plugin : IDalamudPlugin
services.AddSingleton<HubFactory>(); services.AddSingleton<HubFactory>();
services.AddSingleton<FileUploadManager>(); services.AddSingleton<FileUploadManager>();
services.AddSingleton<FileTransferOrchestrator>(); services.AddSingleton<FileTransferOrchestrator>();
services.AddSingleton<FileDownloadDeduplicator>();
services.AddSingleton<LightlessPlugin>(); services.AddSingleton<LightlessPlugin>();
services.AddSingleton<LightlessProfileManager>(); services.AddSingleton<LightlessProfileManager>();
services.AddSingleton<TextureCompressionService>(); services.AddSingleton<TextureCompressionService>();
@@ -377,11 +378,6 @@ public sealed class Plugin : IDalamudPlugin
sp.GetRequiredService<DalamudUtilService>(), sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessMediator>())); sp.GetRequiredService<LightlessMediator>()));
services.AddSingleton(sp => new IpcCallerLifestream(
pluginInterface,
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<ILogger<IpcCallerLifestream>>()));
services.AddSingleton(sp => new IpcManager( services.AddSingleton(sp => new IpcManager(
sp.GetRequiredService<ILogger<IpcManager>>(), sp.GetRequiredService<ILogger<IpcManager>>(),
sp.GetRequiredService<LightlessMediator>(), sp.GetRequiredService<LightlessMediator>(),
@@ -392,9 +388,7 @@ public sealed class Plugin : IDalamudPlugin
sp.GetRequiredService<IpcCallerHonorific>(), sp.GetRequiredService<IpcCallerHonorific>(),
sp.GetRequiredService<IpcCallerMoodles>(), sp.GetRequiredService<IpcCallerMoodles>(),
sp.GetRequiredService<IpcCallerPetNames>(), sp.GetRequiredService<IpcCallerPetNames>(),
sp.GetRequiredService<IpcCallerBrio>(), sp.GetRequiredService<IpcCallerBrio>()));
sp.GetRequiredService<IpcCallerLifestream>()
));
// Notifications / HTTP // Notifications / HTTP
services.AddSingleton(sp => new NotificationService( services.AddSingleton(sp => new NotificationService(
@@ -492,11 +486,19 @@ 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>();
@@ -582,6 +584,7 @@ public sealed class Plugin : IDalamudPlugin
public void Dispose() public void Dispose()
{ {
_host.StopAsync().ContinueWith(_ => _host.Dispose()).Wait(TimeSpan.FromSeconds(5)); _host.StopAsync().GetAwaiter().GetResult();
_host.Dispose();
} }
} }

View File

@@ -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;

View File

@@ -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;
@@ -705,7 +703,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
if (location.ServerId is 0 || location.TerritoryId is 0) return String.Empty; if (location.ServerId is 0 || location.TerritoryId is 0) return String.Empty;
var str = WorldData.Value[(ushort)location.ServerId]; var str = WorldData.Value[(ushort)location.ServerId];
if (ContentFinderData.Value.TryGetValue(location.TerritoryId, out var dutyName)) if (ContentFinderData.Value.TryGetValue(location.TerritoryId , out var dutyName))
{ {
str += $" - [In Duty]{dutyName}"; str += $" - [In Duty]{dutyName}";
} }
@@ -845,43 +843,33 @@ 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 tick = 250;
const int initialSettle = 50; int curWaitTime = 0;
var sw = Stopwatch.StartNew();
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) catch (AccessViolationException ex)
{
// ignore
}
catch (Exception ex)
{ {
logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler); logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler);
} }
@@ -931,92 +919,21 @@ 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;
}
});
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool IsBadReadPtr(IntPtr ptr, UIntPtr size);
private static bool IsValidPointer(nint ptr, int size = 8)
{
if (ptr == nint.Zero)
return false;
try
{
if (!Util.IsWine())
{
return !IsBadReadPtr(ptr, (UIntPtr)size);
}
return ptr != nint.Zero && (ptr % IntPtr.Size) == 0;
}
catch
{
return false;
}
}
private unsafe void CheckCharacterForDrawing(nint address, string characterName) private unsafe void CheckCharacterForDrawing(nint address, string characterName)
{ {
if (address == nint.Zero)
return;
if (!IsValidPointer(address))
{
_logger.LogDebug("Invalid pointer for character {name} at {addr}", characterName, address.ToString("X"));
return;
}
var gameObj = (GameObject*)address; var gameObj = (GameObject*)address;
if (gameObj == null)
return;
if (!_objectTable.Any(o => o?.Address == address))
{
_logger.LogDebug("Character {name} at {addr} no longer in object table", characterName, address.ToString("X"));
return;
}
if (gameObj->ObjectKind == 0)
return;
var drawObj = gameObj->DrawObject; var drawObj = gameObj->DrawObject;
bool isDrawing = false; bool isDrawing = false;
bool isDrawingChanged = false; bool isDrawingChanged = false;
if ((nint)drawObj != IntPtr.Zero)
if ((nint)drawObj != IntPtr.Zero && IsValidPointer((nint)drawObj))
{ {
isDrawing = gameObj->RenderFlags == (VisibilityFlags)0b100000000000; isDrawing = gameObj->RenderFlags == (VisibilityFlags)0b100000000000;
if (!isDrawing) if (!isDrawing)
{ {
var charBase = (CharacterBase*)drawObj; isDrawing = ((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0;
if (charBase != null && IsValidPointer((nint)charBase))
{
isDrawing = charBase->HasModelInSlotLoaded != 0;
if (!isDrawing) if (!isDrawing)
{ {
isDrawing = charBase->HasModelFilesInSlotLoaded != 0; isDrawing = ((CharacterBase*)drawObj)->HasModelFilesInSlotLoaded != 0;
if (isDrawing && !string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) if (isDrawing && !string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal)
&& !string.Equals(_lastGlobalBlockReason, "HasModelFilesInSlotLoaded", StringComparison.Ordinal)) && !string.Equals(_lastGlobalBlockReason, "HasModelFilesInSlotLoaded", StringComparison.Ordinal))
{ {
@@ -1036,7 +953,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
} }
} }
} }
}
else else
{ {
if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal) if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal)
@@ -1064,11 +980,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private unsafe void FrameworkOnUpdateInternal() private unsafe void FrameworkOnUpdateInternal()
{ {
if (!_clientState.IsLoggedIn || _objectTable.LocalPlayer == null)
{
return;
}
if ((_objectTable.LocalPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty]) if ((_objectTable.LocalPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty])
{ {
return; return;
@@ -1088,17 +999,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
} }
var playerDescriptors = _actorObjectService.PlayerDescriptors; var playerDescriptors = _actorObjectService.PlayerDescriptors;
var descriptorCount = playerDescriptors.Count; for (var i = 0; i < playerDescriptors.Count; i++)
for (var i = 0; i < descriptorCount; i++)
{ {
if (i >= playerDescriptors.Count)
break;
var actor = playerDescriptors[i]; var actor = playerDescriptors[i];
var playerAddress = actor.Address; var playerAddress = actor.Address;
if (playerAddress == nint.Zero || !IsValidPointer(playerAddress)) if (playerAddress == nint.Zero)
continue; continue;
if (actor.ObjectIndex >= 200) if (actor.ObjectIndex >= 200)
@@ -1112,16 +1018,17 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
if (!IsAnythingDrawing) if (!IsAnythingDrawing)
{ {
if (!_objectTable.Any(o => o?.Address == playerAddress)) var gameObj = (GameObject*)playerAddress;
{ var currentName = gameObj != null ? gameObj->NameString ?? string.Empty : string.Empty;
continue; var charaName = string.IsNullOrEmpty(currentName) ? actor.Name : currentName;
} CheckCharacterForDrawing(playerAddress, charaName);
CheckCharacterForDrawing(playerAddress, actor.Name);
if (IsAnythingDrawing) if (IsAnythingDrawing)
break; break;
} }
else
{
break;
}
} }
}); });
@@ -1190,7 +1097,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
}); });
// Cutscene // Cutscene
HandleStateTransition(() => IsInCutscene, v => IsInCutscene = v, shouldBeInCutscene, "Cutscene", HandleStateTransition(() => IsInCutscene,v => IsInCutscene = v, shouldBeInCutscene, "Cutscene",
onEnter: () => onEnter: () =>
{ {
Mediator.Publish(new CutsceneStartMessage()); Mediator.Publish(new CutsceneStartMessage());

View File

@@ -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,7 +23,6 @@ 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);
@@ -37,7 +36,6 @@ 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);
@@ -70,9 +68,6 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
public void Update() public void Update()
{ {
if (_disposed)
return;
_frameCounter++; _frameCounter++;
var lookupsThisFrame = 0; var lookupsThisFrame = 0;
@@ -116,14 +111,7 @@ 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)
@@ -142,9 +130,6 @@ 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)
@@ -157,9 +142,6 @@ 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();
@@ -176,7 +158,6 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
_pendingLocalBroadcast = true; _pendingLocalBroadcast = true;
_pendingLocalTtl = msg.Ttl; _pendingLocalTtl = msg.Ttl;
_pendingLocalGid = msg.Gid;
TryPrimeLocalBroadcastCache(); TryPrimeLocalBroadcastCache();
} }
@@ -192,12 +173,11 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
var expiry = DateTime.UtcNow + ttl; var expiry = DateTime.UtcNow + ttl;
_broadcastCache.AddOrUpdate(localCid, _broadcastCache.AddOrUpdate(localCid,
new BroadcastEntry(true, expiry, _pendingLocalGid), new BroadcastEntry(true, expiry, null),
(_, old) => new BroadcastEntry(true, expiry, _pendingLocalGid ?? old.GID)); (_, old) => new BroadcastEntry(true, expiry, 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
@@ -207,14 +187,10 @@ 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
@@ -348,35 +324,17 @@ 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(); _cleanupCts.Cancel();
} _cleanupCts.Dispose();
catch (ObjectDisposedException)
{
// Already disposed, can be ignored :)
}
try
{
_cleanupTask?.Wait(100); _cleanupTask?.Wait(100);
}
catch (Exception)
{
// Task may have already completed or been cancelled?
}
try
{
_cleanupCts.Dispose(); _cleanupCts.Dispose();
} }
catch (ObjectDisposedException)
{
// Already disposed, ignore
}
}
} }

View File

@@ -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,10 +121,7 @@ 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;

View File

@@ -62,8 +62,6 @@ public sealed class LightlessMediator : IHostedService
_logger.LogInformation("Starting LightlessMediator"); _logger.LogInformation("Starting LightlessMediator");
_ = Task.Run(async () => _ = Task.Run(async () =>
{
try
{ {
while (!_loopCts.Token.IsCancellationRequested) while (!_loopCts.Token.IsCancellationRequested)
{ {
@@ -78,17 +76,11 @@ public sealed class LightlessMediator : IHostedService
while (_messageQueue.TryDequeue(out var message)) while (_messageQueue.TryDequeue(out var message))
{ {
if (processedMessages.Contains(message)) { continue; } if (processedMessages.Contains(message)) { continue; }
processedMessages.Add(message); processedMessages.Add(message);
ExecuteMessage(message); ExecuteMessage(message);
} }
} }
}
catch (OperationCanceledException)
{
_logger.LogInformation("LightlessMediator stopped");
}
}); });
_logger.LogInformation("Started LightlessMediator"); _logger.LogInformation("Started LightlessMediator");

View File

@@ -124,7 +124,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, string? Gid = null) : MessageBase; public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : 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;

View File

@@ -8,7 +8,6 @@ using LightlessSync.UI.Tags;
using LightlessSync.WebAPI; using LightlessSync.WebAPI;
using LightlessSync.UI.Services; using LightlessSync.UI.Services;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using LightlessSync.PlayerData.Factories;
namespace LightlessSync.Services; namespace LightlessSync.Services;
@@ -24,7 +23,6 @@ public class UiFactory
private readonly PerformanceCollectorService _performanceCollectorService; private readonly PerformanceCollectorService _performanceCollectorService;
private readonly ProfileTagService _profileTagService; private readonly ProfileTagService _profileTagService;
private readonly DalamudUtilService _dalamudUtilService; private readonly DalamudUtilService _dalamudUtilService;
private readonly PairFactory _pairFactory;
public UiFactory( public UiFactory(
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
@@ -36,8 +34,7 @@ public class UiFactory
LightlessProfileManager lightlessProfileManager, LightlessProfileManager lightlessProfileManager,
PerformanceCollectorService performanceCollectorService, PerformanceCollectorService performanceCollectorService,
ProfileTagService profileTagService, ProfileTagService profileTagService,
DalamudUtilService dalamudUtilService, DalamudUtilService dalamudUtilService)
PairFactory pairFactory)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_lightlessMediator = lightlessMediator; _lightlessMediator = lightlessMediator;
@@ -49,7 +46,6 @@ public class UiFactory
_performanceCollectorService = performanceCollectorService; _performanceCollectorService = performanceCollectorService;
_profileTagService = profileTagService; _profileTagService = profileTagService;
_dalamudUtilService = dalamudUtilService; _dalamudUtilService = dalamudUtilService;
_pairFactory = pairFactory;
} }
public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto) public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto)
@@ -62,8 +58,7 @@ public class UiFactory
_pairUiService, _pairUiService,
dto, dto,
_performanceCollectorService, _performanceCollectorService,
_lightlessProfileManager, _lightlessProfileManager);
_pairFactory);
} }
public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair) public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair)

View File

@@ -1,22 +1,19 @@
using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using FFXIVClientStructs.Havok.Common.Serialize.Resource;
using FFXIVClientStructs.Havok.Animation; using FFXIVClientStructs.Havok.Animation;
using FFXIVClientStructs.Havok.Common.Base.Types; using FFXIVClientStructs.Havok.Common.Base.Types;
using FFXIVClientStructs.Havok.Common.Serialize.Util; 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.Collections.Concurrent; using System.Collections.Concurrent;
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;
@@ -34,622 +31,126 @@ 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); public unsafe Dictionary<string, List<ushort>>? GetBoneIndicesFromPap(string hash)
foreach (var (key, set) in sets)
{ {
if (set.Count == 0) if (_configService.Current.BonesDictionary.TryGetValue(hash, out var bones)) return bones;
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)
{
if (string.IsNullOrWhiteSpace(hash))
return null;
if (_configService.Current.BonesDictionary.TryGetValue(hash, out var cached) && cached is not null)
return cached;
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash); var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
if (cacheEntity == null || 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:
try reader.ReadInt32(); // ignore
{ reader.ReadInt32(); // ignore
_ = reader.ReadInt32(); // ignore reader.ReadInt16(); // read 2 (num animations)
_ = reader.ReadInt32(); // ignore reader.ReadInt16(); // read 2 (modelid)
var numAnimations = reader.ReadInt16(); // num animations var type = reader.ReadByte();// read 1 (type)
var modelId = reader.ReadInt16(); // modelid if (type != 0) return null; // it's not human, just ignore it, whatever
if (numAnimations < 0 || numAnimations > 1000)
{
_logger.LogWarning("PAP file {hash} has invalid animation count {count}, skipping", hash, numAnimations);
return null;
}
var type = reader.ReadByte(); // type
if (type != 0)
return null; // not human
_ = reader.ReadByte(); // variant
_ = reader.ReadInt32(); // ignore
reader.ReadByte(); // read 1 (variant)
reader.ReadInt32(); // ignore
var havokPosition = reader.ReadInt32(); var havokPosition = reader.ReadInt32();
var footerPosition = reader.ReadInt32(); var footerPosition = reader.ReadInt32();
var havokDataSize = footerPosition - havokPosition;
if (havokPosition <= 0 || footerPosition <= havokPosition ||
footerPosition > fs.Length || havokPosition >= fs.Length)
{
_logger.LogWarning("PAP file {hash} has invalid offsets (havok={havok}, footer={footer}, length={length})",
hash, havokPosition, footerPosition, fs.Length);
return null;
}
var havokDataSizeLong = (long)footerPosition - havokPosition;
if (havokDataSizeLong <= 8 || havokDataSizeLong > int.MaxValue)
{
_logger.LogWarning("PAP file {hash} has invalid Havok data size {size}", hash, havokDataSizeLong);
return null;
}
var havokDataSize = (int)havokDataSizeLong;
reader.BaseStream.Position = havokPosition; reader.BaseStream.Position = havokPosition;
var havokData = reader.ReadBytes(havokDataSize);
if (havokData.Length <= 8) return null; // no havok data
var havokData = new byte[havokDataSize]; var output = new Dictionary<string, List<ushort>>(StringComparer.OrdinalIgnoreCase);
var bytesRead = reader.Read(havokData, 0, havokDataSize); var tempHavokDataPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) + ".hkx";
if (bytesRead != havokDataSize) var tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath);
{
_logger.LogWarning("PAP file {hash}: Expected to read {expected} bytes but got {actual}",
hash, havokDataSize, bytesRead);
return null;
}
if (havokData.Length < 8)
return null;
var tempSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
var tempFileName = $"lightless_pap_{Guid.NewGuid():N}_{hash[..Math.Min(8, hash.Length)]}.hkx";
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), tempFileName);
IntPtr tempHavokDataPathAnsi = IntPtr.Zero;
try
{
var tempDir = Path.GetDirectoryName(tempHavokDataPath);
if (!Directory.Exists(tempDir))
{
_logger.LogWarning("Temp directory {dir} doesn't exist", tempDir);
return null;
}
// Write the file with explicit error handling
try try
{ {
File.WriteAllBytes(tempHavokDataPath, havokData); File.WriteAllBytes(tempHavokDataPath, havokData);
}
catch (Exception writeEx)
{
_logger.LogError(writeEx, "Failed to write temporary Havok file to {path}", tempHavokDataPath);
return null;
}
if (!File.Exists(tempHavokDataPath))
{
_logger.LogWarning("Temporary havok file was not created at {path}", tempHavokDataPath);
return null;
}
var writtenFileInfo = new FileInfo(tempHavokDataPath);
if (writtenFileInfo.Length != havokData.Length)
{
_logger.LogWarning("Written temp file size mismatch: expected {expected}, got {actual}",
havokData.Length, writtenFileInfo.Length);
try { File.Delete(tempHavokDataPath); } catch { }
return null;
}
Thread.Sleep(10); // stabilize file system
try
{
using var testStream = File.OpenRead(tempHavokDataPath);
if (testStream.Length != havokData.Length)
{
_logger.LogWarning("File verification failed: length mismatch after write");
try { File.Delete(tempHavokDataPath); } catch { }
return null;
}
}
catch (Exception readEx)
{
_logger.LogError(readEx, "Cannot read back temporary file at {path}", tempHavokDataPath);
try { File.Delete(tempHavokDataPath); } catch { }
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)
}; };
hkResource* resource = null; var resource = hkSerializeUtil.LoadFromFile((byte*)tempHavokDataPathAnsi, null, loadoptions);
try
{
if (tempHavokDataPathAnsi == IntPtr.Zero)
{
_logger.LogError("Failed to allocate ANSI string for path");
return null;
}
resource = hkSerializeUtil.LoadFromFile((byte*)tempHavokDataPathAnsi, null, loadoptions);
}
catch (SEHException ex)
{
_logger.LogError(ex, "SEH exception loading Havok file from {path} (hash={hash}). Native error code: 0x{code:X}. This may indicate a corrupted PAP file or incompatible Havok format.",
tempHavokDataPath, hash, ex.ErrorCode);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected exception loading Havok file from {path} (hash={hash})",
tempHavokDataPath, hash);
return null;
}
if (resource == null) if (resource == null)
{ {
_logger.LogDebug("Havok resource was null after loading from {path} (hash={hash}). File may be corrupted or in an unsupported format.", throw new InvalidOperationException("Resource was null after loading");
tempHavokDataPath, hash);
return null;
}
if ((nint)resource == nint.Zero || !IsValidPointer((IntPtr)resource))
{
_logger.LogDebug("Havok resource pointer is invalid (hash={hash})", hash);
return null;
} }
var rootLevelName = @"hkRootLevelContainer"u8; 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)
{
_logger.LogDebug("hkRootLevelContainer is null (hash={hash})", hash);
return null;
}
if ((nint)container == nint.Zero || !IsValidPointer((IntPtr)container))
{
_logger.LogDebug("hkRootLevelContainer pointer is invalid (hash={hash})", hash);
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)
{
_logger.LogDebug("hkaAnimationContainer is null (hash={hash})", hash);
return null;
}
if ((nint)animContainer == nint.Zero || !IsValidPointer((IntPtr)animContainer))
{
_logger.LogDebug("hkaAnimationContainer pointer is invalid (hash={hash})", hash);
return null;
}
if (animContainer->Bindings.Length < 0 || animContainer->Bindings.Length > 10000)
{
_logger.LogDebug("Invalid bindings count {count} (hash={hash})", animContainer->Bindings.Length, hash);
return null;
}
for (int i = 0; i < animContainer->Bindings.Length; i++) 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;
if ((nint)binding == nint.Zero || !IsValidPointer((IntPtr)binding))
{
_logger.LogDebug("Skipping invalid binding at index {index} (hash={hash})", i, hash);
continue;
}
var rawSkel = binding->OriginalSkeletonName.String;
var skeletonKey = CanonicalizeSkeletonKey(rawSkel);
if (string.IsNullOrEmpty(skeletonKey))
continue;
var boneTransform = binding->TransformTrackToBoneIndices; var boneTransform = binding->TransformTrackToBoneIndices;
if (boneTransform.Length <= 0 || boneTransform.Length > 10000) string name = binding->OriginalSkeletonName.String! + "_" + i;
{ output[name] = [];
_logger.LogDebug("Invalid bone transform length {length} for skeleton {skel} (hash={hash})",
boneTransform.Length, skeletonKey, hash);
continue;
}
if (!tempSets.TryGetValue(skeletonKey, out var set))
{
set = [];
tempSets[skeletonKey] = set;
}
for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++) for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++)
{ {
var v = boneTransform[boneIdx]; output[name].Add((ushort)boneTransform[boneIdx]);
if (v < 0 || v > ushort.MaxValue) }
continue; output[name].Sort();
set.Add((ushort)v); }
} }
} }
} }
}
}
catch (SEHException ex)
{
_logger.LogError(ex, "SEH exception processing PAP file {hash} from {path}. Error code: 0x{code:X}",
hash, tempHavokDataPath, ex.ErrorCode);
return null;
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Managed exception loading havok file {hash} from {path}", hash, 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);
int retryCount = 3;
while (retryCount > 0 && File.Exists(tempHavokDataPath))
{
try
{
File.Delete(tempHavokDataPath); File.Delete(tempHavokDataPath);
break;
} }
catch (IOException ex)
{
retryCount--;
if (retryCount == 0)
{
_logger.LogDebug(ex, "Failed to delete temporary havok file after retries: {path}", tempHavokDataPath);
}
else
{
Thread.Sleep(50);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Unexpected error deleting temporary havok file: {path}", tempHavokDataPath);
break;
}
}
}
if (tempSets.Count == 0)
{
_logger.LogDebug("No bone sets found in PAP file (hash={hash})", hash);
return null;
}
var output = new Dictionary<string, List<ushort>>(tempSets.Count, StringComparer.OrdinalIgnoreCase);
foreach (var (key, set) in tempSets)
{
if (set.Count == 0) continue;
var list = set.ToList();
list.Sort();
output[key] = list;
}
if (output.Count == 0)
return null;
_configService.Current.BonesDictionary[hash] = output; _configService.Current.BonesDictionary[hash] = output;
if (persistToConfig)
_configService.Save(); _configService.Save();
return output; return output;
} }
catch (Exception ex)
{
_logger.LogError(ex, "Outer exception reading PAP file (hash={hash})", hash);
return null;
}
}
private static bool IsValidPointer(IntPtr ptr)
{
if (ptr == IntPtr.Zero)
return false;
try
{
_ = Marshal.ReadByte(ptr);
return true;
}
catch
{
return false;
}
}
public static string CanonicalizeSkeletonKey(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
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)
{ {
@@ -738,23 +239,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();
} }

View File

@@ -34,65 +34,44 @@ namespace LightlessSync.UI;
public class CompactUi : WindowMediatorSubscriberBase public class CompactUi : WindowMediatorSubscriberBase
{ {
#region Constants
private const float ConnectButtonHighlightThickness = 14f;
#endregion
#region Services
private readonly ApiController _apiController;
private readonly CharacterAnalyzer _characterAnalyzer; private readonly CharacterAnalyzer _characterAnalyzer;
private readonly DalamudUtilService _dalamudUtilService; private readonly ApiController _apiController;
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, IReadOnlyDictionary<string, FileDownloadStatus>> _currentDownloads = new();
private readonly DrawEntityFactory _drawEntityFactory;
private readonly FileUploadManager _fileTransferManager;
private readonly PlayerPerformanceConfigService _playerPerformanceConfig; private readonly PlayerPerformanceConfigService _playerPerformanceConfig;
private readonly PairUiService _pairUiService; private readonly PairUiService _pairUiService;
private readonly ServerConfigurationManager _serverManager;
private readonly TagHandler _tagHandler;
private readonly UiSharedService _uiSharedService;
#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 SelectTagForPairUi _selectTagForPairUi;
private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi; private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi;
private readonly SeluneBrush _seluneBrush = new(); 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 TopTabMenu _tabMenu; private readonly TopTabMenu _tabMenu;
private readonly TagHandler _tagHandler;
private readonly UiSharedService _uiSharedService;
private readonly LightFinderService _broadcastService;
private readonly DalamudUtilService _dalamudUtilService;
#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();
#endregion private const float _connectButtonHighlightThickness = 14f;
private Pair? _focusedPair;
#region Constructor private Pair? _pendingFocusPair;
private int _pendingFocusFrame = -1;
public CompactUi( public CompactUi(
ILogger<CompactUi> logger, ILogger<CompactUi> logger,
@@ -149,11 +128,6 @@ public class CompactUi : WindowMediatorSubscriberBase
_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";
var ver = Assembly.GetExecutingAssembly().GetName().Version!; var ver = Assembly.GetExecutingAssembly().GetName().Version!;
@@ -167,26 +141,18 @@ public class CompactUi : WindowMediatorSubscriberBase
Mediator.Subscribe<SwitchToIntroUiMessage>(this, (_) => IsOpen = false); Mediator.Subscribe<SwitchToIntroUiMessage>(this, (_) => IsOpen = false);
Mediator.Subscribe<CutsceneStartMessage>(this, (_) => UiSharedService_GposeStart()); Mediator.Subscribe<CutsceneStartMessage>(this, (_) => UiSharedService_GposeStart());
Mediator.Subscribe<CutsceneEndMessage>(this, (_) => UiSharedService_GposeEnd()); Mediator.Subscribe<CutsceneEndMessage>(this, (_) => UiSharedService_GposeEnd());
Mediator.Subscribe<DownloadStartedMessage>(this, msg => Mediator.Subscribe<DownloadStartedMessage>(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus);
{
_currentDownloads[msg.DownloadId] = new Dictionary<string, FileDownloadStatus>(msg.DownloadStatus, StringComparer.Ordinal);
});
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) => _currentDownloads.TryRemove(msg.DownloadId, out _)); Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) => _currentDownloads.TryRemove(msg.DownloadId, out _));
Mediator.Subscribe<RefreshUiMessage>(this, (msg) => _drawFolders = [.. DrawFolders]); Mediator.Subscribe<RefreshUiMessage>(this, (msg) => _drawFolders = DrawFolders.ToList());
_characterAnalyzer = characterAnalyzer; _characterAnalyzer = characterAnalyzer;
_playerPerformanceConfig = playerPerformanceConfig; _playerPerformanceConfig = playerPerformanceConfig;
_lightlessMediator = mediator; _lightlessMediator = mediator;
} }
#endregion
#region Lifecycle
public override void OnClose() public override void OnClose()
{ {
ForceReleaseFocus(); ForceReleaseFocus();
_animatedHeader.ClearParticles();
base.OnClose(); base.OnClose();
} }
@@ -198,13 +164,6 @@ 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;
@@ -250,11 +209,17 @@ 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)
{ {
@@ -262,6 +227,7 @@ 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);
@@ -324,10 +290,6 @@ 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
@@ -346,6 +308,95 @@ 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();
@@ -441,9 +492,11 @@ public class CompactUi : WindowMediatorSubscriberBase
return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes); return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes);
} }
#endregion [StructLayout(LayoutKind.Auto)]
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()
{ {
@@ -479,52 +532,21 @@ 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 uidStartX = 25f; float contentWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X;
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)
{ {
ImGui.SetCursorScreenPos(new Vector2(nextIconX, uidTextRectMin.Y + textVerticalOffset)); float iconYOffset = (uidTextSize.Y - iconSize.Y) * 0.5f;
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.Wifi.ToIconString()); ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.PersonCirclePlus.ToIconString());
nextIconX = ImGui.GetItemRectMax().X + 6f;
if (ImGui.IsItemHovered()) if (ImGui.IsItemHovered())
@@ -597,7 +619,49 @@ public class CompactUi : WindowMediatorSubscriberBase
_lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); _lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
} }
// Warning threshold icon (next to lightfinder or UID text) 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);
}
}
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);
@@ -611,21 +675,17 @@ public class CompactUi : WindowMediatorSubscriberBase
if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds) if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds)
{ {
ImGui.SetCursorScreenPos(new Vector2(nextIconX, uidTextRectMin.Y + textVerticalOffset)); ImGui.SameLine();
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());
if (ImGui.IsItemHovered())
{
string warningMessage = ""; string warningMessage = "";
if (isOverTriHold) if (isOverTriHold)
{ {
warningMessage += $"You exceed your own triangles threshold by " + warningMessage += $"You exceed your own triangles threshold by " +
$"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles."; $"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles.";
warningMessage += Environment.NewLine; warningMessage += Environment.NewLine;
} }
if (isOverVRAMUsage) if (isOverVRAMUsage)
{ {
@@ -633,8 +693,6 @@ public class CompactUi : WindowMediatorSubscriberBase
$"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}."; $"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}.";
} }
UiSharedService.AttachToolTip(warningMessage); UiSharedService.AttachToolTip(warningMessage);
}
if (ImGui.IsItemClicked()) if (ImGui.IsItemClicked())
{ {
_lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi))); _lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi)));
@@ -643,34 +701,6 @@ 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)
@@ -678,12 +708,10 @@ public class CompactUi : WindowMediatorSubscriberBase
ImGui.SetClipboardText(_apiController.DisplayName); ImGui.SetClipboardText(_apiController.DisplayName);
} }
// Only show smaller UID line if DisplayName differs from UID (custom vanity name) if (!string.Equals(_apiController.DisplayName, _apiController.UID, StringComparison.Ordinal))
bool hasCustomName = !string.Equals(_apiController.DisplayName, _apiController.UID, StringComparison.OrdinalIgnoreCase);
if (hasCustomName)
{ {
ImGui.SetCursorPosX(uidStartX); var origTextSize = ImGui.CalcTextSize(_apiController.UID);
ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X) / 2 - (origTextSize.X / 2));
if (useVanityColors) if (useVanityColors)
{ {
@@ -718,88 +746,14 @@ 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
@@ -935,10 +889,6 @@ 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;
@@ -1083,11 +1033,10 @@ public class CompactUi : WindowMediatorSubscriberBase
return SortGroupEntries(entries, group); return SortGroupEntries(entries, group);
} }
#endregion private void UiSharedService_GposeEnd()
{
#region GPose Handlers IsOpen = _wasOpen;
}
private void UiSharedService_GposeEnd() => IsOpen = _wasOpen;
private void UiSharedService_GposeStart() private void UiSharedService_GposeStart()
{ {
@@ -1095,10 +1044,6 @@ 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;
@@ -1144,16 +1089,4 @@ 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
} }

View File

@@ -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;
@@ -65,15 +63,9 @@ public class DownloadUi : WindowMediatorSubscriberBase
IsOpen = true; IsOpen = true;
Mediator.Subscribe<DownloadStartedMessage>(this, msg => Mediator.Subscribe<DownloadStartedMessage>(this, (msg) =>
{ {
_currentDownloads[msg.DownloadId] = msg.DownloadStatus; _currentDownloads[msg.DownloadId] = msg.DownloadStatus;
var snap = msg.DownloadStatus.ToArray();
var totalFiles = snap.Sum(kv => kv.Value?.TotalFiles ?? 0);
var totalBytes = snap.Sum(kv => kv.Value?.TotalBytes ?? 0);
_downloadInitialTotals[msg.DownloadId] = (totalFiles, totalBytes);
_notificationDismissed = false; _notificationDismissed = false;
}); });
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) => Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) =>
@@ -81,7 +73,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
_currentDownloads.TryRemove(msg.DownloadId, out _); _currentDownloads.TryRemove(msg.DownloadId, out _);
// Dismiss notification if all downloads are complete // Dismiss notification if all downloads are complete
if (_currentDownloads.IsEmpty && !_notificationDismissed) if (!_currentDownloads.Any() && !_notificationDismissed)
{ {
Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress"));
_notificationDismissed = true; _notificationDismissed = true;
@@ -175,7 +167,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
List<KeyValuePair<GameObjectHandler, IReadOnlyDictionary<string, FileDownloadStatus>>> transfers; List<KeyValuePair<GameObjectHandler, IReadOnlyDictionary<string, FileDownloadStatus>>> transfers;
try try
{ {
transfers = [.. _currentDownloads]; transfers = _currentDownloads.ToList();
} }
catch (ArgumentException) catch (ArgumentException)
{ {
@@ -462,13 +454,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;
@@ -476,7 +464,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
totalBytes += playerTotalBytes; totalBytes += playerTotalBytes;
transferredBytes += playerTransferredBytes; transferredBytes += playerTransferredBytes;
// per-player W/Q/P/D/C // per-player W/Q/P/D
var playerDlSlot = 0; var playerDlSlot = 0;
var playerDlQueue = 0; var playerDlQueue = 0;
var playerDlProg = 0; var playerDlProg = 0;
@@ -489,7 +477,6 @@ public class DownloadUi : WindowMediatorSubscriberBase
switch (fileStatus.DownloadStatus) switch (fileStatus.DownloadStatus)
{ {
case DownloadStatus.Initializing: case DownloadStatus.Initializing:
case DownloadStatus.WaitingForQueue:
playerDlQueue++; playerDlQueue++;
totalDlQueue++; totalDlQueue++;
break; break;
@@ -497,6 +484,10 @@ public class DownloadUi : WindowMediatorSubscriberBase
playerDlSlot++; playerDlSlot++;
totalDlSlot++; totalDlSlot++;
break; break;
case DownloadStatus.WaitingForQueue:
playerDlQueue++;
totalDlQueue++;
break;
case DownloadStatus.Downloading: case DownloadStatus.Downloading:
playerDlProg++; playerDlProg++;
totalDlProg++; totalDlProg++;
@@ -549,6 +540,11 @@ public class DownloadUi : WindowMediatorSubscriberBase
if (totalFiles == 0 || totalBytes == 0) if (totalFiles == 0 || totalBytes == 0)
return; return;
// max speed for per-player bar scale (clamped)
double maxSpeed = perPlayer.Count > 0 ? perPlayer.Max(p => p.SpeedBytesPerSecond) : 0;
if (maxSpeed <= 0)
maxSpeed = 1;
var drawList = ImGui.GetBackgroundDrawList(); var drawList = ImGui.GetBackgroundDrawList();
var windowPos = ImGui.GetWindowPos(); var windowPos = ImGui.GetWindowPos();

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@ using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Utility.Raii;
using Dalamud.Utility; using Dalamud.Utility;
using Lifestream.Enums;
using LightlessSync.API.Data; using LightlessSync.API.Data;
using LightlessSync.API.Data.Comparer; using LightlessSync.API.Data.Comparer;
using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Enum;
@@ -15,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;
@@ -42,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;
@@ -72,8 +69,6 @@ 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;
@@ -110,8 +105,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",
@@ -121,8 +116,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",
@@ -211,10 +205,7 @@ 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)
@@ -250,7 +241,6 @@ 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();
@@ -265,8 +255,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)
@@ -1240,7 +1230,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;
@@ -1342,6 +1332,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.TreePop(); ImGui.TreePop();
} }
#endif
if (_uiShared.IconTextButton(FontAwesomeIcon.Copy, "[DEBUG] Copy Last created Character Data to clipboard")) if (_uiShared.IconTextButton(FontAwesomeIcon.Copy, "[DEBUG] Copy Last created Character Data to clipboard"))
{ {
if (LastCreatedCharacterData != null) if (LastCreatedCharacterData != null)
@@ -1357,39 +1348,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
UiSharedService.AttachToolTip("Use this when reporting mods being rejected from the server."); UiSharedService.AttachToolTip("Use this when reporting mods being rejected from the server.");
if (_uiShared.IconTextButton(FontAwesomeIcon.Home, "Teleport to Limsa [LIFESTREAM TEST]") && _ipcManager.Lifestream.APIAvailable)
{
_ipcManager.Lifestream.ExecuteLifestreamCommand("limsa");
}
if (_uiShared.IconTextButton(FontAwesomeIcon.Home, "Teleport to JoyHouse [LIFESTREAM TEST]") && _ipcManager.Lifestream.APIAvailable)
{
var twintania = _dalamudUtilService.WorldData.Value
.FirstOrDefault(kvp => kvp.Value.Equals("Twintania", StringComparison.OrdinalIgnoreCase));
int ward = 29;
int plot = 7;
AddressBookEntryTuple addressEntry = (
Name: "",
World: (int)twintania.Key,
City: (int)ResidentialAetheryteKind.Kugane,
Ward: ward,
PropertyType: 0,
Plot: plot,
Apartment: 1,
ApartmentSubdivision: false,
AliasEnabled: false,
Alias: ""
);
_logger.LogInformation("going to: {address}", addressEntry);
_ipcManager.Lifestream.GoToHousingAddress(addressEntry);
}
#endif
_uiShared.DrawCombo("Log Level", Enum.GetValues<LogLevel>(), (l) => l.ToString(), (l) => _uiShared.DrawCombo("Log Level", Enum.GetValues<LogLevel>(), (l) => l.ToString(), (l) =>
{ {
_configService.Current.LogLevel = l; _configService.Current.LogLevel = l;
@@ -2057,25 +2015,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)
@@ -2289,20 +2236,11 @@ 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;
@@ -3019,13 +2957,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
("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))
{ {
@@ -3231,102 +3164,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);
} }
} }
@@ -3416,7 +3257,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;
@@ -3724,7 +3564,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)

View File

@@ -43,24 +43,11 @@ 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
/// </summary> /// </summary>
@@ -159,21 +146,16 @@ 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(top), ImGui.GetColorU32(TopColor),
ImGui.GetColorU32(top), ImGui.GetColorU32(TopColor),
ImGui.GetColorU32(bottom), ImGui.GetColorU32(BottomColor),
ImGui.GetColorU32(bottom) ImGui.GetColorU32(BottomColor)
); );
// 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++)
{ {
@@ -182,28 +164,23 @@ 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;
var starColor = starBase with { W = starBase.W * brightness }; drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(new Vector4(1f, 1f, 1f, 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 = GradientHeight; var gradientHeight = 60f;
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 r = bottom.X + (0.0f - bottom.X) * smoothProgress; var g = BottomColor.Y + (0.0f - BottomColor.Y) * smoothProgress;
var g = bottom.Y + (0.0f - bottom.Y) * smoothProgress; var b = BottomColor.Z + (0.0f - BottomColor.Z) * 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),
@@ -331,11 +308,9 @@ 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 baseColor = shootingBase; var cyanColor = new Vector4(0.4f, 0.8f, 1.0f, 1.0f);
for (int t = 1; t < particle.Trail.Count; t++) for (int t = 1; t < particle.Trail.Count; t++)
{ {
@@ -344,18 +319,17 @@ 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(baseColor with { W = glowAlpha }), ImGui.GetColorU32(cyanColor 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(baseColor with { W = trailAlpha }), ImGui.GetColorU32(cyanColor with { W = trailAlpha }),
trailWidth trailWidth
); );
} }
@@ -474,13 +448,6 @@ 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.

View File

@@ -40,10 +40,9 @@ 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(22, 14, 41, 255), ImGuiCol.TitleBg), new("color.titleBg", "Title Background", () => Rgba(24, 24, 24, 232), ImGuiCol.TitleBg),
new("color.titleBgActive", "Title Background (Active)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgActive), new("color.titleBgActive", "Title Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.TitleBgActive),
new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgCollapsed), new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(27, 27, 27, 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),

View File

@@ -4,11 +4,9 @@ using Dalamud.Interface.Colors;
using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Textures.TextureWraps;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Utility.Raii;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions; using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.Group;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
@@ -44,32 +42,13 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
private Task<int>? _pruneTask; private Task<int>? _pruneTask;
private int _pruneDays = 14; private int _pruneDays = 14;
// Ban management fields
private Task<List<BannedGroupUserDto>>? _bannedUsersTask;
private bool _bannedUsersLoaded;
private string? _bannedUsersLoadError;
private string _newBanUid = string.Empty;
private string _newBanReason = string.Empty;
private Task? _newBanTask;
private string? _newBanError;
private DateTime _newBanBusyUntilUtc;
// Ban editing fields
private string? _editingBanUid;
private readonly Dictionary<string, string> _banReasonEdits = new(StringComparer.Ordinal);
private Task? _banEditTask;
private string? _banEditError;
private Task<GroupPruneSettingsDto>? _pruneSettingsTask; private Task<GroupPruneSettingsDto>? _pruneSettingsTask;
private bool _pruneSettingsLoaded; private bool _pruneSettingsLoaded;
private bool _autoPruneEnabled; private bool _autoPruneEnabled;
private int _autoPruneDays = 14; private int _autoPruneDays = 14;
private readonly PairFactory _pairFactory;
public SyncshellAdminUI(ILogger<SyncshellAdminUI> logger, LightlessMediator mediator, ApiController apiController, public SyncshellAdminUI(ILogger<SyncshellAdminUI> logger, LightlessMediator mediator, ApiController apiController,
UiSharedService uiSharedService, PairUiService pairUiService, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager, PairFactory pairFactory) UiSharedService uiSharedService, PairUiService pairUiService, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager)
: base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService) : base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService)
{ {
GroupFullInfo = groupFullInfo; GroupFullInfo = groupFullInfo;
@@ -97,7 +76,6 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
MaximumSize = new(700, 2000), MaximumSize = new(700, 2000),
}; };
_pairUiService = pairUiService; _pairUiService = pairUiService;
_pairFactory = pairFactory;
} }
public GroupFullInfoDto GroupFullInfo { get; private set; } public GroupFullInfoDto GroupFullInfo { get; private set; }
@@ -676,345 +654,34 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
_uiSharedService.MediumText("User Bans", UIColors.Get("LightlessYellow")); _uiSharedService.MediumText("User Bans", UIColors.Get("LightlessYellow"));
ImGuiHelpers.ScaledDummy(3f); ImGuiHelpers.ScaledDummy(3f);
EnsureBanListLoaded(); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Retweet, "Refresh Banlist from Server"))
DrawNewBanEntryRow();
ImGuiHelpers.ScaledDummy(4f);
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Retweet, "Refresh Banlist"))
{ {
QueueBanListRefresh(force: true); _bannedUsers = _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group)).Result;
} }
ImGuiHelpers.ScaledDummy(2f); ImGuiHelpers.ScaledDummy(2f);
if (!_bannedUsersLoaded)
{
UiSharedService.ColorTextWrapped("Loading banlist from server...", ImGuiColors.DalamudGrey);
return;
}
if (!string.IsNullOrWhiteSpace(_bannedUsersLoadError))
{
UiSharedService.ColorTextWrapped(_bannedUsersLoadError!, ImGuiColors.DalamudRed);
return;
}
ImGui.BeginChild("bannedListScroll#" + GroupFullInfo.GID, new Vector2(0, 0), true); ImGui.BeginChild("bannedListScroll#" + GroupFullInfo.GID, new Vector2(0, 0), true);
var style = ImGui.GetStyle(); var style = ImGui.GetStyle();
float fullW = ImGui.GetContentRegionAvail().X; float fullW = ImGui.GetContentRegionAvail().X;
float scale = ImGuiHelpers.GlobalScale;
float frame = ImGui.GetFrameHeight();
float actionIcons = 3;
float colActions = actionIcons * frame + (actionIcons - 1) * style.ItemSpacing.X + 10f * scale;
float colIdentity = fullW * 0.45f;
float colMeta = fullW * 0.35f; float colMeta = fullW * 0.35f;
float colActions = fullW - colIdentity - colMeta - style.ItemSpacing.X * 2.0f;
float colIdentity = fullW - colMeta - colActions - style.ItemSpacing.X * 2.0f; // Header
float minIdentity = fullW * 0.40f;
if (colIdentity < minIdentity)
{
colIdentity = minIdentity;
colMeta = fullW - colIdentity - colActions - style.ItemSpacing.X * 2.0f;
if (colMeta < 80f * scale) colMeta = 80f * scale;
}
DrawBannedListHeader(colIdentity, colMeta); DrawBannedListHeader(colIdentity, colMeta);
int rowIndex = 0; int rowIndex = 0;
foreach (var bannedUser in _bannedUsers.ToList()) foreach (var bannedUser in _bannedUsers.ToList())
{ {
// Each row
DrawBannedRow(bannedUser, rowIndex++, colIdentity, colMeta, colActions); DrawBannedRow(bannedUser, rowIndex++, colIdentity, colMeta, colActions);
} }
ImGui.EndChild(); ImGui.EndChild();
} }
private void DrawNewBanEntryRow()
{
ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessYellow"));
ImGui.TextUnformatted("Add new ban");
ImGui.PopStyleColor();
UiSharedService.TextWrapped("Enter a UID (Not Alias!) and optional reason. (Hold CTRL to enable the ban button.)");
var style = ImGui.GetStyle();
float fullW = ImGui.GetContentRegionAvail().X;
float uidW = fullW * 0.35f;
float reasonW = fullW * 0.50f;
float btnW = fullW - uidW - reasonW - style.ItemSpacing.X * 2f;
// UID
ImGui.SetNextItemWidth(uidW);
ImGui.InputTextWithHint("##newBanUid", "UID...", ref _newBanUid, 128);
// Reason
ImGui.SameLine(0f, style.ItemSpacing.X);
ImGui.SetNextItemWidth(reasonW);
ImGui.InputTextWithHint("##newBanReason", "Reason (optional)...", ref _newBanReason, 256);
// Ban button
ImGui.SameLine(0f, style.ItemSpacing.X);
var trimmedUid = (_newBanUid ?? string.Empty).Trim();
var now = DateTime.UtcNow;
bool taskRunning = _newBanTask != null && !_newBanTask.IsCompleted;
bool busyLatched = now < _newBanBusyUntilUtc;
bool busy = taskRunning || busyLatched;
bool canBan = UiSharedService.CtrlPressed()
&& !string.IsNullOrWhiteSpace(_newBanUid)
&& !busy;
using (ImRaii.Disabled(!canBan))
using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("DimRed")))
{
ImGui.SetNextItemWidth(btnW);
if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserSlash, "Ban"))
{
_newBanError = null;
_newBanBusyUntilUtc = DateTime.UtcNow.AddMilliseconds(750);
_newBanTask = SubmitNewBanByUidAsync(trimmedUid, _newBanReason);
}
}
UiSharedService.AttachToolTip("Hold CTRL to enable banning by UID.");
if (busy)
{
UiSharedService.ColorTextWrapped("Banning user...", ImGuiColors.DalamudGrey);
}
if (_newBanTask != null && _newBanTask.IsCompleted && DateTime.UtcNow >= _newBanBusyUntilUtc)
{
if (_newBanTask.IsFaulted)
{
var _ = _newBanTask.Exception;
_newBanError ??= "Ban failed (see log).";
}
QueueBanListRefresh(force: true);
_newBanTask = null;
}
}
private async Task SubmitNewBanByUidAsync(string uidOrAlias, string reason)
{
try
{
await Task.Yield();
uidOrAlias = (uidOrAlias ?? string.Empty).Trim();
reason = (reason ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(uidOrAlias))
{
_newBanError = "UID is empty.";
return;
}
string targetUid = uidOrAlias;
string? typedAlias = null;
var snap = _pairUiService.GetSnapshot();
if (snap.GroupPairs.TryGetValue(GroupFullInfo, out var pairs))
{
var match = pairs.FirstOrDefault(p =>
string.Equals(p.UserData.UID, uidOrAlias, StringComparison.Ordinal) ||
string.Equals(p.UserData.AliasOrUID, uidOrAlias, StringComparison.OrdinalIgnoreCase));
if (match != null)
{
targetUid = match.UserData.UID;
typedAlias = match.UserData.Alias;
}
else
{
typedAlias = null;
}
}
var userData = new UserData(UID: targetUid, Alias: typedAlias);
await _apiController
.GroupBanUser(new GroupPairDto(GroupFullInfo.Group, userData), reason)
.ConfigureAwait(false);
_newBanUid = string.Empty;
_newBanReason = string.Empty;
_newBanError = null;
QueueBanListRefresh(force: true);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to ban '{uidOrAlias}' in group {gid}", uidOrAlias, GroupFullInfo.Group.GID);
_newBanError = "Failed to ban user (see log).";
}
}
private async Task SaveBanReasonViaBanUserAsync(string uid)
{
try
{
if (!_banReasonEdits.TryGetValue(uid, out var newReason))
newReason = string.Empty;
newReason = (newReason ?? string.Empty).Trim();
var userData = new UserData(uid.Trim());
await _apiController
.GroupBanUser(new GroupPairDto(GroupFullInfo.Group, userData), newReason)
.ConfigureAwait(false);
_editingBanUid = null;
_banEditError = null;
await Task.Delay(450).ConfigureAwait(false);
QueueBanListRefresh(force: true);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to edit ban reason for {uid} in group {gid}", uid, GroupFullInfo.Group.GID);
_banEditError = "Failed to update reason (see log).";
}
}
private void DrawBannedRow(BannedGroupUserDto bannedUser, int rowIndex, float colIdentity, float colMeta, float colActions)
{
using var id = ImRaii.PushId("banRow_" + bannedUser.UID);
var style = ImGui.GetStyle();
float x0 = ImGui.GetCursorPosX();
if (rowIndex % 2 == 0)
{
var drawList = ImGui.GetWindowDrawList();
var pMin = ImGui.GetCursorScreenPos();
var rowHeight = ImGui.GetTextLineHeightWithSpacing() * 2.6f;
var pMax = new Vector2(
pMin.X + colIdentity + colMeta + colActions + style.ItemSpacing.X * 2.0f,
pMin.Y + rowHeight);
var bgColor = UIColors.Get("FullBlack").WithAlpha(0.10f);
drawList.AddRectFilled(pMin, pMax, ImGui.ColorConvertFloat4ToU32(bgColor));
}
ImGui.SetCursorPosX(x0);
ImGui.AlignTextToFramePadding();
string alias = bannedUser.UserAlias ?? string.Empty;
string line1 = string.IsNullOrEmpty(alias)
? bannedUser.UID
: $"{alias} ({bannedUser.UID})";
ImGui.TextUnformatted(line1);
var fullReason = bannedUser.Reason ?? string.Empty;
if (string.Equals(_editingBanUid, bannedUser.UID, StringComparison.Ordinal))
{
_banReasonEdits.TryGetValue(bannedUser.UID, out var editReason);
editReason ??= StripAliasSuffix(fullReason);
ImGui.SetCursorPosX(x0);
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey);
ImGui.SetNextItemWidth(colIdentity);
ImGui.InputTextWithHint("##banReasonEdit", "Reason...", ref editReason, 255);
ImGui.PopStyleColor();
_banReasonEdits[bannedUser.UID] = editReason;
if (!string.IsNullOrWhiteSpace(_banEditError))
UiSharedService.ColorTextWrapped(_banEditError!, ImGuiColors.DalamudRed);
}
else
{
if (!string.IsNullOrWhiteSpace(fullReason))
{
ImGui.SetCursorPosX(x0);
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey);
ImGui.PushTextWrapPos(x0 + colIdentity);
UiSharedService.TextWrapped(fullReason);
ImGui.PopTextWrapPos();
ImGui.PopStyleColor();
}
}
ImGui.SameLine();
ImGui.SetCursorPosX(x0 + colIdentity + style.ItemSpacing.X);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted($"By: {bannedUser.BannedBy}");
var dateText = bannedUser.BannedOn.ToLocalTime().ToString(CultureInfo.CurrentCulture);
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey);
ImGui.TextUnformatted(dateText);
ImGui.PopStyleColor();
ImGui.SameLine();
float frame = ImGui.GetFrameHeight();
float actionsX0 = x0 + colIdentity + colMeta + style.ItemSpacing.X * 2.0f;
ImGui.SameLine();
ImGui.SetCursorPosX(actionsX0);
bool isEditing = string.Equals(_editingBanUid, bannedUser.UID, StringComparison.Ordinal);
int actionCount = 1 + (isEditing ? 2 : 1);
float totalW = actionCount * frame + (actionCount - 1) * style.ItemSpacing.X;
float startX = actionsX0 + MathF.Max(0, colActions - totalW) - 36f;
ImGui.SetCursorPosX(startX);
if (_uiSharedService.IconButton(FontAwesomeIcon.Check))
{
_apiController.GroupUnbanUser(bannedUser);
_bannedUsers.RemoveAll(b => string.Equals(b.UID, bannedUser.UID, StringComparison.Ordinal));
}
UiSharedService.AttachToolTip("Unban");
ImGui.SameLine(0f, style.ItemSpacing.X);
if (!isEditing)
{
if (_uiSharedService.IconButton(FontAwesomeIcon.Edit))
{
_banEditError = null;
_editingBanUid = bannedUser.UID;
_banReasonEdits[bannedUser.UID] = StripAliasSuffix(bannedUser.Reason ?? string.Empty);
}
UiSharedService.AttachToolTip("Edit reason");
}
else
{
if (_uiSharedService.IconButton(FontAwesomeIcon.Save))
{
_banEditError = null;
_banEditTask = SaveBanReasonViaBanUserAsync(bannedUser.UID);
}
UiSharedService.AttachToolTip("Save");
ImGui.SameLine(0f, style.ItemSpacing.X);
if (_uiSharedService.IconButton(FontAwesomeIcon.Times))
{
_banEditError = null;
_editingBanUid = null;
}
UiSharedService.AttachToolTip("Cancel");
}
}
private void DrawInvites(GroupPermissions perm) private void DrawInvites(GroupPermissions perm)
{ {
var inviteTab = ImRaii.TabItem("Invites"); var inviteTab = ImRaii.TabItem("Invites");
@@ -1235,9 +902,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
if (buttonCount == 0) if (buttonCount == 0)
return; return;
float totalWidth = _isOwner float totalWidth = buttonCount * frameH + (buttonCount - 1) * style.ItemSpacing.X;
? buttonCount * frameH + buttonCount * style.ItemSpacing.X + 20f
: buttonCount * frameH + buttonCount * style.ItemSpacing.X;
float curX = ImGui.GetCursorPosX(); float curX = ImGui.GetCursorPosX();
float avail = ImGui.GetContentRegionAvail().X; float avail = ImGui.GetContentRegionAvail().X;
@@ -1366,40 +1031,69 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.0f); UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.0f);
} }
private void QueueBanListRefresh(bool force = false) private void DrawBannedRow(BannedGroupUserDto bannedUser, int rowIndex, float colIdentity, float colMeta, float colActions)
{ {
if (!force) using var id = ImRaii.PushId("banRow_" + bannedUser.UID);
var style = ImGui.GetStyle();
float x0 = ImGui.GetCursorPosX();
if (rowIndex % 2 == 0)
{ {
if (_bannedUsersTask != null && !_bannedUsersTask.IsCompleted) var drawList = ImGui.GetWindowDrawList();
return; var pMin = ImGui.GetCursorScreenPos();
var rowHeight = ImGui.GetTextLineHeightWithSpacing() * 2.6f;
var pMax = new Vector2(
pMin.X + colIdentity + colMeta + colActions + style.ItemSpacing.X * 2.0f,
pMin.Y + rowHeight);
var bgColor = UIColors.Get("FullBlack").WithAlpha(0.10f);
drawList.AddRectFilled(pMin, pMax, ImGui.ColorConvertFloat4ToU32(bgColor));
} }
_bannedUsersLoaded = false; ImGui.SetCursorPosX(x0);
_bannedUsersLoadError = null; ImGui.AlignTextToFramePadding();
_bannedUsersTask = _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group)); string alias = bannedUser.UserAlias ?? string.Empty;
string line1 = string.IsNullOrEmpty(alias)
? bannedUser.UID
: $"{alias} ({bannedUser.UID})";
ImGui.TextUnformatted(line1);
var reason = bannedUser.Reason ?? string.Empty;
if (!string.IsNullOrWhiteSpace(reason))
{
var reasonPos = new Vector2(x0, ImGui.GetCursorPosY());
ImGui.SetCursorPos(reasonPos);
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey);
UiSharedService.TextWrapped(reason);
ImGui.PopStyleColor();
} }
private void EnsureBanListLoaded() ImGui.SameLine();
ImGui.SetCursorPosX(x0 + colIdentity + style.ItemSpacing.X);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted($"By: {bannedUser.BannedBy}");
var dateText = bannedUser.BannedOn.ToLocalTime().ToString(CultureInfo.CurrentCulture);
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey);
ImGui.TextUnformatted(dateText);
ImGui.PopStyleColor();
ImGui.SameLine();
ImGui.SetCursorPosX(x0 + colIdentity + colMeta + style.ItemSpacing.X * 2.0f);
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Check, "Unban"))
{ {
_bannedUsersTask ??= _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group)); _apiController.GroupUnbanUser(bannedUser);
_bannedUsers.RemoveAll(b => string.Equals(b.UID, bannedUser.UID, StringComparison.Ordinal));
if (_bannedUsersLoaded || _bannedUsersTask == null)
return;
if (!_bannedUsersTask.IsCompleted)
return;
if (_bannedUsersTask.IsFaulted || _bannedUsersTask.IsCanceled)
{
_bannedUsersLoadError = "Failed to load banlist from server.";
_bannedUsers = [];
_bannedUsersLoaded = true;
return;
} }
_bannedUsers = _bannedUsersTask.GetAwaiter().GetResult() ?? []; UiSharedService.AttachToolTip($"Unban {alias} ({bannedUser.UID}) from this Syncshell");
_bannedUsersLoaded = true;
ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale));
} }
private void SavePruneSettings() private void SavePruneSettings()
@@ -1422,13 +1116,6 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
} }
} }
private static string StripAliasSuffix(string reason)
{
const string marker = " (Alias at time of ban:";
var idx = reason.IndexOf(marker, StringComparison.Ordinal);
return idx >= 0 ? reason[..idx] : reason;
}
private static bool MatchesUserFilter(Pair pair, string filterLower) private static bool MatchesUserFilter(Pair pair, string filterLower)
{ {
var note = pair.GetNote() ?? string.Empty; var note = pair.GetNote() ?? string.Empty;
@@ -1440,11 +1127,6 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|| alias.Contains(filterLower, StringComparison.OrdinalIgnoreCase); || alias.Contains(filterLower, StringComparison.OrdinalIgnoreCase);
} }
public override void OnOpen()
{
base.OnOpen();
QueueBanListRefresh(force: true);
}
public override void OnClose() public override void OnClose()
{ {
Mediator.Publish(new RemoveWindowMessage(this)); Mediator.Publish(new RemoveWindowMessage(this));

View File

@@ -0,0 +1,855 @@
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;
}
}

View File

@@ -162,32 +162,24 @@ 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))
{ {
_lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); TabSelection = TabSelection == SelectedTab.Lightfinder ? SelectedTab.None : SelectedTab.Lightfinder;
} }
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))
@@ -242,7 +234,10 @@ 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);
@@ -782,21 +777,52 @@ public class TopTabMenu
} }
} }
private int GetNearbySyncshellCount() private void DrawLightfinderMenu(float availableWidth, float spacingX)
{
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 0; return "Syncshell Finder";
var myHashedCid = _dalamudUtilService.GetCID().ToString().GetHash256(); var nearbyCount = _lightFinderScannerService
.GetActiveSyncshellBroadcasts(excludeLocal: true)
return _lightFinderScannerService .Where(b => !string.IsNullOrEmpty(b.GID))
.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)

View File

@@ -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,12 +31,6 @@ 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;
@@ -51,7 +45,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);
@@ -59,7 +53,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)
@@ -89,7 +83,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);
@@ -102,7 +96,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)

View File

@@ -79,7 +79,6 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
private readonly Dictionary<string, DateTime> _oauthTokenExpiry = []; private readonly Dictionary<string, DateTime> _oauthTokenExpiry = [];
private bool _penumbraExists = false; private bool _penumbraExists = false;
private bool _petNamesExists = false; private bool _petNamesExists = false;
private bool _lifestreamExists = false;
private int _serverSelectionIndex = -1; private int _serverSelectionIndex = -1;
public UiSharedService(ILogger<UiSharedService> logger, IpcManager ipcManager, ApiController apiController, public UiSharedService(ILogger<UiSharedService> logger, IpcManager ipcManager, ApiController apiController,
CacheMonitor cacheMonitor, FileDialogManager fileDialogManager, CacheMonitor cacheMonitor, FileDialogManager fileDialogManager,
@@ -113,7 +112,6 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
_moodlesExists = _ipcManager.Moodles.APIAvailable; _moodlesExists = _ipcManager.Moodles.APIAvailable;
_petNamesExists = _ipcManager.PetNames.APIAvailable; _petNamesExists = _ipcManager.PetNames.APIAvailable;
_brioExists = _ipcManager.Brio.APIAvailable; _brioExists = _ipcManager.Brio.APIAvailable;
_lifestreamExists = _ipcManager.Lifestream.APIAvailable;
}); });
UidFont = _pluginInterface.UiBuilder.FontAtlas.NewDelegateFontHandle(e => UidFont = _pluginInterface.UiBuilder.FontAtlas.NewDelegateFontHandle(e =>
@@ -1107,10 +1105,6 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
ColorText("Brio", GetBoolColor(_brioExists)); ColorText("Brio", GetBoolColor(_brioExists));
AttachToolTip(BuildPluginTooltip("Brio", _brioExists, _ipcManager.Brio.State)); AttachToolTip(BuildPluginTooltip("Brio", _brioExists, _ipcManager.Brio.State));
ImGui.SameLine();
ColorText("Lifestream", GetBoolColor(_lifestreamExists));
AttachToolTip(BuildPluginTooltip("Lifestream", _lifestreamExists, _ipcManager.Lifestream.State));
if (!_penumbraExists || !_glamourerExists) if (!_penumbraExists || !_glamourerExists)
{ {
ImGui.TextColored(ImGuiColors.DalamudRed, "You need to install both Penumbra and Glamourer and keep them up to date to use Lightless Sync."); ImGui.TextColored(ImGuiColors.DalamudRed, "You need to install both Penumbra and Glamourer and keep them up to date to use Lightless Sync.");

View File

@@ -40,7 +40,6 @@ 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;
@@ -50,7 +49,6 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase
PositionCondition = ImGuiCond.Always; PositionCondition = ImGuiCond.Always;
WindowBuilder.For(this) WindowBuilder.For(this)
.AllowPinning(false) .AllowPinning(false)
.AllowClickthrough(false) .AllowClickthrough(false)

View File

@@ -29,10 +29,6 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
private readonly TextureMetadataHelper _textureMetadataHelper; private readonly TextureMetadataHelper _textureMetadataHelper;
private readonly ConcurrentDictionary<ThrottledStream, byte> _activeDownloadStreams; private readonly ConcurrentDictionary<ThrottledStream, byte> _activeDownloadStreams;
private readonly SemaphoreSlim _decompressGate =
new(Math.Max(1, Environment.ProcessorCount / 2), Math.Max(1, Environment.ProcessorCount / 2));
private readonly ConcurrentQueue<string> _deferredCompressionQueue = new();
private volatile bool _disableDirectDownloads; private volatile bool _disableDirectDownloads;
private int _consecutiveDirectDownloadFailures; private int _consecutiveDirectDownloadFailures;
@@ -397,33 +393,77 @@ 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; {
try
{
await Task.Delay(250, composite.Token).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
if (downloadCt.IsCancellationRequested) throw;
using var resp = await _orchestrator.SendRequestAsync( var req = await _orchestrator.SendRequestAsync(
HttpMethod.Get, HttpMethod.Get,
LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId), LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId),
downloadFileTransfer.Select(t => t.Hash).ToList(), downloadFileTransfer.Select(c => c.Hash).ToList(),
downloadCt).ConfigureAwait(false); downloadCt).ConfigureAwait(false);
resp.EnsureSuccessStatusCode(); req.EnsureSuccessStatusCode();
var body = (await resp.Content.ReadAsStringAsync(downloadCt).ConfigureAwait(false)).Trim(); localTimeoutCts.Dispose();
if (string.Equals(body, "true", StringComparison.OrdinalIgnoreCase) || composite.Dispose();
body.Contains("\"ready\":true", StringComparison.OrdinalIgnoreCase))
localTimeoutCts = new();
localTimeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
composite = CancellationTokenSource.CreateLinkedTokenSource(downloadCt, localTimeoutCts.Token);
}
}
localTimeoutCts.Dispose();
composite.Dispose();
Logger.LogDebug("Download {requestId} ready", requestId);
}
catch (TaskCanceledException)
{ {
break; try
{
await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCancelFullPath(downloadFileTransfer[0].DownloadUri, requestId))
.ConfigureAwait(false);
alreadyCancelled = true;
}
catch
{
// ignore
} }
await Task.Delay(250, downloadCt).ConfigureAwait(false); 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); _orchestrator.ClearDownloadRequest(requestId);
} }
}
private async Task DownloadQueuedBlockFileAsync( private async Task DownloadQueuedBlockFileAsync(
string statusKey, string statusKey,
@@ -475,9 +515,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))
@@ -489,9 +531,11 @@ 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);
@@ -503,36 +547,13 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
&& expectedRawSize > 0 && expectedRawSize > 0
&& decompressed.LongLength != expectedRawSize) && decompressed.LongLength != expectedRawSize)
{ {
await _fileCompactor.WriteAllBytesAsync(filePath, Array.Empty<byte>(), ct).ConfigureAwait(false); Logger.LogWarning("{dlName}: Decompressed size mismatch for {fileHash} (expected {expected}, got {actual})",
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation); downloadLabel, fileHash, expectedRawSize, decompressed.LongLength);
continue; continue;
} }
MungeBuffer(compressed); await _fileCompactor.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false);
await _decompressGate.WaitAsync(ct).ConfigureAwait(false);
try
{
// offload CPU-intensive decompression to threadpool to free up worker
await Task.Run(async () =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
// decompress
var decompressed = LZ4Wrapper.Unwrap(compressed);
Logger.LogTrace("{dlName}: Unwrap {fileHash} took {ms}ms (compressed {c} bytes, decompressed {d} bytes)",
downloadLabel, fileHash, sw.ElapsedMilliseconds, compressed.Length, decompressed?.Length ?? -1);
// write to file without compacting during download
await File.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false);
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation); PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation);
}, ct).ConfigureAwait(false);
}
finally
{
_decompressGate.Release();
}
} }
catch (EndOfStreamException) catch (EndOfStreamException)
{ {
@@ -715,16 +736,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
@@ -740,9 +753,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();
} }
@@ -773,6 +783,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
@@ -968,12 +979,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) ?? [];
} }
@@ -993,10 +1006,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.CreateCacheEntryWithKnownHash(filePath, fileHash); var entry = _fileDbManager.CreateCacheEntryWithKnownHash(filePath, fileHash);
@@ -1031,52 +1040,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;

View File

@@ -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, )"
} }
}, },