Compare commits
12 Commits
2.0.2.76-D
...
just-exper
| Author | SHA1 | Date | |
|---|---|---|---|
| 17dd8a307b | |||
| 24d0c38f59 | |||
| 4da2548e03 | |||
| b6b9c81a57 | |||
| a824d94ffe | |||
| 4b13dfe8d4 | |||
| 979443d9bb | |||
| 92cb861710 | |||
| aeed8503c2 | |||
| 44bb53023e | |||
| 05b91ed243 | |||
| cfc5c1e0f3 |
@@ -27,7 +27,6 @@ public sealed class FileCacheManager : IHostedService
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, FileCacheEntity>> _fileCaches = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, FileCacheEntity> _fileCachesByPrefixedPath = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly SemaphoreSlim _getCachesByPathsSemaphore = new(1, 1);
|
||||
private readonly SemaphoreSlim _evictSemaphore = new(1, 1);
|
||||
private readonly Lock _fileWriteLock = new();
|
||||
private readonly IpcManager _ipcManager;
|
||||
private readonly ILogger<FileCacheManager> _logger;
|
||||
@@ -227,23 +226,13 @@ public sealed class FileCacheManager : IHostedService
|
||||
var compressed = LZ4Wrapper.WrapHC(raw, 0, raw.Length);
|
||||
|
||||
var tmpPath = compressedPath + ".tmp";
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false);
|
||||
File.Move(tmpPath, compressedPath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { /* ignore */ }
|
||||
}
|
||||
await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false);
|
||||
File.Move(tmpPath, compressedPath, overwrite: true);
|
||||
|
||||
var compressedSize = new FileInfo(compressedPath).Length;
|
||||
var compressedSize = compressed.LongLength;
|
||||
SetSizeInfo(hash, originalSize, compressedSize);
|
||||
UpdateEntitiesSizes(hash, originalSize, compressedSize);
|
||||
|
||||
var maxBytes = GiBToBytes(_configService.Current.MaxLocalCacheInGiB);
|
||||
await EnforceCacheLimitAsync(maxBytes, token).ConfigureAwait(false);
|
||||
|
||||
return compressed;
|
||||
}
|
||||
finally
|
||||
@@ -942,83 +931,6 @@ public sealed class FileCacheManager : IHostedService
|
||||
}, token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task EnforceCacheLimitAsync(long maxBytes, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(CacheFolder) || maxBytes <= 0) return;
|
||||
|
||||
await _evictSemaphore.WaitAsync(token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(CacheFolder);
|
||||
|
||||
foreach (var tmp in Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension + ".tmp"))
|
||||
{
|
||||
try { File.Delete(tmp); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
var files = Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension, SearchOption.TopDirectoryOnly)
|
||||
.Select(p => new FileInfo(p))
|
||||
.Where(fi => fi.Exists)
|
||||
.OrderBy(fi => fi.LastWriteTimeUtc)
|
||||
.ToList();
|
||||
|
||||
long total = files.Sum(f => f.Length);
|
||||
if (total <= maxBytes) return;
|
||||
|
||||
foreach (var fi in files)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (total <= maxBytes) break;
|
||||
|
||||
var hash = Path.GetFileNameWithoutExtension(fi.Name);
|
||||
|
||||
try
|
||||
{
|
||||
var len = fi.Length;
|
||||
fi.Delete();
|
||||
total -= len;
|
||||
_sizeCache.TryRemove(hash, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to evict cache file {file}", fi.FullName);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_evictSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static long GiBToBytes(double gib)
|
||||
{
|
||||
if (double.IsNaN(gib) || double.IsInfinity(gib) || gib <= 0)
|
||||
return 0;
|
||||
|
||||
var bytes = gib * 1024d * 1024d * 1024d;
|
||||
|
||||
if (bytes >= long.MaxValue) return long.MaxValue;
|
||||
|
||||
return (long)Math.Round(bytes, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private void CleanupOrphanCompressedCache()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(CacheFolder) || !Directory.Exists(CacheFolder))
|
||||
return;
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension))
|
||||
{
|
||||
var hash = Path.GetFileNameWithoutExtension(path);
|
||||
if (!_fileCaches.ContainsKey(hash))
|
||||
{
|
||||
try { File.Delete(path); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Failed deleting orphan {file}", path); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting FileCacheManager");
|
||||
@@ -1202,8 +1114,6 @@ public sealed class FileCacheManager : IHostedService
|
||||
{
|
||||
await WriteOutFullCsvAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
CleanupOrphanCompressedCache();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Started FileCacheManager");
|
||||
|
||||
@@ -297,7 +297,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
|
||||
private void DalamudUtil_FrameworkUpdate()
|
||||
{
|
||||
_ = Task.Run(() => RefreshPlayerRelatedAddressMap());
|
||||
RefreshPlayerRelatedAddressMap();
|
||||
|
||||
lock (_cacheAdditionLock)
|
||||
{
|
||||
@@ -306,64 +306,20 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
|
||||
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)
|
||||
_lastClassJobId = _dalamudUtil.ClassJobId;
|
||||
if (SemiTransientResources.TryGetValue(ObjectKind.Pet, out HashSet<string>? value))
|
||||
{
|
||||
var address = (nint)handler.Address;
|
||||
if (address != nint.Zero)
|
||||
{
|
||||
tempMap[address] = handler;
|
||||
updatedFrameAddresses[address] = handler.ObjectKind;
|
||||
}
|
||||
value?.Clear();
|
||||
}
|
||||
|
||||
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
|
||||
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
|
||||
SemiTransientResources[ObjectKind.Pet] = new HashSet<string>(
|
||||
petSpecificData ?? [],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
_playerRelatedByAddress.Clear();
|
||||
foreach (var kvp in tempMap)
|
||||
{
|
||||
_playerRelatedByAddress[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
_cachedFrameAddresses.Clear();
|
||||
foreach (var kvp in updatedFrameAddresses)
|
||||
{
|
||||
_cachedFrameAddresses[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateClassJobCache()
|
||||
{
|
||||
_lastClassJobId = _dalamudUtil.ClassJobId;
|
||||
if (SemiTransientResources.TryGetValue(ObjectKind.Pet, out HashSet<string>? value))
|
||||
{
|
||||
value?.Clear();
|
||||
}
|
||||
|
||||
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
|
||||
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache
|
||||
.Concat(jobSpecificData ?? [])
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
|
||||
SemiTransientResources[ObjectKind.Pet] = new HashSet<string>(
|
||||
petSpecificData ?? [],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void CleanupAbsentObjects()
|
||||
{
|
||||
foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast<ObjectKind>())
|
||||
{
|
||||
if (!_cachedFrameAddresses.Any(k => k.Value == kind) && TransientResources.Remove(kind, out _))
|
||||
@@ -393,6 +349,26 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
_semiTransientResources = null;
|
||||
}
|
||||
|
||||
private void RefreshPlayerRelatedAddressMap()
|
||||
{
|
||||
_playerRelatedByAddress.Clear();
|
||||
var updatedFrameAddresses = new ConcurrentDictionary<nint, ObjectKind>();
|
||||
lock (_playerRelatedLock)
|
||||
{
|
||||
foreach (var handler in _playerRelatedPointers)
|
||||
{
|
||||
var address = (nint)handler.Address;
|
||||
if (address != nint.Zero)
|
||||
{
|
||||
_playerRelatedByAddress[address] = handler;
|
||||
updatedFrameAddresses[address] = handler.ObjectKind;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_cachedFrameAddresses = updatedFrameAddresses;
|
||||
}
|
||||
|
||||
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
|
||||
{
|
||||
if (descriptor.IsInGpose)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace Lifestream.Enums;
|
||||
|
||||
public enum ResidentialAetheryteKind
|
||||
{
|
||||
None = -1,
|
||||
Uldah = 9,
|
||||
Gridania = 2,
|
||||
Limsa = 8,
|
||||
Foundation = 70,
|
||||
Kugane = 111,
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,7 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
|
||||
|
||||
public IpcManager(ILogger<IpcManager> logger, LightlessMediator mediator,
|
||||
IpcCallerPenumbra penumbraIpc, IpcCallerGlamourer glamourerIpc, IpcCallerCustomize customizeIpc, IpcCallerHeels heelsIpc,
|
||||
IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio,
|
||||
IpcCallerLifestream ipcCallerLifestream) : base(logger, mediator)
|
||||
IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio) : base(logger, mediator)
|
||||
{
|
||||
CustomizePlus = customizeIpc;
|
||||
Heels = heelsIpc;
|
||||
@@ -20,7 +19,6 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
|
||||
Moodles = moodlesIpc;
|
||||
PetNames = ipcCallerPetNames;
|
||||
Brio = ipcCallerBrio;
|
||||
Lifestream = ipcCallerLifestream;
|
||||
|
||||
_wasInitialized = Initialized;
|
||||
if (_wasInitialized)
|
||||
@@ -49,8 +47,8 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
|
||||
public IpcCallerPenumbra Penumbra { get; }
|
||||
public IpcCallerMoodles Moodles { get; }
|
||||
public IpcCallerPetNames PetNames { get; }
|
||||
|
||||
public IpcCallerBrio Brio { get; }
|
||||
public IpcCallerLifestream Lifestream { get; }
|
||||
|
||||
private void PeriodicApiStateCheck()
|
||||
{
|
||||
@@ -71,6 +69,5 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
|
||||
}
|
||||
|
||||
_wasInitialized = initialized;
|
||||
Lifestream.CheckAPI();
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.UI;
|
||||
using LightlessSync.UI.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using LightlessSync.PlayerData.Factories;
|
||||
|
||||
namespace LightlessSync.LightlessConfiguration.Configurations;
|
||||
|
||||
@@ -157,10 +156,5 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
public bool SyncshellFinderEnabled { get; set; } = false;
|
||||
public string? SelectedFinderSyncshell { get; set; } = null;
|
||||
public string LastSeenVersion { get; set; } = string.Empty;
|
||||
public bool EnableParticleEffects { get; set; } = true;
|
||||
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
|
||||
public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Safe;
|
||||
public bool AnimationAllowOneBasedShift { get; set; } = true;
|
||||
|
||||
public bool AnimationAllowNeighborIndexTolerance { get; set; } = false;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors></Authors>
|
||||
<Company></Company>
|
||||
<Version>2.0.2.76</Version>
|
||||
<Version>2.0.3</Version>
|
||||
<Description></Description>
|
||||
<Copyright></Copyright>
|
||||
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>
|
||||
@@ -24,15 +24,6 @@
|
||||
<Compile Remove="PlayerData\Export\**" />
|
||||
<EmbeddedResource Remove="PlayerData\Export\**" />
|
||||
<None Remove="PlayerData\Export\**" />
|
||||
<EmbeddedResource Update="Resources\Resources.resx">
|
||||
<Generator>PublicResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
<Compile Update="Resources\Resources.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Resources.resx</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -77,6 +68,8 @@
|
||||
</None>
|
||||
<EmbeddedResource Include="Changelog\changelog.yaml" />
|
||||
<EmbeddedResource Include="Changelog\credits.yaml" />
|
||||
<EmbeddedResource Include="Localization\de.json" />
|
||||
<EmbeddedResource Include="Localization\fr.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||
<s:String x:Key="/Default/CodeEditing/Localization/Localizable/@EntryValue">Yes</s:String>
|
||||
<s:String x:Key="/Default/CodeEditing/Localization/LocalizableInspector/@EntryValue">Pessimistic</s:String></wpf:ResourceDictionary>
|
||||
44
LightlessSync/Localization/Strings.cs
Normal file
44
LightlessSync/Localization/Strings.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using CheapLoc;
|
||||
|
||||
namespace LightlessSync.Localization;
|
||||
|
||||
public static class Strings
|
||||
{
|
||||
public static ToSStrings ToS { get; set; } = new();
|
||||
|
||||
public class ToSStrings
|
||||
{
|
||||
public readonly string AgreeLabel = Loc.Localize("AgreeLabel", "I agree");
|
||||
public readonly string AgreementLabel = Loc.Localize("AgreementLabel", "Agreement of Usage of Service");
|
||||
public readonly string ButtonWillBeAvailableIn = Loc.Localize("ButtonWillBeAvailableIn", "'I agree' button will be available in");
|
||||
public readonly string LanguageLabel = Loc.Localize("LanguageLabel", "Language");
|
||||
|
||||
public readonly string Paragraph1 = Loc.Localize("Paragraph1",
|
||||
"All of the mod files currently active on your character as well as your current character state will be uploaded to the service you registered yourself at automatically. " +
|
||||
"The plugin will exclusively upload the necessary mod files and not the whole mod.");
|
||||
|
||||
public readonly string Paragraph2 = Loc.Localize("Paragraph2",
|
||||
"If you are on a data capped internet connection, higher fees due to data usage depending on the amount of downloaded and uploaded mod files might occur. " +
|
||||
"Mod files will be compressed on up- and download to save on bandwidth usage. Due to varying up- and download speeds, changes in characters might not be visible immediately. " +
|
||||
"Files present on the service that already represent your active mod files will not be uploaded again.");
|
||||
|
||||
public readonly string Paragraph3 = Loc.Localize("Paragraph3",
|
||||
"The mod files you are uploading are confidential and will not be distributed to parties other than the ones who are requesting the exact same mod files. " +
|
||||
"Please think about who you are going to pair since it is unavoidable that they will receive and locally cache the necessary mod files that you have currently in use. " +
|
||||
"Locally cached mod files will have arbitrary file names to discourage attempts at replicating the original mod.");
|
||||
|
||||
public readonly string Paragraph4 = Loc.Localize("Paragraph4",
|
||||
"The plugin creator tried their best to keep you secure. However, there is no guarantee for 100% security. Do not blindly pair your client with everyone.");
|
||||
|
||||
public readonly string Paragraph5 = Loc.Localize("Paragraph5",
|
||||
"Mod files that are saved on the service will remain on the service as long as there are requests for the files from clients. " +
|
||||
"After a period of not being used, the mod files will be automatically deleted. " +
|
||||
"You will also be able to wipe all the files you have personally uploaded on request. " +
|
||||
"The service holds no information about which mod files belong to which mod.");
|
||||
|
||||
public readonly string Paragraph6 = Loc.Localize("Paragraph6",
|
||||
"This service is provided as-is. In case of abuse join the Lightless Sync Discord.");
|
||||
|
||||
public readonly string ReadLabel = Loc.Localize("ReadLabel", "READ THIS CAREFULLY");
|
||||
}
|
||||
}
|
||||
46
LightlessSync/Localization/de.json
Normal file
46
LightlessSync/Localization/de.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"LanguageLabel": {
|
||||
"message": "Language",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"AgreementLabel": {
|
||||
"message": "Nutzungsbedingungen",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"ReadLabel": {
|
||||
"message": "BITTE LIES DIES SORGFÄLTIG",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"Paragraph1": {
|
||||
"message": "Alle Moddateien, die aktuell auf deinem Charakter aktiv sind und dein Charakterzustand werden automatisch zu dem Service, an dem du dich registriert hast, hochgeladen. Das Plugin wird ausschließlich die nötigen Moddateien hochladen und nicht die gesamte Modifikation.",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"Paragraph2": {
|
||||
"message": "Falls du mit einer getakteten Internetverbindung verbunden bist, können durch den Datentransfer von Hoch- und Runtergeladenen Moddateien höhere Kosten entstehen. Moddateien werden beim Hoch- und Runterladen komprimiert um Bandbreite zu sparen. Durch unterschiedliche Hoch- und Runterladgeschwindigkeiten ist es möglich, dass Änderungen an Charakteren nicht sofort sichtbar sind. Dateien die bereits auf dem Service existieren, werden nicht nochmals hochgeladen.",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"Paragraph3": {
|
||||
"message": "Die Moddateien die du hochlädst sind vertraulich und werden nicht mit anderen Nutzern geteilt, die nicht die exakt selben Dateien anfordern. Bitte überlege dir sorgfältig mit wem du deinen Identifikationscode teilst, da es unvermeidlich ist, dass die andere Person deine Moddateien erhält und lokal zwischenspeichert. Lokal zwischengespeicherte Dateien haben willkürrliche Namen um vor Versuchen abzuschrecken die originalen Moddateien aus diesen wiederherzustellen.",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"Paragraph4": {
|
||||
"message": "Der Ersteller des Plugins hat sein Bestes getan, um deine Sicherheit zu gewährleisten. Es gibt jedoch keine Garantie für 100%ige Sicherheit. Teile deinen Identifikationscode nicht blind mit jedem.",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"Paragraph5": {
|
||||
"message": "Moddateien, die auf dem Service gespeichert sind, verbleiben auf dem Service, solange es Anforderungen für diese Dateien gibt. Nach einer Zeitspanne in der die Dateien nicht verwendet wurden, werden diese automatisch gelöscht. Du hast auch die Möglichkeit manuell alle Dateien auf dem Service zu löschen. Der Service hat keine Informationen welche Moddateien zu welcher Modifikation gehören.",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"Paragraph6": {
|
||||
"message": "Dieser Dienst wird ohne Gewähr angeboten. Im Falle eines Missbrauchs tretet dem Lightless Sync Discord bei.",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"AgreeLabel": {
|
||||
"message": "Ich Stimme zu",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"ButtonWillBeAvailableIn": {
|
||||
"message": "\"Ich stimme zu\" Knopf verfügbar in",
|
||||
"description": "ToSStrings..ctor"
|
||||
}
|
||||
}
|
||||
46
LightlessSync/Localization/fr.json
Normal file
46
LightlessSync/Localization/fr.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"LanguageLabel": {
|
||||
"message": "Language",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"AgreementLabel": {
|
||||
"message": "Conditions d'Utilisation",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"ReadLabel": {
|
||||
"message": "LISEZ CES INFORMATIONS ATTENTIVEMENT",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"Paragraph1": {
|
||||
"message": "Tous les fichiers moddés actuellement en cours d'utilisation ainsi que le statut actuel de votre personnage vont être mix en ligne via le service sur lequel vous vous êtes automatiquement enregistré. Seuls les fichiers nécessaires seront téléversés par le plugin et non pas le mod en entier.",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"Paragraph2": {
|
||||
"message": "Si le débit de votre connexion internet est limité, le téléchargement et téléversement d'un grand nombre de fichiers peut entraîner des coûts supplémentaires. Les fichiers seront compressés au chargement et versement pour réduire l'impact sur votre bande passants. Selon la rapidité de vos téléchargements et téléversements, les changements ne seront peut-être pas visibles instantanément sur les personnages. Les fichiers déja présents sur le service qui correspondent à ceux de vos mods en cours d'utilisation ne seront pas remis en ligne.",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"Paragraph3": {
|
||||
"message": "Les fichiers que vous allez partager sont confidentiels et ne seront envoyés qu'aux utilisateurs qui feront une requête exacte de ceux-çi. Nous vous demandons de (re)considérer qui sera synchronisé avec vous, puisqu'ils recevront et stockeront inévitablement en local les fichiers nécéssaires utilisés à cet instant. Les noms des fichiers stockés localement sont changés de manière arbitraire afin de décourager toute tentative de réplication des originaux.",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"Paragraph4": {
|
||||
"message": "Le créateur de ce plugin a tenté de sécuriser l'application du mieux possible. Cependant, il ne peut pas garantir une protection 100% infaillible. Pour votre sécurité, ne vous synchronisez pas aveuglément et avec n'importe qui.",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"Paragraph5": {
|
||||
"message": "Les fichiers sauvegardés sur le service resteront en ligne tant que des utilisateurs en feront usage. Ils seront effacés automatiquement après une certaine période d'inactivité. Vous pouvez également demander l'effacement de tous les fichiers que vous avez mis en ligne vous-même. Le service en soi ne contient aucune information pouvant identifier quel fichier appartient à quel mod.",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"Paragraph6": {
|
||||
"message": "Ce service et ses composants vous sont fournis en l'état. En cas d'abus rejoindre le serveur Discord Lightless Sync.",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"AgreeLabel": {
|
||||
"message": "J'accept",
|
||||
"description": "ToSStrings..ctor"
|
||||
},
|
||||
"ButtonWillBeAvailableIn": {
|
||||
"message": "Bouton \"J'accept\" disposible dans",
|
||||
"description": "ToSStrings..ctor"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace LightlessSync.PlayerData.Factories
|
||||
{
|
||||
public enum AnimationValidationMode
|
||||
{
|
||||
Unsafe = 0,
|
||||
Safe = 1,
|
||||
Safest = 2,
|
||||
}
|
||||
}
|
||||
@@ -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.FileCache;
|
||||
using LightlessSync.Interop.Ipc;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.PlayerData.Data;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace LightlessSync.PlayerData.Factories;
|
||||
|
||||
@@ -24,34 +18,13 @@ public class PlayerDataFactory
|
||||
private readonly IpcManager _ipcManager;
|
||||
private readonly ILogger<PlayerDataFactory> _logger;
|
||||
private readonly PerformanceCollectorService _performanceCollector;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly XivDataAnalyzer _modelAnalyzer;
|
||||
private readonly LightlessMediator _lightlessMediator;
|
||||
private readonly TransientResourceManager _transientResourceManager;
|
||||
private static readonly SemaphoreSlim _papParseLimiter = new(1, 1);
|
||||
|
||||
// Transient resolved entries threshold
|
||||
private const int _maxTransientResolvedEntries = 1000;
|
||||
|
||||
// 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)
|
||||
public PlayerDataFactory(ILogger<PlayerDataFactory> logger, DalamudUtilService dalamudUtil, IpcManager ipcManager,
|
||||
TransientResourceManager transientResourceManager, FileCacheManager fileReplacementFactory,
|
||||
PerformanceCollectorService performanceCollector, XivDataAnalyzer modelAnalyzer, LightlessMediator lightlessMediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_dalamudUtil = dalamudUtil;
|
||||
@@ -61,15 +34,15 @@ public class PlayerDataFactory
|
||||
_performanceCollector = performanceCollector;
|
||||
_modelAnalyzer = modelAnalyzer;
|
||||
_lightlessMediator = lightlessMediator;
|
||||
_configService = configService;
|
||||
_logger.LogTrace("Creating {this}", nameof(PlayerDataFactory));
|
||||
}
|
||||
private sealed record CacheEntry(CharacterDataFragment Fragment, DateTime CreatedUtc);
|
||||
|
||||
public async Task<CharacterDataFragment?> BuildCharacterData(GameObjectHandler playerRelatedObject, CancellationToken token)
|
||||
{
|
||||
if (!_ipcManager.Initialized)
|
||||
{
|
||||
throw new InvalidOperationException("Penumbra or Glamourer is not connected");
|
||||
}
|
||||
|
||||
if (playerRelatedObject == null) return null;
|
||||
|
||||
@@ -94,17 +67,16 @@ public class PlayerDataFactory
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _performanceCollector.LogPerformance(
|
||||
this,
|
||||
$"CreateCharacterData>{playerRelatedObject.ObjectKind}",
|
||||
async () => await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false)
|
||||
).ConfigureAwait(false);
|
||||
return await _performanceCollector.LogPerformance(this, $"CreateCharacterData>{playerRelatedObject.ObjectKind}", async () =>
|
||||
{
|
||||
return await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false);
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -120,17 +92,17 @@ public class PlayerDataFactory
|
||||
}
|
||||
|
||||
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)
|
||||
return true;
|
||||
|
||||
if (!IsPointerValid(playerPointer))
|
||||
return true;
|
||||
|
||||
var character = (Character*)playerPointer;
|
||||
|
||||
if (character == null)
|
||||
return true;
|
||||
|
||||
@@ -138,190 +110,99 @@ public class PlayerDataFactory
|
||||
if (gameObject == null)
|
||||
return true;
|
||||
|
||||
if (!IsPointerValid((IntPtr)gameObject))
|
||||
return true;
|
||||
|
||||
return gameObject->DrawObject == null;
|
||||
}
|
||||
|
||||
private static bool IsPointerValid(IntPtr ptr)
|
||||
{
|
||||
if (ptr == IntPtr.Zero)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
_ = Marshal.ReadByte(ptr);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCacheFresh(CacheEntry entry)
|
||||
=> (DateTime.UtcNow - entry.CreatedUtc) <= _characterCacheTtl;
|
||||
|
||||
private Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
|
||||
=> CreateCharacterDataCoalesced(playerRelatedObject, ct);
|
||||
|
||||
private async Task<CharacterDataFragment> CreateCharacterDataCoalesced(GameObjectHandler obj, CancellationToken ct)
|
||||
{
|
||||
var key = obj.Address;
|
||||
|
||||
if (_characterBuildCache.TryGetValue(key, out 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)
|
||||
private async Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
|
||||
{
|
||||
var objectKind = playerRelatedObject.ObjectKind;
|
||||
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);
|
||||
var logDebug = _logger.IsEnabled(LogLevel.Debug);
|
||||
|
||||
await EnsureObjectPresentAsync(playerRelatedObject, ct).ConfigureAwait(false);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var waitRecordingTask = _transientResourceManager.WaitForRecording(ct);
|
||||
|
||||
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// get all remaining paths and resolve them
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (await CheckForNullDrawObject(playerRelatedObject.Address).ConfigureAwait(false))
|
||||
throw new InvalidOperationException("DrawObject became null during build (actor despawned)");
|
||||
|
||||
Task<string> getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address);
|
||||
Task<string?> getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address);
|
||||
Task<string?>? getMoodlesData = null;
|
||||
Task<string>? getHeelsOffset = null;
|
||||
Task<string>? getHonorificTitle = null;
|
||||
|
||||
if (objectKind == ObjectKind.Player)
|
||||
// wait until chara is not drawing and present so nothing spontaneously explodes
|
||||
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct).ConfigureAwait(false);
|
||||
int totalWaitTime = 10000;
|
||||
while (!await _dalamudUtil.IsObjectPresentAsync(await _dalamudUtil.CreateGameObjectAsync(playerRelatedObject.Address).ConfigureAwait(false)).ConfigureAwait(false) && totalWaitTime > 0)
|
||||
{
|
||||
getHeelsOffset = _ipcManager.Heels.GetOffsetAsync();
|
||||
getHonorificTitle = _ipcManager.Honorific.GetTitle();
|
||||
getMoodlesData = _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address);
|
||||
_logger.LogTrace("Character is null but it shouldn't be, waiting");
|
||||
await Task.Delay(50, ct).ConfigureAwait(false);
|
||||
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();
|
||||
|
||||
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)
|
||||
{
|
||||
_logger.LogDebug("== Static Replacements ==");
|
||||
foreach (var replacement in fragment.FileReplacements
|
||||
.Where(i => i.HasFileReplacement)
|
||||
.OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
|
||||
foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement).OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogDebug("=> {repl}", replacement);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
else
|
||||
{
|
||||
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();
|
||||
playerFragment.HonorificData = await getHonorificTitle!.ConfigureAwait(false);
|
||||
_logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData);
|
||||
await _transientResourceManager.WaitForRecording(ct).ConfigureAwait(false);
|
||||
|
||||
playerFragment.PetNamesData = _ipcManager.PetNames.GetLocalNames();
|
||||
_logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);
|
||||
// if it's pet then it's summoner, if it's summoner we actually want to keep all filereplacements alive at all times
|
||||
// or we get into redraw city for every change and nothing works properly
|
||||
if (objectKind == ObjectKind.Pet)
|
||||
{
|
||||
foreach (var item in fragment.FileReplacements.Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths))
|
||||
{
|
||||
if (_transientResourceManager.AddTransientResource(objectKind, item))
|
||||
{
|
||||
_logger.LogDebug("Marking static {item} for Pet as transient", item);
|
||||
}
|
||||
}
|
||||
|
||||
playerFragment.HeelsData = await getHeelsOffset!.ConfigureAwait(false);
|
||||
_logger.LogDebug("Heels is now: {heels}", playerFragment!.HeelsData);
|
||||
|
||||
playerFragment.MoodlesData = (await getMoodlesData!.ConfigureAwait(false)) ?? string.Empty;
|
||||
_logger.LogDebug("Moodles is now: {moodles}", playerFragment!.MoodlesData);
|
||||
_logger.LogTrace("Clearing {count} Static Replacements for Pet", fragment.FileReplacements.Count);
|
||||
fragment.FileReplacements.Clear();
|
||||
}
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var (resolvedTransientPaths, clearedForPet) = await transientTask.ConfigureAwait(false);
|
||||
if (clearedForPet != null)
|
||||
fragment.FileReplacements.Clear();
|
||||
_logger.LogDebug("Handling transient update for {obj}", playerRelatedObject);
|
||||
|
||||
// remove all potentially gathered paths from the transient resource manager that are resolved through static resolving
|
||||
_transientResourceManager.ClearTransientPaths(objectKind, fragment.FileReplacements.SelectMany(c => c.GamePaths).ToList());
|
||||
|
||||
// get all remaining paths and resolve them
|
||||
var transientPaths = ManageSemiTransientData(objectKind);
|
||||
var resolvedTransientPaths = transientPaths.Count == 0
|
||||
? new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase).AsReadOnly()
|
||||
: await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
|
||||
|
||||
if (logDebug)
|
||||
{
|
||||
_logger.LogDebug("== Transient Replacements ==");
|
||||
foreach (var replacement in resolvedTransientPaths
|
||||
.Select(c => new FileReplacement([.. c.Value], c.Key))
|
||||
.OrderBy(f => f.ResolvedPath, StringComparer.Ordinal))
|
||||
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)).OrderBy(f => f.ResolvedPath, StringComparer.Ordinal))
|
||||
{
|
||||
_logger.LogDebug("=> {repl}", replacement);
|
||||
fragment.FileReplacements.Add(replacement);
|
||||
@@ -330,64 +211,85 @@ public class PlayerDataFactory
|
||||
else
|
||||
{
|
||||
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)))
|
||||
{
|
||||
fragment.FileReplacements.Add(replacement);
|
||||
}
|
||||
}
|
||||
|
||||
// clean up all semi transient resources that don't have any file replacement (aka null resolve)
|
||||
_transientResourceManager.CleanUpSemiTransientResources(objectKind, [.. fragment.FileReplacements]);
|
||||
|
||||
fragment.FileReplacements = new HashSet<FileReplacement>(
|
||||
fragment.FileReplacements
|
||||
.Where(v => v.HasFileReplacement)
|
||||
.OrderBy(v => v.ResolvedPath, StringComparer.Ordinal),
|
||||
FileReplacementComparer.Instance);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// make sure we only return data that actually has file replacements
|
||||
fragment.FileReplacements = new HashSet<FileReplacement>(fragment.FileReplacements.Where(v => v.HasFileReplacement).OrderBy(v => v.ResolvedPath, StringComparer.Ordinal), FileReplacementComparer.Instance);
|
||||
|
||||
// gather up data from ipc
|
||||
Task<string> getHeelsOffset = _ipcManager.Heels.GetOffsetAsync();
|
||||
Task<string> getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address);
|
||||
Task<string?> getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address);
|
||||
Task<string> getHonorificTitle = _ipcManager.Honorific.GetTitle();
|
||||
fragment.GlamourerString = await getGlamourerData.ConfigureAwait(false);
|
||||
_logger.LogDebug("Glamourer is now: {data}", fragment.GlamourerString);
|
||||
var customizeScale = await getCustomizeData.ConfigureAwait(false);
|
||||
fragment.CustomizePlusScale = customizeScale ?? string.Empty;
|
||||
_logger.LogDebug("Customize is now: {data}", fragment.CustomizePlusScale);
|
||||
|
||||
if (objectKind == ObjectKind.Player)
|
||||
{
|
||||
var playerFragment = (fragment as CharacterDataFragmentPlayer)!;
|
||||
playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations();
|
||||
|
||||
playerFragment!.HonorificData = await getHonorificTitle.ConfigureAwait(false);
|
||||
_logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData);
|
||||
|
||||
playerFragment!.HeelsData = await getHeelsOffset.ConfigureAwait(false);
|
||||
_logger.LogDebug("Heels is now: {heels}", playerFragment!.HeelsData);
|
||||
|
||||
playerFragment!.MoodlesData = await _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address).ConfigureAwait(false) ?? string.Empty;
|
||||
_logger.LogDebug("Moodles is now: {moodles}", playerFragment!.MoodlesData);
|
||||
|
||||
playerFragment!.PetNamesData = _ipcManager.PetNames.GetLocalNames();
|
||||
_logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);
|
||||
}
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var toCompute = fragment.FileReplacements.Where(f => !f.IsFileSwap).ToArray();
|
||||
_logger.LogDebug("Getting Hashes for {amount} Files", toCompute.Length);
|
||||
|
||||
await Task.Run(() =>
|
||||
var computedPaths = _fileCacheManager.GetFileCachesByPaths(toCompute.Select(c => c.ResolvedPath).ToArray());
|
||||
foreach (var file in toCompute)
|
||||
{
|
||||
var computedPaths = _fileCacheManager.GetFileCachesByPaths([.. toCompute.Select(c => c.ResolvedPath)]);
|
||||
foreach (var file in toCompute)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty;
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty;
|
||||
}
|
||||
var removed = fragment.FileReplacements.RemoveWhere(f => !f.IsFileSwap && string.IsNullOrEmpty(f.Hash));
|
||||
if (removed > 0)
|
||||
{
|
||||
_logger.LogDebug("Removed {amount} of invalid files", removed);
|
||||
}
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
Dictionary<string, List<ushort>>? boneIndices = null;
|
||||
var hasPapFiles = false;
|
||||
|
||||
if (objectKind == ObjectKind.Player)
|
||||
{
|
||||
hasPapFiles = fragment.FileReplacements.Any(f =>
|
||||
!f.IsFileSwap && f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
!f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase));
|
||||
if (hasPapFiles)
|
||||
{
|
||||
boneIndices = await _dalamudUtil
|
||||
.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject))
|
||||
.ConfigureAwait(false);
|
||||
boneIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (objectKind == ObjectKind.Player)
|
||||
{
|
||||
try
|
||||
{
|
||||
#if DEBUG
|
||||
if (hasPapFiles && boneIndices != null)
|
||||
_modelAnalyzer.DumpLocalSkeletonIndices(playerRelatedObject);
|
||||
#endif
|
||||
|
||||
if (hasPapFiles)
|
||||
{
|
||||
await VerifyPlayerAnimationBones(boneIndices, (CharacterDataFragmentPlayer)fragment, ct)
|
||||
.ConfigureAwait(false);
|
||||
await VerifyPlayerAnimationBones(boneIndices, (fragment as CharacterDataFragmentPlayer)!, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException e)
|
||||
@@ -401,286 +303,80 @@ public class PlayerDataFactory
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Building character data for {obj} took {time}ms",
|
||||
objectKind, sw.Elapsed.TotalMilliseconds);
|
||||
_logger.LogInformation("Building character data for {obj} took {time}ms", objectKind, TimeSpan.FromTicks(DateTime.UtcNow.Ticks - start.Ticks).TotalMilliseconds);
|
||||
|
||||
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;
|
||||
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 (boneIndices == null) return;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("SEND local buckets: {b}",
|
||||
string.Join(", ", localBoneSets.Keys.Order(StringComparer.Ordinal)));
|
||||
|
||||
foreach (var kvp in localBoneSets.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
|
||||
foreach (var kvp in boneIndices)
|
||||
{
|
||||
var min = kvp.Value.Count > 0 ? kvp.Value.Min() : 0;
|
||||
var max = kvp.Value.Count > 0 ? kvp.Value.Max() : 0;
|
||||
_logger.LogDebug("Local bucket {bucket}: count={count} min={min} max={max}",
|
||||
kvp.Key, kvp.Value.Count, min, max);
|
||||
_logger.LogDebug("Found {skellyname} ({idx} bone indices) on player: {bones}", kvp.Key, kvp.Value.Any() ? kvp.Value.Max() : 0, string.Join(',', kvp.Value));
|
||||
}
|
||||
}
|
||||
|
||||
var papGroups = fragment.FileReplacements
|
||||
.Where(f => !f.IsFileSwap
|
||||
&& !string.IsNullOrEmpty(f.Hash)
|
||||
&& f.GamePaths is { Count: > 0 }
|
||||
&& f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
|
||||
.GroupBy(f => f.Hash!, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var maxPlayerBoneIndex = boneIndices.SelectMany(kvp => kvp.Value).DefaultIfEmpty().Max();
|
||||
if (maxPlayerBoneIndex <= 0) return;
|
||||
|
||||
int noValidationFailed = 0;
|
||||
|
||||
foreach (var g in papGroups)
|
||||
foreach (var file in fragment.FileReplacements.Where(f => !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)).ToList())
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var hash = g.Key;
|
||||
|
||||
var resolvedPath = g.Select(f => f.ResolvedPath).Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
var papPathSummary = string.Join(", ", resolvedPath);
|
||||
if (papPathSummary.IsNullOrEmpty())
|
||||
papPathSummary = "<unknown pap path>";
|
||||
|
||||
Dictionary<string, List<ushort>>? papIndices = null;
|
||||
|
||||
await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
var skeletonIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetBoneIndicesFromPap(file.Hash)).ConfigureAwait(false);
|
||||
bool validationFailed = false;
|
||||
if (skeletonIndices != null)
|
||||
{
|
||||
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
|
||||
var papPath = cacheEntity?.ResolvedFilepath;
|
||||
|
||||
if (!string.IsNullOrEmpty(papPath) && File.Exists(papPath))
|
||||
// 105 is the maximum vanilla skellington spoopy bone index
|
||||
if (skeletonIndices.All(k => k.Value.Max() <= 105))
|
||||
{
|
||||
var havokBytes = await Task.Run(() => XivDataAnalyzer.ReadHavokBytesFromPap(papPath), ct)
|
||||
.ConfigureAwait(false);
|
||||
_logger.LogTrace("All indices of {path} are <= 105, ignoring", file.ResolvedPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (havokBytes is { Length: > 8 })
|
||||
_logger.LogDebug("Verifying bone indices for {path}, found {x} skeletons", file.ResolvedPath, skeletonIndices.Count);
|
||||
|
||||
foreach (var boneCount in skeletonIndices)
|
||||
{
|
||||
var maxAnimationIndex = boneCount.Value.DefaultIfEmpty().Max();
|
||||
if (maxAnimationIndex > maxPlayerBoneIndex)
|
||||
{
|
||||
papIndices = await _dalamudUtil.RunOnFrameworkThread(
|
||||
() => _modelAnalyzer.ParseHavokBytesOnFrameworkThread(havokBytes, hash, persistToConfig: false))
|
||||
.ConfigureAwait(false);
|
||||
_logger.LogWarning("Found more bone indices on the animation {path} skeleton {skl} (max indice {idx}) than on any player related skeleton (max indice {idx2})",
|
||||
file.ResolvedPath, boneCount.Key, maxAnimationIndex, maxPlayerBoneIndex);
|
||||
validationFailed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
||||
if (validationFailed)
|
||||
{
|
||||
_papParseLimiter.Release();
|
||||
}
|
||||
|
||||
if (papIndices == null || papIndices.Count == 0)
|
||||
continue;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
try
|
||||
noValidationFailed++;
|
||||
_logger.LogDebug("Removing {file} from sent file replacements and transient data", file.ResolvedPath);
|
||||
fragment.FileReplacements.Remove(file);
|
||||
foreach (var gamePath in file.GamePaths)
|
||||
{
|
||||
var papBuckets = papIndices
|
||||
.Where(kvp => kvp.Value is { Count: > 0 })
|
||||
.Select(kvp => new
|
||||
{
|
||||
Raw = kvp.Key,
|
||||
Key = XivDataAnalyzer.CanonicalizeSkeletonKey(kvp.Key),
|
||||
Indices = kvp.Value
|
||||
})
|
||||
.Where(x => x.Indices is { Count: > 0 })
|
||||
.GroupBy(x => string.IsNullOrEmpty(x.Key) ? x.Raw : x.Key!, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(grp =>
|
||||
{
|
||||
var all = grp.SelectMany(v => v.Indices).ToList();
|
||||
var min = all.Count > 0 ? all.Min() : 0;
|
||||
var max = all.Count > 0 ? all.Max() : 0;
|
||||
var raws = string.Join(',', grp.Select(v => v.Raw).Distinct(StringComparer.OrdinalIgnoreCase));
|
||||
return $"{grp.Key}(min={min},max={max},raw=[{raws}])";
|
||||
})
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug("SEND pap buckets for hash={hash}: {b}",
|
||||
hash,
|
||||
string.Join(" | ", papBuckets));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error logging PAP bucket details for hash={hash}", hash);
|
||||
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gamePath);
|
||||
}
|
||||
}
|
||||
|
||||
bool isCompatible = false;
|
||||
string reason = string.Empty;
|
||||
try
|
||||
{
|
||||
isCompatible = XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out reason);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error checking PAP compatibility for hash={hash}, path={path}. Treating as incompatible.", hash, papPathSummary);
|
||||
reason = $"Exception during compatibility check: {ex.Message}";
|
||||
isCompatible = false;
|
||||
}
|
||||
|
||||
if (isCompatible)
|
||||
continue;
|
||||
|
||||
noValidationFailed++;
|
||||
|
||||
_logger.LogWarning(
|
||||
"Animation PAP is not compatible with local skeletons; dropping mappings for {papPath}. Reason: {reason}",
|
||||
papPathSummary,
|
||||
reason);
|
||||
|
||||
var removedGamePaths = fragment.FileReplacements
|
||||
.Where(fr => !fr.IsFileSwap
|
||||
&& string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
|
||||
&& fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
|
||||
.SelectMany(fr => fr.GamePaths.Where(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
fragment.FileReplacements.RemoveWhere(fr =>
|
||||
!fr.IsFileSwap
|
||||
&& string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
|
||||
&& fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
foreach (var gp in removedGamePaths)
|
||||
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gp);
|
||||
}
|
||||
|
||||
if (noValidationFailed > 0)
|
||||
{
|
||||
_lightlessMediator.Publish(new NotificationMessage(
|
||||
"Invalid Skeleton Setup",
|
||||
$"Your client is attempting to send {noValidationFailed} animation files that don't match your current skeleton validation mode ({mode}). " +
|
||||
"Please adjust your skeleton/mods or change the validation mode if this is unexpected. " +
|
||||
"Those animation files have been removed from your sent (player) data. (Check /xllog for details).",
|
||||
NotificationType.Warning,
|
||||
TimeSpan.FromSeconds(10)));
|
||||
_lightlessMediator.Publish(new NotificationMessage("Invalid Skeleton Setup",
|
||||
$"Your client is attempting to send {noValidationFailed} animation files with invalid bone data. Those animation files have been removed from your sent data. " +
|
||||
$"Verify that you are using the correct skeleton for those animation files (Check /xllog for more information).",
|
||||
NotificationType.Warning, TimeSpan.FromSeconds(10)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 reversePaths = reverseResolve.ToArray();
|
||||
@@ -689,8 +385,8 @@ public class PlayerDataFactory
|
||||
return new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase).AsReadOnly();
|
||||
}
|
||||
|
||||
var forwardPathsLower = forwardPaths.Length == 0 ? [] : forwardPaths.Select(p => p.ToLowerInvariant()).ToArray();
|
||||
var reversePathsLower = reversePaths.Length == 0 ? [] : reversePaths.Select(p => p.ToLowerInvariant()).ToArray();
|
||||
var forwardPathsLower = forwardPaths.Length == 0 ? Array.Empty<string>() : forwardPaths.Select(p => p.ToLowerInvariant()).ToArray();
|
||||
var reversePathsLower = reversePaths.Length == 0 ? Array.Empty<string>() : reversePaths.Select(p => p.ToLowerInvariant()).ToArray();
|
||||
|
||||
Dictionary<string, List<string>> resolvedPaths = new(forwardPaths.Length + reversePaths.Length, StringComparer.Ordinal);
|
||||
if (handler.ObjectKind != ObjectKind.Player)
|
||||
@@ -699,15 +395,21 @@ public class PlayerDataFactory
|
||||
{
|
||||
var idx = handler.GetGameObject()?.ObjectIndex;
|
||||
if (!idx.HasValue)
|
||||
{
|
||||
return ((int?)null, Array.Empty<string>(), Array.Empty<string[]>());
|
||||
}
|
||||
|
||||
var resolvedForward = new string[forwardPaths.Length];
|
||||
for (int i = 0; i < forwardPaths.Length; i++)
|
||||
{
|
||||
resolvedForward[i] = _ipcManager.Penumbra.ResolveGameObjectPath(forwardPaths[i], idx.Value);
|
||||
}
|
||||
|
||||
var resolvedReverse = new string[reversePaths.Length][];
|
||||
for (int i = 0; i < reversePaths.Length; i++)
|
||||
{
|
||||
resolvedReverse[i] = _ipcManager.Penumbra.ReverseResolveGameObjectPath(reversePaths[i], idx.Value);
|
||||
}
|
||||
|
||||
return (idx, resolvedForward, resolvedReverse);
|
||||
}).ConfigureAwait(false);
|
||||
@@ -718,10 +420,14 @@ public class PlayerDataFactory
|
||||
{
|
||||
var filePath = forwardResolved[i]?.ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolvedPaths.TryGetValue(filePath, out var list))
|
||||
list.Add(forwardPaths[i].ToLowerInvariant());
|
||||
{
|
||||
list.Add(forwardPathsLower[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
resolvedPaths[filePath] = [forwardPathsLower[i]];
|
||||
@@ -737,9 +443,13 @@ public class PlayerDataFactory
|
||||
reverseResolvedLower[j] = reverseResolved[i][j].ToLowerInvariant();
|
||||
}
|
||||
if (resolvedPaths.TryGetValue(filePath, out var list))
|
||||
list.AddRange(reverseResolved[i].Select(c => c.ToLowerInvariant()));
|
||||
{
|
||||
list.AddRange(reverseResolvedLower);
|
||||
}
|
||||
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();
|
||||
@@ -747,14 +457,17 @@ public class PlayerDataFactory
|
||||
}
|
||||
|
||||
var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false);
|
||||
|
||||
for (int i = 0; i < forwardPaths.Length; i++)
|
||||
{
|
||||
var filePath = forward[i].ToLowerInvariant();
|
||||
if (resolvedPaths.TryGetValue(filePath, out var list))
|
||||
list.Add(forwardPaths[i].ToLowerInvariant());
|
||||
{
|
||||
list.Add(forwardPathsLower[i]);
|
||||
}
|
||||
else
|
||||
resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
|
||||
{
|
||||
resolvedPaths[filePath] = [forwardPathsLower[i]];
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < reversePaths.Length; i++)
|
||||
@@ -766,9 +479,13 @@ public class PlayerDataFactory
|
||||
reverseResolvedLower[j] = reverse[i][j].ToLowerInvariant();
|
||||
}
|
||||
if (resolvedPaths.TryGetValue(filePath, out var list))
|
||||
list.AddRange(reverse[i].Select(c => c.ToLowerInvariant()));
|
||||
{
|
||||
list.AddRange(reverseResolvedLower);
|
||||
}
|
||||
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();
|
||||
@@ -779,29 +496,11 @@ public class PlayerDataFactory
|
||||
_transientResourceManager.PersistTransientResources(objectKind);
|
||||
|
||||
HashSet<string> pathsToResolve = new(StringComparer.Ordinal);
|
||||
|
||||
int scanned = 0, skippedEmpty = 0, skippedVfx = 0;
|
||||
|
||||
foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind))
|
||||
foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind).Where(path => !string.IsNullOrEmpty(path)))
|
||||
{
|
||||
scanned++;
|
||||
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
skippedEmpty++;
|
||||
continue;
|
||||
}
|
||||
|
||||
pathsToResolve.Add(path);
|
||||
}
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"ManageSemiTransientData({kind}): scanned={scanned}, added={added}, skippedEmpty={skippedEmpty}, skippedVfx={skippedVfx}",
|
||||
objectKind, scanned, pathsToResolve.Count, skippedEmpty, skippedVfx);
|
||||
}
|
||||
|
||||
return pathsToResolve;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,16 +113,16 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
||||
public async Task ActOnFrameworkAfterEnsureNoDrawAsync(Action<Dalamud.Game.ClientState.Objects.Types.ICharacter> act, CancellationToken token)
|
||||
{
|
||||
while (await _dalamudUtil.RunOnFrameworkThread(() =>
|
||||
{
|
||||
EnsureLatestObjectState();
|
||||
if (CurrentDrawCondition != DrawCondition.None) return true;
|
||||
var gameObj = _dalamudUtil.CreateGameObject(Address);
|
||||
if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter chara)
|
||||
{
|
||||
act.Invoke(chara);
|
||||
}
|
||||
return false;
|
||||
}).ConfigureAwait(false))
|
||||
{
|
||||
EnsureLatestObjectState();
|
||||
if (CurrentDrawCondition != DrawCondition.None) return true;
|
||||
var gameObj = _dalamudUtil.CreateGameObject(Address);
|
||||
if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter chara)
|
||||
{
|
||||
act.Invoke(chara);
|
||||
}
|
||||
return false;
|
||||
}).ConfigureAwait(false))
|
||||
{
|
||||
await Task.Delay(250, token).ConfigureAwait(false);
|
||||
}
|
||||
@@ -169,36 +169,37 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
||||
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 prevDrawObj = DrawObjectAddress;
|
||||
string? nameString = null;
|
||||
|
||||
Address = _getAddress();
|
||||
|
||||
if (Address != IntPtr.Zero)
|
||||
{
|
||||
var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address;
|
||||
DrawObjectAddress = (IntPtr)gameObject->DrawObject;
|
||||
var drawObjAddr = (IntPtr)gameObject->DrawObject;
|
||||
DrawObjectAddress = drawObjAddr;
|
||||
EntityId = gameObject->EntityId;
|
||||
|
||||
var chara = (Character*)Address;
|
||||
nameString = chara->GameObject.NameString;
|
||||
if (!string.IsNullOrEmpty(nameString) && !string.Equals(nameString, Name, StringComparison.Ordinal))
|
||||
Name = nameString;
|
||||
CurrentDrawCondition = DrawCondition.None;
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawObjectAddress = IntPtr.Zero;
|
||||
EntityId = uint.MaxValue;
|
||||
CurrentDrawCondition = DrawCondition.DrawObjectZero;
|
||||
}
|
||||
|
||||
CurrentDrawCondition = IsBeingDrawnUnsafe();
|
||||
|
||||
if (_haltProcessing || !allowPublish) return;
|
||||
if (_haltProcessing) return;
|
||||
|
||||
bool drawObjDiff = DrawObjectAddress != prevDrawObj;
|
||||
bool addrDiff = Address != prevAddr;
|
||||
@@ -206,18 +207,16 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
||||
if (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero)
|
||||
{
|
||||
var chara = (Character*)Address;
|
||||
var drawObj = (DrawObject*)DrawObjectAddress;
|
||||
var objType = drawObj->Object.GetObjectType();
|
||||
var isHuman = objType == ObjectType.CharacterBase
|
||||
&& ((CharacterBase*)drawObj)->GetModelType() == CharacterBase.ModelType.Human;
|
||||
|
||||
nameString ??= ((Character*)Address)->GameObject.NameString;
|
||||
var nameChange = !string.Equals(nameString, Name, StringComparison.Ordinal);
|
||||
if (nameChange) Name = nameString;
|
||||
|
||||
var name = chara->GameObject.NameString;
|
||||
bool nameChange = !string.Equals(name, Name, StringComparison.Ordinal);
|
||||
if (nameChange)
|
||||
{
|
||||
Name = name;
|
||||
}
|
||||
bool equipDiff = false;
|
||||
|
||||
if (isHuman)
|
||||
if (((DrawObject*)DrawObjectAddress)->Object.GetObjectType() == ObjectType.CharacterBase
|
||||
&& ((CharacterBase*)DrawObjectAddress)->GetModelType() == CharacterBase.ModelType.Human)
|
||||
{
|
||||
var classJob = chara->CharacterData.ClassJob;
|
||||
if (classJob != _classJob)
|
||||
@@ -227,7 +226,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
||||
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 oh = ref chara->DrawData.Weapon(WeaponSlot.OffHand);
|
||||
@@ -252,11 +251,12 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
||||
|
||||
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 raceId = ((Human*)drawObj)->Customize.Race;
|
||||
var tribeId = ((Human*)drawObj)->Customize.Tribe;
|
||||
var gender = ((Human*)DrawObjectAddress)->Customize.Sex;
|
||||
var raceId = ((Human*)DrawObjectAddress)->Customize.Race;
|
||||
var tribeId = ((Human*)DrawObjectAddress)->Customize.Tribe;
|
||||
|
||||
if (_isOwnedObject && ObjectKind == ObjectKind.Player
|
||||
&& (gender != Gender || raceId != RaceId || tribeId != TribeId))
|
||||
@@ -267,7 +267,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
||||
TribeId = tribeId;
|
||||
}
|
||||
|
||||
customizeDiff = CompareAndUpdateCustomizeData(((Human*)drawObj)->Customize.Data);
|
||||
customizeDiff = CompareAndUpdateCustomizeData(((Human*)DrawObjectAddress)->Customize.Data);
|
||||
if (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()
|
||||
{
|
||||
if (!_delayedZoningTask?.IsCompleted ?? false) return;
|
||||
|
||||
try
|
||||
{
|
||||
var zoningDelayActive = !(_delayedZoningTask?.IsCompleted ?? true);
|
||||
_performanceCollector.LogPerformance(this, $"CheckAndUpdateObject>{(_isOwnedObject ? "Self" : "Other")}+{ObjectKind}/{(string.IsNullOrEmpty(Name) ? "Unk" : Name)}", () => CheckAndUpdateObject(allowPublish: !zoningDelayActive));
|
||||
_performanceCollector.LogPerformance(this, $"CheckAndUpdateObject>{(_isOwnedObject ? "Self" : "Other")}+{ObjectKind}/{(string.IsNullOrEmpty(Name) ? "Unk" : Name)}"
|
||||
+ $"+{Address.ToString("X")}", CheckAndUpdateObject);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -460,6 +462,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
||||
Logger.LogDebug("[{this}] Delay after zoning complete", this);
|
||||
_zoningCts.Dispose();
|
||||
}
|
||||
}, _zoningCts.Token);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
private readonly ModelDecimationService _modelDecimationService;
|
||||
private readonly PairStateCache _pairStateCache;
|
||||
private readonly PairPerformanceMetricsCache _performanceMetricsCache;
|
||||
private readonly XivDataAnalyzer _modelAnalyzer;
|
||||
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly PairManager _pairManager;
|
||||
private readonly IFramework _framework;
|
||||
private CancellationTokenSource? _applicationCancellationTokenSource;
|
||||
@@ -95,9 +93,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
private readonly Dictionary<ObjectKind, HashSet<PlayerChanges>> _pendingOwnedChanges = new();
|
||||
private CancellationTokenSource? _ownedRetryCts;
|
||||
private Task _ownedRetryTask = Task.CompletedTask;
|
||||
|
||||
|
||||
|
||||
private static readonly TimeSpan OwnedRetryInitialDelay = TimeSpan.FromSeconds(1);
|
||||
private static readonly TimeSpan OwnedRetryMaxDelay = TimeSpan.FromSeconds(10);
|
||||
private static readonly TimeSpan OwnedRetryStaleDataGrace = TimeSpan.FromMinutes(5);
|
||||
@@ -109,19 +104,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
".avfx",
|
||||
".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? _visibilityEvictionDueAtUtc;
|
||||
private DateTime _nextActorLookupUtc = DateTime.MinValue;
|
||||
private static readonly TimeSpan ActorLookupInterval = TimeSpan.FromSeconds(1);
|
||||
private static readonly SemaphoreSlim ActorInitializationLimiter = new(1, 1);
|
||||
private static readonly SemaphoreSlim _papParseLimiter = new(1, 1);
|
||||
private const int FullyLoadedTimeoutMsPlayer = 30000;
|
||||
private const int FullyLoadedTimeoutMsOther = 5000;
|
||||
private readonly object _actorInitializationGate = new();
|
||||
@@ -220,9 +207,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
ModelDecimationService modelDecimationService,
|
||||
PairStateCache pairStateCache,
|
||||
PairPerformanceMetricsCache performanceMetricsCache,
|
||||
PenumbraTempCollectionJanitor tempCollectionJanitor,
|
||||
XivDataAnalyzer modelAnalyzer,
|
||||
LightlessConfigService configService) : base(logger, mediator)
|
||||
PenumbraTempCollectionJanitor tempCollectionJanitor) : base(logger, mediator)
|
||||
{
|
||||
_pairManager = pairManager;
|
||||
Ident = ident;
|
||||
@@ -244,8 +229,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
_pairStateCache = pairStateCache;
|
||||
_performanceMetricsCache = performanceMetricsCache;
|
||||
_tempCollectionJanitor = tempCollectionJanitor;
|
||||
_modelAnalyzer = modelAnalyzer;
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
@@ -2136,36 +2119,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
return;
|
||||
}
|
||||
|
||||
SplitPapMappings(moddedPaths, out var withoutPap, out var papOnly);
|
||||
|
||||
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false);
|
||||
|
||||
await _ipcManager.Penumbra.SetTemporaryModsAsync(
|
||||
Logger, _applicationId, penumbraCollection,
|
||||
withoutPap.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await _ipcManager.Penumbra.RedrawAsync(Logger, handlerForApply, _applicationId, token).ConfigureAwait(false);
|
||||
if (handlerForApply.Address != nint.Zero)
|
||||
await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false);
|
||||
|
||||
var removedPap = await StripIncompatiblePapAsync(handlerForApply, charaData, papOnly, token).ConfigureAwait(false);
|
||||
if (removedPap > 0)
|
||||
{
|
||||
Logger.LogTrace("[{applicationId}] Removed {removedPap} incompatible PAP mappings found for {handler}", _applicationId, removedPap, GetLogIdentifier());
|
||||
}
|
||||
|
||||
var merged = new Dictionary<(string GamePath, string? Hash), string>(withoutPap, withoutPap.Comparer);
|
||||
foreach (var kv in papOnly)
|
||||
merged[kv.Key] = kv.Value;
|
||||
|
||||
await _ipcManager.Penumbra.SetTemporaryModsAsync(
|
||||
Logger, _applicationId, penumbraCollection,
|
||||
merged.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(merged, merged.Comparer);
|
||||
|
||||
await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, _applicationId, penumbraCollection,
|
||||
moddedPaths.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)).ConfigureAwait(false);
|
||||
_lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(moddedPaths, moddedPaths.Comparer);
|
||||
LastAppliedDataBytes = -1;
|
||||
foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists))
|
||||
{
|
||||
@@ -2233,20 +2191,20 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException))
|
||||
{
|
||||
IsVisible = false;
|
||||
_forceApplyMods = true;
|
||||
_cachedData = charaData;
|
||||
_pairStateCache.Store(Ident, charaData);
|
||||
_forceFullReapply = true;
|
||||
Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId);
|
||||
_forceFullReapply = true;
|
||||
}
|
||||
RecordFailure($"Application failed: {ex.Message}", "Exception");
|
||||
_forceApplyMods = true;
|
||||
_cachedData = charaData;
|
||||
_pairStateCache.Store(Ident, charaData);
|
||||
_forceFullReapply = true;
|
||||
Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId);
|
||||
_forceFullReapply = true;
|
||||
}
|
||||
RecordFailure($"Application failed: {ex.Message}", "Exception");
|
||||
}
|
||||
}
|
||||
|
||||
private void FrameworkUpdate()
|
||||
{
|
||||
@@ -2461,8 +2419,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
|
||||
try
|
||||
{
|
||||
RefreshPapBlockCacheIfAnimSettingsChanged();
|
||||
|
||||
var replacementList = charaData.FileReplacements.SelectMany(k => k.Value.Where(v => string.IsNullOrEmpty(v.FileSwapPath))).ToList();
|
||||
Parallel.ForEach(replacementList, new ParallelOptions()
|
||||
{
|
||||
@@ -2485,37 +2441,20 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
if (string.IsNullOrEmpty(new FileInfo(fileCache.ResolvedFilepath).Extension))
|
||||
{
|
||||
hasMigrationChanges = true;
|
||||
var anyGamePath = item.GamePaths.FirstOrDefault();
|
||||
|
||||
if (!string.IsNullOrEmpty(anyGamePath))
|
||||
{
|
||||
var ext = Path.GetExtension(anyGamePath);
|
||||
var extNoDot = ext.StartsWith('.') ? ext[1..] : ext;
|
||||
|
||||
if (!string.IsNullOrEmpty(extNoDot))
|
||||
{
|
||||
hasMigrationChanges = true;
|
||||
fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, extNoDot);
|
||||
}
|
||||
}
|
||||
fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, item.GamePaths[0].Split(".")[^1]);
|
||||
}
|
||||
|
||||
foreach (var gamePath in item.GamePaths)
|
||||
{
|
||||
var mode = _configService.Current.AnimationValidationMode;
|
||||
|
||||
if (mode != AnimationValidationMode.Unsafe
|
||||
&& gamePath.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrEmpty(item.Hash)
|
||||
&& _blockedPapHashes.ContainsKey(item.Hash))
|
||||
var preferredPath = fileCache.ResolvedFilepath;
|
||||
if (!skipDownscaleForPair && gamePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2827,7 +2766,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
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;
|
||||
if (!string.IsNullOrEmpty(hashedCid))
|
||||
@@ -2863,168 +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 allowNeighborIndex = _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) || list == null || list.Count == 0)
|
||||
continue;
|
||||
|
||||
if (!localBoneSets.TryGetValue(key, out var set))
|
||||
localBoneSets[key] = set = new HashSet<ushort>();
|
||||
|
||||
foreach (var v in list)
|
||||
set.Add(v);
|
||||
}
|
||||
|
||||
if (localBoneSets.Count == 0)
|
||||
{
|
||||
var removedCount = papOnly.Count;
|
||||
papOnly.Clear();
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
int removed = 0;
|
||||
|
||||
var groups = papOnly
|
||||
.Where(kvp => !string.IsNullOrEmpty(kvp.Key.Hash))
|
||||
.GroupBy(kvp => kvp.Key.Hash!, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
foreach (var grp in groups)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var hash = grp.Key;
|
||||
|
||||
var papPath = grp.Select(x => x.Value)
|
||||
.FirstOrDefault(p => !string.IsNullOrEmpty(p) && File.Exists(p));
|
||||
|
||||
if (string.IsNullOrEmpty(papPath))
|
||||
continue;
|
||||
|
||||
var havokBytes = await Task.Run(() => XivDataAnalyzer.ReadHavokBytesFromPap(papPath), token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (havokBytes is not { Length: > 8 })
|
||||
continue;
|
||||
|
||||
Dictionary<string, List<ushort>>? papIndices;
|
||||
|
||||
await _papParseLimiter.WaitAsync(token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
papIndices = await _dalamudUtil.RunOnFrameworkThread(
|
||||
() => _modelAnalyzer.ParseHavokBytesOnFrameworkThread(havokBytes, hash, persistToConfig: false))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_papParseLimiter.Release();
|
||||
}
|
||||
|
||||
if (papIndices == null || papIndices.Count == 0)
|
||||
continue;
|
||||
|
||||
if (papIndices.All(k => k.Value == null || k.Value.Count == 0 || k.Value.Max() <= 105))
|
||||
continue;
|
||||
|
||||
if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allowNeighborIndex, out var reason))
|
||||
continue;
|
||||
|
||||
var keysToRemove = grp.Select(x => x.Key).ToList();
|
||||
foreach (var k in keysToRemove)
|
||||
papOnly.Remove(k);
|
||||
|
||||
removed += keysToRemove.Count;
|
||||
|
||||
if (_blockedPapHashes.TryAdd(hash, 0))
|
||||
Logger.LogWarning("Blocked remote object PAP {papPath} (hash {hash}) for {handler}: {reason}",
|
||||
papPath, 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)
|
||||
{
|
||||
_customizeIds[kind] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false);
|
||||
|
||||
@@ -37,8 +37,6 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
||||
private readonly PairStateCache _pairStateCache;
|
||||
private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache;
|
||||
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly XivDataAnalyzer _modelAnalyzer;
|
||||
private readonly IFramework _framework;
|
||||
|
||||
public PairHandlerAdapterFactory(
|
||||
@@ -61,9 +59,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
||||
ModelDecimationService modelDecimationService,
|
||||
PairStateCache pairStateCache,
|
||||
PairPerformanceMetricsCache pairPerformanceMetricsCache,
|
||||
PenumbraTempCollectionJanitor tempCollectionJanitor,
|
||||
XivDataAnalyzer modelAnalyzer,
|
||||
LightlessConfigService configService)
|
||||
PenumbraTempCollectionJanitor tempCollectionJanitor)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_mediator = mediator;
|
||||
@@ -85,8 +81,6 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
||||
_pairStateCache = pairStateCache;
|
||||
_pairPerformanceMetricsCache = pairPerformanceMetricsCache;
|
||||
_tempCollectionJanitor = tempCollectionJanitor;
|
||||
_modelAnalyzer = modelAnalyzer;
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
public IPairHandlerAdapter Create(string ident)
|
||||
@@ -116,8 +110,6 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
||||
_modelDecimationService,
|
||||
_pairStateCache,
|
||||
_pairPerformanceMetricsCache,
|
||||
_tempCollectionJanitor,
|
||||
_modelAnalyzer,
|
||||
_configService);
|
||||
_tempCollectionJanitor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
services.AddSingleton<HubFactory>();
|
||||
services.AddSingleton<FileUploadManager>();
|
||||
services.AddSingleton<FileTransferOrchestrator>();
|
||||
services.AddSingleton<FileDownloadDeduplicator>();
|
||||
services.AddSingleton<LightlessPlugin>();
|
||||
services.AddSingleton<LightlessProfileManager>();
|
||||
services.AddSingleton<TextureCompressionService>();
|
||||
@@ -377,11 +378,6 @@ public sealed class Plugin : IDalamudPlugin
|
||||
sp.GetRequiredService<DalamudUtilService>(),
|
||||
sp.GetRequiredService<LightlessMediator>()));
|
||||
|
||||
services.AddSingleton(sp => new IpcCallerLifestream(
|
||||
pluginInterface,
|
||||
sp.GetRequiredService<LightlessMediator>(),
|
||||
sp.GetRequiredService<ILogger<IpcCallerLifestream>>()));
|
||||
|
||||
services.AddSingleton(sp => new IpcManager(
|
||||
sp.GetRequiredService<ILogger<IpcManager>>(),
|
||||
sp.GetRequiredService<LightlessMediator>(),
|
||||
@@ -392,9 +388,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
sp.GetRequiredService<IpcCallerHonorific>(),
|
||||
sp.GetRequiredService<IpcCallerMoodles>(),
|
||||
sp.GetRequiredService<IpcCallerPetNames>(),
|
||||
sp.GetRequiredService<IpcCallerBrio>(),
|
||||
sp.GetRequiredService<IpcCallerLifestream>()
|
||||
));
|
||||
sp.GetRequiredService<IpcCallerBrio>()));
|
||||
|
||||
// Notifications / HTTP
|
||||
services.AddSingleton(sp => new NotificationService(
|
||||
@@ -492,11 +486,19 @@ public sealed class Plugin : IDalamudPlugin
|
||||
sp.GetRequiredService<UiSharedService>(),
|
||||
sp.GetRequiredService<ApiController>(),
|
||||
sp.GetRequiredService<LightFinderScannerService>(),
|
||||
sp.GetRequiredService<LightFinderPlateHandler>()));
|
||||
|
||||
services.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>(sp => new SyncshellFinderUI(
|
||||
sp.GetRequiredService<ILogger<SyncshellFinderUI>>(),
|
||||
sp.GetRequiredService<LightlessMediator>(),
|
||||
sp.GetRequiredService<PerformanceCollectorService>(),
|
||||
sp.GetRequiredService<LightFinderService>(),
|
||||
sp.GetRequiredService<UiSharedService>(),
|
||||
sp.GetRequiredService<ApiController>(),
|
||||
sp.GetRequiredService<LightFinderScannerService>(),
|
||||
sp.GetRequiredService<PairUiService>(),
|
||||
sp.GetRequiredService<DalamudUtilService>(),
|
||||
sp.GetRequiredService<LightlessProfileManager>(),
|
||||
sp.GetRequiredService<ActorObjectService>(),
|
||||
sp.GetRequiredService<LightFinderPlateHandler>()));
|
||||
sp.GetRequiredService<LightlessProfileManager>()));
|
||||
|
||||
services.AddScoped<IPopupHandler, BanUserPopupHandler>();
|
||||
services.AddScoped<IPopupHandler, CensusPopupHandler>();
|
||||
@@ -582,6 +584,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_host.StopAsync().ContinueWith(_ => _host.Dispose()).Wait(TimeSpan.FromSeconds(5));
|
||||
_host.StopAsync().GetAwaiter().GetResult();
|
||||
_host.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace LightlessSync.Resources;
|
||||
|
||||
public static class LocalizationExtensions
|
||||
{
|
||||
public static string F(this string mask, params object[] args)
|
||||
{
|
||||
return string.Format(mask, args);
|
||||
}
|
||||
}
|
||||
171
LightlessSync/Resources/Resources.Designer.cs
generated
171
LightlessSync/Resources/Resources.Designer.cs
generated
@@ -1,171 +0,0 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
// Runtime Version:4.0.30319.42000
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace LightlessSync.Resources {
|
||||
using System;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A strongly-typed resource class, for looking up localized strings, etc.
|
||||
/// </summary>
|
||||
// This class was auto-generated by the StronglyTypedResourceBuilder
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
public class Resources {
|
||||
|
||||
private static global::System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal Resources() {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached ResourceManager instance used by this class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
public static global::System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.ReferenceEquals(resourceMan, null)) {
|
||||
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("LightlessSync.Resources.Resources", typeof(Resources).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the current thread's CurrentUICulture property for all
|
||||
/// resource lookups using this strongly typed resource class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
public static global::System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to I agree.
|
||||
/// </summary>
|
||||
public static string ToSStrings_AgreeLabel {
|
||||
get {
|
||||
return ResourceManager.GetString("ToSStrings_AgreeLabel", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Agreement of Usage of Service.
|
||||
/// </summary>
|
||||
public static string ToSStrings_AgreementLabel {
|
||||
get {
|
||||
return ResourceManager.GetString("ToSStrings_AgreementLabel", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to 'I agree' button will be available in.
|
||||
/// </summary>
|
||||
public static string ToSStrings_ButtonWillBeAvailableIn {
|
||||
get {
|
||||
return ResourceManager.GetString("ToSStrings_ButtonWillBeAvailableIn", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Language.
|
||||
/// </summary>
|
||||
public static string ToSStrings_LanguageLabel {
|
||||
get {
|
||||
return ResourceManager.GetString("ToSStrings_LanguageLabel", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to All of the mod files currently active on your character as well as your current character state will be uploaded to the service you registered yourself at automatically. The plugin will exclusively upload the necessary mod files and not the whole mod..
|
||||
/// </summary>
|
||||
public static string ToSStrings_Paragraph1 {
|
||||
get {
|
||||
return ResourceManager.GetString("ToSStrings_Paragraph1", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to If you are on a data capped internet connection, higher fees due to data usage depending on the amount of downloaded and uploaded mod files might occur. Mod files will be compressed on up- and download to save on bandwidth usage. Due to varying up- and download speeds, changes in characters might not be visible immediately. Files present on the service that already represent your active mod files will not be uploaded again..
|
||||
/// </summary>
|
||||
public static string ToSStrings_Paragraph2 {
|
||||
get {
|
||||
return ResourceManager.GetString("ToSStrings_Paragraph2", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The mod files you are uploading are confidential and will not be distributed to parties other than the ones who are requesting the exact same mod files. Please think about who you are going to pair since it is unavoidable that they will receive and locally cache the necessary mod files that you have currently in use. Locally cached mod files will have arbitrary file names to discourage attempts at replicating the original mod..
|
||||
/// </summary>
|
||||
public static string ToSStrings_Paragraph3 {
|
||||
get {
|
||||
return ResourceManager.GetString("ToSStrings_Paragraph3", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The plugin creator tried their best to keep you secure. However, there is no guarantee for 100% security. Do not blindly pair your client with everyone..
|
||||
/// </summary>
|
||||
public static string ToSStrings_Paragraph4 {
|
||||
get {
|
||||
return ResourceManager.GetString("ToSStrings_Paragraph4", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Mod files that are saved on the service will remain on the service as long as there are requests for the files from clients. After a period of not being used, the mod files will be automatically deleted. You will also be able to wipe all the files you have personally uploaded on request. The service holds no information about which mod files belong to which mod..
|
||||
/// </summary>
|
||||
public static string ToSStrings_Paragraph5 {
|
||||
get {
|
||||
return ResourceManager.GetString("ToSStrings_Paragraph5", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to This service is provided as-is. In case of abuse join the Lightless Sync Discord..
|
||||
/// </summary>
|
||||
public static string ToSStrings_Paragraph6 {
|
||||
get {
|
||||
return ResourceManager.GetString("ToSStrings_Paragraph6", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to READ THIS CAREFULLY.
|
||||
/// </summary>
|
||||
public static string ToSStrings_ReadLabel {
|
||||
get {
|
||||
return ResourceManager.GetString("ToSStrings_ReadLabel", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Users Online.
|
||||
/// </summary>
|
||||
public static string Users_Online {
|
||||
get {
|
||||
return ResourceManager.GetString("Users_Online", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="ToSStrings_LanguageLabel" xml:space="preserve">
|
||||
<value>Language</value>
|
||||
</data>
|
||||
<data name="ToSStrings_AgreementLabel" xml:space="preserve">
|
||||
<value>Nutzungsbedingungen</value>
|
||||
</data>
|
||||
<data name="ToSStrings_ReadLabel" xml:space="preserve">
|
||||
<value>BITTE LIES DIES SORGFÄLTIG</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph1" xml:space="preserve">
|
||||
<value>Alle Moddateien, die aktuell auf deinem Charakter aktiv sind und dein Charakterzustand werden automatisch zu dem Service, an dem du dich registriert hast, hochgeladen. Das Plugin wird ausschließlich die nötigen Moddateien hochladen und nicht die gesamte Modifikation.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph2" xml:space="preserve">
|
||||
<value>Falls du mit einer getakteten Internetverbindung verbunden bist, können durch den Datentransfer von Hoch- und Runtergeladenen Moddateien höhere Kosten entstehen. Moddateien werden beim Hoch- und Runterladen komprimiert um Bandbreite zu sparen. Durch unterschiedliche Hoch- und Runterladgeschwindigkeiten ist es möglich, dass Änderungen an Charakteren nicht sofort sichtbar sind. Dateien die bereits auf dem Service existieren, werden nicht nochmals hochgeladen.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph3" xml:space="preserve">
|
||||
<value>Die Moddateien die du hochlädst sind vertraulich und werden nicht mit anderen Nutzern geteilt, die nicht die exakt selben Dateien anfordern. Bitte überlege dir sorgfältig mit wem du deinen Identifikationscode teilst, da es unvermeidlich ist, dass die andere Person deine Moddateien erhält und lokal zwischenspeichert. Lokal zwischengespeicherte Dateien haben willkürrliche Namen um vor Versuchen abzuschrecken die originalen Moddateien aus diesen wiederherzustellen.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph4" xml:space="preserve">
|
||||
<value>Der Ersteller des Plugins hat sein Bestes getan, um deine Sicherheit zu gewährleisten. Es gibt jedoch keine Garantie für 100%ige Sicherheit. Teile deinen Identifikationscode nicht blind mit jedem.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph5" xml:space="preserve">
|
||||
<value>Moddateien, die auf dem Service gespeichert sind, verbleiben auf dem Service, solange es Anforderungen für diese Dateien gibt. Nach einer Zeitspanne in der die Dateien nicht verwendet wurden, werden diese automatisch gelöscht. Du hast auch die Möglichkeit manuell alle Dateien auf dem Service zu löschen. Der Service hat keine Informationen welche Moddateien zu welcher Modifikation gehören.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph6" xml:space="preserve">
|
||||
<value>Dieser Dienst wird ohne Gewähr angeboten. Im Falle eines Missbrauchs tretet dem Lightless Sync Discord bei.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_AgreeLabel" xml:space="preserve">
|
||||
<value>Ich Stimme zu</value>
|
||||
</data>
|
||||
<data name="ToSStrings_ButtonWillBeAvailableIn" xml:space="preserve">
|
||||
<value>"Ich stimme zu" Knopf verfügbar in</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1,47 +0,0 @@
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="ToSStrings_LanguageLabel" xml:space="preserve">
|
||||
<value>Language</value>
|
||||
</data>
|
||||
<data name="ToSStrings_AgreementLabel" xml:space="preserve">
|
||||
<value>Conditions d'Utilisation</value>
|
||||
</data>
|
||||
<data name="ToSStrings_ReadLabel" xml:space="preserve">
|
||||
<value>LISEZ CES INFORMATIONS ATTENTIVEMENT</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph1" xml:space="preserve">
|
||||
<value>Tous les fichiers moddés actuellement en cours d'utilisation ainsi que le statut actuel de votre personnage vont être mix en ligne via le service sur lequel vous vous êtes automatiquement enregistré. Seuls les fichiers nécessaires seront téléversés par le plugin et non pas le mod en entier.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph2" xml:space="preserve">
|
||||
<value>Si le débit de votre connexion internet est limité, le téléchargement et téléversement d'un grand nombre de fichiers peut entraîner des coûts supplémentaires. Les fichiers seront compressés au chargement et versement pour réduire l'impact sur votre bande passants. Selon la rapidité de vos téléchargements et téléversements, les changements ne seront peut-être pas visibles instantanément sur les personnages. Les fichiers déja présents sur le service qui correspondent à ceux de vos mods en cours d'utilisation ne seront pas remis en ligne.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph3" xml:space="preserve">
|
||||
<value>Les fichiers que vous allez partager sont confidentiels et ne seront envoyés qu'aux utilisateurs qui feront une requête exacte de ceux-çi. Nous vous demandons de (re)considérer qui sera synchronisé avec vous, puisqu'ils recevront et stockeront inévitablement en local les fichiers nécéssaires utilisés à cet instant. Les noms des fichiers stockés localement sont changés de manière arbitraire afin de décourager toute tentative de réplication des originaux.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph4" xml:space="preserve">
|
||||
<value>Le créateur de ce plugin a tenté de sécuriser l'application du mieux possible. Cependant, il ne peut pas garantir une protection 100% infaillible. Pour votre sécurité, ne vous synchronisez pas aveuglément et avec n'importe qui.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph5" xml:space="preserve">
|
||||
<value>Les fichiers sauvegardés sur le service resteront en ligne tant que des utilisateurs en feront usage. Ils seront effacés automatiquement après une certaine période d'inactivité. Vous pouvez également demander l'effacement de tous les fichiers que vous avez mis en ligne vous-même. Le service en soi ne contient aucune information pouvant identifier quel fichier appartient à quel mod.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph6" xml:space="preserve">
|
||||
<value>Ce service et ses composants vous sont fournis en l'état. En cas d'abus rejoindre le serveur Discord Lightless Sync.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_AgreeLabel" xml:space="preserve">
|
||||
<value>J'accept</value>
|
||||
</data>
|
||||
<data name="ToSStrings_ButtonWillBeAvailableIn" xml:space="preserve">
|
||||
<value>Bouton "J'accept" disposible dans</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="ToSStrings_AgreeLabel" xml:space="preserve">
|
||||
<value>I agree</value>
|
||||
</data>
|
||||
<data name="ToSStrings_AgreementLabel" xml:space="preserve">
|
||||
<value>Agreement of Usage of Service</value>
|
||||
</data>
|
||||
<data name="ToSStrings_ButtonWillBeAvailableIn" xml:space="preserve">
|
||||
<value>'I agree' button will be available in</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph1" xml:space="preserve">
|
||||
<value>All of the mod files currently active on your character as well as your current character state will be uploaded to the service you registered yourself at automatically. The plugin will exclusively upload the necessary mod files and not the whole mod.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph2" xml:space="preserve">
|
||||
<value>If you are on a data capped internet connection, higher fees due to data usage depending on the amount of downloaded and uploaded mod files might occur. Mod files will be compressed on up- and download to save on bandwidth usage. Due to varying up- and download speeds, changes in characters might not be visible immediately. Files present on the service that already represent your active mod files will not be uploaded again.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph3" xml:space="preserve">
|
||||
<value>The mod files you are uploading are confidential and will not be distributed to parties other than the ones who are requesting the exact same mod files. Please think about who you are going to pair since it is unavoidable that they will receive and locally cache the necessary mod files that you have currently in use. Locally cached mod files will have arbitrary file names to discourage attempts at replicating the original mod.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph4" xml:space="preserve">
|
||||
<value>The plugin creator tried their best to keep you secure. However, there is no guarantee for 100% security. Do not blindly pair your client with everyone.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph5" xml:space="preserve">
|
||||
<value>Mod files that are saved on the service will remain on the service as long as there are requests for the files from clients. After a period of not being used, the mod files will be automatically deleted. You will also be able to wipe all the files you have personally uploaded on request. The service holds no information about which mod files belong to which mod.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_Paragraph6" xml:space="preserve">
|
||||
<value>This service is provided as-is. In case of abuse join the Lightless Sync Discord.</value>
|
||||
</data>
|
||||
<data name="ToSStrings_ReadLabel" xml:space="preserve">
|
||||
<value>READ THIS CAREFULLY</value>
|
||||
</data>
|
||||
<data name="ToSStrings_LanguageLabel" xml:space="preserve">
|
||||
<value>Language</value>
|
||||
</data>
|
||||
<data name="Users_Online" xml:space="preserve">
|
||||
<value>Users Online</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1,20 +0,0 @@
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="ToSStrings_LanguageLabel" xml:space="preserve">
|
||||
<value>语言</value>
|
||||
</data>
|
||||
<data name="Users_Online" xml:space="preserve">
|
||||
<value>用户在线</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -28,7 +28,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
{
|
||||
_baseAnalysisCts = _baseAnalysisCts.CancelRecreate();
|
||||
var token = _baseAnalysisCts.Token;
|
||||
_ = Task.Run(async () => await BaseAnalysis(msg.CharacterData, token).ConfigureAwait(false), token);
|
||||
_ = BaseAnalysis(msg.CharacterData, token);
|
||||
});
|
||||
_fileCacheManager = fileCacheManager;
|
||||
_xivDataAnalyzer = modelAnalyzer;
|
||||
|
||||
@@ -22,10 +22,8 @@ using LightlessSync.Utils;
|
||||
using Lumina.Excel.Sheets;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
using System.Numerics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind;
|
||||
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
||||
@@ -666,7 +664,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
|
||||
var location = new LocationInfo();
|
||||
location.ServerId = _playerState.CurrentWorld.RowId;
|
||||
location.InstanceId = UIState.Instance()->PublicInstance.InstanceId;
|
||||
location.InstanceId = UIState.Instance()->PublicInstance.InstanceId;
|
||||
location.TerritoryId = _clientState.TerritoryType;
|
||||
location.MapId = _clientState.MapId;
|
||||
if (houseMan != null)
|
||||
@@ -699,13 +697,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
}
|
||||
return location;
|
||||
}
|
||||
|
||||
|
||||
public string LocationToString(LocationInfo location)
|
||||
{
|
||||
if (location.ServerId is 0 || location.TerritoryId is 0) return String.Empty;
|
||||
var str = WorldData.Value[(ushort)location.ServerId];
|
||||
|
||||
if (ContentFinderData.Value.TryGetValue(location.TerritoryId, out var dutyName))
|
||||
if (ContentFinderData.Value.TryGetValue(location.TerritoryId , out var dutyName))
|
||||
{
|
||||
str += $" - [In Duty]{dutyName}";
|
||||
}
|
||||
@@ -845,43 +843,33 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task WaitWhileCharacterIsDrawing(
|
||||
ILogger logger,
|
||||
GameObjectHandler handler,
|
||||
Guid redrawId,
|
||||
int timeOut = 5000,
|
||||
CancellationToken? ct = null)
|
||||
public async Task WaitWhileCharacterIsDrawing(ILogger logger, GameObjectHandler handler, Guid redrawId, int timeOut = 5000, CancellationToken? ct = null)
|
||||
{
|
||||
if (!_clientState.IsLoggedIn) return;
|
||||
|
||||
var token = ct ?? CancellationToken.None;
|
||||
if (ct == null)
|
||||
ct = CancellationToken.None;
|
||||
|
||||
const int tick = 250;
|
||||
const int initialSettle = 50;
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
int curWaitTime = 0;
|
||||
try
|
||||
{
|
||||
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 (!token.IsCancellationRequested
|
||||
&& sw.ElapsedMilliseconds < timeOut
|
||||
&& await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false))
|
||||
while ((!ct.Value.IsCancellationRequested)
|
||||
&& curWaitTime < timeOut
|
||||
&& await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false)) // 0b100000000000 is "still rendering" or something
|
||||
{
|
||||
logger.LogTrace("[{redrawId}] Waiting for {handler} to finish drawing", redrawId, handler);
|
||||
await Task.Delay(tick, token).ConfigureAwait(false);
|
||||
curWaitTime += tick;
|
||||
await Task.Delay(tick, ct.Value).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
logger.LogTrace("[{redrawId}] Finished drawing after {ms}ms", redrawId, sw.ElapsedMilliseconds);
|
||||
logger.LogTrace("[{redrawId}] Finished drawing after {curWaitTime}ms", redrawId, curWaitTime);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (AccessViolationException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler);
|
||||
}
|
||||
@@ -922,118 +910,46 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
public string? GetWorldNameFromPlayerAddress(nint address)
|
||||
{
|
||||
if (address == nint.Zero) return null;
|
||||
|
||||
|
||||
EnsureIsOnFramework();
|
||||
var playerCharacter = _objectTable.OfType<IPlayerCharacter>().FirstOrDefault(p => p.Address == address);
|
||||
if (playerCharacter == null) return null;
|
||||
|
||||
|
||||
var worldId = (ushort)playerCharacter.HomeWorld.RowId;
|
||||
return WorldData.Value.TryGetValue(worldId, out var worldName) ? worldName : null;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
|
||||
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;
|
||||
bool isDrawing = false;
|
||||
bool isDrawingChanged = false;
|
||||
|
||||
if ((nint)drawObj != IntPtr.Zero && IsValidPointer((nint)drawObj))
|
||||
if ((nint)drawObj != IntPtr.Zero)
|
||||
{
|
||||
isDrawing = gameObj->RenderFlags == (VisibilityFlags)0b100000000000;
|
||||
|
||||
if (!isDrawing)
|
||||
{
|
||||
var charBase = (CharacterBase*)drawObj;
|
||||
if (charBase != null && IsValidPointer((nint)charBase))
|
||||
isDrawing = ((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0;
|
||||
if (!isDrawing)
|
||||
{
|
||||
isDrawing = charBase->HasModelInSlotLoaded != 0;
|
||||
if (!isDrawing)
|
||||
isDrawing = ((CharacterBase*)drawObj)->HasModelFilesInSlotLoaded != 0;
|
||||
if (isDrawing && !string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal)
|
||||
&& !string.Equals(_lastGlobalBlockReason, "HasModelFilesInSlotLoaded", StringComparison.Ordinal))
|
||||
{
|
||||
isDrawing = charBase->HasModelFilesInSlotLoaded != 0;
|
||||
if (isDrawing && !string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal)
|
||||
&& !string.Equals(_lastGlobalBlockReason, "HasModelFilesInSlotLoaded", StringComparison.Ordinal))
|
||||
{
|
||||
_lastGlobalBlockPlayer = characterName;
|
||||
_lastGlobalBlockReason = "HasModelFilesInSlotLoaded";
|
||||
isDrawingChanged = true;
|
||||
}
|
||||
_lastGlobalBlockPlayer = characterName;
|
||||
_lastGlobalBlockReason = "HasModelFilesInSlotLoaded";
|
||||
isDrawingChanged = true;
|
||||
}
|
||||
else
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal)
|
||||
&& !string.Equals(_lastGlobalBlockReason, "HasModelInSlotLoaded", StringComparison.Ordinal))
|
||||
{
|
||||
if (!string.Equals(_lastGlobalBlockPlayer, characterName, StringComparison.Ordinal)
|
||||
&& !string.Equals(_lastGlobalBlockReason, "HasModelInSlotLoaded", StringComparison.Ordinal))
|
||||
{
|
||||
_lastGlobalBlockPlayer = characterName;
|
||||
_lastGlobalBlockReason = "HasModelInSlotLoaded";
|
||||
isDrawingChanged = true;
|
||||
}
|
||||
_lastGlobalBlockPlayer = characterName;
|
||||
_lastGlobalBlockReason = "HasModelInSlotLoaded";
|
||||
isDrawingChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1064,11 +980,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
|
||||
private unsafe void FrameworkOnUpdateInternal()
|
||||
{
|
||||
if (!_clientState.IsLoggedIn || _objectTable.LocalPlayer == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ((_objectTable.LocalPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty])
|
||||
{
|
||||
return;
|
||||
@@ -1088,17 +999,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
}
|
||||
|
||||
var playerDescriptors = _actorObjectService.PlayerDescriptors;
|
||||
var descriptorCount = playerDescriptors.Count;
|
||||
|
||||
for (var i = 0; i < descriptorCount; i++)
|
||||
for (var i = 0; i < playerDescriptors.Count; i++)
|
||||
{
|
||||
if (i >= playerDescriptors.Count)
|
||||
break;
|
||||
|
||||
var actor = playerDescriptors[i];
|
||||
|
||||
var playerAddress = actor.Address;
|
||||
if (playerAddress == nint.Zero || !IsValidPointer(playerAddress))
|
||||
if (playerAddress == nint.Zero)
|
||||
continue;
|
||||
|
||||
if (actor.ObjectIndex >= 200)
|
||||
@@ -1112,16 +1018,17 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
|
||||
if (!IsAnythingDrawing)
|
||||
{
|
||||
if (!_objectTable.Any(o => o?.Address == playerAddress))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
CheckCharacterForDrawing(playerAddress, actor.Name);
|
||||
|
||||
var gameObj = (GameObject*)playerAddress;
|
||||
var currentName = gameObj != null ? gameObj->NameString ?? string.Empty : string.Empty;
|
||||
var charaName = string.IsNullOrEmpty(currentName) ? actor.Name : currentName;
|
||||
CheckCharacterForDrawing(playerAddress, charaName);
|
||||
if (IsAnythingDrawing)
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1190,7 +1097,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
});
|
||||
|
||||
// Cutscene
|
||||
HandleStateTransition(() => IsInCutscene, v => IsInCutscene = v, shouldBeInCutscene, "Cutscene",
|
||||
HandleStateTransition(() => IsInCutscene,v => IsInCutscene = v, shouldBeInCutscene, "Cutscene",
|
||||
onEnter: () =>
|
||||
{
|
||||
Mediator.Publish(new CutsceneStartMessage());
|
||||
@@ -1233,7 +1140,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
Mediator.Publish(new ZoneSwitchEndMessage());
|
||||
Mediator.Publish(new ResumeScanMessage(nameof(ConditionFlag.BetweenAreas)));
|
||||
}
|
||||
|
||||
|
||||
//Map
|
||||
if (!_sentBetweenAreas)
|
||||
{
|
||||
@@ -1244,7 +1151,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
Mediator.Publish(new MapChangedMessage(mapid));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
var localPlayer = _objectTable.LocalPlayer;
|
||||
if (localPlayer != null)
|
||||
@@ -1330,4 +1237,4 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
onExit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Dalamud.Plugin.Services;
|
||||
using Dalamud.Plugin.Services;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.Services.ActorTracking;
|
||||
using LightlessSync.Services.Mediator;
|
||||
@@ -23,7 +23,6 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
private readonly HashSet<string> _syncshellCids = [];
|
||||
private volatile bool _pendingLocalBroadcast;
|
||||
private TimeSpan? _pendingLocalTtl;
|
||||
private string? _pendingLocalGid;
|
||||
|
||||
private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4);
|
||||
private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1);
|
||||
@@ -37,7 +36,6 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
private const int _maxQueueSize = 100;
|
||||
|
||||
private volatile bool _batchRunning = false;
|
||||
private volatile bool _disposed = false;
|
||||
|
||||
public IReadOnlyDictionary<string, BroadcastEntry> BroadcastCache => _broadcastCache;
|
||||
public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID);
|
||||
@@ -70,9 +68,6 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
|
||||
public void Update()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_frameCounter++;
|
||||
var lookupsThisFrame = 0;
|
||||
|
||||
@@ -116,14 +111,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
|
||||
private async Task BatchUpdateBroadcastCacheAsync(List<string> cids)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
var results = await _broadcastService.AreUsersBroadcastingAsync(cids).ConfigureAwait(false);
|
||||
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var (cid, info) in results)
|
||||
@@ -142,9 +130,6 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
(_, old) => new BroadcastEntry(info.IsBroadcasting, expiry, info.GID));
|
||||
}
|
||||
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
var activeCids = _broadcastCache
|
||||
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now)
|
||||
.Select(e => e.Key)
|
||||
@@ -157,9 +142,6 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
|
||||
private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
if (!msg.Enabled)
|
||||
{
|
||||
_broadcastCache.Clear();
|
||||
@@ -176,7 +158,6 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
|
||||
_pendingLocalBroadcast = true;
|
||||
_pendingLocalTtl = msg.Ttl;
|
||||
_pendingLocalGid = msg.Gid;
|
||||
TryPrimeLocalBroadcastCache();
|
||||
}
|
||||
|
||||
@@ -192,12 +173,11 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
var expiry = DateTime.UtcNow + ttl;
|
||||
|
||||
_broadcastCache.AddOrUpdate(localCid,
|
||||
new BroadcastEntry(true, expiry, _pendingLocalGid),
|
||||
(_, old) => new BroadcastEntry(true, expiry, _pendingLocalGid ?? old.GID));
|
||||
new BroadcastEntry(true, expiry, null),
|
||||
(_, old) => new BroadcastEntry(true, expiry, old.GID));
|
||||
|
||||
_pendingLocalBroadcast = false;
|
||||
_pendingLocalTtl = null;
|
||||
_pendingLocalGid = null;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var activeCids = _broadcastCache
|
||||
@@ -207,14 +187,10 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
|
||||
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
|
||||
_lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids);
|
||||
UpdateSyncshellBroadcasts();
|
||||
}
|
||||
|
||||
private void UpdateSyncshellBroadcasts()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var nearbyCids = GetNearbyHashedCids(out _);
|
||||
var newSet = nearbyCids.Count == 0
|
||||
@@ -348,35 +324,17 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
_disposed = true;
|
||||
base.Dispose(disposing);
|
||||
_framework.Update -= OnFrameworkUpdate;
|
||||
|
||||
try
|
||||
if (_cleanupTask != null)
|
||||
{
|
||||
_cleanupCts.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already disposed, can be ignored :)
|
||||
_cleanupTask?.Wait(100, _cleanupCts.Token);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_cleanupTask?.Wait(100);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Task may have already completed or been cancelled?
|
||||
}
|
||||
_cleanupCts.Cancel();
|
||||
_cleanupCts.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
_cleanupCts.Dispose();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already disposed, ignore
|
||||
}
|
||||
_cleanupTask?.Wait(100);
|
||||
_cleanupCts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
@@ -121,10 +121,7 @@ public class LightFinderService : IHostedService, IMediatorSubscriber
|
||||
_waitingForTtlFetch = false;
|
||||
|
||||
if (!wasEnabled || previousRemaining != validTtl)
|
||||
{
|
||||
var gid = _config.Current.SyncshellFinderEnabled ? _config.Current.SelectedFinderSyncshell : null;
|
||||
_mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl, gid));
|
||||
}
|
||||
_mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl));
|
||||
|
||||
_logger.LogInformation("Lightfinder broadcast enabled ({Context}), TTL: {TTL}", context, validTtl);
|
||||
return true;
|
||||
|
||||
@@ -63,31 +63,23 @@ public sealed class LightlessMediator : IHostedService
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
while (!_loopCts.Token.IsCancellationRequested)
|
||||
{
|
||||
while (!_loopCts.Token.IsCancellationRequested)
|
||||
while (!_processQueue)
|
||||
{
|
||||
while (!_processQueue)
|
||||
{
|
||||
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
|
||||
|
||||
HashSet<MessageBase> processedMessages = [];
|
||||
while (_messageQueue.TryDequeue(out var message))
|
||||
{
|
||||
if (processedMessages.Contains(message)) { continue; }
|
||||
|
||||
processedMessages.Add(message);
|
||||
|
||||
ExecuteMessage(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("LightlessMediator stopped");
|
||||
|
||||
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
|
||||
|
||||
HashSet<MessageBase> processedMessages = [];
|
||||
while (_messageQueue.TryDequeue(out var message))
|
||||
{
|
||||
if (processedMessages.Contains(message)) { continue; }
|
||||
processedMessages.Add(message);
|
||||
|
||||
ExecuteMessage(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) :
|
||||
public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase;
|
||||
public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase;
|
||||
public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase;
|
||||
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl, string? Gid = null) : MessageBase;
|
||||
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : MessageBase;
|
||||
public record UserLeftSyncshell(string gid) : MessageBase;
|
||||
public record UserJoinedSyncshell(string gid) : MessageBase;
|
||||
public record SyncshellBroadcastsUpdatedMessage : MessageBase;
|
||||
|
||||
@@ -8,7 +8,6 @@ using LightlessSync.UI.Tags;
|
||||
using LightlessSync.WebAPI;
|
||||
using LightlessSync.UI.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using LightlessSync.PlayerData.Factories;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
@@ -24,7 +23,6 @@ public class UiFactory
|
||||
private readonly PerformanceCollectorService _performanceCollectorService;
|
||||
private readonly ProfileTagService _profileTagService;
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private readonly PairFactory _pairFactory;
|
||||
|
||||
public UiFactory(
|
||||
ILoggerFactory loggerFactory,
|
||||
@@ -36,8 +34,7 @@ public class UiFactory
|
||||
LightlessProfileManager lightlessProfileManager,
|
||||
PerformanceCollectorService performanceCollectorService,
|
||||
ProfileTagService profileTagService,
|
||||
DalamudUtilService dalamudUtilService,
|
||||
PairFactory pairFactory)
|
||||
DalamudUtilService dalamudUtilService)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_lightlessMediator = lightlessMediator;
|
||||
@@ -49,7 +46,6 @@ public class UiFactory
|
||||
_performanceCollectorService = performanceCollectorService;
|
||||
_profileTagService = profileTagService;
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
_pairFactory = pairFactory;
|
||||
}
|
||||
|
||||
public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto)
|
||||
@@ -62,8 +58,7 @@ public class UiFactory
|
||||
_pairUiService,
|
||||
dto,
|
||||
_performanceCollectorService,
|
||||
_lightlessProfileManager,
|
||||
_pairFactory);
|
||||
_lightlessProfileManager);
|
||||
}
|
||||
|
||||
public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair)
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
||||
using FFXIVClientStructs.Havok.Common.Serialize.Resource;
|
||||
using FFXIVClientStructs.Havok.Animation;
|
||||
using FFXIVClientStructs.Havok.Common.Base.Types;
|
||||
using FFXIVClientStructs.Havok.Common.Serialize.Resource;
|
||||
using FFXIVClientStructs.Havok.Common.Serialize.Util;
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.Interop.GameModel;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.PlayerData.Factories;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OtterGui.Text.EndObjects;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public sealed partial class XivDataAnalyzer
|
||||
public sealed class XivDataAnalyzer
|
||||
{
|
||||
private readonly ILogger<XivDataAnalyzer> _logger;
|
||||
private readonly FileCacheManager _fileCacheManager;
|
||||
@@ -36,435 +31,127 @@ public sealed partial class XivDataAnalyzer
|
||||
|
||||
public unsafe Dictionary<string, List<ushort>>? GetSkeletonBoneIndices(GameObjectHandler handler)
|
||||
{
|
||||
if (handler is null || handler.Address == nint.Zero)
|
||||
return null;
|
||||
|
||||
Dictionary<string, HashSet<ushort>> sets = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (handler.Address == nint.Zero) return null;
|
||||
var chara = (CharacterBase*)(((Character*)handler.Address)->GameObject.DrawObject);
|
||||
if (chara->GetModelType() != CharacterBase.ModelType.Human) return null;
|
||||
var resHandles = chara->Skeleton->SkeletonResourceHandles;
|
||||
Dictionary<string, List<ushort>> outputIndices = [];
|
||||
try
|
||||
{
|
||||
var drawObject = ((Character*)handler.Address)->GameObject.DrawObject;
|
||||
if (drawObject == null)
|
||||
return null;
|
||||
|
||||
var chara = (CharacterBase*)drawObject;
|
||||
if (chara->GetModelType() != CharacterBase.ModelType.Human)
|
||||
return null;
|
||||
|
||||
var skeleton = chara->Skeleton;
|
||||
if (skeleton == null)
|
||||
return null;
|
||||
|
||||
var resHandles = skeleton->SkeletonResourceHandles;
|
||||
var partialCount = skeleton->PartialSkeletonCount;
|
||||
if (partialCount <= 0)
|
||||
return null;
|
||||
|
||||
for (int i = 0; i < partialCount; i++)
|
||||
for (int i = 0; i < chara->Skeleton->PartialSkeletonCount; i++)
|
||||
{
|
||||
var handle = *(resHandles + i);
|
||||
if ((nint)handle == nint.Zero)
|
||||
continue;
|
||||
|
||||
if (handle->FileName.Length > 1024)
|
||||
continue;
|
||||
|
||||
var rawName = handle->FileName.ToString();
|
||||
if (string.IsNullOrWhiteSpace(rawName))
|
||||
continue;
|
||||
|
||||
var skeletonKey = CanonicalizeSkeletonKey(rawName);
|
||||
if (string.IsNullOrEmpty(skeletonKey))
|
||||
continue;
|
||||
|
||||
var boneCount = handle->BoneCount;
|
||||
if (boneCount == 0)
|
||||
continue;
|
||||
|
||||
var havokSkel = handle->HavokSkeleton;
|
||||
if ((nint)havokSkel == nint.Zero)
|
||||
continue;
|
||||
|
||||
if (!sets.TryGetValue(skeletonKey, out var set))
|
||||
_logger.LogTrace("Iterating over SkeletonResourceHandle #{i}:{x}", i, ((nint)handle).ToString("X"));
|
||||
if ((nint)handle == nint.Zero) continue;
|
||||
var curBones = handle->BoneCount;
|
||||
// this is unrealistic, the filename shouldn't ever be that long
|
||||
if (handle->FileName.Length > 1024) continue;
|
||||
var skeletonName = handle->FileName.ToString();
|
||||
if (string.IsNullOrEmpty(skeletonName)) continue;
|
||||
outputIndices[skeletonName] = [];
|
||||
for (ushort boneIdx = 0; boneIdx < curBones; boneIdx++)
|
||||
{
|
||||
set = [];
|
||||
sets[skeletonKey] = set;
|
||||
var boneName = handle->HavokSkeleton->Bones[boneIdx].Name.String;
|
||||
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)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not process skeleton data");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sets.Count == 0)
|
||||
return null;
|
||||
|
||||
var output = new Dictionary<string, List<ushort>>(sets.Count, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, set) in sets)
|
||||
{
|
||||
if (set.Count == 0)
|
||||
continue;
|
||||
|
||||
var list = set.ToList();
|
||||
list.Sort();
|
||||
output[key] = list;
|
||||
}
|
||||
|
||||
return (output.Count != 0 && output.Values.All(v => v.Count > 0)) ? output : null;
|
||||
return (outputIndices.Count != 0 && outputIndices.Values.All(u => u.Count > 0)) ? outputIndices : null;
|
||||
}
|
||||
|
||||
public static byte[]? ReadHavokBytesFromPap(string papPath)
|
||||
public unsafe Dictionary<string, List<ushort>>? GetBoneIndicesFromPap(string hash)
|
||||
{
|
||||
using var fs = File.Open(papPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var reader = new BinaryReader(fs);
|
||||
if (_configService.Current.BonesDictionary.TryGetValue(hash, out var bones)) return bones;
|
||||
|
||||
_ = reader.ReadInt32();
|
||||
_ = reader.ReadInt32();
|
||||
_ = reader.ReadInt16();
|
||||
_ = reader.ReadInt16();
|
||||
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
|
||||
if (cacheEntity == null) return null;
|
||||
|
||||
var type = reader.ReadByte();
|
||||
if (type != 0) return null;
|
||||
using BinaryReader reader = new(File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read));
|
||||
|
||||
_ = reader.ReadByte();
|
||||
_ = reader.ReadInt32();
|
||||
// most of this shit is from vfxeditor, surely nothing will change in the pap format :copium:
|
||||
reader.ReadInt32(); // ignore
|
||||
reader.ReadInt32(); // ignore
|
||||
reader.ReadInt16(); // read 2 (num animations)
|
||||
reader.ReadInt16(); // read 2 (modelid)
|
||||
var type = reader.ReadByte();// read 1 (type)
|
||||
if (type != 0) return null; // it's not human, just ignore it, whatever
|
||||
|
||||
reader.ReadByte(); // read 1 (variant)
|
||||
reader.ReadInt32(); // ignore
|
||||
var havokPosition = reader.ReadInt32();
|
||||
var footerPosition = reader.ReadInt32();
|
||||
var havokDataSize = footerPosition - havokPosition;
|
||||
reader.BaseStream.Position = havokPosition;
|
||||
var havokData = reader.ReadBytes(havokDataSize);
|
||||
if (havokData.Length <= 8) return null; // no havok data
|
||||
|
||||
if (havokPosition <= 0 || footerPosition <= havokPosition || footerPosition > fs.Length)
|
||||
return null;
|
||||
|
||||
var sizeLong = (long)footerPosition - havokPosition;
|
||||
if (sizeLong <= 8 || sizeLong > int.MaxValue)
|
||||
return null;
|
||||
|
||||
var size = (int)sizeLong;
|
||||
|
||||
fs.Position = havokPosition;
|
||||
var bytes = reader.ReadBytes(size);
|
||||
return bytes.Length > 8 ? bytes : null;
|
||||
}
|
||||
|
||||
public unsafe Dictionary<string, List<ushort>>? ParseHavokBytesOnFrameworkThread(
|
||||
byte[] havokData,
|
||||
string hash,
|
||||
bool persistToConfig)
|
||||
{
|
||||
var tempSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var tempHkxPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx");
|
||||
IntPtr pathAnsi = IntPtr.Zero;
|
||||
var output = new Dictionary<string, List<ushort>>(StringComparer.OrdinalIgnoreCase);
|
||||
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) + ".hkx";
|
||||
var tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath);
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(tempHkxPath, havokData);
|
||||
File.WriteAllBytes(tempHavokDataPath, havokData);
|
||||
|
||||
pathAnsi = Marshal.StringToHGlobalAnsi(tempHkxPath);
|
||||
|
||||
hkSerializeUtil.LoadOptions loadOptions = default;
|
||||
loadOptions.TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry();
|
||||
loadOptions.ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry();
|
||||
loadOptions.Flags = new hkFlags<hkSerializeUtil.LoadOptionBits, int>
|
||||
var loadoptions = stackalloc hkSerializeUtil.LoadOptions[1];
|
||||
loadoptions->TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry();
|
||||
loadoptions->ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry();
|
||||
loadoptions->Flags = new hkFlags<hkSerializeUtil.LoadOptionBits, int>
|
||||
{
|
||||
Storage = (int)hkSerializeUtil.LoadOptionBits.Default
|
||||
Storage = (int)(hkSerializeUtil.LoadOptionBits.Default)
|
||||
};
|
||||
|
||||
hkSerializeUtil.LoadOptions* pOpts = &loadOptions;
|
||||
|
||||
var resource = hkSerializeUtil.LoadFromFile((byte*)pathAnsi, errorResult: null, pOpts);
|
||||
var resource = hkSerializeUtil.LoadFromFile((byte*)tempHavokDataPathAnsi, null, loadoptions);
|
||||
if (resource == null)
|
||||
return null;
|
||||
{
|
||||
throw new InvalidOperationException("Resource was null after loading");
|
||||
}
|
||||
|
||||
var rootLevelName = @"hkRootLevelContainer"u8;
|
||||
fixed (byte* n1 = rootLevelName)
|
||||
{
|
||||
var container = (hkRootLevelContainer*)resource->GetContentsPointer(
|
||||
n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry());
|
||||
|
||||
if (container == null) return null;
|
||||
|
||||
var container = (hkRootLevelContainer*)resource->GetContentsPointer(n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry());
|
||||
var animationName = @"hkaAnimationContainer"u8;
|
||||
fixed (byte* n2 = animationName)
|
||||
{
|
||||
var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null);
|
||||
if (animContainer == null) return null;
|
||||
|
||||
for (int i = 0; i < animContainer->Bindings.Length; i++)
|
||||
{
|
||||
var binding = animContainer->Bindings[i].ptr;
|
||||
if (binding == null) continue;
|
||||
|
||||
var rawSkel = binding->OriginalSkeletonName.String;
|
||||
var skeletonKey = CanonicalizeSkeletonKey(rawSkel);
|
||||
if (string.IsNullOrEmpty(skeletonKey)) continue;
|
||||
|
||||
var boneTransform = binding->TransformTrackToBoneIndices;
|
||||
if (boneTransform.Length <= 0) continue;
|
||||
|
||||
if (!tempSets.TryGetValue(skeletonKey, out var set))
|
||||
tempSets[skeletonKey] = set = [];
|
||||
|
||||
string name = binding->OriginalSkeletonName.String! + "_" + i;
|
||||
output[name] = [];
|
||||
for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++)
|
||||
{
|
||||
var v = boneTransform[boneIdx];
|
||||
if (v < 0) continue;
|
||||
set.Add((ushort)v);
|
||||
output[name].Add((ushort)boneTransform[boneIdx]);
|
||||
}
|
||||
output[name].Sort();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not load havok file in {path}", tempHavokDataPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pathAnsi != IntPtr.Zero)
|
||||
Marshal.FreeHGlobal(pathAnsi);
|
||||
|
||||
try { if (File.Exists(tempHkxPath)) File.Delete(tempHkxPath); }
|
||||
catch { /* ignore */ }
|
||||
Marshal.FreeHGlobal(tempHavokDataPathAnsi);
|
||||
File.Delete(tempHavokDataPath);
|
||||
}
|
||||
|
||||
if (tempSets.Count == 0) return null;
|
||||
|
||||
var output = new Dictionary<string, List<ushort>>(tempSets.Count, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, set) in tempSets)
|
||||
{
|
||||
if (set.Count == 0) continue;
|
||||
var list = set.ToList();
|
||||
list.Sort();
|
||||
output[key] = list;
|
||||
}
|
||||
|
||||
if (output.Count == 0) return null;
|
||||
|
||||
_configService.Current.BonesDictionary[hash] = output;
|
||||
if (persistToConfig) _configService.Save();
|
||||
|
||||
_configService.Save();
|
||||
return output;
|
||||
}
|
||||
|
||||
public static string CanonicalizeSkeletonKey(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return string.Empty;
|
||||
|
||||
var s = raw.Replace('\\', '/').Trim();
|
||||
|
||||
var underscore = s.LastIndexOf('_');
|
||||
if (underscore > 0 && underscore + 1 < s.Length && char.IsDigit(s[underscore + 1]))
|
||||
s = s[..underscore];
|
||||
|
||||
if (s.StartsWith("skeleton", StringComparison.OrdinalIgnoreCase))
|
||||
return "skeleton";
|
||||
|
||||
var m = _bucketPathRegex.Match(s);
|
||||
if (m.Success)
|
||||
return m.Groups["bucket"].Value.ToLowerInvariant();
|
||||
|
||||
m = _bucketSklRegex.Match(s);
|
||||
if (m.Success)
|
||||
return m.Groups["bucket"].Value.ToLowerInvariant();
|
||||
|
||||
m = _bucketLooseRegex.Match(s);
|
||||
if (m.Success)
|
||||
return m.Groups["bucket"].Value.ToLowerInvariant();
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
public static bool ContainsIndexCompat(
|
||||
HashSet<ushort> available,
|
||||
ushort idx,
|
||||
bool papLikelyOneBased,
|
||||
bool allowOneBasedShift,
|
||||
bool allowNeighborTolerance)
|
||||
{
|
||||
Span<ushort> candidates = stackalloc ushort[2];
|
||||
int count = 0;
|
||||
|
||||
candidates[count++] = idx;
|
||||
|
||||
if (allowOneBasedShift && papLikelyOneBased && idx > 0)
|
||||
candidates[count++] = (ushort)(idx - 1);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var c = candidates[i];
|
||||
|
||||
if (available.Contains(c))
|
||||
return true;
|
||||
|
||||
if (allowNeighborTolerance)
|
||||
{
|
||||
if (c > 0 && available.Contains((ushort)(c - 1)))
|
||||
return true;
|
||||
|
||||
if (c < ushort.MaxValue && available.Contains((ushort)(c + 1)))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool IsPapCompatible(
|
||||
IReadOnlyDictionary<string, HashSet<ushort>> localBoneSets,
|
||||
IReadOnlyDictionary<string, List<ushort>> papBoneIndices,
|
||||
AnimationValidationMode mode,
|
||||
bool allowOneBasedShift,
|
||||
bool allowNeighborTolerance,
|
||||
out string reason)
|
||||
{
|
||||
reason = string.Empty;
|
||||
|
||||
if (mode == AnimationValidationMode.Unsafe)
|
||||
return true;
|
||||
|
||||
var papByBucket = new Dictionary<string, List<ushort>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var (rawKey, list) in papBoneIndices)
|
||||
{
|
||||
var key = CanonicalizeSkeletonKey(rawKey);
|
||||
if (string.IsNullOrEmpty(key))
|
||||
continue;
|
||||
|
||||
if (string.Equals(key, "skeleton", StringComparison.OrdinalIgnoreCase))
|
||||
key = "__any__";
|
||||
|
||||
if (!papByBucket.TryGetValue(key, out var acc))
|
||||
papByBucket[key] = acc = [];
|
||||
|
||||
if (list is { Count: > 0 })
|
||||
acc.AddRange(list);
|
||||
}
|
||||
|
||||
foreach (var k in papByBucket.Keys.ToList())
|
||||
papByBucket[k] = papByBucket[k].Distinct().ToList();
|
||||
|
||||
if (papByBucket.Count == 0)
|
||||
{
|
||||
reason = "No skeleton bucket bindings found in the PAP";
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool AllIndicesOk(
|
||||
HashSet<ushort> available,
|
||||
List<ushort> indices,
|
||||
bool papLikelyOneBased,
|
||||
bool allowOneBasedShift,
|
||||
bool allowNeighborTolerance,
|
||||
out ushort missing)
|
||||
{
|
||||
foreach (var idx in indices)
|
||||
{
|
||||
if (!ContainsIndexCompat(available, idx, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance))
|
||||
{
|
||||
missing = idx;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
missing = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var (bucket, indices) in papByBucket)
|
||||
{
|
||||
if (indices.Count == 0)
|
||||
continue;
|
||||
|
||||
bool has0 = false, has1 = false;
|
||||
ushort min = ushort.MaxValue;
|
||||
foreach (var v in indices)
|
||||
{
|
||||
if (v == 0) has0 = true;
|
||||
if (v == 1) has1 = true;
|
||||
if (v < min) min = v;
|
||||
}
|
||||
bool papLikelyOneBased = allowOneBasedShift && (min == 1) && has1 && !has0;
|
||||
|
||||
if (string.Equals(bucket, "__any__", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var (lk, ls) in localBoneSets)
|
||||
{
|
||||
if (AllIndicesOk(ls, indices, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance, out _))
|
||||
goto nextBucket;
|
||||
}
|
||||
|
||||
reason = $"No compatible local skeleton bucket for generic PAP skeleton '{bucket}'. Local buckets: {string.Join(", ", localBoneSets.Keys)}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!localBoneSets.TryGetValue(bucket, out var available))
|
||||
{
|
||||
reason = $"Missing skeleton bucket '{bucket}' on local actor.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!AllIndicesOk(available, indices, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance, out var missing))
|
||||
{
|
||||
reason = $"No compatible local skeleton for PAP '{bucket}': missing bone index {missing}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
nextBucket:
|
||||
;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void DumpLocalSkeletonIndices(GameObjectHandler handler, string? filter = null)
|
||||
{
|
||||
var skels = GetSkeletonBoneIndices(handler);
|
||||
if (skels == null)
|
||||
{
|
||||
_logger.LogTrace("DumpLocalSkeletonIndices: local skeleton indices are null or not found");
|
||||
return;
|
||||
}
|
||||
|
||||
var keys = skels.Keys
|
||||
.Order(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
_logger.LogTrace("Local skeleton indices found ({count}): {keys}",
|
||||
keys.Length,
|
||||
string.Join(", ", keys));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
var hits = keys.Where(k =>
|
||||
k.Equals(filter, StringComparison.OrdinalIgnoreCase) ||
|
||||
k.StartsWith(filter + "_", StringComparison.OrdinalIgnoreCase) ||
|
||||
filter.StartsWith(k + "_", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Contains(filter, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
_logger.LogTrace("Matches found for '{filter}': {hits}",
|
||||
filter,
|
||||
hits.Length == 0 ? "<none>" : string.Join(", ", hits));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<long> GetTrianglesByHash(string hash)
|
||||
{
|
||||
if (_configService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris) && cachedTris > 0)
|
||||
@@ -552,23 +239,4 @@ public sealed partial class XivDataAnalyzer
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Regexes for canonicalizing skeleton keys
|
||||
private static readonly Regex _bucketPathRegex =
|
||||
BucketRegex();
|
||||
|
||||
private static readonly Regex _bucketSklRegex =
|
||||
SklRegex();
|
||||
|
||||
private static readonly Regex _bucketLooseRegex =
|
||||
LooseBucketRegex();
|
||||
|
||||
[GeneratedRegex(@"(?i)(?:^|/)(?<bucket>c\d{4})(?:/|$)", RegexOptions.Compiled, "en-NL")]
|
||||
private static partial Regex BucketRegex();
|
||||
|
||||
[GeneratedRegex(@"(?i)\bskl_(?<bucket>c\d{4})[a-z]\d{4}\b", RegexOptions.Compiled, "en-NL")]
|
||||
private static partial Regex SklRegex();
|
||||
|
||||
[GeneratedRegex(@"(?i)(?<![a-z0-9])(?<bucket>c\d{4})(?!\d)", RegexOptions.Compiled, "en-NL")]
|
||||
private static partial Regex LooseBucketRegex();
|
||||
}
|
||||
|
||||
@@ -34,65 +34,44 @@ namespace LightlessSync.UI;
|
||||
|
||||
public class CompactUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
#region Constants
|
||||
|
||||
private const float ConnectButtonHighlightThickness = 14f;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Services
|
||||
|
||||
private readonly ApiController _apiController;
|
||||
private readonly CharacterAnalyzer _characterAnalyzer;
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private readonly DrawEntityFactory _drawEntityFactory;
|
||||
private readonly FileUploadManager _fileTransferManager;
|
||||
private readonly IpcManager _ipcManager;
|
||||
private readonly LightFinderService _broadcastService;
|
||||
private readonly ApiController _apiController;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly LightlessMediator _lightlessMediator;
|
||||
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 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 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;
|
||||
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
|
||||
private readonly ConcurrentDictionary<GameObjectHandler, Dictionary<string, FileDownloadStatus>> _currentDownloads = new();
|
||||
private readonly TagHandler _tagHandler;
|
||||
private readonly UiSharedService _uiSharedService;
|
||||
private readonly LightFinderService _broadcastService;
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
|
||||
private List<IDrawFolder> _drawFolders;
|
||||
private Pair? _focusedPair;
|
||||
private Pair? _lastAddedUser;
|
||||
private string _lastAddedUserComment = string.Empty;
|
||||
private Vector2 _lastPosition = Vector2.One;
|
||||
private Vector2 _lastSize = Vector2.One;
|
||||
private int _pendingFocusFrame = -1;
|
||||
private Pair? _pendingFocusPair;
|
||||
private bool _showModalForUserAddition;
|
||||
private float _transferPartHeight;
|
||||
private bool _wasOpen;
|
||||
private float _windowContentWidth;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
private readonly SeluneBrush _seluneBrush = new();
|
||||
private const float _connectButtonHighlightThickness = 14f;
|
||||
private Pair? _focusedPair;
|
||||
private Pair? _pendingFocusPair;
|
||||
private int _pendingFocusFrame = -1;
|
||||
|
||||
public CompactUi(
|
||||
ILogger<CompactUi> logger,
|
||||
@@ -148,11 +127,6 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
.Apply();
|
||||
|
||||
_drawFolders = [.. DrawFolders];
|
||||
|
||||
_animatedHeader.Height = 120f;
|
||||
_animatedHeader.EnableBottomGradient = true;
|
||||
_animatedHeader.GradientHeight = 250f;
|
||||
_animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects;
|
||||
|
||||
#if DEBUG
|
||||
string dev = "Dev Build";
|
||||
@@ -167,26 +141,18 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
Mediator.Subscribe<SwitchToIntroUiMessage>(this, (_) => IsOpen = false);
|
||||
Mediator.Subscribe<CutsceneStartMessage>(this, (_) => UiSharedService_GposeStart());
|
||||
Mediator.Subscribe<CutsceneEndMessage>(this, (_) => UiSharedService_GposeEnd());
|
||||
Mediator.Subscribe<DownloadStartedMessage>(this, msg =>
|
||||
{
|
||||
_currentDownloads[msg.DownloadId] = new Dictionary<string, FileDownloadStatus>(msg.DownloadStatus, StringComparer.Ordinal);
|
||||
});
|
||||
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus);
|
||||
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;
|
||||
_playerPerformanceConfig = playerPerformanceConfig;
|
||||
_lightlessMediator = mediator;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
|
||||
public override void OnClose()
|
||||
{
|
||||
ForceReleaseFocus();
|
||||
_animatedHeader.ClearParticles();
|
||||
base.OnClose();
|
||||
}
|
||||
|
||||
@@ -198,13 +164,6 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize);
|
||||
|
||||
_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)
|
||||
{
|
||||
var ver = _apiController.CurrentClientVersion;
|
||||
@@ -250,11 +209,17 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
|
||||
using (ImRaii.PushId("header")) DrawUIDHeader();
|
||||
_uiSharedService.RoundedSeparator(UIColors.Get("LightlessPurple"), 2.5f, 1f, 12f);
|
||||
using (ImRaii.PushId("serverstatus"))
|
||||
{
|
||||
DrawServerStatus();
|
||||
}
|
||||
selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
|
||||
var style = ImGui.GetStyle();
|
||||
var contentMinY = windowPos.Y + ImGui.GetWindowContentRegionMin().Y;
|
||||
var gradientInset = 4f * ImGuiHelpers.GlobalScale;
|
||||
var gradientTop = MathF.Max(contentMinY, ImGui.GetCursorScreenPos().Y - style.ItemSpacing.Y + gradientInset);
|
||||
ImGui.Separator();
|
||||
|
||||
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("pairlist")) DrawPairs();
|
||||
ImGui.Separator();
|
||||
var transfersTop = ImGui.GetCursorScreenPos().Y;
|
||||
var gradientBottom = MathF.Max(gradientTop, transfersTop - style.ItemSpacing.Y - gradientInset);
|
||||
selune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime);
|
||||
@@ -324,10 +290,6 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Content Drawing
|
||||
|
||||
private void DrawPairs()
|
||||
{
|
||||
float ySize = Math.Abs(_transferPartHeight) < 0.0001f
|
||||
@@ -346,6 +308,95 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
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()
|
||||
{
|
||||
var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot();
|
||||
@@ -441,9 +492,11 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Header Drawing
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes)
|
||||
{
|
||||
public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0;
|
||||
}
|
||||
|
||||
private void DrawUIDHeader()
|
||||
{
|
||||
@@ -479,52 +532,21 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
using (_uiSharedService.IconFont.Push())
|
||||
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();
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
|
||||
var iconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset);
|
||||
using (_uiSharedService.IconFont.Push())
|
||||
ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.Wifi.ToIconString());
|
||||
|
||||
nextIconX = ImGui.GetItemRectMax().X + 6f;
|
||||
ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.PersonCirclePlus.ToIconString());
|
||||
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
@@ -596,8 +618,50 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
if (ImGui.IsItemClicked())
|
||||
_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)
|
||||
{
|
||||
var objectSummary = analysisSummary.Objects.Values.FirstOrDefault(summary => summary.HasEntries);
|
||||
@@ -611,30 +675,24 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
|
||||
if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds)
|
||||
{
|
||||
ImGui.SetCursorScreenPos(new Vector2(nextIconX, uidTextRectMin.Y + textVerticalOffset));
|
||||
|
||||
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());
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosY(cursorY + 15f);
|
||||
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow"));
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
string warningMessage = "";
|
||||
if (isOverTriHold)
|
||||
{
|
||||
string warningMessage = "";
|
||||
if (isOverTriHold)
|
||||
{
|
||||
warningMessage += $"You exceed your own triangles threshold by " +
|
||||
$"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles.";
|
||||
warningMessage += Environment.NewLine;
|
||||
}
|
||||
if (isOverVRAMUsage)
|
||||
{
|
||||
warningMessage += $"You exceed your own VRAM threshold by " +
|
||||
$"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}.";
|
||||
}
|
||||
UiSharedService.AttachToolTip(warningMessage);
|
||||
warningMessage += $"You exceed your own triangles threshold by " +
|
||||
$"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles.";
|
||||
warningMessage += Environment.NewLine;
|
||||
|
||||
}
|
||||
|
||||
if (isOverVRAMUsage)
|
||||
{
|
||||
warningMessage += $"You exceed your own VRAM threshold by " +
|
||||
$"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}.";
|
||||
}
|
||||
UiSharedService.AttachToolTip(warningMessage);
|
||||
if (ImGui.IsItemClicked())
|
||||
{
|
||||
_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 (headerItemClicked)
|
||||
@@ -678,12 +708,10 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
ImGui.SetClipboardText(_apiController.DisplayName);
|
||||
}
|
||||
|
||||
// Only show smaller UID line if DisplayName differs from UID (custom vanity name)
|
||||
bool hasCustomName = !string.Equals(_apiController.DisplayName, _apiController.UID, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (hasCustomName)
|
||||
if (!string.Equals(_apiController.DisplayName, _apiController.UID, StringComparison.Ordinal))
|
||||
{
|
||||
ImGui.SetCursorPosX(uidStartX);
|
||||
var origTextSize = ImGui.CalcTextSize(_apiController.UID);
|
||||
ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X) / 2 - (origTextSize.X / 2));
|
||||
|
||||
if (useVanityColors)
|
||||
{
|
||||
@@ -718,88 +746,14 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
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
|
||||
{
|
||||
ImGui.SetCursorPosX(uidStartX);
|
||||
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
|
||||
{
|
||||
get
|
||||
@@ -935,10 +889,6 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Filtering & Sorting
|
||||
|
||||
private static bool PassesFilter(PairUiEntry entry, string filter)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filter)) return true;
|
||||
@@ -1083,11 +1033,10 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
return SortGroupEntries(entries, group);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GPose Handlers
|
||||
|
||||
private void UiSharedService_GposeEnd() => IsOpen = _wasOpen;
|
||||
private void UiSharedService_GposeEnd()
|
||||
{
|
||||
IsOpen = _wasOpen;
|
||||
}
|
||||
|
||||
private void UiSharedService_GposeStart()
|
||||
{
|
||||
@@ -1095,10 +1044,6 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
IsOpen = false;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Focus Tracking
|
||||
|
||||
private void RegisterFocusCharacter(Pair pair)
|
||||
{
|
||||
_pendingFocusPair = pair;
|
||||
@@ -1144,16 +1089,4 @@ public class CompactUi : WindowMediatorSubscriberBase
|
||||
_pendingFocusPair = null;
|
||||
_pendingFocusFrame = -1;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Types
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes)
|
||||
{
|
||||
public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -25,8 +25,6 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
private readonly ConcurrentDictionary<GameObjectHandler, bool> _uploadingPlayers = new();
|
||||
private readonly Dictionary<GameObjectHandler, Vector2> _smoothed = [];
|
||||
private readonly Dictionary<GameObjectHandler, DownloadSpeedTracker> _downloadSpeeds = [];
|
||||
private readonly Dictionary<GameObjectHandler, (int TotalFiles, long TotalBytes)> _downloadInitialTotals = [];
|
||||
|
||||
|
||||
private byte _transferBoxTransparency = 100;
|
||||
private bool _notificationDismissed = true;
|
||||
@@ -65,15 +63,9 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
|
||||
IsOpen = true;
|
||||
|
||||
Mediator.Subscribe<DownloadStartedMessage>(this, msg =>
|
||||
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) =>
|
||||
{
|
||||
_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;
|
||||
});
|
||||
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) =>
|
||||
@@ -81,7 +73,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
_currentDownloads.TryRemove(msg.DownloadId, out _);
|
||||
|
||||
// Dismiss notification if all downloads are complete
|
||||
if (_currentDownloads.IsEmpty && !_notificationDismissed)
|
||||
if (!_currentDownloads.Any() && !_notificationDismissed)
|
||||
{
|
||||
Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress"));
|
||||
_notificationDismissed = true;
|
||||
@@ -175,7 +167,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
List<KeyValuePair<GameObjectHandler, IReadOnlyDictionary<string, FileDownloadStatus>>> transfers;
|
||||
try
|
||||
{
|
||||
transfers = [.. _currentDownloads];
|
||||
transfers = _currentDownloads.ToList();
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
@@ -462,13 +454,9 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
var handler = transfer.Key;
|
||||
var statuses = transfer.Value.Values;
|
||||
|
||||
var (playerTotalFiles, playerTotalBytes) = _downloadInitialTotals.TryGetValue(handler, out var totals)
|
||||
? totals
|
||||
: (statuses.Sum(s => s.TotalFiles), statuses.Sum(s => s.TotalBytes));
|
||||
|
||||
var playerTransferredFiles = statuses.Count(s =>
|
||||
s.DownloadStatus == DownloadStatus.Decompressing ||
|
||||
s.TransferredBytes >= s.TotalBytes);
|
||||
var playerTotalFiles = statuses.Sum(s => s.TotalFiles);
|
||||
var playerTransferredFiles = statuses.Sum(s => s.TransferredFiles);
|
||||
var playerTotalBytes = statuses.Sum(s => s.TotalBytes);
|
||||
var playerTransferredBytes = statuses.Sum(s => s.TransferredBytes);
|
||||
|
||||
totalFiles += playerTotalFiles;
|
||||
@@ -476,7 +464,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
totalBytes += playerTotalBytes;
|
||||
transferredBytes += playerTransferredBytes;
|
||||
|
||||
// per-player W/Q/P/D/C
|
||||
// per-player W/Q/P/D
|
||||
var playerDlSlot = 0;
|
||||
var playerDlQueue = 0;
|
||||
var playerDlProg = 0;
|
||||
@@ -489,7 +477,6 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
switch (fileStatus.DownloadStatus)
|
||||
{
|
||||
case DownloadStatus.Initializing:
|
||||
case DownloadStatus.WaitingForQueue:
|
||||
playerDlQueue++;
|
||||
totalDlQueue++;
|
||||
break;
|
||||
@@ -497,6 +484,10 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
playerDlSlot++;
|
||||
totalDlSlot++;
|
||||
break;
|
||||
case DownloadStatus.WaitingForQueue:
|
||||
playerDlQueue++;
|
||||
totalDlQueue++;
|
||||
break;
|
||||
case DownloadStatus.Downloading:
|
||||
playerDlProg++;
|
||||
totalDlProg++;
|
||||
@@ -549,6 +540,11 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
if (totalFiles == 0 || totalBytes == 0)
|
||||
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 windowPos = ImGui.GetWindowPos();
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ using Dalamud.Utility;
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.Localization;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Services.ServerConfiguration;
|
||||
using LightlessSync.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
@@ -21,7 +21,7 @@ public partial class IntroUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly CacheMonitor _cacheMonitor;
|
||||
private readonly Dictionary<string, string> _languages = new(StringComparer.Ordinal) { { "English", "en" }, { "Deutsch", "de" }, { "Français", "fr" }, { "中文", "zh"} };
|
||||
private readonly Dictionary<string, string> _languages = new(StringComparer.Ordinal) { { "English", "en" }, { "Deutsch", "de" }, { "Français", "fr" } };
|
||||
private readonly ServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private readonly UiSharedService _uiShared;
|
||||
@@ -31,6 +31,7 @@ public partial class IntroUi : WindowMediatorSubscriberBase
|
||||
private string _secretKey = string.Empty;
|
||||
private string _timeoutLabel = string.Empty;
|
||||
private Task? _timeoutTask;
|
||||
private string[]? _tosParagraphs;
|
||||
private bool _useLegacyLogin = false;
|
||||
|
||||
public IntroUi(ILogger<IntroUi> logger, UiSharedService uiShared, LightlessConfigService configService,
|
||||
@@ -49,7 +50,8 @@ public partial class IntroUi : WindowMediatorSubscriberBase
|
||||
WindowBuilder.For(this)
|
||||
.SetSizeConstraints(new Vector2(600, 400), new Vector2(600, 2000))
|
||||
.Apply();
|
||||
|
||||
|
||||
GetToSLocalization();
|
||||
|
||||
Mediator.Subscribe<SwitchToMainUiMessage>(this, (_) => IsOpen = false);
|
||||
Mediator.Subscribe<SwitchToIntroUiMessage>(this, (_) =>
|
||||
@@ -86,7 +88,7 @@ public partial class IntroUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
for (int i = 60; i > 0; i--)
|
||||
{
|
||||
_timeoutLabel = $"{Resources.Resources.ToSStrings_ButtonWillBeAvailableIn} {i}s";
|
||||
_timeoutLabel = $"{Strings.ToS.ButtonWillBeAvailableIn} {i}s";
|
||||
await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
@@ -100,46 +102,44 @@ public partial class IntroUi : WindowMediatorSubscriberBase
|
||||
Vector2 textSize;
|
||||
using (_uiShared.UidFont.Push())
|
||||
{
|
||||
textSize = ImGui.CalcTextSize(Resources.Resources.ToSStrings_LanguageLabel);
|
||||
ImGui.TextUnformatted(Resources.Resources.ToSStrings_AgreementLabel);
|
||||
textSize = ImGui.CalcTextSize(Strings.ToS.LanguageLabel);
|
||||
ImGui.TextUnformatted(Strings.ToS.AgreementLabel);
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
var languageSize = ImGui.CalcTextSize(Resources.Resources.ToSStrings_LanguageLabel);
|
||||
var languageSize = ImGui.CalcTextSize(Strings.ToS.LanguageLabel);
|
||||
ImGui.SetCursorPosX(ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X - languageSize.X - 80);
|
||||
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + textSize.Y / 2 - languageSize.Y / 2);
|
||||
|
||||
ImGui.TextUnformatted(Resources.Resources.ToSStrings_LanguageLabel);
|
||||
ImGui.TextUnformatted(Strings.ToS.LanguageLabel);
|
||||
ImGui.SameLine();
|
||||
ImGui.SetCursorPosY(ImGui.GetCursorPosY() + textSize.Y / 2 - (languageSize.Y + ImGui.GetStyle().FramePadding.Y) / 2);
|
||||
ImGui.SetNextItemWidth(80);
|
||||
if (ImGui.Combo("", ref _currentLanguage, _languages.Keys.ToArray(), _languages.Count))
|
||||
{
|
||||
var culture = new CultureInfo(_languages.Values.ToArray()[_currentLanguage]);
|
||||
CultureInfo.DefaultThreadCurrentCulture = culture;
|
||||
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
||||
GetToSLocalization(_currentLanguage);
|
||||
}
|
||||
|
||||
ImGui.Separator();
|
||||
ImGui.SetWindowFontScale(1.5f);
|
||||
string readThis = Resources.Resources.ToSStrings_ReadLabel;
|
||||
string readThis = Strings.ToS.ReadLabel;
|
||||
textSize = ImGui.CalcTextSize(readThis);
|
||||
ImGui.SetCursorPosX(ImGui.GetWindowSize().X / 2 - textSize.X / 2);
|
||||
UiSharedService.ColorText(readThis, ImGuiColors.DalamudRed);
|
||||
ImGui.SetWindowFontScale(1.0f);
|
||||
ImGui.Separator();
|
||||
|
||||
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph1);
|
||||
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph2);
|
||||
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph3);
|
||||
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph4);
|
||||
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph5);
|
||||
UiSharedService.TextWrapped(Resources.Resources.ToSStrings_Paragraph6);
|
||||
UiSharedService.TextWrapped(_tosParagraphs![0]);
|
||||
UiSharedService.TextWrapped(_tosParagraphs![1]);
|
||||
UiSharedService.TextWrapped(_tosParagraphs![2]);
|
||||
UiSharedService.TextWrapped(_tosParagraphs![3]);
|
||||
UiSharedService.TextWrapped(_tosParagraphs![4]);
|
||||
UiSharedService.TextWrapped(_tosParagraphs![5]);
|
||||
|
||||
ImGui.Separator();
|
||||
if (_timeoutTask?.IsCompleted ?? true)
|
||||
{
|
||||
if (ImGui.Button(Resources.Resources.ToSStrings_AgreeLabel + "##toSetup"))
|
||||
if (ImGui.Button(Strings.ToS.AgreeLabel + "##toSetup"))
|
||||
{
|
||||
_configService.Current.AcceptedAgreement = true;
|
||||
_configService.Save();
|
||||
@@ -349,6 +349,16 @@ public partial class IntroUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
|
||||
private void GetToSLocalization(int changeLanguageTo = -1)
|
||||
{
|
||||
if (changeLanguageTo != -1)
|
||||
{
|
||||
_uiShared.LoadLocalization(_languages.ElementAt(changeLanguageTo).Value);
|
||||
}
|
||||
|
||||
_tosParagraphs = [Strings.ToS.Paragraph1, Strings.ToS.Paragraph2, Strings.ToS.Paragraph3, Strings.ToS.Paragraph4, Strings.ToS.Paragraph5, Strings.ToS.Paragraph6];
|
||||
}
|
||||
|
||||
[GeneratedRegex("^[A-F0-9]{64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex SecretRegex();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@ using Dalamud.Interface.Colors;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using Dalamud.Utility;
|
||||
using Lifestream.Enums;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Comparer;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
@@ -15,7 +14,6 @@ using LightlessSync.Interop.Ipc;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.LightlessConfiguration.Configurations;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.PlayerData.Factories;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services;
|
||||
@@ -42,7 +40,6 @@ using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -72,8 +69,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
private readonly UiSharedService _uiShared;
|
||||
private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress;
|
||||
private readonly NameplateService _nameplateService;
|
||||
private readonly AnimatedHeader _animatedHeader = new();
|
||||
|
||||
private (int, int, FileCacheEntity) _currentProgress;
|
||||
private bool _deleteAccountPopupModalShown = 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 List<UiSharedService.TabOption<ServerSettingsTab>> _serverTabOptions = new(4);
|
||||
private readonly string[] _generalTreeNavOrder =
|
||||
[
|
||||
private readonly string[] _generalTreeNavOrder = new[]
|
||||
{
|
||||
"Import & Export",
|
||||
"Popup & Auto Fill",
|
||||
"Behavior",
|
||||
@@ -121,8 +116,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
"Colors",
|
||||
"Server Info Bar",
|
||||
"Nameplate",
|
||||
"Animation & Bones"
|
||||
];
|
||||
};
|
||||
private static readonly HashSet<string> _generalNavSeparatorAfter = new(StringComparer.Ordinal)
|
||||
{
|
||||
"Popup & Auto Fill",
|
||||
@@ -211,10 +205,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
_nameplateService = nameplateService;
|
||||
_actorObjectService = actorObjectService;
|
||||
_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)
|
||||
.AllowPinning(true)
|
||||
.AllowClickthrough(false)
|
||||
@@ -250,7 +241,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
|
||||
public override void OnClose()
|
||||
{
|
||||
_animatedHeader.ClearParticles();
|
||||
_uiShared.EditTrackerPosition = false;
|
||||
_uidToAddForIgnore = string.Empty;
|
||||
_secretKeysConversionCts = _secretKeysConversionCts.CancelRecreate();
|
||||
@@ -265,8 +255,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
|
||||
protected override void DrawInternal()
|
||||
{
|
||||
_animatedHeader.Draw(ImGui.GetContentRegionAvail().X, (_, _) => { });
|
||||
_ = _uiShared.DrawOtherPluginState();
|
||||
|
||||
DrawSettingsContent();
|
||||
}
|
||||
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)
|
||||
{
|
||||
List<string> speedTestResults = [];
|
||||
List<string> speedTestResults = new();
|
||||
foreach (var server in servers)
|
||||
{
|
||||
HttpResponseMessage? result = null;
|
||||
@@ -1342,6 +1332,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
|
||||
ImGui.TreePop();
|
||||
}
|
||||
#endif
|
||||
if (_uiShared.IconTextButton(FontAwesomeIcon.Copy, "[DEBUG] Copy Last created Character Data to clipboard"))
|
||||
{
|
||||
if (LastCreatedCharacterData != null)
|
||||
@@ -1357,39 +1348,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
|
||||
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) =>
|
||||
{
|
||||
_configService.Current.LogLevel = l;
|
||||
@@ -2057,25 +2015,14 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
using (ImRaii.PushIndent(20f))
|
||||
{
|
||||
if (_validationTask.IsCompletedSuccessfully)
|
||||
if (_validationTask.IsCompleted)
|
||||
{
|
||||
UiSharedService.TextWrapped(
|
||||
$"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
|
||||
{
|
||||
|
||||
UiSharedService.TextWrapped(
|
||||
$"Storage validation is running: {_currentProgress.Item1}/{_currentProgress.Item2}");
|
||||
if (_currentProgress.Item3 != null)
|
||||
@@ -2232,7 +2179,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
|
||||
ImGui.Separator();
|
||||
var openPopupOnAddition = _configService.Current.OpenPopupOnAdd;
|
||||
|
||||
|
||||
using (var popupTree = BeginGeneralTree("Popup & Auto Fill", UIColors.Get("LightlessPurple")))
|
||||
{
|
||||
if (popupTree.Visible)
|
||||
@@ -2289,20 +2236,11 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
var groupInVisible = _configService.Current.ShowSyncshellUsersInVisible;
|
||||
var syncshellOfflineSeparate = _configService.Current.ShowSyncshellOfflineUsersSeparately;
|
||||
var greenVisiblePair = _configService.Current.ShowVisiblePairsGreenEye;
|
||||
var enableParticleEffects = _configService.Current.EnableParticleEffects;
|
||||
|
||||
using (var behaviorTree = BeginGeneralTree("Behavior", UIColors.Get("LightlessPurple")))
|
||||
{
|
||||
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))
|
||||
{
|
||||
_configService.Current.EnableRightClickMenus = enableRightClickMenu;
|
||||
@@ -3011,21 +2949,16 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
|
||||
var colorNames = new[]
|
||||
{
|
||||
("LightlessPurple", "Primary Purple", "Section titles and dividers"),
|
||||
("LightlessPurpleActive", "Primary Purple (Active)", "Active tabs and hover highlights"),
|
||||
("LightlessPurpleDefault", "Primary Purple (Inactive)", "Inactive tabs and default dividers"),
|
||||
("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"),
|
||||
("LightlessGreen", "Success Green", "Join buttons and success messages"),
|
||||
("LightlessYellow", "Warning Yellow", "Warning colors"),
|
||||
("LightlessOrange", "Performance Orange", "Performance notifications and warnings"),
|
||||
("PairBlue", "Syncshell Blue", "Syncshell headers, toggle highlights, and moderator actions"),
|
||||
("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"),
|
||||
};
|
||||
|
||||
("LightlessPurple", "Primary Purple", "Section titles and dividers"),
|
||||
("LightlessPurpleActive", "Primary Purple (Active)", "Active tabs and hover highlights"),
|
||||
("LightlessPurpleDefault", "Primary Purple (Inactive)", "Inactive tabs and default dividers"),
|
||||
("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"),
|
||||
("LightlessGreen", "Success Green", "Join buttons and success messages"),
|
||||
("LightlessYellow", "Warning Yellow", "Warning colors"),
|
||||
("LightlessOrange", "Performance Orange", "Performance notifications and warnings"),
|
||||
("PairBlue", "Syncshell Blue", "Syncshell headers, toggle highlights, and moderator actions"),
|
||||
("DimRed", "Error Red", "Error and offline colors")
|
||||
};
|
||||
if (ImGui.BeginTable("##ColorTable", 3,
|
||||
ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit))
|
||||
{
|
||||
@@ -3231,102 +3164,10 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
|
||||
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.EndGroup();
|
||||
|
||||
ImGui.Separator();
|
||||
generalSelune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
|
||||
}
|
||||
}
|
||||
@@ -3416,7 +3257,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
return 1f - (elapsed / GeneralTreeHighlightDuration);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private struct GeneralTreeScope : IDisposable
|
||||
{
|
||||
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.");
|
||||
|
||||
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 selectedIndex = Array.IndexOf(dimensionOptions, currentDimension);
|
||||
if (selectedIndex < 0)
|
||||
@@ -4812,7 +4652,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
ImGui.TextColored(UIColors.Get("LightlessBlue"),
|
||||
_apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture));
|
||||
ImGui.SameLine();
|
||||
ImGui.TextUnformatted(Resources.Resources.Users_Online);
|
||||
ImGui.TextUnformatted("Users Online");
|
||||
ImGui.SameLine();
|
||||
ImGui.TextUnformatted(")");
|
||||
}
|
||||
|
||||
@@ -43,23 +43,10 @@ public class AnimatedHeader
|
||||
private const float _extendedParticleHeight = 40f;
|
||||
|
||||
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 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 EnableBottomGradient { get; set; } = true;
|
||||
|
||||
public float GradientHeight { get; set; } = 60f;
|
||||
|
||||
/// <summary>
|
||||
/// Draws the animated header with some customizable content
|
||||
@@ -159,21 +146,16 @@ public class AnimatedHeader
|
||||
{
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
|
||||
var top = ResolveColor(TopColorKey, TopColor);
|
||||
var bottom = ResolveColor(BottomColorKey, BottomColor);
|
||||
|
||||
drawList.AddRectFilledMultiColor(
|
||||
headerStart,
|
||||
headerEnd,
|
||||
ImGui.GetColorU32(top),
|
||||
ImGui.GetColorU32(top),
|
||||
ImGui.GetColorU32(bottom),
|
||||
ImGui.GetColorU32(bottom)
|
||||
ImGui.GetColorU32(TopColor),
|
||||
ImGui.GetColorU32(TopColor),
|
||||
ImGui.GetColorU32(BottomColor),
|
||||
ImGui.GetColorU32(BottomColor)
|
||||
);
|
||||
|
||||
// Draw static background stars
|
||||
var starBase = ResolveColor(StaticStarColorKey, StaticStarColor);
|
||||
|
||||
var random = new Random(42);
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
@@ -182,28 +164,23 @@ public class AnimatedHeader
|
||||
(float)random.NextDouble() * (headerEnd.Y - headerStart.Y)
|
||||
);
|
||||
var brightness = 0.3f + (float)random.NextDouble() * 0.4f;
|
||||
var starColor = starBase with { W = starBase.W * brightness };
|
||||
|
||||
drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(starColor));
|
||||
drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(new Vector4(1f, 1f, 1f, brightness)));
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width)
|
||||
{
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
var gradientHeight = GradientHeight;
|
||||
var bottom = ResolveColor(BottomColorKey, BottomColor);
|
||||
var gradientHeight = 60f;
|
||||
|
||||
for (int i = 0; i < gradientHeight; i++)
|
||||
{
|
||||
var progress = i / gradientHeight;
|
||||
var smoothProgress = progress * progress;
|
||||
|
||||
var r = bottom.X + (0.0f - bottom.X) * smoothProgress;
|
||||
var g = bottom.Y + (0.0f - bottom.Y) * smoothProgress;
|
||||
var b = bottom.Z + (0.0f - bottom.Z) * smoothProgress;
|
||||
var r = BottomColor.X + (0.0f - BottomColor.X) * smoothProgress;
|
||||
var g = BottomColor.Y + (0.0f - BottomColor.Y) * smoothProgress;
|
||||
var b = BottomColor.Z + (0.0f - BottomColor.Z) * smoothProgress;
|
||||
var alpha = 1f - smoothProgress;
|
||||
|
||||
var gradientColor = new Vector4(r, g, b, alpha);
|
||||
drawList.AddLine(
|
||||
new Vector2(headerStart.X, headerEnd.Y + i),
|
||||
@@ -331,11 +308,9 @@ public class AnimatedHeader
|
||||
? baseAlpha * (0.6f + 0.4f * MathF.Sin(particle.Twinkle))
|
||||
: baseAlpha;
|
||||
|
||||
var shootingBase = ResolveColor(ShootingStarColorKey, ShootingStarColor);
|
||||
|
||||
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++)
|
||||
{
|
||||
@@ -344,18 +319,17 @@ public class AnimatedHeader
|
||||
var trailWidth = (1f - trailProgress) * 3f + 1f;
|
||||
|
||||
var glowAlpha = trailAlpha * 0.4f;
|
||||
|
||||
drawList.AddLine(
|
||||
bannerStart + particle.Trail[t - 1],
|
||||
bannerStart + particle.Trail[t],
|
||||
ImGui.GetColorU32(baseColor with { W = glowAlpha }),
|
||||
ImGui.GetColorU32(cyanColor with { W = glowAlpha }),
|
||||
trailWidth + 4f
|
||||
);
|
||||
|
||||
drawList.AddLine(
|
||||
bannerStart + particle.Trail[t - 1],
|
||||
bannerStart + particle.Trail[t],
|
||||
ImGui.GetColorU32(baseColor with { W = trailAlpha }),
|
||||
ImGui.GetColorU32(cyanColor with { W = trailAlpha }),
|
||||
trailWidth
|
||||
);
|
||||
}
|
||||
@@ -474,13 +448,6 @@ public class AnimatedHeader
|
||||
Hue = 270f
|
||||
});
|
||||
}
|
||||
private static Vector4 ResolveColor(string? key, Vector4 fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
return fallback;
|
||||
|
||||
return UIColors.Get(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all active particles. Useful when closing or hiding a window with an animated header.
|
||||
|
||||
@@ -40,10 +40,9 @@ internal static class MainStyle
|
||||
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.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.titleBgActive", "Title Background (Active)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgActive),
|
||||
new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgCollapsed),
|
||||
|
||||
new("color.titleBg", "Title Background", () => Rgba(24, 24, 24, 232), ImGuiCol.TitleBg),
|
||||
new("color.titleBgActive", "Title Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.TitleBgActive),
|
||||
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.scrollbarBg", "Scrollbar Background", () => Rgba(0, 0, 0, 0), ImGuiCol.ScrollbarBg),
|
||||
new("color.scrollbarGrab", "Scrollbar Grab", () => Rgba(62, 62, 62, 255), ImGuiCol.ScrollbarGrab),
|
||||
|
||||
@@ -4,11 +4,9 @@ using Dalamud.Interface.Colors;
|
||||
using Dalamud.Interface.Textures.TextureWraps;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.API.Data.Extensions;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.PlayerData.Factories;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
@@ -44,32 +42,13 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
private Task<int>? _pruneTask;
|
||||
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 bool _pruneSettingsLoaded;
|
||||
private bool _autoPruneEnabled;
|
||||
private int _autoPruneDays = 14;
|
||||
private readonly PairFactory _pairFactory;
|
||||
|
||||
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)
|
||||
{
|
||||
GroupFullInfo = groupFullInfo;
|
||||
@@ -97,7 +76,6 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
MaximumSize = new(700, 2000),
|
||||
};
|
||||
_pairUiService = pairUiService;
|
||||
_pairFactory = pairFactory;
|
||||
}
|
||||
|
||||
public GroupFullInfoDto GroupFullInfo { get; private set; }
|
||||
@@ -676,345 +654,34 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
_uiSharedService.MediumText("User Bans", UIColors.Get("LightlessYellow"));
|
||||
ImGuiHelpers.ScaledDummy(3f);
|
||||
|
||||
EnsureBanListLoaded();
|
||||
|
||||
DrawNewBanEntryRow();
|
||||
|
||||
ImGuiHelpers.ScaledDummy(4f);
|
||||
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Retweet, "Refresh Banlist"))
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Retweet, "Refresh Banlist from Server"))
|
||||
{
|
||||
QueueBanListRefresh(force: true);
|
||||
_bannedUsers = _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group)).Result;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
var style = ImGui.GetStyle();
|
||||
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 colActions = fullW - colIdentity - colMeta - style.ItemSpacing.X * 2.0f;
|
||||
|
||||
float colIdentity = fullW - colMeta - colActions - style.ItemSpacing.X * 2.0f;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Header
|
||||
DrawBannedListHeader(colIdentity, colMeta);
|
||||
|
||||
int rowIndex = 0;
|
||||
foreach (var bannedUser in _bannedUsers.ToList())
|
||||
{
|
||||
// Each row
|
||||
DrawBannedRow(bannedUser, rowIndex++, colIdentity, colMeta, colActions);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var inviteTab = ImRaii.TabItem("Invites");
|
||||
@@ -1235,9 +902,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
if (buttonCount == 0)
|
||||
return;
|
||||
|
||||
float totalWidth = _isOwner
|
||||
? buttonCount * frameH + buttonCount * style.ItemSpacing.X + 20f
|
||||
: buttonCount * frameH + buttonCount * style.ItemSpacing.X;
|
||||
float totalWidth = buttonCount * frameH + (buttonCount - 1) * style.ItemSpacing.X;
|
||||
|
||||
float curX = ImGui.GetCursorPosX();
|
||||
float avail = ImGui.GetContentRegionAvail().X;
|
||||
@@ -1366,40 +1031,69 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
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)
|
||||
return;
|
||||
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));
|
||||
}
|
||||
|
||||
_bannedUsersLoaded = false;
|
||||
_bannedUsersLoadError = null;
|
||||
ImGui.SetCursorPosX(x0);
|
||||
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})";
|
||||
|
||||
private void EnsureBanListLoaded()
|
||||
{
|
||||
_bannedUsersTask ??= _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group));
|
||||
ImGui.TextUnformatted(line1);
|
||||
|
||||
if (_bannedUsersLoaded || _bannedUsersTask == null)
|
||||
return;
|
||||
|
||||
if (!_bannedUsersTask.IsCompleted)
|
||||
return;
|
||||
|
||||
if (_bannedUsersTask.IsFaulted || _bannedUsersTask.IsCanceled)
|
||||
var reason = bannedUser.Reason ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
_bannedUsersLoadError = "Failed to load banlist from server.";
|
||||
_bannedUsers = [];
|
||||
_bannedUsersLoaded = true;
|
||||
return;
|
||||
var reasonPos = new Vector2(x0, ImGui.GetCursorPosY());
|
||||
ImGui.SetCursorPos(reasonPos);
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey);
|
||||
UiSharedService.TextWrapped(reason);
|
||||
ImGui.PopStyleColor();
|
||||
}
|
||||
|
||||
_bannedUsers = _bannedUsersTask.GetAwaiter().GetResult() ?? [];
|
||||
_bannedUsersLoaded = true;
|
||||
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"))
|
||||
{
|
||||
_apiController.GroupUnbanUser(bannedUser);
|
||||
_bannedUsers.RemoveAll(b => string.Equals(b.UID, bannedUser.UID, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
UiSharedService.AttachToolTip($"Unban {alias} ({bannedUser.UID}) from this Syncshell");
|
||||
|
||||
ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var note = pair.GetNote() ?? string.Empty;
|
||||
@@ -1440,11 +1127,6 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
|| alias.Contains(filterLower, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public override void OnOpen()
|
||||
{
|
||||
base.OnOpen();
|
||||
QueueBanListRefresh(force: true);
|
||||
}
|
||||
public override void OnClose()
|
||||
{
|
||||
Mediator.Publish(new RemoveWindowMessage(this));
|
||||
|
||||
855
LightlessSync/UI/SyncshellFinderUI.cs
Normal file
855
LightlessSync/UI/SyncshellFinderUI.cs
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -162,32 +162,24 @@ public class TopTabMenu
|
||||
ImGui.SameLine();
|
||||
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||
{
|
||||
var x = ImGui.GetCursorScreenPos();
|
||||
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())
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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");
|
||||
UiSharedService.AttachToolTip("Lightfinder");
|
||||
|
||||
ImGui.SameLine();
|
||||
using (ImRaii.PushFont(UiBuilder.IconFont))
|
||||
@@ -242,7 +234,10 @@ public class TopTabMenu
|
||||
DrawSyncshellMenu(availableWidth, spacing.X);
|
||||
DrawGlobalSyncshellButtons(availableWidth, spacing.X);
|
||||
}
|
||||
|
||||
else if (TabSelection == SelectedTab.Lightfinder)
|
||||
{
|
||||
DrawLightfinderMenu(availableWidth, spacing.X);
|
||||
}
|
||||
else if (TabSelection == SelectedTab.UserConfig)
|
||||
{
|
||||
DrawUserConfig(availableWidth, spacing.X);
|
||||
@@ -781,22 +776,53 @@ 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)
|
||||
return 0;
|
||||
return "Syncshell Finder";
|
||||
|
||||
var myHashedCid = _dalamudUtilService.GetCID().ToString().GetHash256();
|
||||
|
||||
return _lightFinderScannerService
|
||||
.GetActiveSyncshellBroadcasts()
|
||||
.Where(b =>
|
||||
!string.IsNullOrEmpty(b.GID) &&
|
||||
!string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal))
|
||||
var nearbyCount = _lightFinderScannerService
|
||||
.GetActiveSyncshellBroadcasts(excludeLocal: true)
|
||||
.Where(b => !string.IsNullOrEmpty(b.GID))
|
||||
.Select(b => b.GID!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Count();
|
||||
|
||||
return nearbyCount > 0 ? $"Syncshell Finder ({nearbyCount})" : "Syncshell Finder";
|
||||
}
|
||||
|
||||
private void DrawUserConfig(float availableWidth, float spacingX)
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace LightlessSync.UI
|
||||
{
|
||||
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" },
|
||||
{ "LightlessPurpleActive", "#be9eff" },
|
||||
@@ -31,12 +31,6 @@ namespace LightlessSync.UI
|
||||
|
||||
{ "ProfileBodyGradientTop", "#2f283fff" },
|
||||
{ "ProfileBodyGradientBottom", "#372d4d00" },
|
||||
|
||||
{ "HeaderGradientTop", "#140D26FF" },
|
||||
{ "HeaderGradientBottom", "#1F1433FF" },
|
||||
|
||||
{ "HeaderStaticStar", "#FFFFFFFF" },
|
||||
{ "HeaderShootingStar", "#66CCFFFF" },
|
||||
};
|
||||
|
||||
private static LightlessConfigService? _configService;
|
||||
@@ -51,7 +45,7 @@ namespace LightlessSync.UI
|
||||
if (_configService?.Current.CustomUIColors.TryGetValue(name, out var customColorHex) == true)
|
||||
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));
|
||||
|
||||
return HexToRgba(hex);
|
||||
@@ -59,7 +53,7 @@ namespace LightlessSync.UI
|
||||
|
||||
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));
|
||||
|
||||
if (_configService != null)
|
||||
@@ -89,7 +83,7 @@ namespace LightlessSync.UI
|
||||
|
||||
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));
|
||||
|
||||
return HexToRgba(hex);
|
||||
@@ -102,7 +96,7 @@ namespace LightlessSync.UI
|
||||
|
||||
public static IEnumerable<string> GetColorNames()
|
||||
{
|
||||
return _defaultHexColors.Keys;
|
||||
return DefaultHexColors.Keys;
|
||||
}
|
||||
|
||||
public static Vector4 HexToRgba(string hexColor)
|
||||
|
||||
@@ -18,6 +18,7 @@ using LightlessSync.Interop.Ipc;
|
||||
using LightlessSync.Interop.Ipc.Framework;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.Localization;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
@@ -78,7 +79,6 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
|
||||
private readonly Dictionary<string, DateTime> _oauthTokenExpiry = [];
|
||||
private bool _penumbraExists = false;
|
||||
private bool _petNamesExists = false;
|
||||
private bool _lifestreamExists = false;
|
||||
private int _serverSelectionIndex = -1;
|
||||
public UiSharedService(ILogger<UiSharedService> logger, IpcManager ipcManager, ApiController apiController,
|
||||
CacheMonitor cacheMonitor, FileDialogManager fileDialogManager,
|
||||
@@ -112,7 +112,6 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
|
||||
_moodlesExists = _ipcManager.Moodles.APIAvailable;
|
||||
_petNamesExists = _ipcManager.PetNames.APIAvailable;
|
||||
_brioExists = _ipcManager.Brio.APIAvailable;
|
||||
_lifestreamExists = _ipcManager.Lifestream.APIAvailable;
|
||||
});
|
||||
|
||||
UidFont = _pluginInterface.UiBuilder.FontAtlas.NewDelegateFontHandle(e =>
|
||||
@@ -1106,10 +1105,6 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
|
||||
ColorText("Brio", GetBoolColor(_brioExists));
|
||||
AttachToolTip(BuildPluginTooltip("Brio", _brioExists, _ipcManager.Brio.State));
|
||||
|
||||
ImGui.SameLine();
|
||||
ColorText("Lifestream", GetBoolColor(_lifestreamExists));
|
||||
AttachToolTip(BuildPluginTooltip("Lifestream", _lifestreamExists, _ipcManager.Lifestream.State));
|
||||
|
||||
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.");
|
||||
@@ -1467,6 +1462,12 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
|
||||
return false;
|
||||
}
|
||||
|
||||
public void LoadLocalization(string languageCode)
|
||||
{
|
||||
_localization.SetupWithLangCode(languageCode);
|
||||
Strings.ToS = new Strings.ToSStrings();
|
||||
}
|
||||
|
||||
internal static void DistanceSeparator()
|
||||
{
|
||||
ImGuiHelpers.ScaledDummy(5);
|
||||
|
||||
@@ -40,7 +40,6 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase
|
||||
logger.LogInformation("UpdateNotesUi constructor called");
|
||||
_uiShared = uiShared;
|
||||
_configService = configService;
|
||||
_animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects;
|
||||
|
||||
RespectCloseHotkey = true;
|
||||
ShowCloseButton = true;
|
||||
@@ -49,8 +48,7 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase
|
||||
ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoMove;
|
||||
|
||||
PositionCondition = ImGuiCond.Always;
|
||||
|
||||
|
||||
|
||||
WindowBuilder.For(this)
|
||||
.AllowPinning(false)
|
||||
.AllowClickthrough(false)
|
||||
|
||||
@@ -29,10 +29,6 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
private readonly TextureMetadataHelper _textureMetadataHelper;
|
||||
|
||||
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 int _consecutiveDirectDownloadFailures;
|
||||
@@ -397,32 +393,76 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
|
||||
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))
|
||||
break;
|
||||
|
||||
using var resp = await _orchestrator.SendRequestAsync(
|
||||
HttpMethod.Get,
|
||||
LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId),
|
||||
downloadFileTransfer.Select(t => t.Hash).ToList(),
|
||||
downloadCt).ConfigureAwait(false);
|
||||
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
var body = (await resp.Content.ReadAsStringAsync(downloadCt).ConfigureAwait(false)).Trim();
|
||||
if (string.Equals(body, "true", StringComparison.OrdinalIgnoreCase) ||
|
||||
body.Contains("\"ready\":true", StringComparison.OrdinalIgnoreCase))
|
||||
while (!_orchestrator.IsDownloadReady(requestId))
|
||||
{
|
||||
break;
|
||||
try
|
||||
{
|
||||
await Task.Delay(250, composite.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
if (downloadCt.IsCancellationRequested) throw;
|
||||
|
||||
var req = await _orchestrator.SendRequestAsync(
|
||||
HttpMethod.Get,
|
||||
LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId),
|
||||
downloadFileTransfer.Select(c => c.Hash).ToList(),
|
||||
downloadCt).ConfigureAwait(false);
|
||||
|
||||
req.EnsureSuccessStatusCode();
|
||||
|
||||
localTimeoutCts.Dispose();
|
||||
composite.Dispose();
|
||||
|
||||
localTimeoutCts = new();
|
||||
localTimeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
composite = CancellationTokenSource.CreateLinkedTokenSource(downloadCt, localTimeoutCts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(250, downloadCt).ConfigureAwait(false);
|
||||
}
|
||||
localTimeoutCts.Dispose();
|
||||
composite.Dispose();
|
||||
|
||||
_orchestrator.ClearDownloadRequest(requestId);
|
||||
Logger.LogDebug("Download {requestId} ready", requestId);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCancelFullPath(downloadFileTransfer[0].DownloadUri, requestId))
|
||||
.ConfigureAwait(false);
|
||||
alreadyCancelled = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (downloadCt.IsCancellationRequested && !alreadyCancelled)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCancelFullPath(downloadFileTransfer[0].DownloadUri, requestId))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
_orchestrator.ClearDownloadRequest(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadQueuedBlockFileAsync(
|
||||
@@ -475,9 +515,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
|
||||
try
|
||||
{
|
||||
// sanity check length
|
||||
if (fileLengthBytes < 0 || fileLengthBytes > int.MaxValue)
|
||||
throw new InvalidDataException($"Invalid block entry length: {fileLengthBytes}");
|
||||
|
||||
// safe cast after check
|
||||
var len = checked((int)fileLengthBytes);
|
||||
|
||||
if (!replacementLookup.TryGetValue(fileHash, out var repl))
|
||||
@@ -489,9 +531,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
continue;
|
||||
}
|
||||
|
||||
// decompress
|
||||
var filePath = _fileDbManager.GetCacheFilePath(fileHash, repl.Extension);
|
||||
Logger.LogTrace("{dlName}: Decompressing {file}:{len} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath);
|
||||
|
||||
// read compressed data
|
||||
var compressed = new byte[len];
|
||||
|
||||
await ReadExactlyAsync(fileBlockStream, compressed.AsMemory(0, len), ct).ConfigureAwait(false);
|
||||
@@ -503,36 +547,13 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
&& expectedRawSize > 0
|
||||
&& decompressed.LongLength != expectedRawSize)
|
||||
{
|
||||
await _fileCompactor.WriteAllBytesAsync(filePath, Array.Empty<byte>(), ct).ConfigureAwait(false);
|
||||
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation);
|
||||
Logger.LogWarning("{dlName}: Decompressed size mismatch for {fileHash} (expected {expected}, got {actual})",
|
||||
downloadLabel, fileHash, expectedRawSize, decompressed.LongLength);
|
||||
continue;
|
||||
}
|
||||
|
||||
MungeBuffer(compressed);
|
||||
|
||||
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);
|
||||
}, ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_decompressGate.Release();
|
||||
}
|
||||
await _fileCompactor.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false);
|
||||
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation);
|
||||
}
|
||||
catch (EndOfStreamException)
|
||||
{
|
||||
@@ -715,16 +736,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
if (gameObjectHandler is not null)
|
||||
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.
|
||||
var workerDop = Math.Clamp(baseWorkers + extraWorkers, 2, coreCount);
|
||||
var workerDop = Math.Clamp(slots * 2, 2, 16);
|
||||
|
||||
// batch downloads
|
||||
Task batchTask = batchChunks.Length == 0
|
||||
@@ -740,9 +753,6 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
|
||||
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);
|
||||
ClearDownload();
|
||||
}
|
||||
@@ -773,6 +783,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
|
||||
try
|
||||
{
|
||||
// download (with slot)
|
||||
var progress = CreateInlineProgress(bytes => AddTransferredBytes(statusKey, bytes));
|
||||
|
||||
// Download slot held on get
|
||||
@@ -968,12 +979,14 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
|
||||
if (!_orchestrator.IsInitialized)
|
||||
throw new InvalidOperationException("FileTransferManager is not initialized");
|
||||
|
||||
// batch request
|
||||
var response = await _orchestrator.SendRequestAsync(
|
||||
HttpMethod.Get,
|
||||
LightlessFiles.ServerFilesGetSizesFullPath(_orchestrator.FilesCdnUri!),
|
||||
hashes,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
// ensure success
|
||||
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.LastWriteTime = RandomDayInThePast().Invoke();
|
||||
|
||||
// queue file for deferred compression instead of compressing immediately
|
||||
if (_configService.Current.UseCompactor)
|
||||
_deferredCompressionQueue.Enqueue(filePath);
|
||||
|
||||
try
|
||||
{
|
||||
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 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 readonly Action<long> _callback;
|
||||
|
||||
@@ -76,19 +76,6 @@
|
||||
"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": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.1, )",
|
||||
@@ -246,14 +233,6 @@
|
||||
"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": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.1",
|
||||
@@ -639,7 +618,7 @@
|
||||
"FlatSharp.Compiler": "[7.9.0, )",
|
||||
"FlatSharp.Runtime": "[7.9.0, )",
|
||||
"OtterGui": "[1.0.0, )",
|
||||
"Penumbra.Api": "[5.13.1, )",
|
||||
"Penumbra.Api": "[5.13.0, )",
|
||||
"Penumbra.String": "[1.0.7, )"
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user