Compare commits

...

77 Commits

Author SHA1 Message Date
cake
43d2b31eda Merge branch 'just-experimenting' into test-abel 2026-01-05 20:36:47 +01:00
cake
5920622b9a Merge 2.0.3 into branch 2026-01-05 01:44:38 +01:00
cake
d357e37c65 Merge abel stuff 2026-01-05 01:41:03 +01:00
e0b8070aa8 Merge pull request 'Lifestream IPC witrh Debug Example' (#124) from lifestream-ipc into 2.0.3
Reviewed-on: #124
2026-01-04 14:50:00 +00:00
3241b9222b Merge pull request 'Changes of admin ui for banning users.' (#128) from ban-admin-changes into 2.0.3
Reviewed-on: #128
2026-01-04 14:49:47 +00:00
80b082240f Merge branch '2.0.3' into ban-admin-changes 2026-01-04 14:49:38 +00:00
b8c8f3dffd Merge pull request 'Lightless Lightfinder redesign + stuff' (#127) from 2.0.0-crashing-bugfixes into 2.0.3
Reviewed-on: #127
2026-01-04 14:21:27 +00:00
543ea6c865 Merge branch '2.0.3' into 2.0.0-crashing-bugfixes 2026-01-04 14:19:23 +00:00
defnotken
de9c9955ef add more functionality for future features. 2026-01-04 00:54:40 -06:00
cake
2eb0c463e3 Fixed refreshing of ban list 2026-01-04 05:40:34 +01:00
cake
cd510f93af Changed banning into syncshell 2026-01-04 05:08:08 +01:00
cake
2d526bcfbf Merge branch 'cake-attempts-2.0.3' into test-abel 2026-01-04 00:32:13 +01:00
defnotken
4664071eb3 Merge branch 'cake-attempts-2.0.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into cake-attempts-2.0.3 2026-01-03 17:28:25 -06:00
defnotken
11099c05ff Throwing analysis in background task so dalamud can breath 2026-01-03 17:28:05 -06:00
cake
3bbda69699 Revert "Added another try on fetching download status"
This reverts commit deb7f67e59.
2026-01-03 23:22:18 +01:00
cake
deb7f67e59 Added another try on fetching download status 2026-01-03 23:12:18 +01:00
cake
57a076ae77 test-abel-cake-changes 2026-01-03 22:45:55 +01:00
cake
6af61451dc Fixed naming of setting 2026-01-03 16:32:39 +01:00
cake
02d091eefa Added more loose matching options, fixed some race issues 2026-01-03 15:59:10 +01:00
cake
e41a7149c5 Refactored many parts, added settings for detection 2026-01-03 14:58:54 +01:00
choco
9ba45670c5 top menu cleanup, removed duplicate old code 2026-01-03 02:08:28 +01:00
cake
e16ddb0a1d change log to trace 2026-01-02 19:29:50 +01:00
cake
f7bb73bcd1 Updated api 2026-01-02 18:34:07 +01:00
cake
ba26edc33c Merge branch 'cake-attempts-2.0.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into cake-attempts-2.0.3 2026-01-02 18:31:14 +01:00
cake
14c4c1d669 Added caching in the playerdata factory, refactored 2026-01-02 18:30:37 +01:00
defnotken
e8c157d8ac Creating temp havok file to not crash the analyzer 2026-01-02 10:23:32 -06:00
defnotken
2af0b5774b Merge branch 'cake-attempts-2.0.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into cake-attempts-2.0.3 2026-01-02 09:29:22 -06:00
defnotken
bb365442cf Maybe? 2026-01-02 09:29:04 -06:00
choco
4c07162ee3 Merge remote-tracking branch 'origin/2.0.3' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessAPI
2026-01-02 09:26:21 +01:00
choco
a4d62af73d lightfinder user text 2026-01-02 09:23:23 +01:00
choco
5fba3c01e7 lightfinder nearby badge alignment 2026-01-02 09:19:39 +01:00
cake
277d368f83 Adjustments in PAP handling. 2026-01-02 07:43:30 +01:00
defnotken
3487891185 Testing asyncing the transient task 2026-01-01 22:23:46 -06:00
defnotken
96f8d33cde Fixing dls ui and fixed cancel cache validation breaking menu. 2026-01-01 21:57:48 -06:00
cake
a033d4d4d8 Merge conflict 2026-01-02 04:01:44 +01:00
defnotken
7d2a914c84 Queue File compacting to let workers download as priority, Offload decompression task 2026-01-01 20:57:37 -06:00
cake
d6fe09ba8e Testing PAP handling changes. 2026-01-02 03:56:59 +01:00
defnotken
df33a0f0a2 Move buttons to debug 2026-01-01 17:27:12 -06:00
c439d1c822 Merge branch '2.0.3' into lifestream-ipc 2026-01-01 23:21:46 +00:00
choco
906dda3885 lightfinder nearby badge icon 2026-01-01 22:32:45 +01:00
choco
f812b6d09e own syncshell sometimes not showing in list bug 2026-01-01 22:32:34 +01:00
choco
89f59a98f5 Merge remote-tracking branch 'origin/2.0.3' into 2.0.0-crashing-bugfixes 2025-12-31 09:02:55 +01:00
defnotken
fb58d8657d Lifestream IPC witrh Debug Example 2025-12-30 23:43:22 -06:00
choco
e95a2c3352 Merge remote-tracking branch 'refs/remotes/origin/2.0.2' into 2.0.0-crashing-bugfixes 2025-12-30 19:32:42 +01:00
choco
a8340c3279 Merge remote-tracking branch 'origin/2.0.2-Location' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessSync/Services/DalamudUtilService.cs
2025-12-30 14:55:42 +01:00
Tsubasahane
e25979e089 fix Icon direction 2025-12-30 18:04:54 +08:00
Tsubasahane
ca7375b9c3 dont check location when target is offline 2025-12-30 14:42:02 +08:00
Tsubasahane
f8752fcb4d changed kanmoji to show correctly 2025-12-30 14:37:13 +08:00
Tsubasahane
d1c955c74f Reuse WorldData and make context menu work for non-Global uses 2025-12-30 14:23:37 +08:00
Tsubasahane
91e60694ad triggers update when map changes 2025-12-30 11:20:12 +08:00
Tsubasahane
f37fdefddd show icon correctly 2025-12-29 16:43:12 +08:00
Tsubasahane
18fa0a47b1 Locationshare fix 2025-12-29 15:42:55 +08:00
Tsubasahane
9f5cc9e0d1 Merge branch '2.0.2' into 2.0.2-Location 2025-12-29 14:48:07 +08:00
choco
b02db4c1e1 Merge remote-tracking branch 'origin/2.0.0-crashing-bugfixes' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessSync/Services/DalamudUtilService.cs
#	LightlessSync/UI/DtrEntry.cs
2025-12-28 16:56:06 +01:00
cake
d6b31ed5b9 Fixed finder again. 2025-12-28 16:55:01 +01:00
cake
9e600bfae0 Fixed merge conflicts. 2025-12-28 16:48:51 +01:00
cake
1a73d5a4d9 2.0.2 merged again 2025-12-28 16:40:47 +01:00
Tsubasahane
a933330418 Share location 2025-12-28 23:07:45 +08:00
Tsubasahane
ea34b18f40 Merge branch '2.0.2' into 2.0.2-Location 2025-12-28 13:10:17 +08:00
defnotken
67dc215e83 Merge branch '2.0.2-Location' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 2.0.2-Location 2025-12-27 21:17:32 -06:00
defnotken
baf3869cec Merge conf 2025-12-27 21:17:26 -06:00
Tsubasahane
eeda5aeb66 Revert "Location Sharing"
This reverts commit 70745613e1.
2025-12-28 10:54:01 +08:00
choco
754df95071 Merge remote-tracking branch 'origin/2.0.2-Location' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessSync/UI/DtrEntry.cs
2025-12-27 23:13:20 +01:00
choco
24fca31606 join syncshell draw modal 2025-12-27 23:09:29 +01:00
choco
a99c1c01b0 Merge remote-tracking branch 'origin/2.0.2' into 2.0.0-crashing-bugfixes 2025-12-27 23:08:03 +01:00
choco
85999fab8f Merge remote-tracking branch 'origin/2.0.2' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs
#	LightlessSync/UI/SyncshellFinderUI.cs
#	LightlessSync/UI/TopTabMenu.cs
#	LightlessSync/WebAPI/Files/FileDownloadManager.cs
2025-12-27 20:49:20 +01:00
Tsubasahane
70745613e1 Location Sharing 2025-12-27 19:57:21 +08:00
Tsubasahane
5c8e239a7b implement playerState
- use IPlayerState for DalamudUtilService and make things less asynced
- make LocationInfo work with ContentFinderData
2025-12-27 17:04:39 +08:00
choco
5eed65149a nearby lightfinder users window, wiht pair func 2025-12-27 02:38:56 +01:00
cake
1ab4e2f94b Added color options for header 2025-12-26 22:26:29 +01:00
choco
f792bc1954 compact ui design refactor with lightfinder redesign 2025-12-26 00:00:13 +01:00
choco
ced72ab9eb icon centering changes 2025-12-24 16:59:46 +01:00
choco
6c1cc77aaa settings animated header 2025-12-23 17:36:36 +01:00
choco
5b81caf5a8 compact menu redesign with new animated particle header, enable particles toggle added in UI settings 2025-12-23 17:16:51 +01:00
choco
4e03b381dc animated header main menu redesign test 2025-12-23 00:48:47 +01:00
choco
3222133aa0 Merge branch '2.0.1' into 2.0.0-crashing-bugfixes 2025-12-23 00:36:56 +01:00
choco
0ec423e65c potential resolve disposal crashes and race conditions 2025-12-21 22:34:39 +01:00
34 changed files with 4228 additions and 2086 deletions

View File

@@ -27,6 +27,7 @@ public sealed class FileCacheManager : IHostedService
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, FileCacheEntity>> _fileCaches = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, FileCacheEntity>> _fileCaches = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, FileCacheEntity> _fileCachesByPrefixedPath = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary<string, FileCacheEntity> _fileCachesByPrefixedPath = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _getCachesByPathsSemaphore = new(1, 1); private readonly SemaphoreSlim _getCachesByPathsSemaphore = new(1, 1);
private readonly SemaphoreSlim _evictSemaphore = new(1, 1);
private readonly Lock _fileWriteLock = new(); private readonly Lock _fileWriteLock = new();
private readonly IpcManager _ipcManager; private readonly IpcManager _ipcManager;
private readonly ILogger<FileCacheManager> _logger; private readonly ILogger<FileCacheManager> _logger;
@@ -226,13 +227,23 @@ public sealed class FileCacheManager : IHostedService
var compressed = LZ4Wrapper.WrapHC(raw, 0, raw.Length); var compressed = LZ4Wrapper.WrapHC(raw, 0, raw.Length);
var tmpPath = compressedPath + ".tmp"; var tmpPath = compressedPath + ".tmp";
await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false); try
File.Move(tmpPath, compressedPath, overwrite: true); {
await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false);
File.Move(tmpPath, compressedPath, overwrite: true);
}
finally
{
try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { /* ignore */ }
}
var compressedSize = compressed.LongLength; var compressedSize = new FileInfo(compressedPath).Length;
SetSizeInfo(hash, originalSize, compressedSize); SetSizeInfo(hash, originalSize, compressedSize);
UpdateEntitiesSizes(hash, originalSize, compressedSize); UpdateEntitiesSizes(hash, originalSize, compressedSize);
var maxBytes = GiBToBytes(_configService.Current.MaxLocalCacheInGiB);
await EnforceCacheLimitAsync(maxBytes, token).ConfigureAwait(false);
return compressed; return compressed;
} }
finally finally
@@ -931,6 +942,83 @@ public sealed class FileCacheManager : IHostedService
}, token).ConfigureAwait(false); }, token).ConfigureAwait(false);
} }
private async Task EnforceCacheLimitAsync(long maxBytes, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(CacheFolder) || maxBytes <= 0) return;
await _evictSemaphore.WaitAsync(token).ConfigureAwait(false);
try
{
Directory.CreateDirectory(CacheFolder);
foreach (var tmp in Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension + ".tmp"))
{
try { File.Delete(tmp); } catch { /* ignore */ }
}
var files = Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension, SearchOption.TopDirectoryOnly)
.Select(p => new FileInfo(p))
.Where(fi => fi.Exists)
.OrderBy(fi => fi.LastWriteTimeUtc)
.ToList();
long total = files.Sum(f => f.Length);
if (total <= maxBytes) return;
foreach (var fi in files)
{
token.ThrowIfCancellationRequested();
if (total <= maxBytes) break;
var hash = Path.GetFileNameWithoutExtension(fi.Name);
try
{
var len = fi.Length;
fi.Delete();
total -= len;
_sizeCache.TryRemove(hash, out _);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to evict cache file {file}", fi.FullName);
}
}
}
finally
{
_evictSemaphore.Release();
}
}
private static long GiBToBytes(double gib)
{
if (double.IsNaN(gib) || double.IsInfinity(gib) || gib <= 0)
return 0;
var bytes = gib * 1024d * 1024d * 1024d;
if (bytes >= long.MaxValue) return long.MaxValue;
return (long)Math.Round(bytes, MidpointRounding.AwayFromZero);
}
private void CleanupOrphanCompressedCache()
{
if (string.IsNullOrWhiteSpace(CacheFolder) || !Directory.Exists(CacheFolder))
return;
foreach (var path in Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension))
{
var hash = Path.GetFileNameWithoutExtension(path);
if (!_fileCaches.ContainsKey(hash))
{
try { File.Delete(path); }
catch (Exception ex) { _logger.LogWarning(ex, "Failed deleting orphan {file}", path); }
}
}
}
public async Task StartAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
{ {
_logger.LogInformation("Starting FileCacheManager"); _logger.LogInformation("Starting FileCacheManager");
@@ -1114,6 +1202,8 @@ public sealed class FileCacheManager : IHostedService
{ {
await WriteOutFullCsvAsync(cancellationToken).ConfigureAwait(false); await WriteOutFullCsvAsync(cancellationToken).ConfigureAwait(false);
} }
CleanupOrphanCompressedCache();
} }
_logger.LogInformation("Started FileCacheManager"); _logger.LogInformation("Started FileCacheManager");

View File

@@ -297,7 +297,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
private void DalamudUtil_FrameworkUpdate() private void DalamudUtil_FrameworkUpdate()
{ {
RefreshPlayerRelatedAddressMap(); _ = Task.Run(() => RefreshPlayerRelatedAddressMap());
lock (_cacheAdditionLock) lock (_cacheAdditionLock)
{ {
@@ -306,20 +306,64 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
if (_lastClassJobId != _dalamudUtil.ClassJobId) if (_lastClassJobId != _dalamudUtil.ClassJobId)
{ {
_lastClassJobId = _dalamudUtil.ClassJobId; UpdateClassJobCache();
if (SemiTransientResources.TryGetValue(ObjectKind.Pet, out HashSet<string>? value))
{
value?.Clear();
}
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
SemiTransientResources[ObjectKind.Pet] = new HashSet<string>(
petSpecificData ?? [],
StringComparer.OrdinalIgnoreCase);
} }
CleanupAbsentObjects();
}
private void RefreshPlayerRelatedAddressMap()
{
var tempMap = new ConcurrentDictionary<nint, GameObjectHandler>();
var updatedFrameAddresses = new ConcurrentDictionary<nint, ObjectKind>();
lock (_playerRelatedLock)
{
foreach (var handler in _playerRelatedPointers)
{
var address = (nint)handler.Address;
if (address != nint.Zero)
{
tempMap[address] = handler;
updatedFrameAddresses[address] = handler.ObjectKind;
}
}
}
_playerRelatedByAddress.Clear();
foreach (var kvp in tempMap)
{
_playerRelatedByAddress[kvp.Key] = kvp.Value;
}
_cachedFrameAddresses.Clear();
foreach (var kvp in updatedFrameAddresses)
{
_cachedFrameAddresses[kvp.Key] = kvp.Value;
}
}
private void UpdateClassJobCache()
{
_lastClassJobId = _dalamudUtil.ClassJobId;
if (SemiTransientResources.TryGetValue(ObjectKind.Pet, out HashSet<string>? value))
{
value?.Clear();
}
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache
.Concat(jobSpecificData ?? [])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
SemiTransientResources[ObjectKind.Pet] = new HashSet<string>(
petSpecificData ?? [],
StringComparer.OrdinalIgnoreCase);
}
private void CleanupAbsentObjects()
{
foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast<ObjectKind>()) foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast<ObjectKind>())
{ {
if (!_cachedFrameAddresses.Any(k => k.Value == kind) && TransientResources.Remove(kind, out _)) if (!_cachedFrameAddresses.Any(k => k.Value == kind) && TransientResources.Remove(kind, out _))
@@ -349,26 +393,6 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
_semiTransientResources = null; _semiTransientResources = null;
} }
private void RefreshPlayerRelatedAddressMap()
{
_playerRelatedByAddress.Clear();
var updatedFrameAddresses = new ConcurrentDictionary<nint, ObjectKind>();
lock (_playerRelatedLock)
{
foreach (var handler in _playerRelatedPointers)
{
var address = (nint)handler.Address;
if (address != nint.Zero)
{
_playerRelatedByAddress[address] = handler;
updatedFrameAddresses[address] = handler.ObjectKind;
}
}
}
_cachedFrameAddresses = updatedFrameAddresses;
}
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor) private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
{ {
if (descriptor.IsInGpose) if (descriptor.IsInGpose)

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.UI; using LightlessSync.UI;
using LightlessSync.UI.Models; using LightlessSync.UI.Models;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using LightlessSync.PlayerData.Factories;
namespace LightlessSync.LightlessConfiguration.Configurations; namespace LightlessSync.LightlessConfiguration.Configurations;
@@ -156,5 +157,10 @@ public class LightlessConfig : ILightlessConfiguration
public bool SyncshellFinderEnabled { get; set; } = false; public bool SyncshellFinderEnabled { get; set; } = false;
public string? SelectedFinderSyncshell { get; set; } = null; public string? SelectedFinderSyncshell { get; set; } = null;
public string LastSeenVersion { get; set; } = string.Empty; public string LastSeenVersion { get; set; } = string.Empty;
public bool EnableParticleEffects { get; set; } = true;
public HashSet<Guid> OrphanableTempCollections { get; set; } = []; public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Safe;
public bool AnimationAllowOneBasedShift { get; set; } = true;
public bool AnimationAllowNeighborIndexTolerance { get; set; } = false;
} }

View File

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

View File

@@ -2,12 +2,15 @@
using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Enum;
using LightlessSync.FileCache; using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc; using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models; using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Data; using LightlessSync.PlayerData.Data;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Diagnostics;
namespace LightlessSync.PlayerData.Factories; namespace LightlessSync.PlayerData.Factories;
@@ -18,13 +21,34 @@ public class PlayerDataFactory
private readonly IpcManager _ipcManager; private readonly IpcManager _ipcManager;
private readonly ILogger<PlayerDataFactory> _logger; private readonly ILogger<PlayerDataFactory> _logger;
private readonly PerformanceCollectorService _performanceCollector; private readonly PerformanceCollectorService _performanceCollector;
private readonly LightlessConfigService _configService;
private readonly XivDataAnalyzer _modelAnalyzer; private readonly XivDataAnalyzer _modelAnalyzer;
private readonly LightlessMediator _lightlessMediator; private readonly LightlessMediator _lightlessMediator;
private readonly TransientResourceManager _transientResourceManager; private readonly TransientResourceManager _transientResourceManager;
private static readonly SemaphoreSlim _papParseLimiter = new(1, 1);
public PlayerDataFactory(ILogger<PlayerDataFactory> logger, DalamudUtilService dalamudUtil, IpcManager ipcManager, // Transient resolved entries threshold
TransientResourceManager transientResourceManager, FileCacheManager fileReplacementFactory, private const int _maxTransientResolvedEntries = 1000;
PerformanceCollectorService performanceCollector, XivDataAnalyzer modelAnalyzer, LightlessMediator lightlessMediator)
// Character build caches
private readonly ConcurrentDictionary<nint, Task<CharacterDataFragment>> _characterBuildInflight = new();
private readonly ConcurrentDictionary<nint, CacheEntry> _characterBuildCache = new();
// Time out thresholds
private static readonly TimeSpan _characterCacheTtl = TimeSpan.FromMilliseconds(750);
private static readonly TimeSpan _softReturnIfBusyAfter = TimeSpan.FromMilliseconds(250);
private static readonly TimeSpan _hardBuildTimeout = TimeSpan.FromSeconds(30);
public PlayerDataFactory(
ILogger<PlayerDataFactory> logger,
DalamudUtilService dalamudUtil,
IpcManager ipcManager,
TransientResourceManager transientResourceManager,
FileCacheManager fileReplacementFactory,
PerformanceCollectorService performanceCollector,
XivDataAnalyzer modelAnalyzer,
LightlessMediator lightlessMediator,
LightlessConfigService configService)
{ {
_logger = logger; _logger = logger;
_dalamudUtil = dalamudUtil; _dalamudUtil = dalamudUtil;
@@ -34,15 +58,15 @@ public class PlayerDataFactory
_performanceCollector = performanceCollector; _performanceCollector = performanceCollector;
_modelAnalyzer = modelAnalyzer; _modelAnalyzer = modelAnalyzer;
_lightlessMediator = lightlessMediator; _lightlessMediator = lightlessMediator;
_configService = configService;
_logger.LogTrace("Creating {this}", nameof(PlayerDataFactory)); _logger.LogTrace("Creating {this}", nameof(PlayerDataFactory));
} }
private sealed record CacheEntry(CharacterDataFragment Fragment, DateTime CreatedUtc);
public async Task<CharacterDataFragment?> BuildCharacterData(GameObjectHandler playerRelatedObject, CancellationToken token) public async Task<CharacterDataFragment?> BuildCharacterData(GameObjectHandler playerRelatedObject, CancellationToken token)
{ {
if (!_ipcManager.Initialized) if (!_ipcManager.Initialized)
{
throw new InvalidOperationException("Penumbra or Glamourer is not connected"); throw new InvalidOperationException("Penumbra or Glamourer is not connected");
}
if (playerRelatedObject == null) return null; if (playerRelatedObject == null) return null;
@@ -67,16 +91,17 @@ public class PlayerDataFactory
if (pointerIsZero) if (pointerIsZero)
{ {
_logger.LogTrace("Pointer was zero for {objectKind}", playerRelatedObject.ObjectKind); _logger.LogTrace("Pointer was zero for {objectKind}; couldn't build character", playerRelatedObject.ObjectKind);
return null; return null;
} }
try try
{ {
return await _performanceCollector.LogPerformance(this, $"CreateCharacterData>{playerRelatedObject.ObjectKind}", async () => return await _performanceCollector.LogPerformance(
{ this,
return await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false); $"CreateCharacterData>{playerRelatedObject.ObjectKind}",
}).ConfigureAwait(true); async () => await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false)
).ConfigureAwait(false);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -92,17 +117,14 @@ public class PlayerDataFactory
} }
private async Task<bool> CheckForNullDrawObject(IntPtr playerPointer) private async Task<bool> CheckForNullDrawObject(IntPtr playerPointer)
{ => await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
return await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
}
private unsafe bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer) private unsafe static bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
{ {
if (playerPointer == IntPtr.Zero) if (playerPointer == IntPtr.Zero)
return true; return true;
var character = (Character*)playerPointer; var character = (Character*)playerPointer;
if (character == null) if (character == null)
return true; return true;
@@ -113,96 +135,177 @@ public class PlayerDataFactory
return gameObject->DrawObject == null; return gameObject->DrawObject == null;
} }
private async Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct) private static bool IsCacheFresh(CacheEntry entry)
=> (DateTime.UtcNow - entry.CreatedUtc) <= _characterCacheTtl;
private Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
=> CreateCharacterDataCoalesced(playerRelatedObject, ct);
private async Task<CharacterDataFragment> CreateCharacterDataCoalesced(GameObjectHandler obj, CancellationToken ct)
{ {
var objectKind = playerRelatedObject.ObjectKind; var key = obj.Address;
CharacterDataFragment fragment = objectKind == ObjectKind.Player ? new CharacterDataFragmentPlayer() : new();
_logger.LogDebug("Building character data for {obj}", playerRelatedObject); if (_characterBuildCache.TryGetValue(key, out var cached) && IsCacheFresh(cached) && !_characterBuildInflight.ContainsKey(key))
var logDebug = _logger.IsEnabled(LogLevel.Debug); return cached.Fragment;
// wait until chara is not drawing and present so nothing spontaneously explodes var buildTask = _characterBuildInflight.GetOrAdd(key, _ => BuildAndCacheAsync(obj, key));
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct).ConfigureAwait(false);
int totalWaitTime = 10000; if (_characterBuildCache.TryGetValue(key, out cached))
while (!await _dalamudUtil.IsObjectPresentAsync(await _dalamudUtil.CreateGameObjectAsync(playerRelatedObject.Address).ConfigureAwait(false)).ConfigureAwait(false) && totalWaitTime > 0)
{ {
_logger.LogTrace("Character is null but it shouldn't be, waiting"); var completed = await Task.WhenAny(buildTask, Task.Delay(_softReturnIfBusyAfter, ct)).ConfigureAwait(false);
await Task.Delay(50, ct).ConfigureAwait(false); if (completed != buildTask && (DateTime.UtcNow - cached.CreatedUtc) <= TimeSpan.FromSeconds(5))
totalWaitTime -= 50; {
return cached.Fragment;
}
} }
ct.ThrowIfCancellationRequested(); return await WithCancellation(buildTask, ct).ConfigureAwait(false);
}
DateTime start = DateTime.UtcNow; private async Task<CharacterDataFragment> BuildAndCacheAsync(GameObjectHandler obj, nint key)
{
// penumbra call, it's currently broken try
Dictionary<string, HashSet<string>>? resolvedPaths; {
using var cts = new CancellationTokenSource(_hardBuildTimeout);
resolvedPaths = (await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false)); var fragment = await CreateCharacterDataInternal(obj, cts.Token).ConfigureAwait(false);
if (resolvedPaths == null) throw new InvalidOperationException("Penumbra returned null data");
ct.ThrowIfCancellationRequested();
fragment.FileReplacements = fragment.FileReplacements =
new HashSet<FileReplacement>(resolvedPaths.Select(c => new FileReplacement([.. c.Value], c.Key)), FileReplacementComparer.Instance) new HashSet<FileReplacement>(resolvedPaths.Select(c => new FileReplacement([.. c.Value], c.Key)), FileReplacementComparer.Instance)
.Where(p => p.HasFileReplacement).ToHashSet(); .Where(p => p.HasFileReplacement).ToHashSet();
var allowedExtensions = CacheMonitor.AllowedFileExtensions; var allowedExtensions = CacheMonitor.AllowedFileExtensions;
fragment.FileReplacements.RemoveWhere(c => c.GamePaths.Any(g => !allowedExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase)))); fragment.FileReplacements.RemoveWhere(c => c.GamePaths.Any(g => !allowedExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase))));
_characterBuildCache[key] = new CacheEntry(fragment, DateTime.UtcNow);
PruneCharacterCacheIfNeeded();
return fragment;
}
finally
{
_characterBuildInflight.TryRemove(key, out _);
}
}
private void PruneCharacterCacheIfNeeded()
{
if (_characterBuildCache.Count < 2048) return;
var cutoff = DateTime.UtcNow - TimeSpan.FromSeconds(10);
foreach (var kv in _characterBuildCache)
{
if (kv.Value.CreatedUtc < cutoff)
_characterBuildCache.TryRemove(kv.Key, out _);
}
}
private static async Task<T> WithCancellation<T>(Task<T> task, CancellationToken ct)
=> await task.WaitAsync(ct).ConfigureAwait(false);
private async Task<CharacterDataFragment> CreateCharacterDataInternal(GameObjectHandler playerRelatedObject, CancellationToken ct)
{
var objectKind = playerRelatedObject.ObjectKind;
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);
await EnsureObjectPresentAsync(playerRelatedObject, ct).ConfigureAwait(false);
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
if (logDebug) var waitRecordingTask = _transientResourceManager.WaitForRecording(ct);
{
_logger.LogDebug("== Static Replacements ==");
foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement).OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
{
_logger.LogDebug("=> {repl}", replacement);
ct.ThrowIfCancellationRequested();
}
}
else
{
foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement))
{
ct.ThrowIfCancellationRequested();
}
}
await _transientResourceManager.WaitForRecording(ct).ConfigureAwait(false); await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct)
.ConfigureAwait(false);
// 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);
}
}
_logger.LogTrace("Clearing {count} Static Replacements for Pet", fragment.FileReplacements.Count);
fragment.FileReplacements.Clear();
}
ct.ThrowIfCancellationRequested();
_logger.LogDebug("Handling transient update for {obj}", playerRelatedObject);
// remove all potentially gathered paths from the transient resource manager that are resolved through static resolving
_transientResourceManager.ClearTransientPaths(objectKind, fragment.FileReplacements.SelectMany(c => c.GamePaths).ToList());
// get all remaining paths and resolve them // get all remaining paths and resolve them
var transientPaths = ManageSemiTransientData(objectKind); var transientPaths = ManageSemiTransientData(objectKind);
var resolvedTransientPaths = transientPaths.Count == 0 var resolvedTransientPaths = transientPaths.Count == 0
? new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase).AsReadOnly() ? new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase).AsReadOnly()
: await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false); : await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
ct.ThrowIfCancellationRequested();
if (await CheckForNullDrawObject(playerRelatedObject.Address).ConfigureAwait(false))
throw new InvalidOperationException("DrawObject became null during build (actor despawned)");
Task<string> getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address);
Task<string?> getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address);
Task<string?>? getMoodlesData = null;
Task<string>? getHeelsOffset = null;
Task<string>? getHonorificTitle = null;
if (objectKind == ObjectKind.Player)
{
getHeelsOffset = _ipcManager.Heels.GetOffsetAsync();
getHonorificTitle = _ipcManager.Honorific.GetTitle();
getMoodlesData = _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address);
}
var resolvedPaths = await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false) ?? throw new InvalidOperationException("Penumbra returned null data; couldn't proceed with character");
ct.ThrowIfCancellationRequested();
var staticBuildTask = Task.Run(() => BuildStaticReplacements(resolvedPaths), ct);
fragment.FileReplacements = await staticBuildTask.ConfigureAwait(false);
if (logDebug)
{
_logger.LogDebug("== Static Replacements ==");
foreach (var replacement in fragment.FileReplacements
.Where(i => i.HasFileReplacement)
.OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
{
_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)
{
CharacterDataFragmentPlayer? playerFragment = fragment as CharacterDataFragmentPlayer ?? throw new InvalidOperationException("Failed to cast CharacterDataFragment to Player variant");
playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations();
playerFragment.HonorificData = await getHonorificTitle!.ConfigureAwait(false);
_logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData);
playerFragment.PetNamesData = _ipcManager.PetNames.GetLocalNames();
_logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);
playerFragment.HeelsData = await getHeelsOffset!.ConfigureAwait(false);
_logger.LogDebug("Heels is now: {heels}", playerFragment!.HeelsData);
playerFragment.MoodlesData = (await getMoodlesData!.ConfigureAwait(false)) ?? string.Empty;
_logger.LogDebug("Moodles is now: {moodles}", playerFragment!.MoodlesData);
}
ct.ThrowIfCancellationRequested();
var (resolvedTransientPaths, clearedForPet) = await transientTask.ConfigureAwait(false);
if (clearedForPet != null)
fragment.FileReplacements.Clear();
if (logDebug) if (logDebug)
{ {
_logger.LogDebug("== Transient Replacements =="); _logger.LogDebug("== Transient Replacements ==");
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)).OrderBy(f => f.ResolvedPath, StringComparer.Ordinal)) foreach (var replacement in resolvedTransientPaths
.Select(c => new FileReplacement([.. c.Value], c.Key))
.OrderBy(f => f.ResolvedPath, StringComparer.Ordinal))
{ {
_logger.LogDebug("=> {repl}", replacement); _logger.LogDebug("=> {repl}", replacement);
fragment.FileReplacements.Add(replacement); fragment.FileReplacements.Add(replacement);
@@ -211,85 +314,64 @@ public class PlayerDataFactory
else else
{ {
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key))) foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)))
{
fragment.FileReplacements.Add(replacement); fragment.FileReplacements.Add(replacement);
}
} }
// clean up all semi transient resources that don't have any file replacement (aka null resolve)
_transientResourceManager.CleanUpSemiTransientResources(objectKind, [.. fragment.FileReplacements]); _transientResourceManager.CleanUpSemiTransientResources(objectKind, [.. fragment.FileReplacements]);
ct.ThrowIfCancellationRequested(); fragment.FileReplacements = new HashSet<FileReplacement>(
fragment.FileReplacements
// make sure we only return data that actually has file replacements .Where(v => v.HasFileReplacement)
fragment.FileReplacements = new HashSet<FileReplacement>(fragment.FileReplacements.Where(v => v.HasFileReplacement).OrderBy(v => v.ResolvedPath, StringComparer.Ordinal), FileReplacementComparer.Instance); .OrderBy(v => v.ResolvedPath, StringComparer.Ordinal),
FileReplacementComparer.Instance);
// gather up data from ipc
Task<string> getHeelsOffset = _ipcManager.Heels.GetOffsetAsync();
Task<string> getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address);
Task<string?> getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address);
Task<string> getHonorificTitle = _ipcManager.Honorific.GetTitle();
fragment.GlamourerString = await getGlamourerData.ConfigureAwait(false);
_logger.LogDebug("Glamourer is now: {data}", fragment.GlamourerString);
var customizeScale = await getCustomizeData.ConfigureAwait(false);
fragment.CustomizePlusScale = customizeScale ?? string.Empty;
_logger.LogDebug("Customize is now: {data}", fragment.CustomizePlusScale);
if (objectKind == ObjectKind.Player)
{
var playerFragment = (fragment as CharacterDataFragmentPlayer)!;
playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations();
playerFragment!.HonorificData = await getHonorificTitle.ConfigureAwait(false);
_logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData);
playerFragment!.HeelsData = await getHeelsOffset.ConfigureAwait(false);
_logger.LogDebug("Heels is now: {heels}", playerFragment!.HeelsData);
playerFragment!.MoodlesData = await _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address).ConfigureAwait(false) ?? string.Empty;
_logger.LogDebug("Moodles is now: {moodles}", playerFragment!.MoodlesData);
playerFragment!.PetNamesData = _ipcManager.PetNames.GetLocalNames();
_logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);
}
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
var toCompute = fragment.FileReplacements.Where(f => !f.IsFileSwap).ToArray(); var toCompute = fragment.FileReplacements.Where(f => !f.IsFileSwap).ToArray();
_logger.LogDebug("Getting Hashes for {amount} Files", toCompute.Length); _logger.LogDebug("Getting Hashes for {amount} Files", toCompute.Length);
var computedPaths = _fileCacheManager.GetFileCachesByPaths(toCompute.Select(c => c.ResolvedPath).ToArray());
foreach (var file in toCompute) await Task.Run(() =>
{ {
ct.ThrowIfCancellationRequested(); var computedPaths = _fileCacheManager.GetFileCachesByPaths([.. toCompute.Select(c => c.ResolvedPath)]);
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty; foreach (var file in toCompute)
} {
ct.ThrowIfCancellationRequested();
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty;
}
}, ct).ConfigureAwait(false);
var removed = fragment.FileReplacements.RemoveWhere(f => !f.IsFileSwap && string.IsNullOrEmpty(f.Hash)); var removed = fragment.FileReplacements.RemoveWhere(f => !f.IsFileSwap && string.IsNullOrEmpty(f.Hash));
if (removed > 0) if (removed > 0)
{
_logger.LogDebug("Removed {amount} of invalid files", removed); _logger.LogDebug("Removed {amount} of invalid files", removed);
}
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
Dictionary<string, List<ushort>>? boneIndices = null; Dictionary<string, List<ushort>>? boneIndices = null;
var hasPapFiles = false; var hasPapFiles = false;
if (objectKind == ObjectKind.Player)
{
hasPapFiles = fragment.FileReplacements.Any(f =>
!f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase));
if (hasPapFiles)
{
boneIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject)).ConfigureAwait(false);
}
}
if (objectKind == ObjectKind.Player) if (objectKind == ObjectKind.Player)
{ {
hasPapFiles = fragment.FileReplacements.Any(f =>
!f.IsFileSwap && f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
if (hasPapFiles)
{
boneIndices = await _dalamudUtil
.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject))
.ConfigureAwait(false);
}
try try
{ {
#if DEBUG
if (hasPapFiles && boneIndices != null)
_modelAnalyzer.DumpLocalSkeletonIndices(playerRelatedObject);
#endif
if (hasPapFiles) if (hasPapFiles)
{ {
await VerifyPlayerAnimationBones(boneIndices, (fragment as CharacterDataFragmentPlayer)!, ct).ConfigureAwait(false); await VerifyPlayerAnimationBones(boneIndices, (CharacterDataFragmentPlayer)fragment, ct)
.ConfigureAwait(false);
} }
} }
catch (OperationCanceledException e) catch (OperationCanceledException e)
@@ -303,80 +385,250 @@ public class PlayerDataFactory
} }
} }
_logger.LogInformation("Building character data for {obj} took {time}ms", objectKind, TimeSpan.FromTicks(DateTime.UtcNow.Ticks - start.Ticks).TotalMilliseconds); _logger.LogInformation("Building character data for {obj} took {time}ms",
objectKind, sw.Elapsed.TotalMilliseconds);
return fragment; return fragment;
} }
private async Task VerifyPlayerAnimationBones(Dictionary<string, List<ushort>>? boneIndices, CharacterDataFragmentPlayer fragment, CancellationToken ct) private async Task EnsureObjectPresentAsync(GameObjectHandler handler, CancellationToken ct)
{ {
if (boneIndices == null) return; var remaining = 10000;
while (remaining > 0)
if (_logger.IsEnabled(LogLevel.Debug))
{
foreach (var kvp in boneIndices)
{
_logger.LogDebug("Found {skellyname} ({idx} bone indices) on player: {bones}", kvp.Key, kvp.Value.Any() ? kvp.Value.Max() : 0, string.Join(',', kvp.Value));
}
}
var maxPlayerBoneIndex = boneIndices.SelectMany(kvp => kvp.Value).DefaultIfEmpty().Max();
if (maxPlayerBoneIndex <= 0) return;
int noValidationFailed = 0;
foreach (var file in fragment.FileReplacements.Where(f => !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)).ToList())
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
var skeletonIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetBoneIndicesFromPap(file.Hash)).ConfigureAwait(false); var obj = await _dalamudUtil.CreateGameObjectAsync(handler.Address).ConfigureAwait(false);
bool validationFailed = false; if (await _dalamudUtil.IsObjectPresentAsync(obj).ConfigureAwait(false))
if (skeletonIndices != null) return;
_logger.LogTrace("Character is null but it shouldn't be, waiting");
await Task.Delay(50, ct).ConfigureAwait(false);
remaining -= 50;
}
}
private static HashSet<FileReplacement> BuildStaticReplacements(Dictionary<string, HashSet<string>> resolvedPaths)
{
var set = new HashSet<FileReplacement>(FileReplacementComparer.Instance);
foreach (var kvp in resolvedPaths)
{
var fr = new FileReplacement([.. kvp.Value], kvp.Key);
if (!fr.HasFileReplacement) continue;
var allAllowed = fr.GamePaths.All(g =>
CacheMonitor.AllowedFileExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase)));
if (!allAllowed) continue;
set.Add(fr);
}
return set;
}
private async Task<(IReadOnlyDictionary<string, string[]> ResolvedPaths, HashSet<FileReplacement>? ClearedReplacements)>
ResolveTransientReplacementsAsync(
GameObjectHandler obj,
ObjectKind objectKind,
HashSet<FileReplacement> staticReplacements,
Task waitRecordingTask,
CancellationToken ct)
{
await waitRecordingTask.ConfigureAwait(false);
HashSet<FileReplacement>? clearedReplacements = null;
if (objectKind == ObjectKind.Pet)
{
foreach (var item in staticReplacements.Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths))
{ {
// 105 is the maximum vanilla skellington spoopy bone index if (_transientResourceManager.AddTransientResource(objectKind, item))
if (skeletonIndices.All(k => k.Value.Max() <= 105)) _logger.LogDebug("Marking static {item} for Pet as transient", item);
{ }
_logger.LogTrace("All indices of {path} are <= 105, ignoring", file.ResolvedPath);
continue;
}
_logger.LogDebug("Verifying bone indices for {path}, found {x} skeletons", file.ResolvedPath, skeletonIndices.Count); _logger.LogTrace("Clearing {count} Static Replacements for Pet", staticReplacements.Count);
clearedReplacements = staticReplacements;
}
foreach (var boneCount in skeletonIndices) ct.ThrowIfCancellationRequested();
{
var maxAnimationIndex = boneCount.Value.DefaultIfEmpty().Max(); _transientResourceManager.ClearTransientPaths(objectKind, [.. staticReplacements.SelectMany(c => c.GamePaths)]);
if (maxAnimationIndex > maxPlayerBoneIndex)
var transientPaths = ManageSemiTransientData(objectKind);
if (transientPaths.Count == 0)
return (new Dictionary<string, string[]>(StringComparer.Ordinal), clearedReplacements);
var resolved = await GetFileReplacementsFromPaths(obj, transientPaths, new HashSet<string>(StringComparer.Ordinal))
.ConfigureAwait(false);
if (_maxTransientResolvedEntries > 0 && resolved.Count > _maxTransientResolvedEntries)
{
_logger.LogWarning("Transient entries ({resolved}) are above the threshold {max}; Please consider disable some mods (VFX have heavy load) to reduce transient load",
resolved.Count,
_maxTransientResolvedEntries);
}
return (resolved, clearedReplacements);
}
private async Task VerifyPlayerAnimationBones(
Dictionary<string, List<ushort>>? playerBoneIndices,
CharacterDataFragmentPlayer fragment,
CancellationToken ct)
{
var mode = _configService.Current.AnimationValidationMode;
var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift;
var allownNightIndex = _configService.Current.AnimationAllowNeighborIndexTolerance;
if (mode == AnimationValidationMode.Unsafe)
return;
if (playerBoneIndices == null || playerBoneIndices.Count == 0)
return;
var localBoneSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
foreach (var (rawLocalKey, indices) in playerBoneIndices)
{
if (indices is not { Count: > 0 })
continue;
var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawLocalKey);
if (string.IsNullOrEmpty(key))
continue;
if (!localBoneSets.TryGetValue(key, out var set))
localBoneSets[key] = set = [];
foreach (var idx in indices)
set.Add(idx);
}
if (localBoneSets.Count == 0)
return;
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("SEND local buckets: {b}",
string.Join(", ", localBoneSets.Keys.Order(StringComparer.Ordinal)));
foreach (var kvp in localBoneSets.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
{
var min = kvp.Value.Count > 0 ? kvp.Value.Min() : 0;
var max = kvp.Value.Count > 0 ? kvp.Value.Max() : 0;
_logger.LogDebug("Local bucket {bucket}: count={count} min={min} max={max}",
kvp.Key, kvp.Value.Count, min, max);
}
}
var papGroups = fragment.FileReplacements
.Where(f => !f.IsFileSwap
&& !string.IsNullOrEmpty(f.Hash)
&& f.GamePaths is { Count: > 0 }
&& f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
.GroupBy(f => f.Hash!, StringComparer.OrdinalIgnoreCase)
.ToList();
int noValidationFailed = 0;
foreach (var g in papGroups)
{
ct.ThrowIfCancellationRequested();
var hash = g.Key;
Dictionary<string, List<ushort>>? papIndices = null;
await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
try
{
papIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash), ct)
.ConfigureAwait(false);
}
finally
{
_papParseLimiter.Release();
}
if (papIndices == null || papIndices.Count == 0)
continue;
if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105))
continue;
if (_logger.IsEnabled(LogLevel.Debug))
{
var papBuckets = papIndices
.Select(kvp => new
{ {
_logger.LogWarning("Found more bone indices on the animation {path} skeleton {skl} (max indice {idx}) than on any player related skeleton (max indice {idx2})", Raw = kvp.Key,
file.ResolvedPath, boneCount.Key, maxAnimationIndex, maxPlayerBoneIndex); Key = XivDataAnalyzer.CanonicalizeSkeletonKey(kvp.Key),
validationFailed = true; Indices = kvp.Value
break; })
} .Where(x => x.Indices is { Count: > 0 })
} .GroupBy(x => string.IsNullOrEmpty(x.Key) ? x.Raw : x.Key!, StringComparer.OrdinalIgnoreCase)
.Select(grp =>
{
var all = grp.SelectMany(v => v.Indices).ToList();
var min = all.Count > 0 ? all.Min() : 0;
var max = all.Count > 0 ? all.Max() : 0;
var raws = string.Join(',', grp.Select(v => v.Raw).Distinct(StringComparer.OrdinalIgnoreCase));
return $"{grp.Key}(min={min},max={max},raw=[{raws}])";
})
.ToList();
_logger.LogDebug("SEND pap buckets for hash={hash}: {b}",
hash,
string.Join(" | ", papBuckets));
} }
if (validationFailed) if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason))
{ continue;
noValidationFailed++;
_logger.LogDebug("Removing {file} from sent file replacements and transient data", file.ResolvedPath);
fragment.FileReplacements.Remove(file);
foreach (var gamePath in file.GamePaths)
{
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gamePath);
}
}
noValidationFailed++;
_logger.LogWarning(
"Animation PAP hash {hash} is not compatible with local skeletons; dropping all mappings for this hash. Reason: {reason}",
hash,
reason);
var removedGamePaths = fragment.FileReplacements
.Where(fr => !fr.IsFileSwap
&& string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
&& fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
.SelectMany(fr => fr.GamePaths.Where(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
fragment.FileReplacements.RemoveWhere(fr =>
!fr.IsFileSwap
&& string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
&& fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
foreach (var gp in removedGamePaths)
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gp);
} }
if (noValidationFailed > 0) if (noValidationFailed > 0)
{ {
_lightlessMediator.Publish(new NotificationMessage("Invalid Skeleton Setup", _lightlessMediator.Publish(new NotificationMessage(
$"Your client is attempting to send {noValidationFailed} animation files with invalid bone data. Those animation files have been removed from your sent data. " + "Invalid Skeleton Setup",
$"Verify that you are using the correct skeleton for those animation files (Check /xllog for more information).", $"Your client is attempting to send {noValidationFailed} animation files that don't match your current skeleton validation mode ({mode}). " +
NotificationType.Warning, TimeSpan.FromSeconds(10))); "Please adjust your skeleton/mods or change the validation mode if this is unexpected. " +
"Those animation files have been removed from your sent (player) data. (Check /xllog for details).",
NotificationType.Warning,
TimeSpan.FromSeconds(10)));
} }
} }
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(GameObjectHandler handler, HashSet<string> forwardResolve, HashSet<string> reverseResolve)
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(
GameObjectHandler handler,
HashSet<string> forwardResolve,
HashSet<string> reverseResolve)
{ {
var forwardPaths = forwardResolve.ToArray(); var forwardPaths = forwardResolve.ToArray();
var reversePaths = reverseResolve.ToArray(); var reversePaths = reverseResolve.ToArray();
@@ -395,21 +647,15 @@ public class PlayerDataFactory
{ {
var idx = handler.GetGameObject()?.ObjectIndex; var idx = handler.GetGameObject()?.ObjectIndex;
if (!idx.HasValue) if (!idx.HasValue)
{
return ((int?)null, Array.Empty<string>(), Array.Empty<string[]>()); return ((int?)null, Array.Empty<string>(), Array.Empty<string[]>());
}
var resolvedForward = new string[forwardPaths.Length]; var resolvedForward = new string[forwardPaths.Length];
for (int i = 0; i < forwardPaths.Length; i++) for (int i = 0; i < forwardPaths.Length; i++)
{
resolvedForward[i] = _ipcManager.Penumbra.ResolveGameObjectPath(forwardPaths[i], idx.Value); resolvedForward[i] = _ipcManager.Penumbra.ResolveGameObjectPath(forwardPaths[i], idx.Value);
}
var resolvedReverse = new string[reversePaths.Length][]; var resolvedReverse = new string[reversePaths.Length][];
for (int i = 0; i < reversePaths.Length; i++) for (int i = 0; i < reversePaths.Length; i++)
{
resolvedReverse[i] = _ipcManager.Penumbra.ReverseResolveGameObjectPath(reversePaths[i], idx.Value); resolvedReverse[i] = _ipcManager.Penumbra.ReverseResolveGameObjectPath(reversePaths[i], idx.Value);
}
return (idx, resolvedForward, resolvedReverse); return (idx, resolvedForward, resolvedReverse);
}).ConfigureAwait(false); }).ConfigureAwait(false);
@@ -420,17 +666,13 @@ public class PlayerDataFactory
{ {
var filePath = forwardResolved[i]?.ToLowerInvariant(); var filePath = forwardResolved[i]?.ToLowerInvariant();
if (string.IsNullOrEmpty(filePath)) if (string.IsNullOrEmpty(filePath))
{
continue; continue;
}
if (resolvedPaths.TryGetValue(filePath, out var list)) if (resolvedPaths.TryGetValue(filePath, out var list))
{ list.Add(forwardPaths[i].ToLowerInvariant());
list.Add(forwardPathsLower[i]);
}
else else
{ {
resolvedPaths[filePath] = [forwardPathsLower[i]]; resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
} }
} }
@@ -443,13 +685,9 @@ public class PlayerDataFactory
reverseResolvedLower[j] = reverseResolved[i][j].ToLowerInvariant(); reverseResolvedLower[j] = reverseResolved[i][j].ToLowerInvariant();
} }
if (resolvedPaths.TryGetValue(filePath, out var list)) if (resolvedPaths.TryGetValue(filePath, out var list))
{ list.AddRange(reverseResolved[i].Select(c => c.ToLowerInvariant()));
list.AddRange(reverseResolvedLower);
}
else else
{ resolvedPaths[filePath] = [.. reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList()];
resolvedPaths[filePath] = new List<string>(reverseResolvedLower);
}
} }
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly(); return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
@@ -457,17 +695,14 @@ public class PlayerDataFactory
} }
var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false); var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false);
for (int i = 0; i < forwardPaths.Length; i++) for (int i = 0; i < forwardPaths.Length; i++)
{ {
var filePath = forward[i].ToLowerInvariant(); var filePath = forward[i].ToLowerInvariant();
if (resolvedPaths.TryGetValue(filePath, out var list)) if (resolvedPaths.TryGetValue(filePath, out var list))
{ list.Add(forwardPaths[i].ToLowerInvariant());
list.Add(forwardPathsLower[i]);
}
else else
{ resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
resolvedPaths[filePath] = [forwardPathsLower[i]];
}
} }
for (int i = 0; i < reversePaths.Length; i++) for (int i = 0; i < reversePaths.Length; i++)
@@ -479,13 +714,9 @@ public class PlayerDataFactory
reverseResolvedLower[j] = reverse[i][j].ToLowerInvariant(); reverseResolvedLower[j] = reverse[i][j].ToLowerInvariant();
} }
if (resolvedPaths.TryGetValue(filePath, out var list)) if (resolvedPaths.TryGetValue(filePath, out var list))
{ list.AddRange(reverse[i].Select(c => c.ToLowerInvariant()));
list.AddRange(reverseResolvedLower);
}
else else
{ resolvedPaths[filePath] = [.. reverse[i].Select(c => c.ToLowerInvariant()).ToList()];
resolvedPaths[filePath] = new List<string>(reverseResolvedLower);
}
} }
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly(); return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
@@ -496,11 +727,29 @@ public class PlayerDataFactory
_transientResourceManager.PersistTransientResources(objectKind); _transientResourceManager.PersistTransientResources(objectKind);
HashSet<string> pathsToResolve = new(StringComparer.Ordinal); HashSet<string> pathsToResolve = new(StringComparer.Ordinal);
foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind).Where(path => !string.IsNullOrEmpty(path)))
int scanned = 0, skippedEmpty = 0, skippedVfx = 0;
foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind))
{ {
scanned++;
if (string.IsNullOrEmpty(path))
{
skippedEmpty++;
continue;
}
pathsToResolve.Add(path); pathsToResolve.Add(path);
} }
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug(
"ManageSemiTransientData({kind}): scanned={scanned}, added={added}, skippedEmpty={skippedEmpty}, skippedVfx={skippedVfx}",
objectKind, scanned, pathsToResolve.Count, skippedEmpty, skippedVfx);
}
return pathsToResolve; return pathsToResolve;
} }
} }

View File

@@ -25,6 +25,7 @@ using Microsoft.Extensions.Logging;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer; using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer;
using LightlessSync.LightlessConfiguration;
namespace LightlessSync.PlayerData.Pairs; namespace LightlessSync.PlayerData.Pairs;
@@ -51,7 +52,9 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
private readonly ModelDecimationService _modelDecimationService; private readonly ModelDecimationService _modelDecimationService;
private readonly PairStateCache _pairStateCache; private readonly PairStateCache _pairStateCache;
private readonly PairPerformanceMetricsCache _performanceMetricsCache; private readonly PairPerformanceMetricsCache _performanceMetricsCache;
private readonly XivDataAnalyzer _modelAnalyzer;
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor; private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
private readonly LightlessConfigService _configService;
private readonly PairManager _pairManager; private readonly PairManager _pairManager;
private readonly IFramework _framework; private readonly IFramework _framework;
private CancellationTokenSource? _applicationCancellationTokenSource; private CancellationTokenSource? _applicationCancellationTokenSource;
@@ -104,6 +107,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
".avfx", ".avfx",
".scd" ".scd"
}; };
private readonly ConcurrentDictionary<string, byte> _blockedPapHashes = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, byte> _dumpedRemoteSkeletonForHash = new(StringComparer.OrdinalIgnoreCase);
private DateTime? _invisibleSinceUtc; private DateTime? _invisibleSinceUtc;
private DateTime? _visibilityEvictionDueAtUtc; private DateTime? _visibilityEvictionDueAtUtc;
private DateTime _nextActorLookupUtc = DateTime.MinValue; private DateTime _nextActorLookupUtc = DateTime.MinValue;
@@ -207,7 +214,9 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
ModelDecimationService modelDecimationService, ModelDecimationService modelDecimationService,
PairStateCache pairStateCache, PairStateCache pairStateCache,
PairPerformanceMetricsCache performanceMetricsCache, PairPerformanceMetricsCache performanceMetricsCache,
PenumbraTempCollectionJanitor tempCollectionJanitor) : base(logger, mediator) PenumbraTempCollectionJanitor tempCollectionJanitor,
XivDataAnalyzer modelAnalyzer,
LightlessConfigService configService) : base(logger, mediator)
{ {
_pairManager = pairManager; _pairManager = pairManager;
Ident = ident; Ident = ident;
@@ -229,6 +238,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
_pairStateCache = pairStateCache; _pairStateCache = pairStateCache;
_performanceMetricsCache = performanceMetricsCache; _performanceMetricsCache = performanceMetricsCache;
_tempCollectionJanitor = tempCollectionJanitor; _tempCollectionJanitor = tempCollectionJanitor;
_modelAnalyzer = modelAnalyzer;
_configService = configService;
} }
public void Initialize() public void Initialize()
@@ -2119,11 +2130,36 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
return; return;
} }
SplitPapMappings(moddedPaths, out var withoutPap, out var papOnly);
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false); await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false);
await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, _applicationId, penumbraCollection, await _ipcManager.Penumbra.SetTemporaryModsAsync(
moddedPaths.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)).ConfigureAwait(false); Logger, _applicationId, penumbraCollection,
_lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(moddedPaths, moddedPaths.Comparer); withoutPap.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal))
.ConfigureAwait(false);
await _ipcManager.Penumbra.RedrawAsync(Logger, handlerForApply, _applicationId, token).ConfigureAwait(false);
if (handlerForApply.Address != nint.Zero)
await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false);
var removedPap = await StripIncompatiblePapAsync(handlerForApply, charaData, papOnly, token).ConfigureAwait(false);
if (removedPap > 0)
{
Logger.LogTrace("[{applicationId}] Removed {removedPap} incompatible PAP mappings found for {handler}", _applicationId, removedPap, GetLogIdentifier());
}
var merged = new Dictionary<(string GamePath, string? Hash), string>(withoutPap, withoutPap.Comparer);
foreach (var kv in papOnly)
merged[kv.Key] = kv.Value;
await _ipcManager.Penumbra.SetTemporaryModsAsync(
Logger, _applicationId, penumbraCollection,
merged.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal))
.ConfigureAwait(false);
_lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(merged, merged.Comparer);
LastAppliedDataBytes = -1; LastAppliedDataBytes = -1;
foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists)) foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists))
{ {
@@ -2191,20 +2227,20 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException)) if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException))
{ {
IsVisible = false; IsVisible = false;
_forceApplyMods = true; _forceApplyMods = true;
_cachedData = charaData; _cachedData = charaData;
_pairStateCache.Store(Ident, charaData); _pairStateCache.Store(Ident, charaData);
_forceFullReapply = true; _forceFullReapply = true;
Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId); 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");
} }
else
{
Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId);
_forceFullReapply = true;
}
RecordFailure($"Application failed: {ex.Message}", "Exception");
} }
}
private void FrameworkUpdate() private void FrameworkUpdate()
{ {
@@ -2441,20 +2477,37 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
if (string.IsNullOrEmpty(new FileInfo(fileCache.ResolvedFilepath).Extension)) if (string.IsNullOrEmpty(new FileInfo(fileCache.ResolvedFilepath).Extension))
{ {
hasMigrationChanges = true; hasMigrationChanges = true;
fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, item.GamePaths[0].Split(".")[^1]); var anyGamePath = item.GamePaths.FirstOrDefault();
if (!string.IsNullOrEmpty(anyGamePath))
{
var ext = Path.GetExtension(anyGamePath);
var extNoDot = ext.StartsWith('.') ? ext[1..] : ext;
if (!string.IsNullOrEmpty(extNoDot))
{
hasMigrationChanges = true;
fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, extNoDot);
}
}
} }
foreach (var gamePath in item.GamePaths) foreach (var gamePath in item.GamePaths)
{ {
var preferredPath = fileCache.ResolvedFilepath; var mode = _configService.Current.AnimationValidationMode;
if (!skipDownscaleForPair && gamePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))
if (mode != AnimationValidationMode.Unsafe
&& gamePath.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrEmpty(item.Hash)
&& _blockedPapHashes.ContainsKey(item.Hash))
{ {
preferredPath = _textureDownscaleService.GetPreferredPath(item.Hash, fileCache.ResolvedFilepath); continue;
}
else if (!skipDecimationForPair && gamePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase))
{
preferredPath = _modelDecimationService.GetPreferredPath(item.Hash, fileCache.ResolvedFilepath);
} }
var preferredPath = skipDownscaleForPair
? fileCache.ResolvedFilepath
: _textureDownscaleService.GetPreferredPath(item.Hash, fileCache.ResolvedFilepath);
outputDict[(gamePath, item.Hash)] = preferredPath; outputDict[(gamePath, item.Hash)] = preferredPath;
} }
} }
@@ -2766,7 +2819,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
HandleVisibilityLoss(logChange: false); HandleVisibilityLoss(logChange: false);
} }
private bool TryResolveDescriptorHash(ActorObjectService.ActorDescriptor descriptor, out string hashedCid) private static bool TryResolveDescriptorHash(ActorObjectService.ActorDescriptor descriptor, out string hashedCid)
{ {
hashedCid = descriptor.HashedContentId ?? string.Empty; hashedCid = descriptor.HashedContentId ?? string.Empty;
if (!string.IsNullOrEmpty(hashedCid)) if (!string.IsNullOrEmpty(hashedCid))
@@ -2802,6 +2855,106 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
} }
} }
private static void SplitPapMappings(
Dictionary<(string GamePath, string? Hash), string> moddedPaths,
out Dictionary<(string GamePath, string? Hash), string> withoutPap,
out Dictionary<(string GamePath, string? Hash), string> papOnly)
{
withoutPap = new(moddedPaths.Comparer);
papOnly = new(moddedPaths.Comparer);
foreach (var kv in moddedPaths)
{
var gamePath = kv.Key.GamePath;
if (gamePath.EndsWith(".pap", StringComparison.OrdinalIgnoreCase))
papOnly[kv.Key] = kv.Value;
else
withoutPap[kv.Key] = kv.Value;
}
}
private async Task<int> StripIncompatiblePapAsync(
GameObjectHandler handlerForApply,
CharacterData charaData,
Dictionary<(string GamePath, string? Hash), string> papOnly,
CancellationToken token)
{
var mode = _configService.Current.AnimationValidationMode;
var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift;
var allownNightIndex = _configService.Current.AnimationAllowNeighborIndexTolerance;
if (mode == AnimationValidationMode.Unsafe || papOnly.Count == 0)
return 0;
var boneIndices = await _dalamudUtil.RunOnFrameworkThread(
() => _modelAnalyzer.GetSkeletonBoneIndices(handlerForApply))
.ConfigureAwait(false);
if (boneIndices == null || boneIndices.Count == 0)
{
var removedCount = papOnly.Count;
papOnly.Clear();
return removedCount;
}
var localBoneSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
foreach (var (rawKey, list) in boneIndices)
{
var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawKey);
if (string.IsNullOrEmpty(key)) continue;
if (!localBoneSets.TryGetValue(key, out var set))
localBoneSets[key] = set = [];
foreach (var v in list)
set.Add(v);
}
int removed = 0;
foreach (var hash in papOnly.Keys.Select(k => k.Hash).Where(h => !string.IsNullOrEmpty(h)).Distinct(StringComparer.OrdinalIgnoreCase).ToList())
{
token.ThrowIfCancellationRequested();
var papIndices = await _dalamudUtil.RunOnFrameworkThread(
() => _modelAnalyzer.GetBoneIndicesFromPap(hash!))
.ConfigureAwait(false);
if (papIndices == null || papIndices.Count == 0)
continue;
if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105))
continue;
if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason))
continue;
var keysToRemove = papOnly.Keys.Where(k => string.Equals(k.Hash, hash, StringComparison.OrdinalIgnoreCase)).ToList();
foreach (var k in keysToRemove)
papOnly.Remove(k);
removed += keysToRemove.Count;
if (_blockedPapHashes.TryAdd(hash!, 0))
Logger.LogWarning("Blocked remote object PAP (hash {hash}) for {handler}: {reason}", hash, GetLogIdentifier(), reason);
if (charaData.FileReplacements.TryGetValue(ObjectKind.Player, out var list))
{
list.RemoveAll(r => string.Equals(r.Hash, hash, StringComparison.OrdinalIgnoreCase)
&& r.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
}
}
var nullHashKeys = papOnly.Keys.Where(k => string.IsNullOrEmpty(k.Hash)).ToList();
foreach (var k in nullHashKeys)
{
papOnly.Remove(k);
removed++;
}
return removed;
}
private async Task ApplyCustomizeAsync(nint address, string customizeData, ObjectKind kind) private async Task ApplyCustomizeAsync(nint address, string customizeData, ObjectKind kind)
{ {
_customizeIds[kind] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false); _customizeIds[kind] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false);

View File

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

View File

@@ -378,6 +378,11 @@ public sealed class Plugin : IDalamudPlugin
sp.GetRequiredService<DalamudUtilService>(), sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessMediator>())); sp.GetRequiredService<LightlessMediator>()));
services.AddSingleton(sp => new IpcCallerLifestream(
pluginInterface,
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<ILogger<IpcCallerLifestream>>()));
services.AddSingleton(sp => new IpcManager( services.AddSingleton(sp => new IpcManager(
sp.GetRequiredService<ILogger<IpcManager>>(), sp.GetRequiredService<ILogger<IpcManager>>(),
sp.GetRequiredService<LightlessMediator>(), sp.GetRequiredService<LightlessMediator>(),
@@ -388,7 +393,9 @@ public sealed class Plugin : IDalamudPlugin
sp.GetRequiredService<IpcCallerHonorific>(), sp.GetRequiredService<IpcCallerHonorific>(),
sp.GetRequiredService<IpcCallerMoodles>(), sp.GetRequiredService<IpcCallerMoodles>(),
sp.GetRequiredService<IpcCallerPetNames>(), sp.GetRequiredService<IpcCallerPetNames>(),
sp.GetRequiredService<IpcCallerBrio>())); sp.GetRequiredService<IpcCallerBrio>(),
sp.GetRequiredService<IpcCallerLifestream>()
));
// Notifications / HTTP // Notifications / HTTP
services.AddSingleton(sp => new NotificationService( services.AddSingleton(sp => new NotificationService(
@@ -486,19 +493,11 @@ public sealed class Plugin : IDalamudPlugin
sp.GetRequiredService<UiSharedService>(), sp.GetRequiredService<UiSharedService>(),
sp.GetRequiredService<ApiController>(), sp.GetRequiredService<ApiController>(),
sp.GetRequiredService<LightFinderScannerService>(), sp.GetRequiredService<LightFinderScannerService>(),
sp.GetRequiredService<LightFinderPlateHandler>()));
services.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>(sp => new SyncshellFinderUI(
sp.GetRequiredService<ILogger<SyncshellFinderUI>>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<PerformanceCollectorService>(),
sp.GetRequiredService<LightFinderService>(),
sp.GetRequiredService<UiSharedService>(),
sp.GetRequiredService<ApiController>(),
sp.GetRequiredService<LightFinderScannerService>(),
sp.GetRequiredService<PairUiService>(), sp.GetRequiredService<PairUiService>(),
sp.GetRequiredService<DalamudUtilService>(), sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessProfileManager>())); sp.GetRequiredService<LightlessProfileManager>(),
sp.GetRequiredService<ActorObjectService>(),
sp.GetRequiredService<LightFinderPlateHandler>()));
services.AddScoped<IPopupHandler, BanUserPopupHandler>(); services.AddScoped<IPopupHandler, BanUserPopupHandler>();
services.AddScoped<IPopupHandler, CensusPopupHandler>(); services.AddScoped<IPopupHandler, CensusPopupHandler>();
@@ -584,7 +583,6 @@ public sealed class Plugin : IDalamudPlugin
public void Dispose() public void Dispose()
{ {
_host.StopAsync().GetAwaiter().GetResult(); _host.StopAsync().ContinueWith(_ => _host.Dispose()).Wait(TimeSpan.FromSeconds(5));
_host.Dispose();
} }
} }

View File

@@ -28,7 +28,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
{ {
_baseAnalysisCts = _baseAnalysisCts.CancelRecreate(); _baseAnalysisCts = _baseAnalysisCts.CancelRecreate();
var token = _baseAnalysisCts.Token; var token = _baseAnalysisCts.Token;
_ = BaseAnalysis(msg.CharacterData, token); _ = Task.Run(async () => await BaseAnalysis(msg.CharacterData, token).ConfigureAwait(false), token);
}); });
_fileCacheManager = fileCacheManager; _fileCacheManager = fileCacheManager;
_xivDataAnalyzer = modelAnalyzer; _xivDataAnalyzer = modelAnalyzer;

View File

@@ -22,8 +22,10 @@ using LightlessSync.Utils;
using Lumina.Excel.Sheets; using Lumina.Excel.Sheets;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Numerics; using System.Numerics;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text; using System.Text;
using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind; using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
@@ -843,31 +845,41 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return Task.CompletedTask; return Task.CompletedTask;
} }
public async Task WaitWhileCharacterIsDrawing(ILogger logger, GameObjectHandler handler, Guid redrawId, int timeOut = 5000, CancellationToken? ct = null) public async Task WaitWhileCharacterIsDrawing(
ILogger logger,
GameObjectHandler handler,
Guid redrawId,
int timeOut = 5000,
CancellationToken? ct = null)
{ {
if (!_clientState.IsLoggedIn) return; if (!_clientState.IsLoggedIn) return;
if (ct == null) var token = ct ?? CancellationToken.None;
ct = CancellationToken.None;
const int tick = 250;
const int initialSettle = 50;
var sw = Stopwatch.StartNew();
const int tick = 250;
int curWaitTime = 0;
try try
{ {
logger.LogTrace("[{redrawId}] Starting wait for {handler} to draw", redrawId, handler); logger.LogTrace("[{redrawId}] Starting wait for {handler} to draw", redrawId, handler);
await Task.Delay(tick, ct.Value).ConfigureAwait(true);
curWaitTime += tick;
while ((!ct.Value.IsCancellationRequested) await Task.Delay(initialSettle, token).ConfigureAwait(false);
&& curWaitTime < timeOut
&& await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false)) // 0b100000000000 is "still rendering" or something while (!token.IsCancellationRequested
&& sw.ElapsedMilliseconds < timeOut
&& await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false))
{ {
logger.LogTrace("[{redrawId}] Waiting for {handler} to finish drawing", redrawId, handler); logger.LogTrace("[{redrawId}] Waiting for {handler} to finish drawing", redrawId, handler);
curWaitTime += tick; await Task.Delay(tick, token).ConfigureAwait(false);
await Task.Delay(tick, ct.Value).ConfigureAwait(true);
} }
logger.LogTrace("[{redrawId}] Finished drawing after {curWaitTime}ms", redrawId, curWaitTime); logger.LogTrace("[{redrawId}] Finished drawing after {ms}ms", redrawId, sw.ElapsedMilliseconds);
}
catch (OperationCanceledException)
{
// ignore
} }
catch (AccessViolationException ex) catch (AccessViolationException ex)
{ {
@@ -919,6 +931,28 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return WorldData.Value.TryGetValue(worldId, out var worldName) ? worldName : null; return WorldData.Value.TryGetValue(worldId, out var worldName) ? worldName : null;
} }
public void TargetPlayerByAddress(nint address)
{
if (address == nint.Zero) return;
if (_clientState.IsPvP) return;
_ = RunOnFrameworkThread(() =>
{
var gameObject = CreateGameObject(address);
if (gameObject is null) return;
var useFocusTarget = _configService.Current.UseFocusTarget;
if (useFocusTarget)
{
_targetManager.FocusTarget = gameObject;
}
else
{
_targetManager.Target = gameObject;
}
});
}
private unsafe void CheckCharacterForDrawing(nint address, string characterName) private unsafe void CheckCharacterForDrawing(nint address, string characterName)
{ {
var gameObj = (GameObject*)address; var gameObj = (GameObject*)address;

View File

@@ -1,4 +1,4 @@
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using LightlessSync.API.Dto.User; using LightlessSync.API.Dto.User;
using LightlessSync.Services.ActorTracking; using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
@@ -23,6 +23,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
private readonly HashSet<string> _syncshellCids = []; private readonly HashSet<string> _syncshellCids = [];
private volatile bool _pendingLocalBroadcast; private volatile bool _pendingLocalBroadcast;
private TimeSpan? _pendingLocalTtl; private TimeSpan? _pendingLocalTtl;
private string? _pendingLocalGid;
private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4); private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4);
private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1); private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1);
@@ -36,6 +37,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
private const int _maxQueueSize = 100; private const int _maxQueueSize = 100;
private volatile bool _batchRunning = false; private volatile bool _batchRunning = false;
private volatile bool _disposed = false;
public IReadOnlyDictionary<string, BroadcastEntry> BroadcastCache => _broadcastCache; public IReadOnlyDictionary<string, BroadcastEntry> BroadcastCache => _broadcastCache;
public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID); public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID);
@@ -68,6 +70,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
public void Update() public void Update()
{ {
if (_disposed)
return;
_frameCounter++; _frameCounter++;
var lookupsThisFrame = 0; var lookupsThisFrame = 0;
@@ -111,7 +116,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
private async Task BatchUpdateBroadcastCacheAsync(List<string> cids) private async Task BatchUpdateBroadcastCacheAsync(List<string> cids)
{ {
if (_disposed)
return;
var results = await _broadcastService.AreUsersBroadcastingAsync(cids).ConfigureAwait(false); var results = await _broadcastService.AreUsersBroadcastingAsync(cids).ConfigureAwait(false);
if (_disposed)
return;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
foreach (var (cid, info) in results) foreach (var (cid, info) in results)
@@ -130,6 +142,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
(_, old) => new BroadcastEntry(info.IsBroadcasting, expiry, info.GID)); (_, old) => new BroadcastEntry(info.IsBroadcasting, expiry, info.GID));
} }
if (_disposed)
return;
var activeCids = _broadcastCache var activeCids = _broadcastCache
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now) .Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now)
.Select(e => e.Key) .Select(e => e.Key)
@@ -142,6 +157,9 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg) private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg)
{ {
if (_disposed)
return;
if (!msg.Enabled) if (!msg.Enabled)
{ {
_broadcastCache.Clear(); _broadcastCache.Clear();
@@ -158,6 +176,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
_pendingLocalBroadcast = true; _pendingLocalBroadcast = true;
_pendingLocalTtl = msg.Ttl; _pendingLocalTtl = msg.Ttl;
_pendingLocalGid = msg.Gid;
TryPrimeLocalBroadcastCache(); TryPrimeLocalBroadcastCache();
} }
@@ -173,11 +192,12 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
var expiry = DateTime.UtcNow + ttl; var expiry = DateTime.UtcNow + ttl;
_broadcastCache.AddOrUpdate(localCid, _broadcastCache.AddOrUpdate(localCid,
new BroadcastEntry(true, expiry, null), new BroadcastEntry(true, expiry, _pendingLocalGid),
(_, old) => new BroadcastEntry(true, expiry, old.GID)); (_, old) => new BroadcastEntry(true, expiry, _pendingLocalGid ?? old.GID));
_pendingLocalBroadcast = false; _pendingLocalBroadcast = false;
_pendingLocalTtl = null; _pendingLocalTtl = null;
_pendingLocalGid = null;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var activeCids = _broadcastCache var activeCids = _broadcastCache
@@ -187,10 +207,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids); _lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
_lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids); _lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids);
UpdateSyncshellBroadcasts();
} }
private void UpdateSyncshellBroadcasts() private void UpdateSyncshellBroadcasts()
{ {
if (_disposed)
return;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var nearbyCids = GetNearbyHashedCids(out _); var nearbyCids = GetNearbyHashedCids(out _);
var newSet = nearbyCids.Count == 0 var newSet = nearbyCids.Count == 0
@@ -324,17 +348,35 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
_disposed = true;
base.Dispose(disposing); base.Dispose(disposing);
_framework.Update -= OnFrameworkUpdate; _framework.Update -= OnFrameworkUpdate;
if (_cleanupTask != null)
try
{ {
_cleanupTask?.Wait(100, _cleanupCts.Token); _cleanupCts.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed, can be ignored :)
} }
_cleanupCts.Cancel(); try
_cleanupCts.Dispose(); {
_cleanupTask?.Wait(100);
}
catch (Exception)
{
// Task may have already completed or been cancelled?
}
_cleanupTask?.Wait(100); try
_cleanupCts.Dispose(); {
_cleanupCts.Dispose();
}
catch (ObjectDisposedException)
{
// Already disposed, ignore
}
} }
} }

View File

@@ -1,4 +1,4 @@
using Dalamud.Interface; using Dalamud.Interface;
using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User; using LightlessSync.API.Dto.User;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
@@ -121,7 +121,10 @@ public class LightFinderService : IHostedService, IMediatorSubscriber
_waitingForTtlFetch = false; _waitingForTtlFetch = false;
if (!wasEnabled || previousRemaining != validTtl) if (!wasEnabled || previousRemaining != validTtl)
_mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl)); {
var gid = _config.Current.SyncshellFinderEnabled ? _config.Current.SelectedFinderSyncshell : null;
_mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl, gid));
}
_logger.LogInformation("Lightfinder broadcast enabled ({Context}), TTL: {TTL}", context, validTtl); _logger.LogInformation("Lightfinder broadcast enabled ({Context}), TTL: {TTL}", context, validTtl);
return true; return true;

View File

@@ -63,23 +63,31 @@ public sealed class LightlessMediator : IHostedService
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
while (!_loopCts.Token.IsCancellationRequested) try
{ {
while (!_processQueue) while (!_loopCts.Token.IsCancellationRequested)
{ {
while (!_processQueue)
{
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
}
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false); await Task.Delay(100, _loopCts.Token).ConfigureAwait(false);
HashSet<MessageBase> processedMessages = [];
while (_messageQueue.TryDequeue(out var message))
{
if (processedMessages.Contains(message)) { continue; }
processedMessages.Add(message);
ExecuteMessage(message);
}
} }
}
await Task.Delay(100, _loopCts.Token).ConfigureAwait(false); catch (OperationCanceledException)
{
HashSet<MessageBase> processedMessages = []; _logger.LogInformation("LightlessMediator stopped");
while (_messageQueue.TryDequeue(out var message))
{
if (processedMessages.Contains(message)) { continue; }
processedMessages.Add(message);
ExecuteMessage(message);
}
} }
}); });

View File

@@ -124,7 +124,7 @@ public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) :
public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase; public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase;
public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase; public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase;
public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase; public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase;
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : MessageBase; public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl, string? Gid = null) : MessageBase;
public record UserLeftSyncshell(string gid) : MessageBase; public record UserLeftSyncshell(string gid) : MessageBase;
public record UserJoinedSyncshell(string gid) : MessageBase; public record UserJoinedSyncshell(string gid) : MessageBase;
public record SyncshellBroadcastsUpdatedMessage : MessageBase; public record SyncshellBroadcastsUpdatedMessage : MessageBase;

View File

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

View File

@@ -6,14 +6,16 @@ using FFXIVClientStructs.Havok.Common.Serialize.Util;
using LightlessSync.FileCache; using LightlessSync.FileCache;
using LightlessSync.Interop.GameModel; using LightlessSync.Interop.GameModel;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
namespace LightlessSync.Services; namespace LightlessSync.Services;
public sealed class XivDataAnalyzer public sealed partial class XivDataAnalyzer
{ {
private readonly ILogger<XivDataAnalyzer> _logger; private readonly ILogger<XivDataAnalyzer> _logger;
private readonly FileCacheManager _fileCacheManager; private readonly FileCacheManager _fileCacheManager;
@@ -31,127 +33,441 @@ public sealed class XivDataAnalyzer
public unsafe Dictionary<string, List<ushort>>? GetSkeletonBoneIndices(GameObjectHandler handler) public unsafe Dictionary<string, List<ushort>>? GetSkeletonBoneIndices(GameObjectHandler handler)
{ {
if (handler.Address == nint.Zero) return null; if (handler is null || handler.Address == nint.Zero)
var chara = (CharacterBase*)(((Character*)handler.Address)->GameObject.DrawObject); return null;
if (chara->GetModelType() != CharacterBase.ModelType.Human) return null;
var resHandles = chara->Skeleton->SkeletonResourceHandles; Dictionary<string, HashSet<ushort>> sets = new(StringComparer.OrdinalIgnoreCase);
Dictionary<string, List<ushort>> outputIndices = [];
try try
{ {
for (int i = 0; i < chara->Skeleton->PartialSkeletonCount; i++) var drawObject = ((Character*)handler.Address)->GameObject.DrawObject;
if (drawObject == null)
return null;
var chara = (CharacterBase*)drawObject;
if (chara->GetModelType() != CharacterBase.ModelType.Human)
return null;
var skeleton = chara->Skeleton;
if (skeleton == null)
return null;
var resHandles = skeleton->SkeletonResourceHandles;
var partialCount = skeleton->PartialSkeletonCount;
if (partialCount <= 0)
return null;
for (int i = 0; i < partialCount; i++)
{ {
var handle = *(resHandles + i); var handle = *(resHandles + i);
_logger.LogTrace("Iterating over SkeletonResourceHandle #{i}:{x}", i, ((nint)handle).ToString("X")); if ((nint)handle == nint.Zero)
if ((nint)handle == nint.Zero) continue; continue;
var curBones = handle->BoneCount;
// this is unrealistic, the filename shouldn't ever be that long if (handle->FileName.Length > 1024)
if (handle->FileName.Length > 1024) continue; continue;
var skeletonName = handle->FileName.ToString();
if (string.IsNullOrEmpty(skeletonName)) continue; var rawName = handle->FileName.ToString();
outputIndices[skeletonName] = []; if (string.IsNullOrWhiteSpace(rawName))
for (ushort boneIdx = 0; boneIdx < curBones; boneIdx++) continue;
var skeletonKey = CanonicalizeSkeletonKey(rawName);
if (string.IsNullOrEmpty(skeletonKey))
continue;
var boneCount = handle->BoneCount;
if (boneCount == 0)
continue;
var havokSkel = handle->HavokSkeleton;
if ((nint)havokSkel == nint.Zero)
continue;
if (!sets.TryGetValue(skeletonKey, out var set))
{ {
var boneName = handle->HavokSkeleton->Bones[boneIdx].Name.String; set = [];
if (boneName == null) continue; sets[skeletonKey] = set;
outputIndices[skeletonName].Add((ushort)(boneIdx + 1));
} }
uint maxExclusive = boneCount;
uint ushortExclusive = (uint)ushort.MaxValue + 1u;
if (maxExclusive > ushortExclusive)
maxExclusive = ushortExclusive;
for (uint boneIdx = 0; boneIdx < maxExclusive; boneIdx++)
{
var name = havokSkel->Bones[boneIdx].Name.String;
if (name == null)
continue;
set.Add((ushort)boneIdx);
}
_logger.LogTrace("Local skeleton raw file='{raw}', key='{key}', boneCount={count}",
rawName, skeletonKey, boneCount);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Could not process skeleton data"); _logger.LogWarning(ex, "Could not process skeleton data");
return null;
} }
return (outputIndices.Count != 0 && outputIndices.Values.All(u => u.Count > 0)) ? outputIndices : null; if (sets.Count == 0)
return null;
var output = new Dictionary<string, List<ushort>>(sets.Count, StringComparer.OrdinalIgnoreCase);
foreach (var (key, set) in sets)
{
if (set.Count == 0)
continue;
var list = set.ToList();
list.Sort();
output[key] = list;
}
return (output.Count != 0 && output.Values.All(v => v.Count > 0)) ? output : null;
} }
public unsafe Dictionary<string, List<ushort>>? GetBoneIndicesFromPap(string hash) public unsafe Dictionary<string, List<ushort>>? GetBoneIndicesFromPap(string hash, bool persistToConfig = true)
{ {
if (_configService.Current.BonesDictionary.TryGetValue(hash, out var bones)) return bones; if (string.IsNullOrWhiteSpace(hash))
return null;
if (_configService.Current.BonesDictionary.TryGetValue(hash, out var cached) && cached is not null)
return cached;
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash); var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
if (cacheEntity == null) return null; if (cacheEntity == null || string.IsNullOrEmpty(cacheEntity.ResolvedFilepath) || !File.Exists(cacheEntity.ResolvedFilepath))
return null;
using BinaryReader reader = new(File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read)); using var fs = File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new BinaryReader(fs);
// most of this shit is from vfxeditor, surely nothing will change in the pap format :copium: // PAP header (mostly from vfxeditor)
reader.ReadInt32(); // ignore _ = reader.ReadInt32(); // ignore
reader.ReadInt32(); // ignore _ = reader.ReadInt32(); // ignore
reader.ReadInt16(); // read 2 (num animations) _ = reader.ReadInt16(); // num animations
reader.ReadInt16(); // read 2 (modelid) _ = reader.ReadInt16(); // modelid
var type = reader.ReadByte();// read 1 (type)
if (type != 0) return null; // it's not human, just ignore it, whatever var type = reader.ReadByte(); // type
if (type != 0)
return null; // not human
_ = reader.ReadByte(); // variant
_ = reader.ReadInt32(); // ignore
reader.ReadByte(); // read 1 (variant)
reader.ReadInt32(); // ignore
var havokPosition = reader.ReadInt32(); var havokPosition = reader.ReadInt32();
var footerPosition = reader.ReadInt32(); var footerPosition = reader.ReadInt32();
var havokDataSize = footerPosition - havokPosition;
// sanity checks
if (havokPosition <= 0 || footerPosition <= havokPosition || footerPosition > fs.Length)
return null;
var havokDataSizeLong = (long)footerPosition - havokPosition;
if (havokDataSizeLong <= 8 || havokDataSizeLong > int.MaxValue)
return null;
var havokDataSize = (int)havokDataSizeLong;
reader.BaseStream.Position = havokPosition; reader.BaseStream.Position = havokPosition;
var havokData = reader.ReadBytes(havokDataSize); var havokData = reader.ReadBytes(havokDataSize);
if (havokData.Length <= 8) return null; // no havok data if (havokData.Length <= 8)
return null;
var output = new Dictionary<string, List<ushort>>(StringComparer.OrdinalIgnoreCase); var tempSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) + ".hkx";
var tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath); var tempHavokDataPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx");
IntPtr tempHavokDataPathAnsi = IntPtr.Zero;
try try
{ {
File.WriteAllBytes(tempHavokDataPath, havokData); File.WriteAllBytes(tempHavokDataPath, havokData);
if (!File.Exists(tempHavokDataPath))
{
_logger.LogTrace("Temporary havok file did not exist when attempting to load: {path}", tempHavokDataPath);
return null;
}
tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath);
var loadoptions = stackalloc hkSerializeUtil.LoadOptions[1]; var loadoptions = stackalloc hkSerializeUtil.LoadOptions[1];
loadoptions->TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry(); loadoptions->TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry();
loadoptions->ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry(); loadoptions->ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry();
loadoptions->Flags = new hkFlags<hkSerializeUtil.LoadOptionBits, int> loadoptions->Flags = new hkFlags<hkSerializeUtil.LoadOptionBits, int>
{ {
Storage = (int)(hkSerializeUtil.LoadOptionBits.Default) Storage = (int)hkSerializeUtil.LoadOptionBits.Default
}; };
var resource = hkSerializeUtil.LoadFromFile((byte*)tempHavokDataPathAnsi, null, loadoptions); var resource = hkSerializeUtil.LoadFromFile((byte*)tempHavokDataPathAnsi, null, loadoptions);
if (resource == null) if (resource == null)
{ {
throw new InvalidOperationException("Resource was null after loading"); _logger.LogWarning("Havok resource was null after loading from {path}", tempHavokDataPath);
return null;
} }
var rootLevelName = @"hkRootLevelContainer"u8; var rootLevelName = @"hkRootLevelContainer"u8;
fixed (byte* n1 = rootLevelName) fixed (byte* n1 = rootLevelName)
{ {
var container = (hkRootLevelContainer*)resource->GetContentsPointer(n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry()); var container = (hkRootLevelContainer*)resource->GetContentsPointer(n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry());
if (container == null)
return null;
var animationName = @"hkaAnimationContainer"u8; var animationName = @"hkaAnimationContainer"u8;
fixed (byte* n2 = animationName) fixed (byte* n2 = animationName)
{ {
var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null); var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null);
if (animContainer == null)
return null;
for (int i = 0; i < animContainer->Bindings.Length; i++) for (int i = 0; i < animContainer->Bindings.Length; i++)
{ {
var binding = animContainer->Bindings[i].ptr; var binding = animContainer->Bindings[i].ptr;
if (binding == null)
continue;
var rawSkel = binding->OriginalSkeletonName.String;
var skeletonKey = CanonicalizeSkeletonKey(rawSkel);
if (string.IsNullOrEmpty(skeletonKey))
continue;
var boneTransform = binding->TransformTrackToBoneIndices; var boneTransform = binding->TransformTrackToBoneIndices;
string name = binding->OriginalSkeletonName.String! + "_" + i; if (boneTransform.Length <= 0)
output[name] = []; continue;
if (!tempSets.TryGetValue(skeletonKey, out var set))
{
set = [];
tempSets[skeletonKey] = set;
}
for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++) for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++)
{ {
output[name].Add((ushort)boneTransform[boneIdx]); var v = boneTransform[boneIdx];
if (v < 0) continue;
set.Add((ushort)v);
} }
output[name].Sort();
} }
} }
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Could not load havok file in {path}", tempHavokDataPath); _logger.LogWarning(ex, "Could not load havok file in {path}", tempHavokDataPath);
return null;
} }
finally finally
{ {
Marshal.FreeHGlobal(tempHavokDataPathAnsi); if (tempHavokDataPathAnsi != IntPtr.Zero)
File.Delete(tempHavokDataPath); Marshal.FreeHGlobal(tempHavokDataPathAnsi);
try
{
if (File.Exists(tempHavokDataPath))
File.Delete(tempHavokDataPath);
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Could not delete temporary havok file: {path}", tempHavokDataPath);
}
} }
if (tempSets.Count == 0)
return null;
var output = new Dictionary<string, List<ushort>>(tempSets.Count, StringComparer.OrdinalIgnoreCase);
foreach (var (key, set) in tempSets)
{
if (set.Count == 0) continue;
var list = set.ToList();
list.Sort();
output[key] = list;
}
if (output.Count == 0)
return null;
_configService.Current.BonesDictionary[hash] = output; _configService.Current.BonesDictionary[hash] = output;
_configService.Save();
if (persistToConfig)
_configService.Save();
return output; return output;
} }
public static string CanonicalizeSkeletonKey(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return string.Empty;
var s = raw.Replace('\\', '/').Trim();
var underscore = s.LastIndexOf('_');
if (underscore > 0 && underscore + 1 < s.Length && char.IsDigit(s[underscore + 1]))
s = s[..underscore];
if (s.StartsWith("skeleton", StringComparison.OrdinalIgnoreCase))
return "skeleton";
var m = _bucketPathRegex.Match(s);
if (m.Success)
return m.Groups["bucket"].Value.ToLowerInvariant();
m = _bucketSklRegex.Match(s);
if (m.Success)
return m.Groups["bucket"].Value.ToLowerInvariant();
m = _bucketLooseRegex.Match(s);
if (m.Success)
return m.Groups["bucket"].Value.ToLowerInvariant();
return string.Empty;
}
public static bool ContainsIndexCompat(
HashSet<ushort> available,
ushort idx,
bool papLikelyOneBased,
bool allowOneBasedShift,
bool allowNeighborTolerance)
{
Span<ushort> candidates = stackalloc ushort[2];
int count = 0;
candidates[count++] = idx;
if (allowOneBasedShift && papLikelyOneBased && idx > 0)
candidates[count++] = (ushort)(idx - 1);
for (int i = 0; i < count; i++)
{
var c = candidates[i];
if (available.Contains(c))
return true;
if (allowNeighborTolerance)
{
if (c > 0 && available.Contains((ushort)(c - 1)))
return true;
if (c < ushort.MaxValue && available.Contains((ushort)(c + 1)))
return true;
}
}
return false;
}
public static bool IsPapCompatible(
IReadOnlyDictionary<string, HashSet<ushort>> localBoneSets,
IReadOnlyDictionary<string, List<ushort>> papBoneIndices,
AnimationValidationMode mode,
bool allowOneBasedShift,
bool allowNeighborTolerance,
out string reason)
{
reason = string.Empty;
if (mode == AnimationValidationMode.Unsafe)
return true;
var papBuckets = papBoneIndices.Keys
.Select(CanonicalizeSkeletonKey)
.Where(k => !string.IsNullOrEmpty(k))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (papBuckets.Count == 0)
{
reason = "No skeleton bucket bindings found in the PAP";
return false;
}
if (mode == AnimationValidationMode.Safe)
{
if (papBuckets.Any(b => localBoneSets.ContainsKey(b)))
return true;
reason = $"No matching skeleton bucket between PAP [{string.Join(", ", papBuckets)}] and local [{string.Join(", ", localBoneSets.Keys.Order())}].";
return false;
}
foreach (var bucket in papBuckets)
{
if (!localBoneSets.TryGetValue(bucket, out var available))
{
reason = $"Missing skeleton bucket '{bucket}' on local actor.";
return false;
}
var indices = papBoneIndices
.Where(kvp => string.Equals(CanonicalizeSkeletonKey(kvp.Key), bucket, StringComparison.OrdinalIgnoreCase))
.SelectMany(kvp => kvp.Value ?? Enumerable.Empty<ushort>())
.Distinct()
.ToList();
if (indices.Count == 0)
continue;
bool has0 = false, has1 = false;
ushort min = ushort.MaxValue;
foreach (var v in indices)
{
if (v == 0) has0 = true;
if (v == 1) has1 = true;
if (v < min) min = v;
}
bool papLikelyOneBased = allowOneBasedShift && (min == 1) && has1 && !has0;
foreach (var idx in indices)
{
if (!ContainsIndexCompat(available, idx, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance))
{
reason = $"No compatible local skeleton for PAP '{bucket}': missing bone index {idx}.";
return false;
}
}
}
return true;
}
public void DumpLocalSkeletonIndices(GameObjectHandler handler, string? filter = null)
{
var skels = GetSkeletonBoneIndices(handler);
if (skels == null)
{
_logger.LogTrace("DumpLocalSkeletonIndices: local skeleton indices are null or not found");
return;
}
var keys = skels.Keys
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();
_logger.LogTrace("Local skeleton indices found ({count}): {keys}",
keys.Length,
string.Join(", ", keys));
if (!string.IsNullOrWhiteSpace(filter))
{
var hits = keys.Where(k =>
k.Equals(filter, StringComparison.OrdinalIgnoreCase) ||
k.StartsWith(filter + "_", StringComparison.OrdinalIgnoreCase) ||
filter.StartsWith(k + "_", StringComparison.OrdinalIgnoreCase) ||
k.Contains(filter, StringComparison.OrdinalIgnoreCase))
.ToArray();
_logger.LogTrace("Matches found for '{filter}': {hits}",
filter,
hits.Length == 0 ? "<none>" : string.Join(", ", hits));
}
}
public async Task<long> GetTrianglesByHash(string hash) public async Task<long> GetTrianglesByHash(string hash)
{ {
if (_configService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris) && cachedTris > 0) if (_configService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris) && cachedTris > 0)
@@ -239,4 +555,23 @@ public sealed class XivDataAnalyzer
return 0; return 0;
} }
} }
// Regexes for canonicalizing skeleton keys
private static readonly Regex _bucketPathRegex =
BucketRegex();
private static readonly Regex _bucketSklRegex =
SklRegex();
private static readonly Regex _bucketLooseRegex =
LooseBucketRegex();
[GeneratedRegex(@"(?i)(?:^|/)(?<bucket>c\d{4})(?:/|$)", RegexOptions.Compiled, "en-NL")]
private static partial Regex BucketRegex();
[GeneratedRegex(@"(?i)\bskl_(?<bucket>c\d{4})[a-z]\d{4}\b", RegexOptions.Compiled, "en-NL")]
private static partial Regex SklRegex();
[GeneratedRegex(@"(?i)(?<![a-z0-9])(?<bucket>c\d{4})(?!\d)", RegexOptions.Compiled, "en-NL")]
private static partial Regex LooseBucketRegex();
} }

View File

@@ -34,8 +34,21 @@ namespace LightlessSync.UI;
public class CompactUi : WindowMediatorSubscriberBase public class CompactUi : WindowMediatorSubscriberBase
{ {
private readonly CharacterAnalyzer _characterAnalyzer; #region Constants
private const float ConnectButtonHighlightThickness = 14f;
#endregion
#region Services
private readonly ApiController _apiController; private readonly ApiController _apiController;
private readonly CharacterAnalyzer _characterAnalyzer;
private readonly DalamudUtilService _dalamudUtilService;
private readonly DrawEntityFactory _drawEntityFactory;
private readonly FileUploadManager _fileTransferManager;
private readonly IpcManager _ipcManager;
private readonly LightFinderService _broadcastService;
private readonly LightlessConfigService _configService; private readonly LightlessConfigService _configService;
private readonly LightlessMediator _lightlessMediator; private readonly LightlessMediator _lightlessMediator;
private readonly PairLedger _pairLedger; private readonly PairLedger _pairLedger;
@@ -44,34 +57,46 @@ public class CompactUi : WindowMediatorSubscriberBase
private readonly FileUploadManager _fileTransferManager; private readonly FileUploadManager _fileTransferManager;
private readonly PlayerPerformanceConfigService _playerPerformanceConfig; private readonly PlayerPerformanceConfigService _playerPerformanceConfig;
private readonly PairUiService _pairUiService; private readonly PairUiService _pairUiService;
private readonly SelectTagForPairUi _selectTagForPairUi; private readonly PlayerPerformanceConfigService _playerPerformanceConfig;
private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi;
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
private readonly RenameSyncshellTagUi _renameSyncshellTagUi;
private readonly SelectPairForTagUi _selectPairsForGroupUi;
private readonly RenamePairTagUi _renamePairTagUi;
private readonly IpcManager _ipcManager;
private readonly ServerConfigurationManager _serverManager; private readonly ServerConfigurationManager _serverManager;
private readonly TopTabMenu _tabMenu;
private readonly TagHandler _tagHandler; private readonly TagHandler _tagHandler;
private readonly UiSharedService _uiSharedService; private readonly UiSharedService _uiSharedService;
private readonly LightFinderService _broadcastService;
private readonly DalamudUtilService _dalamudUtilService; #endregion
#region UI Components
private readonly AnimatedHeader _animatedHeader = new();
private readonly RenamePairTagUi _renamePairTagUi;
private readonly RenameSyncshellTagUi _renameSyncshellTagUi;
private readonly SelectPairForTagUi _selectPairsForGroupUi;
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
private readonly SelectTagForPairUi _selectTagForPairUi;
private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi;
private readonly SeluneBrush _seluneBrush = new();
private readonly TopTabMenu _tabMenu;
#endregion
#region State
private readonly ConcurrentDictionary<GameObjectHandler, Dictionary<string, FileDownloadStatus>> _currentDownloads = new();
private List<IDrawFolder> _drawFolders; private List<IDrawFolder> _drawFolders;
private Pair? _focusedPair;
private Pair? _lastAddedUser; private Pair? _lastAddedUser;
private string _lastAddedUserComment = string.Empty; private string _lastAddedUserComment = string.Empty;
private Vector2 _lastPosition = Vector2.One; private Vector2 _lastPosition = Vector2.One;
private Vector2 _lastSize = Vector2.One; private Vector2 _lastSize = Vector2.One;
private int _pendingFocusFrame = -1;
private Pair? _pendingFocusPair;
private bool _showModalForUserAddition; private bool _showModalForUserAddition;
private float _transferPartHeight; private float _transferPartHeight;
private bool _wasOpen; private bool _wasOpen;
private float _windowContentWidth; private float _windowContentWidth;
private readonly SeluneBrush _seluneBrush = new();
private const float _connectButtonHighlightThickness = 14f; #endregion
private Pair? _focusedPair;
private Pair? _pendingFocusPair; #region Constructor
private int _pendingFocusFrame = -1;
public CompactUi( public CompactUi(
ILogger<CompactUi> logger, ILogger<CompactUi> logger,
@@ -127,6 +152,11 @@ public class CompactUi : WindowMediatorSubscriberBase
.Apply(); .Apply();
_drawFolders = [.. DrawFolders]; _drawFolders = [.. DrawFolders];
_animatedHeader.Height = 120f;
_animatedHeader.EnableBottomGradient = true;
_animatedHeader.GradientHeight = 250f;
_animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects;
#if DEBUG #if DEBUG
string dev = "Dev Build"; string dev = "Dev Build";
@@ -150,9 +180,14 @@ public class CompactUi : WindowMediatorSubscriberBase
_lightlessMediator = mediator; _lightlessMediator = mediator;
} }
#endregion
#region Lifecycle
public override void OnClose() public override void OnClose()
{ {
ForceReleaseFocus(); ForceReleaseFocus();
_animatedHeader.ClearParticles();
base.OnClose(); base.OnClose();
} }
@@ -164,6 +199,13 @@ public class CompactUi : WindowMediatorSubscriberBase
using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize); using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize);
_windowContentWidth = UiSharedService.GetWindowContentRegionWidth(); _windowContentWidth = UiSharedService.GetWindowContentRegionWidth();
// Draw animated header background (just the gradient/particles, content drawn by existing methods)
var startCursorY = ImGui.GetCursorPosY();
_animatedHeader.Draw(_windowContentWidth, (_, _) => { });
// Reset cursor to draw content on top of the header background
ImGui.SetCursorPosY(startCursorY);
if (!_apiController.IsCurrentVersion) if (!_apiController.IsCurrentVersion)
{ {
var ver = _apiController.CurrentClientVersion; var ver = _apiController.CurrentClientVersion;
@@ -209,17 +251,11 @@ public class CompactUi : WindowMediatorSubscriberBase
} }
using (ImRaii.PushId("header")) DrawUIDHeader(); using (ImRaii.PushId("header")) DrawUIDHeader();
_uiSharedService.RoundedSeparator(UIColors.Get("LightlessPurple"), 2.5f, 1f, 12f);
using (ImRaii.PushId("serverstatus"))
{
DrawServerStatus();
}
selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime); selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
var style = ImGui.GetStyle(); var style = ImGui.GetStyle();
var contentMinY = windowPos.Y + ImGui.GetWindowContentRegionMin().Y; var contentMinY = windowPos.Y + ImGui.GetWindowContentRegionMin().Y;
var gradientInset = 4f * ImGuiHelpers.GlobalScale; var gradientInset = 4f * ImGuiHelpers.GlobalScale;
var gradientTop = MathF.Max(contentMinY, ImGui.GetCursorScreenPos().Y - style.ItemSpacing.Y + gradientInset); var gradientTop = MathF.Max(contentMinY, ImGui.GetCursorScreenPos().Y - style.ItemSpacing.Y + gradientInset);
ImGui.Separator();
if (_apiController.ServerState is ServerState.Connected) if (_apiController.ServerState is ServerState.Connected)
{ {
@@ -227,7 +263,6 @@ public class CompactUi : WindowMediatorSubscriberBase
using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw(pairSnapshot); using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw(pairSnapshot);
using (ImRaii.PushId("pairlist")) DrawPairs(); using (ImRaii.PushId("pairlist")) DrawPairs();
ImGui.Separator();
var transfersTop = ImGui.GetCursorScreenPos().Y; var transfersTop = ImGui.GetCursorScreenPos().Y;
var gradientBottom = MathF.Max(gradientTop, transfersTop - style.ItemSpacing.Y - gradientInset); var gradientBottom = MathF.Max(gradientTop, transfersTop - style.ItemSpacing.Y - gradientInset);
selune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime); selune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime);
@@ -290,6 +325,10 @@ public class CompactUi : WindowMediatorSubscriberBase
} }
} }
#endregion
#region Content Drawing
private void DrawPairs() private void DrawPairs()
{ {
float ySize = Math.Abs(_transferPartHeight) < 0.0001f float ySize = Math.Abs(_transferPartHeight) < 0.0001f
@@ -308,95 +347,6 @@ public class CompactUi : WindowMediatorSubscriberBase
ImGui.EndChild(); ImGui.EndChild();
} }
private void DrawServerStatus()
{
var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Link);
var userCount = _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture);
var userSize = ImGui.CalcTextSize(userCount);
var textSize = ImGui.CalcTextSize("Users Online");
#if DEBUG
string shardConnection = $"Shard: {_apiController.ServerInfo.ShardName}";
#else
string shardConnection = string.Equals(_apiController.ServerInfo.ShardName, "Main", StringComparison.OrdinalIgnoreCase) ? string.Empty : $"Shard: {_apiController.ServerInfo.ShardName}";
#endif
var shardTextSize = ImGui.CalcTextSize(shardConnection);
var printShard = !string.IsNullOrEmpty(_apiController.ServerInfo.ShardName) && shardConnection != string.Empty;
if (_apiController.ServerState is ServerState.Connected)
{
ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth()) / 2 - (userSize.X + textSize.X) / 2 - ImGui.GetStyle().ItemSpacing.X / 2);
if (!printShard) ImGui.AlignTextToFramePadding();
ImGui.TextColored(UIColors.Get("LightlessPurple"), userCount);
ImGui.SameLine();
if (!printShard) ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Users Online");
}
else
{
ImGui.AlignTextToFramePadding();
ImGui.TextColored(UIColors.Get("DimRed"), "Not connected to any server");
}
if (printShard)
{
ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ImGui.GetStyle().ItemSpacing.Y);
ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth()) / 2 - shardTextSize.X / 2);
ImGui.TextUnformatted(shardConnection);
}
ImGui.SameLine();
if (printShard)
{
ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ((userSize.Y + textSize.Y) / 2 + shardTextSize.Y) / 2 - ImGui.GetStyle().ItemSpacing.Y + buttonSize.Y / 2);
}
bool isConnectingOrConnected = _apiController.ServerState is ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting;
var color = UiSharedService.GetBoolColor(!isConnectingOrConnected);
var connectedIcon = isConnectingOrConnected ? FontAwesomeIcon.Unlink : FontAwesomeIcon.Link;
ImGui.SameLine(ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth() - buttonSize.X);
if (printShard)
{
ImGui.SetCursorPosY(ImGui.GetCursorPosY() - ((userSize.Y + textSize.Y) / 2 + shardTextSize.Y) / 2 - ImGui.GetStyle().ItemSpacing.Y + buttonSize.Y / 2);
}
if (_apiController.ServerState is not (ServerState.Reconnecting or ServerState.Disconnecting))
{
using (ImRaii.PushColor(ImGuiCol.Text, color))
{
if (_uiSharedService.IconButton(connectedIcon))
{
if (isConnectingOrConnected && !_serverManager.CurrentServer.FullPause)
{
_serverManager.CurrentServer.FullPause = true;
_serverManager.Save();
}
else if (!isConnectingOrConnected && _serverManager.CurrentServer.FullPause)
{
_serverManager.CurrentServer.FullPause = false;
_serverManager.Save();
}
_ = _apiController.CreateConnectionsAsync();
}
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
{
Selune.RegisterHighlight(
ImGui.GetItemRectMin(),
ImGui.GetItemRectMax(),
SeluneHighlightMode.Both,
borderOnly: true,
borderThicknessOverride: _connectButtonHighlightThickness,
exactSize: true,
clipToElement: true,
roundingOverride: ImGui.GetStyle().FrameRounding);
}
UiSharedService.AttachToolTip(isConnectingOrConnected ? "Disconnect from " + _serverManager.CurrentServer.ServerName : "Connect to " + _serverManager.CurrentServer.ServerName);
}
}
private void DrawTransfers() private void DrawTransfers()
{ {
var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot(); var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot();
@@ -492,11 +442,9 @@ public class CompactUi : WindowMediatorSubscriberBase
return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes); return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes);
} }
[StructLayout(LayoutKind.Auto)] #endregion
private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes)
{ #region Header Drawing
public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0;
}
private void DrawUIDHeader() private void DrawUIDHeader()
{ {
@@ -532,21 +480,52 @@ public class CompactUi : WindowMediatorSubscriberBase
using (_uiSharedService.IconFont.Push()) using (_uiSharedService.IconFont.Push())
iconSize = ImGui.CalcTextSize(FontAwesomeIcon.PersonCirclePlus.ToIconString()); iconSize = ImGui.CalcTextSize(FontAwesomeIcon.PersonCirclePlus.ToIconString());
float contentWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X; float uidStartX = 25f;
float uidStartX = (contentWidth - uidTextSize.X) / 2f;
float cursorY = ImGui.GetCursorPosY(); float cursorY = ImGui.GetCursorPosY();
ImGui.SetCursorPosY(cursorY);
ImGui.SetCursorPosX(uidStartX);
bool headerItemClicked;
using (_uiSharedService.UidFont.Push())
{
if (useVanityColors)
{
var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor);
var cursorPos = ImGui.GetCursorScreenPos();
var targetFontSize = ImGui.GetFontSize();
var font = ImGui.GetFont();
SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, targetFontSize ,font , "uid-header");
}
else
{
ImGui.TextColored(uidColor, uidText);
}
}
// Get the actual rendered text rect for proper icon alignment
var uidTextRect = ImGui.GetItemRectMax() - ImGui.GetItemRectMin();
var uidTextRectMin = ImGui.GetItemRectMin();
var uidTextHovered = ImGui.IsItemHovered();
headerItemClicked = ImGui.IsItemClicked();
// Track position for icons next to UID text
// Use uidTextSize.Y (actual font height) for vertical centering, not hitbox height
float nextIconX = uidTextRectMin.X + uidTextRect.X + 10f;
float iconYOffset = (uidTextSize.Y - iconSize.Y) * 0.5f;
float textVerticalOffset = (uidTextRect.Y - uidTextSize.Y) * 0.5f;
var buttonSize = new Vector2(iconSize.X, uidTextSize.Y);
if (_configService.Current.BroadcastEnabled && _apiController.IsConnected) if (_configService.Current.BroadcastEnabled && _apiController.IsConnected)
{ {
float iconYOffset = (uidTextSize.Y - iconSize.Y) * 0.5f; ImGui.SetCursorScreenPos(new Vector2(nextIconX, uidTextRectMin.Y + textVerticalOffset));
var buttonSize = new Vector2(iconSize.X, uidTextSize.Y);
ImGui.SetCursorPos(new Vector2(ImGui.GetStyle().ItemSpacing.X + 5f, cursorY));
ImGui.InvisibleButton("BroadcastIcon", buttonSize); ImGui.InvisibleButton("BroadcastIcon", buttonSize);
var iconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset); var iconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset);
using (_uiSharedService.IconFont.Push()) using (_uiSharedService.IconFont.Push())
ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.PersonCirclePlus.ToIconString()); ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(UIColors.Get("LightlessGreen")), FontAwesomeIcon.Wifi.ToIconString());
nextIconX = ImGui.GetItemRectMax().X + 6f;
if (ImGui.IsItemHovered()) if (ImGui.IsItemHovered())
@@ -618,50 +597,8 @@ public class CompactUi : WindowMediatorSubscriberBase
if (ImGui.IsItemClicked()) if (ImGui.IsItemClicked())
_lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); _lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
} }
ImGui.SetCursorPosY(cursorY); // Warning threshold icon (next to lightfinder or UID text)
ImGui.SetCursorPosX(uidStartX);
bool headerItemClicked;
using (_uiSharedService.UidFont.Push())
{
if (useVanityColors)
{
var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor);
var cursorPos = ImGui.GetCursorScreenPos();
var targetFontSize = ImGui.GetFontSize();
var font = ImGui.GetFont();
SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, targetFontSize ,font , "uid-header");
}
else
{
ImGui.TextColored(uidColor, uidText);
}
}
if (ImGui.IsItemHovered())
{
var padding = new Vector2(35f * ImGuiHelpers.GlobalScale);
Selune.RegisterHighlight(
ImGui.GetItemRectMin() - padding,
ImGui.GetItemRectMax() + padding,
SeluneHighlightMode.Point,
exactSize: true,
clipToElement: true,
clipPadding: padding,
highlightColorOverride: vanityGlowColor,
highlightAlphaOverride: 0.05f);
}
headerItemClicked = ImGui.IsItemClicked();
if (headerItemClicked)
{
ImGui.SetClipboardText(uidText);
}
UiSharedService.AttachToolTip("Click to copy");
if (_apiController.ServerState is ServerState.Connected && analysisSummary.HasData) if (_apiController.ServerState is ServerState.Connected && analysisSummary.HasData)
{ {
var objectSummary = analysisSummary.Objects.Values.FirstOrDefault(summary => summary.HasEntries); var objectSummary = analysisSummary.Objects.Values.FirstOrDefault(summary => summary.HasEntries);
@@ -675,24 +612,30 @@ public class CompactUi : WindowMediatorSubscriberBase
if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds) if ((isOverTriHold || isOverVRAMUsage) && _playerPerformanceConfig.Current.WarnOnExceedingThresholds)
{ {
ImGui.SameLine(); ImGui.SetCursorScreenPos(new Vector2(nextIconX, uidTextRectMin.Y + textVerticalOffset));
ImGui.SetCursorPosY(cursorY + 15f);
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow")); ImGui.InvisibleButton("WarningThresholdIcon", buttonSize);
var warningIconPos = ImGui.GetItemRectMin() + new Vector2(0f, iconYOffset);
using (_uiSharedService.IconFont.Push())
ImGui.GetWindowDrawList().AddText(warningIconPos, ImGui.GetColorU32(UIColors.Get("LightlessYellow")), FontAwesomeIcon.ExclamationTriangle.ToIconString());
string warningMessage = ""; if (ImGui.IsItemHovered())
if (isOverTriHold)
{ {
warningMessage += $"You exceed your own triangles threshold by " + string warningMessage = "";
$"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles."; if (isOverTriHold)
warningMessage += Environment.NewLine; {
warningMessage += $"You exceed your own triangles threshold by " +
$"{actualTriCount - _playerPerformanceConfig.Current.TrisWarningThresholdThousands * 1000} triangles.";
warningMessage += Environment.NewLine;
}
if (isOverVRAMUsage)
{
warningMessage += $"You exceed your own VRAM threshold by " +
$"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}.";
}
UiSharedService.AttachToolTip(warningMessage);
} }
if (isOverVRAMUsage)
{
warningMessage += $"You exceed your own VRAM threshold by " +
$"{UiSharedService.ByteToString(actualVramUsage - (_playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024))}.";
}
UiSharedService.AttachToolTip(warningMessage);
if (ImGui.IsItemClicked()) if (ImGui.IsItemClicked())
{ {
_lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi))); _lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi)));
@@ -701,6 +644,34 @@ public class CompactUi : WindowMediatorSubscriberBase
} }
} }
if (uidTextHovered)
{
var padding = new Vector2(35f * ImGuiHelpers.GlobalScale);
Selune.RegisterHighlight(
uidTextRectMin - padding,
uidTextRectMin + uidTextRect + padding,
SeluneHighlightMode.Point,
exactSize: true,
clipToElement: true,
clipPadding: padding,
highlightColorOverride: vanityGlowColor,
highlightAlphaOverride: 0.05f);
ImGui.SetTooltip("Click to copy");
}
if (headerItemClicked)
{
ImGui.SetClipboardText(uidText);
}
// Connect/Disconnect button next to big UID (use screen pos to avoid affecting layout)
DrawConnectButton(uidTextRectMin.Y + textVerticalOffset, uidTextSize.Y);
// Add spacing below the big UID
ImGuiHelpers.ScaledDummy(5f);
if (_apiController.ServerState is ServerState.Connected) if (_apiController.ServerState is ServerState.Connected)
{ {
if (headerItemClicked) if (headerItemClicked)
@@ -708,10 +679,12 @@ public class CompactUi : WindowMediatorSubscriberBase
ImGui.SetClipboardText(_apiController.DisplayName); ImGui.SetClipboardText(_apiController.DisplayName);
} }
if (!string.Equals(_apiController.DisplayName, _apiController.UID, StringComparison.Ordinal)) // Only show smaller UID line if DisplayName differs from UID (custom vanity name)
bool hasCustomName = !string.Equals(_apiController.DisplayName, _apiController.UID, StringComparison.OrdinalIgnoreCase);
if (hasCustomName)
{ {
var origTextSize = ImGui.CalcTextSize(_apiController.UID); ImGui.SetCursorPosX(uidStartX);
ImGui.SetCursorPosX((ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X) / 2 - (origTextSize.X / 2));
if (useVanityColors) if (useVanityColors)
{ {
@@ -746,14 +719,88 @@ public class CompactUi : WindowMediatorSubscriberBase
{ {
ImGui.SetClipboardText(_apiController.UID); ImGui.SetClipboardText(_apiController.UID);
} }
// Users Online on same line as smaller UID (with separator)
ImGui.SameLine();
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("|");
ImGui.SameLine();
ImGui.TextColored(UIColors.Get("LightlessGreen"), _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture));
ImGui.SameLine();
ImGui.TextUnformatted("Users Online");
}
else
{
// No custom name - just show Users Online aligned to uidStartX
ImGui.SetCursorPosX(uidStartX);
ImGui.TextColored(UIColors.Get("LightlessGreen"), _apiController.OnlineUsers.ToString(CultureInfo.InvariantCulture));
ImGui.SameLine();
ImGui.TextUnformatted("Users Online");
} }
} }
else else
{ {
ImGui.SetCursorPosX(uidStartX);
UiSharedService.ColorTextWrapped(_apiController.ServerState.GetServerError(_apiController.AuthFailureMessage), uidColor); UiSharedService.ColorTextWrapped(_apiController.ServerState.GetServerError(_apiController.AuthFailureMessage), uidColor);
} }
} }
private void DrawConnectButton(float screenY, float textHeight)
{
var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Link);
bool isConnectingOrConnected = _apiController.ServerState is ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting;
var color = UiSharedService.GetBoolColor(!isConnectingOrConnected);
var connectedIcon = isConnectingOrConnected ? FontAwesomeIcon.Unlink : FontAwesomeIcon.Link;
// Position on right side, vertically centered with text
if (_apiController.ServerState is not (ServerState.Reconnecting or ServerState.Disconnecting))
{
var windowPos = ImGui.GetWindowPos();
var screenX = windowPos.X + UiSharedService.GetWindowContentRegionWidth() - buttonSize.X - 13f;
var yOffset = (textHeight - buttonSize.Y) * 0.5f;
ImGui.SetCursorScreenPos(new Vector2(screenX, screenY + yOffset));
using (ImRaii.PushColor(ImGuiCol.Text, color))
using (ImRaii.PushColor(ImGuiCol.Button, ImGui.ColorConvertFloat4ToU32(new(0, 0, 0, 0))))
{
if (_uiSharedService.IconButton(connectedIcon, buttonSize.Y))
{
if (isConnectingOrConnected && !_serverManager.CurrentServer.FullPause)
{
_serverManager.CurrentServer.FullPause = true;
_serverManager.Save();
}
else if (!isConnectingOrConnected && _serverManager.CurrentServer.FullPause)
{
_serverManager.CurrentServer.FullPause = false;
_serverManager.Save();
}
_ = _apiController.CreateConnectionsAsync();
}
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
{
Selune.RegisterHighlight(
ImGui.GetItemRectMin(),
ImGui.GetItemRectMax(),
SeluneHighlightMode.Both,
borderOnly: true,
borderThicknessOverride: ConnectButtonHighlightThickness,
exactSize: true,
clipToElement: true,
roundingOverride: ImGui.GetStyle().FrameRounding);
}
UiSharedService.AttachToolTip(isConnectingOrConnected ? "Disconnect from " + _serverManager.CurrentServer.ServerName : "Connect to " + _serverManager.CurrentServer.ServerName);
}
}
#endregion
#region Folder Building
private IEnumerable<IDrawFolder> DrawFolders private IEnumerable<IDrawFolder> DrawFolders
{ {
get get
@@ -889,6 +936,10 @@ public class CompactUi : WindowMediatorSubscriberBase
} }
} }
#endregion
#region Filtering & Sorting
private static bool PassesFilter(PairUiEntry entry, string filter) private static bool PassesFilter(PairUiEntry entry, string filter)
{ {
if (string.IsNullOrEmpty(filter)) return true; if (string.IsNullOrEmpty(filter)) return true;
@@ -1033,10 +1084,11 @@ public class CompactUi : WindowMediatorSubscriberBase
return SortGroupEntries(entries, group); return SortGroupEntries(entries, group);
} }
private void UiSharedService_GposeEnd() #endregion
{
IsOpen = _wasOpen; #region GPose Handlers
}
private void UiSharedService_GposeEnd() => IsOpen = _wasOpen;
private void UiSharedService_GposeStart() private void UiSharedService_GposeStart()
{ {
@@ -1044,6 +1096,10 @@ public class CompactUi : WindowMediatorSubscriberBase
IsOpen = false; IsOpen = false;
} }
#endregion
#region Focus Tracking
private void RegisterFocusCharacter(Pair pair) private void RegisterFocusCharacter(Pair pair)
{ {
_pendingFocusPair = pair; _pendingFocusPair = pair;
@@ -1089,4 +1145,16 @@ public class CompactUi : WindowMediatorSubscriberBase
_pendingFocusPair = null; _pendingFocusPair = null;
_pendingFocusFrame = -1; _pendingFocusFrame = -1;
} }
#endregion
#region Helper Types
[StructLayout(LayoutKind.Auto)]
private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes)
{
public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0;
}
#endregion
} }

View File

@@ -25,6 +25,8 @@ public class DownloadUi : WindowMediatorSubscriberBase
private readonly ConcurrentDictionary<GameObjectHandler, bool> _uploadingPlayers = new(); private readonly ConcurrentDictionary<GameObjectHandler, bool> _uploadingPlayers = new();
private readonly Dictionary<GameObjectHandler, Vector2> _smoothed = []; private readonly Dictionary<GameObjectHandler, Vector2> _smoothed = [];
private readonly Dictionary<GameObjectHandler, DownloadSpeedTracker> _downloadSpeeds = []; private readonly Dictionary<GameObjectHandler, DownloadSpeedTracker> _downloadSpeeds = [];
private readonly Dictionary<GameObjectHandler, (int TotalFiles, long TotalBytes)> _downloadInitialTotals = [];
private byte _transferBoxTransparency = 100; private byte _transferBoxTransparency = 100;
private bool _notificationDismissed = true; private bool _notificationDismissed = true;
@@ -66,6 +68,10 @@ public class DownloadUi : WindowMediatorSubscriberBase
Mediator.Subscribe<DownloadStartedMessage>(this, (msg) => Mediator.Subscribe<DownloadStartedMessage>(this, (msg) =>
{ {
_currentDownloads[msg.DownloadId] = msg.DownloadStatus; _currentDownloads[msg.DownloadId] = msg.DownloadStatus;
// Capture initial totals when download starts
var totalFiles = msg.DownloadStatus.Values.Sum(s => s.TotalFiles);
var totalBytes = msg.DownloadStatus.Values.Sum(s => s.TotalBytes);
_downloadInitialTotals[msg.DownloadId] = (totalFiles, totalBytes);
_notificationDismissed = false; _notificationDismissed = false;
}); });
Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) => Mediator.Subscribe<DownloadFinishedMessage>(this, (msg) =>
@@ -167,7 +173,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
List<KeyValuePair<GameObjectHandler, IReadOnlyDictionary<string, FileDownloadStatus>>> transfers; List<KeyValuePair<GameObjectHandler, IReadOnlyDictionary<string, FileDownloadStatus>>> transfers;
try try
{ {
transfers = _currentDownloads.ToList(); transfers = [.. _currentDownloads];
} }
catch (ArgumentException) catch (ArgumentException)
{ {
@@ -454,9 +460,13 @@ public class DownloadUi : WindowMediatorSubscriberBase
var handler = transfer.Key; var handler = transfer.Key;
var statuses = transfer.Value.Values; var statuses = transfer.Value.Values;
var playerTotalFiles = statuses.Sum(s => s.TotalFiles); var (playerTotalFiles, playerTotalBytes) = _downloadInitialTotals.TryGetValue(handler, out var totals)
var playerTransferredFiles = statuses.Sum(s => s.TransferredFiles); ? totals
var playerTotalBytes = statuses.Sum(s => s.TotalBytes); : (statuses.Sum(s => s.TotalFiles), statuses.Sum(s => s.TotalBytes));
var playerTransferredFiles = statuses.Count(s =>
s.DownloadStatus == DownloadStatus.Decompressing ||
s.TransferredBytes >= s.TotalBytes);
var playerTransferredBytes = statuses.Sum(s => s.TransferredBytes); var playerTransferredBytes = statuses.Sum(s => s.TransferredBytes);
totalFiles += playerTotalFiles; totalFiles += playerTotalFiles;

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Utility.Raii;
using Dalamud.Utility; using Dalamud.Utility;
using Lifestream.Enums;
using LightlessSync.API.Data; using LightlessSync.API.Data;
using LightlessSync.API.Data.Comparer; using LightlessSync.API.Data.Comparer;
using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Enum;
@@ -14,6 +15,7 @@ using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Configurations; using LightlessSync.LightlessConfiguration.Configurations;
using LightlessSync.LightlessConfiguration.Models; using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services; using LightlessSync.Services;
@@ -40,6 +42,7 @@ using System.Globalization;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Numerics; using System.Numerics;
using System.Runtime.InteropServices;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
@@ -69,6 +72,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
private readonly UiSharedService _uiShared; private readonly UiSharedService _uiShared;
private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress; private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress;
private readonly NameplateService _nameplateService; private readonly NameplateService _nameplateService;
private readonly AnimatedHeader _animatedHeader = new();
private (int, int, FileCacheEntity) _currentProgress; private (int, int, FileCacheEntity) _currentProgress;
private bool _deleteAccountPopupModalShown = false; private bool _deleteAccountPopupModalShown = false;
private bool _deleteFilesPopupModalShown = false; private bool _deleteFilesPopupModalShown = false;
@@ -105,8 +110,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
}; };
private readonly UiSharedService.TabOption<TransferSettingsTab>[] _transferTabOptions = new UiSharedService.TabOption<TransferSettingsTab>[2]; private readonly UiSharedService.TabOption<TransferSettingsTab>[] _transferTabOptions = new UiSharedService.TabOption<TransferSettingsTab>[2];
private readonly List<UiSharedService.TabOption<ServerSettingsTab>> _serverTabOptions = new(4); private readonly List<UiSharedService.TabOption<ServerSettingsTab>> _serverTabOptions = new(4);
private readonly string[] _generalTreeNavOrder = new[] private readonly string[] _generalTreeNavOrder =
{ [
"Import & Export", "Import & Export",
"Popup & Auto Fill", "Popup & Auto Fill",
"Behavior", "Behavior",
@@ -116,7 +121,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
"Colors", "Colors",
"Server Info Bar", "Server Info Bar",
"Nameplate", "Nameplate",
}; "Animation & Bones"
];
private static readonly HashSet<string> _generalNavSeparatorAfter = new(StringComparer.Ordinal) private static readonly HashSet<string> _generalNavSeparatorAfter = new(StringComparer.Ordinal)
{ {
"Popup & Auto Fill", "Popup & Auto Fill",
@@ -205,7 +211,10 @@ public class SettingsUi : WindowMediatorSubscriberBase
_nameplateService = nameplateService; _nameplateService = nameplateService;
_actorObjectService = actorObjectService; _actorObjectService = actorObjectService;
_validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v); _validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v);
_animatedHeader.Height = 120f;
_animatedHeader.EnableBottomGradient = true;
_animatedHeader.GradientHeight = 250f;
_animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects;
WindowBuilder.For(this) WindowBuilder.For(this)
.AllowPinning(true) .AllowPinning(true)
.AllowClickthrough(false) .AllowClickthrough(false)
@@ -241,6 +250,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
public override void OnClose() public override void OnClose()
{ {
_animatedHeader.ClearParticles();
_uiShared.EditTrackerPosition = false; _uiShared.EditTrackerPosition = false;
_uidToAddForIgnore = string.Empty; _uidToAddForIgnore = string.Empty;
_secretKeysConversionCts = _secretKeysConversionCts.CancelRecreate(); _secretKeysConversionCts = _secretKeysConversionCts.CancelRecreate();
@@ -255,8 +265,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
protected override void DrawInternal() protected override void DrawInternal()
{ {
_animatedHeader.Draw(ImGui.GetContentRegionAvail().X, (_, _) => { });
_ = _uiShared.DrawOtherPluginState(); _ = _uiShared.DrawOtherPluginState();
DrawSettingsContent(); DrawSettingsContent();
} }
private static Vector3 PackedColorToVector3(uint color) private static Vector3 PackedColorToVector3(uint color)
@@ -1230,7 +1240,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
private async Task<List<string>?> RunSpeedTest(List<string> servers, CancellationToken token) private async Task<List<string>?> RunSpeedTest(List<string> servers, CancellationToken token)
{ {
List<string> speedTestResults = new(); List<string> speedTestResults = [];
foreach (var server in servers) foreach (var server in servers)
{ {
HttpResponseMessage? result = null; HttpResponseMessage? result = null;
@@ -1332,7 +1342,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.TreePop(); ImGui.TreePop();
} }
#endif
if (_uiShared.IconTextButton(FontAwesomeIcon.Copy, "[DEBUG] Copy Last created Character Data to clipboard")) if (_uiShared.IconTextButton(FontAwesomeIcon.Copy, "[DEBUG] Copy Last created Character Data to clipboard"))
{ {
if (LastCreatedCharacterData != null) if (LastCreatedCharacterData != null)
@@ -1348,6 +1357,39 @@ public class SettingsUi : WindowMediatorSubscriberBase
UiSharedService.AttachToolTip("Use this when reporting mods being rejected from the server."); UiSharedService.AttachToolTip("Use this when reporting mods being rejected from the server.");
if (_uiShared.IconTextButton(FontAwesomeIcon.Home, "Teleport to Limsa [LIFESTREAM TEST]") && _ipcManager.Lifestream.APIAvailable)
{
_ipcManager.Lifestream.ExecuteLifestreamCommand("limsa");
}
if (_uiShared.IconTextButton(FontAwesomeIcon.Home, "Teleport to JoyHouse [LIFESTREAM TEST]") && _ipcManager.Lifestream.APIAvailable)
{
var twintania = _dalamudUtilService.WorldData.Value
.FirstOrDefault(kvp => kvp.Value.Equals("Twintania", StringComparison.OrdinalIgnoreCase));
int ward = 29;
int plot = 7;
AddressBookEntryTuple addressEntry = (
Name: "",
World: (int)twintania.Key,
City: (int)ResidentialAetheryteKind.Kugane,
Ward: ward,
PropertyType: 0,
Plot: plot,
Apartment: 1,
ApartmentSubdivision: false,
AliasEnabled: false,
Alias: ""
);
_logger.LogInformation("going to: {address}", addressEntry);
_ipcManager.Lifestream.GoToHousingAddress(addressEntry);
}
#endif
_uiShared.DrawCombo("Log Level", Enum.GetValues<LogLevel>(), (l) => l.ToString(), (l) => _uiShared.DrawCombo("Log Level", Enum.GetValues<LogLevel>(), (l) => l.ToString(), (l) =>
{ {
_configService.Current.LogLevel = l; _configService.Current.LogLevel = l;
@@ -2015,14 +2057,25 @@ public class SettingsUi : WindowMediatorSubscriberBase
{ {
using (ImRaii.PushIndent(20f)) using (ImRaii.PushIndent(20f))
{ {
if (_validationTask.IsCompleted) if (_validationTask.IsCompletedSuccessfully)
{ {
UiSharedService.TextWrapped( UiSharedService.TextWrapped(
$"The storage validation has completed and removed {_validationTask.Result.Count} invalid files from storage."); $"The storage validation has completed and removed {_validationTask.Result.Count} invalid files from storage.");
} }
else if (_validationTask.IsCanceled)
{
UiSharedService.ColorTextWrapped(
"Storage validation was cancelled.",
UIColors.Get("LightlessYellow"));
}
else if (_validationTask.IsFaulted)
{
UiSharedService.ColorTextWrapped(
"Storage validation failed with an error.",
UIColors.Get("DimRed"));
}
else else
{ {
UiSharedService.TextWrapped( UiSharedService.TextWrapped(
$"Storage validation is running: {_currentProgress.Item1}/{_currentProgress.Item2}"); $"Storage validation is running: {_currentProgress.Item1}/{_currentProgress.Item2}");
if (_currentProgress.Item3 != null) if (_currentProgress.Item3 != null)
@@ -2179,7 +2232,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.Separator(); ImGui.Separator();
var openPopupOnAddition = _configService.Current.OpenPopupOnAdd; var openPopupOnAddition = _configService.Current.OpenPopupOnAdd;
using (var popupTree = BeginGeneralTree("Popup & Auto Fill", UIColors.Get("LightlessPurple"))) using (var popupTree = BeginGeneralTree("Popup & Auto Fill", UIColors.Get("LightlessPurple")))
{ {
if (popupTree.Visible) if (popupTree.Visible)
@@ -2236,11 +2289,20 @@ public class SettingsUi : WindowMediatorSubscriberBase
var groupInVisible = _configService.Current.ShowSyncshellUsersInVisible; var groupInVisible = _configService.Current.ShowSyncshellUsersInVisible;
var syncshellOfflineSeparate = _configService.Current.ShowSyncshellOfflineUsersSeparately; var syncshellOfflineSeparate = _configService.Current.ShowSyncshellOfflineUsersSeparately;
var greenVisiblePair = _configService.Current.ShowVisiblePairsGreenEye; var greenVisiblePair = _configService.Current.ShowVisiblePairsGreenEye;
var enableParticleEffects = _configService.Current.EnableParticleEffects;
using (var behaviorTree = BeginGeneralTree("Behavior", UIColors.Get("LightlessPurple"))) using (var behaviorTree = BeginGeneralTree("Behavior", UIColors.Get("LightlessPurple")))
{ {
if (behaviorTree.Visible) if (behaviorTree.Visible)
{ {
if (ImGui.Checkbox("Enable Particle Effects", ref enableParticleEffects))
{
_configService.Current.EnableParticleEffects = enableParticleEffects;
_configService.Save();
}
_uiShared.DrawHelpText("This will enable particle effects in the UI.");
if (ImGui.Checkbox("Enable Game Right Click Menu Entries", ref enableRightClickMenu)) if (ImGui.Checkbox("Enable Game Right Click Menu Entries", ref enableRightClickMenu))
{ {
_configService.Current.EnableRightClickMenus = enableRightClickMenu; _configService.Current.EnableRightClickMenus = enableRightClickMenu;
@@ -2949,16 +3011,21 @@ public class SettingsUi : WindowMediatorSubscriberBase
var colorNames = new[] var colorNames = new[]
{ {
("LightlessPurple", "Primary Purple", "Section titles and dividers"), ("LightlessPurple", "Primary Purple", "Section titles and dividers"),
("LightlessPurpleActive", "Primary Purple (Active)", "Active tabs and hover highlights"), ("LightlessPurpleActive", "Primary Purple (Active)", "Active tabs and hover highlights"),
("LightlessPurpleDefault", "Primary Purple (Inactive)", "Inactive tabs and default dividers"), ("LightlessPurpleDefault", "Primary Purple (Inactive)", "Inactive tabs and default dividers"),
("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"), ("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"),
("LightlessGreen", "Success Green", "Join buttons and success messages"), ("LightlessGreen", "Success Green", "Join buttons and success messages"),
("LightlessYellow", "Warning Yellow", "Warning colors"), ("LightlessYellow", "Warning Yellow", "Warning colors"),
("LightlessOrange", "Performance Orange", "Performance notifications and warnings"), ("LightlessOrange", "Performance Orange", "Performance notifications and warnings"),
("PairBlue", "Syncshell Blue", "Syncshell headers, toggle highlights, and moderator actions"), ("PairBlue", "Syncshell Blue", "Syncshell headers, toggle highlights, and moderator actions"),
("DimRed", "Error Red", "Error and offline colors") ("DimRed", "Error Red", "Error and offline colors"),
}; ("HeaderGradientTop", "Header Gradient (Top)", "Top color of the animated header background"),
("HeaderGradientBottom", "Header Gradient (Bottom)", "Bottom color of the animated header background"),
("HeaderStaticStar", "Header Stars", "Tint color for the static background stars in the header"),
("HeaderShootingStar", "Header Shooting Star", "Tint color for the shooting star effect"),
};
if (ImGui.BeginTable("##ColorTable", 3, if (ImGui.BeginTable("##ColorTable", 3,
ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit)) ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit))
{ {
@@ -3164,10 +3231,102 @@ public class SettingsUi : WindowMediatorSubscriberBase
} }
ImGui.Separator(); ImGui.Separator();
ImGui.Dummy(new Vector2(10));
_uiShared.BigText("Animation");
using (var animationTree = BeginGeneralTree("Animation & Bones", UIColors.Get("LightlessPurple")))
{
if (animationTree.Visible)
{
ImGui.TextUnformatted("Animation Options");
var modes = new[]
{
AnimationValidationMode.Unsafe,
AnimationValidationMode.Safe,
AnimationValidationMode.Safest,
};
var labels = new[]
{
"Unsafe",
"Safe (Race)",
"Safest (Race + Bones)",
};
var tooltips = new[]
{
"No validation. Fastest, but may allow incompatible animations (riskier).",
"Validates skeleton race + modded skeleton check (recommended).",
"Requires matching skeleton race + bone compatibility (strictest).",
};
var currentMode = _configService.Current.AnimationValidationMode;
int selectedIndex = Array.IndexOf(modes, currentMode);
if (selectedIndex < 0) selectedIndex = 1;
ImGui.SetNextItemWidth(140 * ImGuiHelpers.GlobalScale);
bool open = ImGui.BeginCombo("Animation validation", labels[selectedIndex]);
if (ImGui.IsItemHovered())
ImGui.SetTooltip(tooltips[selectedIndex]);
if (open)
{
for (int i = 0; i < modes.Length; i++)
{
bool isSelected = (i == selectedIndex);
if (ImGui.Selectable(labels[i], isSelected))
{
selectedIndex = i;
_configService.Current.AnimationValidationMode = modes[i];
_configService.Save();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip(tooltips[i]);
if (isSelected)
ImGui.SetItemDefaultFocus();
}
ImGui.EndCombo();
}
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
var cfg = _configService.Current;
bool oneBased = cfg.AnimationAllowOneBasedShift;
if (ImGui.Checkbox("Treat 1-based PAP indices as compatible", ref oneBased))
{
cfg.AnimationAllowOneBasedShift = oneBased;
_configService.Save();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Fixes off-by-one PAPs (one bone differance in bones and PAP). Can also increase crashing, toggle off if alot of crashing is happening");
bool neighbor = cfg.AnimationAllowNeighborIndexTolerance;
if (ImGui.Checkbox("Allow 1+- bone index tolerance", ref neighbor))
{
cfg.AnimationAllowNeighborIndexTolerance = neighbor;
_configService.Save();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Looser matching on bone matching. Can reduce false blocks happening but also reduces safety and more prone to crashing.");
ImGui.TreePop();
animationTree.MarkContentEnd();
}
}
ImGui.EndChild(); ImGui.EndChild();
ImGui.EndGroup(); ImGui.EndGroup();
ImGui.Separator();
generalSelune.DrawHighlightOnly(ImGui.GetIO().DeltaTime); generalSelune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
} }
} }
@@ -3257,6 +3416,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
return 1f - (elapsed / GeneralTreeHighlightDuration); return 1f - (elapsed / GeneralTreeHighlightDuration);
} }
[StructLayout(LayoutKind.Auto)]
private struct GeneralTreeScope : IDisposable private struct GeneralTreeScope : IDisposable
{ {
private readonly bool _visible; private readonly bool _visible;
@@ -3564,7 +3724,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
_uiShared.DrawHelpText("Controls whether Lightless reduces index textures that exceed the size limit."); _uiShared.DrawHelpText("Controls whether Lightless reduces index textures that exceed the size limit.");
var dimensionOptions = new[] { 512, 1024, 2048, 4096 }; var dimensionOptions = new[] { 512, 1024, 2048, 4096 };
var optionLabels = dimensionOptions.Select(static value => value.ToString()).ToArray(); var optionLabels = dimensionOptions.Select(selector: static value => value.ToString()).ToArray();
var currentDimension = textureConfig.TextureDownscaleMaxDimension; var currentDimension = textureConfig.TextureDownscaleMaxDimension;
var selectedIndex = Array.IndexOf(dimensionOptions, currentDimension); var selectedIndex = Array.IndexOf(dimensionOptions, currentDimension);
if (selectedIndex < 0) if (selectedIndex < 0)

View File

@@ -43,10 +43,23 @@ public class AnimatedHeader
private const float _extendedParticleHeight = 40f; private const float _extendedParticleHeight = 40f;
public float Height { get; set; } = 150f; public float Height { get; set; } = 150f;
// Color keys for theming
public string? TopColorKey { get; set; } = "HeaderGradientTop";
public string? BottomColorKey { get; set; } = "HeaderGradientBottom";
public string? StaticStarColorKey { get; set; } = "HeaderStaticStar";
public string? ShootingStarColorKey { get; set; } = "HeaderShootingStar";
// Fallbacks if the color keys are not found
public Vector4 TopColor { get; set; } = new(0.08f, 0.05f, 0.15f, 1.0f); public Vector4 TopColor { get; set; } = new(0.08f, 0.05f, 0.15f, 1.0f);
public Vector4 BottomColor { get; set; } = new(0.12f, 0.08f, 0.20f, 1.0f); public Vector4 BottomColor { get; set; } = new(0.12f, 0.08f, 0.20f, 1.0f);
public Vector4 StaticStarColor { get; set; } = new(1f, 1f, 1f, 1f);
public Vector4 ShootingStarColor { get; set; } = new(0.4f, 0.8f, 1.0f, 1.0f);
public bool EnableParticles { get; set; } = true; public bool EnableParticles { get; set; } = true;
public bool EnableBottomGradient { get; set; } = true; public bool EnableBottomGradient { get; set; } = true;
public float GradientHeight { get; set; } = 60f;
/// <summary> /// <summary>
/// Draws the animated header with some customizable content /// Draws the animated header with some customizable content
@@ -146,16 +159,21 @@ public class AnimatedHeader
{ {
var drawList = ImGui.GetWindowDrawList(); var drawList = ImGui.GetWindowDrawList();
var top = ResolveColor(TopColorKey, TopColor);
var bottom = ResolveColor(BottomColorKey, BottomColor);
drawList.AddRectFilledMultiColor( drawList.AddRectFilledMultiColor(
headerStart, headerStart,
headerEnd, headerEnd,
ImGui.GetColorU32(TopColor), ImGui.GetColorU32(top),
ImGui.GetColorU32(TopColor), ImGui.GetColorU32(top),
ImGui.GetColorU32(BottomColor), ImGui.GetColorU32(bottom),
ImGui.GetColorU32(BottomColor) ImGui.GetColorU32(bottom)
); );
// Draw static background stars // Draw static background stars
var starBase = ResolveColor(StaticStarColorKey, StaticStarColor);
var random = new Random(42); var random = new Random(42);
for (int i = 0; i < 50; i++) for (int i = 0; i < 50; i++)
{ {
@@ -164,23 +182,28 @@ public class AnimatedHeader
(float)random.NextDouble() * (headerEnd.Y - headerStart.Y) (float)random.NextDouble() * (headerEnd.Y - headerStart.Y)
); );
var brightness = 0.3f + (float)random.NextDouble() * 0.4f; var brightness = 0.3f + (float)random.NextDouble() * 0.4f;
drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(new Vector4(1f, 1f, 1f, brightness))); var starColor = starBase with { W = starBase.W * brightness };
drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(starColor));
} }
} }
private void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width) private void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width)
{ {
var drawList = ImGui.GetWindowDrawList(); var drawList = ImGui.GetWindowDrawList();
var gradientHeight = 60f; var gradientHeight = GradientHeight;
var bottom = ResolveColor(BottomColorKey, BottomColor);
for (int i = 0; i < gradientHeight; i++) for (int i = 0; i < gradientHeight; i++)
{ {
var progress = i / gradientHeight; var progress = i / gradientHeight;
var smoothProgress = progress * progress; var smoothProgress = progress * progress;
var r = BottomColor.X + (0.0f - BottomColor.X) * smoothProgress;
var g = BottomColor.Y + (0.0f - BottomColor.Y) * smoothProgress; var r = bottom.X + (0.0f - bottom.X) * smoothProgress;
var b = BottomColor.Z + (0.0f - BottomColor.Z) * smoothProgress; var g = bottom.Y + (0.0f - bottom.Y) * smoothProgress;
var b = bottom.Z + (0.0f - bottom.Z) * smoothProgress;
var alpha = 1f - smoothProgress; var alpha = 1f - smoothProgress;
var gradientColor = new Vector4(r, g, b, alpha); var gradientColor = new Vector4(r, g, b, alpha);
drawList.AddLine( drawList.AddLine(
new Vector2(headerStart.X, headerEnd.Y + i), new Vector2(headerStart.X, headerEnd.Y + i),
@@ -308,9 +331,11 @@ public class AnimatedHeader
? baseAlpha * (0.6f + 0.4f * MathF.Sin(particle.Twinkle)) ? baseAlpha * (0.6f + 0.4f * MathF.Sin(particle.Twinkle))
: baseAlpha; : baseAlpha;
var shootingBase = ResolveColor(ShootingStarColorKey, ShootingStarColor);
if (particle.Type == ParticleType.ShootingStar && particle.Trail != null && particle.Trail.Count > 1) if (particle.Type == ParticleType.ShootingStar && particle.Trail != null && particle.Trail.Count > 1)
{ {
var cyanColor = new Vector4(0.4f, 0.8f, 1.0f, 1.0f); var baseColor = shootingBase;
for (int t = 1; t < particle.Trail.Count; t++) for (int t = 1; t < particle.Trail.Count; t++)
{ {
@@ -319,17 +344,18 @@ public class AnimatedHeader
var trailWidth = (1f - trailProgress) * 3f + 1f; var trailWidth = (1f - trailProgress) * 3f + 1f;
var glowAlpha = trailAlpha * 0.4f; var glowAlpha = trailAlpha * 0.4f;
drawList.AddLine( drawList.AddLine(
bannerStart + particle.Trail[t - 1], bannerStart + particle.Trail[t - 1],
bannerStart + particle.Trail[t], bannerStart + particle.Trail[t],
ImGui.GetColorU32(cyanColor with { W = glowAlpha }), ImGui.GetColorU32(baseColor with { W = glowAlpha }),
trailWidth + 4f trailWidth + 4f
); );
drawList.AddLine( drawList.AddLine(
bannerStart + particle.Trail[t - 1], bannerStart + particle.Trail[t - 1],
bannerStart + particle.Trail[t], bannerStart + particle.Trail[t],
ImGui.GetColorU32(cyanColor with { W = trailAlpha }), ImGui.GetColorU32(baseColor with { W = trailAlpha }),
trailWidth trailWidth
); );
} }
@@ -448,6 +474,13 @@ public class AnimatedHeader
Hue = 270f Hue = 270f
}); });
} }
private static Vector4 ResolveColor(string? key, Vector4 fallback)
{
if (string.IsNullOrWhiteSpace(key))
return fallback;
return UIColors.Get(key);
}
/// <summary> /// <summary>
/// Clears all active particles. Useful when closing or hiding a window with an animated header. /// Clears all active particles. Useful when closing or hiding a window with an animated header.

View File

@@ -40,9 +40,10 @@ internal static class MainStyle
new("color.frameBg", "Frame Background", () => Rgba(40, 40, 40, 255), ImGuiCol.FrameBg), new("color.frameBg", "Frame Background", () => Rgba(40, 40, 40, 255), ImGuiCol.FrameBg),
new("color.frameBgHovered", "Frame Background (Hover)", () => Rgba(50, 50, 50, 100), ImGuiCol.FrameBgHovered), new("color.frameBgHovered", "Frame Background (Hover)", () => Rgba(50, 50, 50, 100), ImGuiCol.FrameBgHovered),
new("color.frameBgActive", "Frame Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.FrameBgActive), new("color.frameBgActive", "Frame Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.FrameBgActive),
new("color.titleBg", "Title Background", () => Rgba(24, 24, 24, 232), ImGuiCol.TitleBg), new("color.titleBg", "Title Background", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBg),
new("color.titleBgActive", "Title Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.TitleBgActive), new("color.titleBgActive", "Title Background (Active)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgActive),
new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(27, 27, 27, 255), ImGuiCol.TitleBgCollapsed), new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgCollapsed),
new("color.menuBarBg", "Menu Bar Background", () => Rgba(36, 36, 36, 255), ImGuiCol.MenuBarBg), new("color.menuBarBg", "Menu Bar Background", () => Rgba(36, 36, 36, 255), ImGuiCol.MenuBarBg),
new("color.scrollbarBg", "Scrollbar Background", () => Rgba(0, 0, 0, 0), ImGuiCol.ScrollbarBg), new("color.scrollbarBg", "Scrollbar Background", () => Rgba(0, 0, 0, 0), ImGuiCol.ScrollbarBg),
new("color.scrollbarGrab", "Scrollbar Grab", () => Rgba(62, 62, 62, 255), ImGuiCol.ScrollbarGrab), new("color.scrollbarGrab", "Scrollbar Grab", () => Rgba(62, 62, 62, 255), ImGuiCol.ScrollbarGrab),

View File

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

View File

@@ -1,855 +0,0 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Textures.TextureWraps;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Plugin.Services;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto;
using LightlessSync.API.Dto.Group;
using LightlessSync.Services;
using LightlessSync.Services.LightFinder;
using LightlessSync.Services.Mediator;
using LightlessSync.UI.Services;
using LightlessSync.UI.Tags;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using Microsoft.Extensions.Logging;
using System.Numerics;
namespace LightlessSync.UI;
public class SyncshellFinderUI : WindowMediatorSubscriberBase
{
private readonly ApiController _apiController;
private readonly LightFinderService _broadcastService;
private readonly UiSharedService _uiSharedService;
private readonly LightFinderScannerService _broadcastScannerService;
private readonly PairUiService _pairUiService;
private readonly DalamudUtilService _dalamudUtilService;
private Vector4 _tagBackgroundColor = new(0.18f, 0.18f, 0.18f, 0.95f);
private Vector4 _tagBorderColor = new(0.35f, 0.35f, 0.35f, 0.4f);
private readonly List<SeStringUtils.SeStringSegment> _seResolvedSegments = new();
private readonly List<GroupJoinDto> _nearbySyncshells = [];
private List<GroupFullInfoDto> _currentSyncshells = [];
private int _selectedNearbyIndex = -1;
private int _syncshellPageIndex = 0;
private readonly HashSet<string> _recentlyJoined = new(StringComparer.Ordinal);
private GroupJoinDto? _joinDto;
private GroupJoinInfoDto? _joinInfo;
private DefaultPermissionsDto _ownPermissions = null!;
private bool _useTestSyncshells = false;
private bool _compactView = false;
private readonly LightlessProfileManager _lightlessProfileManager;
public SyncshellFinderUI(
ILogger<SyncshellFinderUI> logger,
LightlessMediator mediator,
PerformanceCollectorService performanceCollectorService,
LightFinderService broadcastService,
UiSharedService uiShared,
ApiController apiController,
LightFinderScannerService broadcastScannerService,
PairUiService pairUiService,
DalamudUtilService dalamudUtilService,
LightlessProfileManager lightlessProfileManager) : base(logger, mediator, "Shellfinder###LightlessSyncshellFinderUI", performanceCollectorService)
{
_broadcastService = broadcastService;
_uiSharedService = uiShared;
_apiController = apiController;
_broadcastScannerService = broadcastScannerService;
_pairUiService = pairUiService;
_dalamudUtilService = dalamudUtilService;
_lightlessProfileManager = lightlessProfileManager;
IsOpen = false;
WindowBuilder.For(this)
.SetSizeConstraints(new Vector2(600, 400), new Vector2(600, 550))
.Apply();
Mediator.Subscribe<SyncshellBroadcastsUpdatedMessage>(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false));
Mediator.Subscribe<BroadcastStatusChangedMessage>(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false));
Mediator.Subscribe<UserLeftSyncshell>(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false));
Mediator.Subscribe<UserJoinedSyncshell>(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false));
}
public override async void OnOpen()
{
_ownPermissions = _apiController.DefaultPermissions.DeepClone()!;
await RefreshSyncshellsAsync().ConfigureAwait(false);
}
protected override void DrawInternal()
{
ImGui.BeginGroup();
_uiSharedService.MediumText("Nearby Syncshells", UIColors.Get("LightlessPurple"));
#if DEBUG
if (ImGui.SmallButton("Show test syncshells"))
{
_useTestSyncshells = !_useTestSyncshells;
_ = Task.Run(async () => await RefreshSyncshellsAsync().ConfigureAwait(false));
}
ImGui.SameLine();
#endif
string checkboxLabel = "Compact view";
float availWidth = ImGui.GetContentRegionAvail().X;
float checkboxWidth = ImGui.CalcTextSize(checkboxLabel).X + ImGui.GetFrameHeight();
float rightX = ImGui.GetCursorPosX() + availWidth - checkboxWidth - 4.0f;
ImGui.SetCursorPosX(rightX);
ImGui.Checkbox(checkboxLabel, ref _compactView);
ImGui.EndGroup();
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"));
ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale));
if (_nearbySyncshells.Count == 0)
{
ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted.");
if (!_broadcastService.IsBroadcasting)
{
UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"));
ImGui.TextColored(UIColors.Get("LightlessYellow"), "Lightfinder is currently disabled, to locate nearby syncshells, Lightfinder must be active.");
ImGuiHelpers.ScaledDummy(0.5f);
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 10.0f);
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessPurple"));
if (ImGui.Button("Open Lightfinder", new Vector2(200 * ImGuiHelpers.GlobalScale, 0)))
{
Mediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
}
ImGui.PopStyleColor();
ImGui.PopStyleVar();
return;
}
return;
}
var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts().ToList() ?? [];
_broadcastScannerService.TryGetLocalHashedCid(out var localHashedCid);
var cardData = new List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)>();
foreach (var shell in _nearbySyncshells)
{
string broadcasterName;
if (shell?.Group == null || string.IsNullOrEmpty(shell.Group.GID))
continue;
if (_useTestSyncshells)
{
var displayName = !string.IsNullOrEmpty(shell.Group.Alias)
? shell.Group.Alias
: shell.Group.GID;
broadcasterName = $"{displayName} (Tester of TestWorld)";
}
else
{
var broadcast = broadcasts
.FirstOrDefault(b => string.Equals(b.GID, shell.Group.GID, StringComparison.Ordinal));
if (broadcast == null)
continue;
var (name, address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.HashedCID);
if (string.IsNullOrEmpty(name))
continue;
var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(address);
broadcasterName = !string.IsNullOrEmpty(worldName)
? $"{name} ({worldName})"
: name;
var isSelfBroadcast = !string.IsNullOrEmpty(localHashedCid)
&& string.Equals(broadcast.HashedCID, localHashedCid, StringComparison.Ordinal);
cardData.Add((shell, broadcasterName, isSelfBroadcast));
continue;
}
cardData.Add((shell, broadcasterName, false));
}
if (cardData.Count == 0)
{
ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted.");
return;
}
if (_compactView)
{
DrawSyncshellGrid(cardData);
}
else
{
DrawSyncshellList(cardData);
}
if (_joinDto != null && _joinInfo != null && _joinInfo.Success)
DrawConfirmation();
}
private void DrawSyncshellList(List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)> listData)
{
const int shellsPerPage = 3;
var totalPages = (int)Math.Ceiling(listData.Count / (float)shellsPerPage);
if (totalPages <= 0)
totalPages = 1;
_syncshellPageIndex = Math.Clamp(_syncshellPageIndex, 0, totalPages - 1);
var firstIndex = _syncshellPageIndex * shellsPerPage;
var lastExclusive = Math.Min(firstIndex + shellsPerPage, listData.Count);
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8.0f);
ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.0f);
for (int index = firstIndex; index < lastExclusive; index++)
{
var (shell, broadcasterName, isSelfBroadcast) = listData[index];
var broadcasterLabel = string.IsNullOrEmpty(broadcasterName)
? (isSelfBroadcast ? "You" : string.Empty)
: (isSelfBroadcast ? $"{broadcasterName} (You)" : broadcasterName);
ImGui.PushID(shell.Group.GID);
float rowHeight = 74f * ImGuiHelpers.GlobalScale;
ImGui.BeginChild($"ShellRow##{shell.Group.GID}", new Vector2(-1, rowHeight), border: true);
var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID;
var style = ImGui.GetStyle();
float startX = ImGui.GetCursorPosX();
float regionW = ImGui.GetContentRegionAvail().X;
float rightTxtW = ImGui.CalcTextSize(broadcasterLabel).X;
_uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple"));
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Click to open profile.");
if (ImGui.IsItemClicked())
{
Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group));
}
float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X;
ImGui.SameLine();
ImGui.SetCursorPosX(rightX);
ImGui.TextUnformatted(broadcasterLabel);
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Broadcaster of the syncshell.");
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"));
var groupProfile = _lightlessProfileManager.GetLightlessGroupProfile(shell.Group);
IReadOnlyList<ProfileTagDefinition> groupTags =
groupProfile != null && groupProfile.Tags.Count > 0
? ProfileTagService.ResolveTags(groupProfile.Tags)
: [];
var limitedTags = groupTags.Count > 3
? [.. groupTags.Take(3)]
: groupTags;
float tagScale = ImGuiHelpers.GlobalScale * 0.9f;
Vector2 rowStartLocal = ImGui.GetCursorPos();
float tagsWidth = 0f;
float tagsHeight = 0f;
if (limitedTags.Count > 0)
{
(tagsWidth, tagsHeight) = RenderProfileTagsSingleRow(limitedTags, tagScale);
}
else
{
ImGui.SetCursorPosX(startX);
ImGui.TextDisabled("-- No tags set --");
ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale));
}
float btnBaselineY = rowStartLocal.Y;
float joinX = rowStartLocal.X + (tagsWidth > 0 ? tagsWidth + style.ItemSpacing.X : 0f);
ImGui.SetCursorPos(new Vector2(joinX, btnBaselineY));
DrawJoinButton(shell, isSelfBroadcast);
float btnHeight = ImGui.GetFrameHeightWithSpacing();
float rowHeightUsed = MathF.Max(tagsHeight, btnHeight);
ImGui.SetCursorPos(new Vector2(
rowStartLocal.X,
rowStartLocal.Y + rowHeightUsed));
ImGui.EndChild();
ImGui.PopID();
ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale));
}
ImGui.PopStyleVar(2);
DrawPagination(totalPages);
}
private void DrawSyncshellGrid(List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)> cardData)
{
const int shellsPerPage = 4;
var totalPages = (int)Math.Ceiling(cardData.Count / (float)shellsPerPage);
if (totalPages <= 0)
totalPages = 1;
_syncshellPageIndex = Math.Clamp(_syncshellPageIndex, 0, totalPages - 1);
var firstIndex = _syncshellPageIndex * shellsPerPage;
var lastExclusive = Math.Min(firstIndex + shellsPerPage, cardData.Count);
var avail = ImGui.GetContentRegionAvail();
var spacing = ImGui.GetStyle().ItemSpacing;
var cardWidth = (avail.X - spacing.X) / 2.0f;
var cardHeight = (avail.Y - spacing.Y - (ImGui.GetFrameHeightWithSpacing() * 2.0f)) / 2.0f;
cardHeight = MathF.Max(110f * ImGuiHelpers.GlobalScale, cardHeight);
ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8.0f);
ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.0f);
for (int index = firstIndex; index < lastExclusive; index++)
{
var localIndex = index - firstIndex;
var (shell, broadcasterName, isSelfBroadcast) = cardData[index];
var broadcasterLabel = string.IsNullOrEmpty(broadcasterName)
? (isSelfBroadcast ? "You" : string.Empty)
: (isSelfBroadcast ? $"{broadcasterName} (You)" : broadcasterName);
if (localIndex % 2 != 0)
ImGui.SameLine();
ImGui.PushID(shell.Group.GID);
ImGui.BeginGroup();
_ = ImGui.BeginChild("ShellCard##" + shell.Group.GID, new Vector2(cardWidth, cardHeight), border: true);
var displayName = !string.IsNullOrEmpty(shell.Group.Alias)
? shell.Group.Alias
: shell.Group.GID;
var style = ImGui.GetStyle();
float startX = ImGui.GetCursorPosX();
float availW = ImGui.GetContentRegionAvail().X;
ImGui.BeginGroup();
_uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple"));
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Click to open profile.");
if (ImGui.IsItemClicked())
{
Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group));
}
float nameRightX = ImGui.GetItemRectMax().X;
var regionMinScreen = ImGui.GetCursorScreenPos();
float regionRightX = regionMinScreen.X + availW;
float minBroadcasterX = nameRightX + style.ItemSpacing.X;
float maxBroadcasterWidth = regionRightX - minBroadcasterX;
string broadcasterToShow = broadcasterLabel;
if (!string.IsNullOrEmpty(broadcasterLabel) && maxBroadcasterWidth > 0f)
{
float bcFullWidth = ImGui.CalcTextSize(broadcasterLabel).X;
string toolTip;
if (bcFullWidth > maxBroadcasterWidth)
{
broadcasterToShow = TruncateTextToWidth(broadcasterLabel, maxBroadcasterWidth);
toolTip = broadcasterLabel + Environment.NewLine + Environment.NewLine + "Broadcaster of the syncshell.";
}
else
{
toolTip = "Broadcaster of the syncshell.";
}
float bcWidth = ImGui.CalcTextSize(broadcasterToShow).X;
float broadX = regionRightX - bcWidth;
broadX = MathF.Max(broadX, minBroadcasterX);
ImGui.SameLine();
var curPos = ImGui.GetCursorPos();
ImGui.SetCursorPos(new Vector2(broadX - regionMinScreen.X + startX, curPos.Y + 3f * ImGuiHelpers.GlobalScale));
ImGui.TextUnformatted(broadcasterToShow);
if (ImGui.IsItemHovered())
ImGui.SetTooltip(toolTip);
}
ImGui.EndGroup();
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"));
ImGui.Dummy(new Vector2(0, 6 * ImGuiHelpers.GlobalScale));
var groupProfile = _lightlessProfileManager.GetLightlessGroupProfile(shell.Group);
IReadOnlyList<ProfileTagDefinition> groupTags =
groupProfile != null && groupProfile.Tags.Count > 0
? ProfileTagService.ResolveTags(groupProfile.Tags)
: [];
float tagScale = ImGuiHelpers.GlobalScale * 0.9f;
if (groupTags.Count > 0)
{
var limitedTags = groupTags.Count > 2
? [.. groupTags.Take(2)]
: groupTags;
ImGui.SetCursorPosX(startX);
var (_, tagsHeight) = RenderProfileTagsSingleRow(limitedTags, tagScale);
ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale));
}
else
{
ImGui.SetCursorPosX(startX);
ImGui.TextDisabled("-- No tags set --");
ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale));
}
var buttonHeight = ImGui.GetFrameHeightWithSpacing();
var remainingY = ImGui.GetContentRegionAvail().Y - buttonHeight;
if (remainingY > 0)
ImGui.Dummy(new Vector2(0, remainingY));
DrawJoinButton(shell, isSelfBroadcast);
ImGui.EndChild();
ImGui.EndGroup();
ImGui.PopID();
}
ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale));
ImGui.PopStyleVar(2);
DrawPagination(totalPages);
}
private void DrawPagination(int totalPages)
{
if (totalPages > 1)
{
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"));
var style = ImGui.GetStyle();
string pageLabel = $"Page {_syncshellPageIndex + 1}/{totalPages}";
float prevWidth = ImGui.CalcTextSize("<").X + style.FramePadding.X * 2;
float nextWidth = ImGui.CalcTextSize(">").X + style.FramePadding.X * 2;
float textWidth = ImGui.CalcTextSize(pageLabel).X;
float totalWidth = prevWidth + textWidth + nextWidth + style.ItemSpacing.X * 2;
float availWidth = ImGui.GetContentRegionAvail().X;
float offsetX = (availWidth - totalWidth) * 0.5f;
ImGui.SetCursorPosX(ImGui.GetCursorPosX() + offsetX);
if (ImGui.Button("<##PrevSyncshellPage") && _syncshellPageIndex > 0)
_syncshellPageIndex--;
ImGui.SameLine();
ImGui.Text(pageLabel);
ImGui.SameLine();
if (ImGui.Button(">##NextSyncshellPage") && _syncshellPageIndex < totalPages - 1)
_syncshellPageIndex++;
}
}
private void DrawJoinButton(GroupJoinDto shell, bool isSelfBroadcast)
{
const string visibleLabel = "Join";
var label = $"{visibleLabel}##{shell.Group.GID}";
var isAlreadyMember = _currentSyncshells.Exists(g => string.Equals(g.GID, shell.GID, StringComparison.Ordinal));
var isRecentlyJoined = _recentlyJoined.Contains(shell.GID);
Vector2 buttonSize;
if (!_compactView)
{
var style = ImGui.GetStyle();
var textSize = ImGui.CalcTextSize(visibleLabel);
var width = textSize.X + style.FramePadding.X * 20f;
buttonSize = new Vector2(width, 30f);
float availX = ImGui.GetContentRegionAvail().X;
float curX = ImGui.GetCursorPosX();
float newX = curX + (availX - buttonSize.X);
ImGui.SetCursorPosX(newX);
}
else
{
buttonSize = new Vector2(-1, 0);
}
if (!isAlreadyMember && !isRecentlyJoined && !isSelfBroadcast)
{
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen"));
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f));
ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f));
if (ImGui.Button(label, buttonSize))
{
_logger.LogInformation($"Join requested for Syncshell {shell.Group.GID} ({shell.Group.Alias})");
_ = Task.Run(async () =>
{
try
{
var info = await _apiController.GroupJoinHashed(new GroupJoinHashedDto(
shell.Group,
shell.Password,
shell.GroupUserPreferredPermissions
)).ConfigureAwait(false);
if (info != null && info.Success)
{
_joinDto = new GroupJoinDto(shell.Group, shell.Password, shell.GroupUserPreferredPermissions);
_joinInfo = info;
_ownPermissions = _apiController.DefaultPermissions.DeepClone()!;
_logger.LogInformation($"Fetched join info for {shell.Group.GID}");
}
else
{
_logger.LogWarning($"Failed to join {shell.Group.GID}: info was null or unsuccessful");
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"Join failed for {shell.Group.GID}");
}
});
}
}
else
{
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("DimRed"));
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("DimRed").WithAlpha(0.85f));
ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("DimRed").WithAlpha(0.75f));
using (ImRaii.Disabled())
{
ImGui.Button(label, buttonSize);
}
UiSharedService.AttachToolTip(isSelfBroadcast
? "This is your own Syncshell."
: "Already a member or owner of this Syncshell.");
}
ImGui.PopStyleColor(3);
}
private (float widthUsed, float rowHeight) RenderProfileTagsSingleRow(IReadOnlyList<ProfileTagDefinition> tags, float scale)
{
if (tags == null || tags.Count == 0)
return (0f, 0f);
var drawList = ImGui.GetWindowDrawList();
var style = ImGui.GetStyle();
var defaultTextColorU32 = ImGui.GetColorU32(ImGuiCol.Text);
var baseLocal = ImGui.GetCursorPos();
var baseScreen = ImGui.GetCursorScreenPos();
float availableWidth = ImGui.GetContentRegionAvail().X;
if (availableWidth <= 0f)
availableWidth = 1f;
float cursorLocalX = baseLocal.X;
float cursorScreenX = baseScreen.X;
float rowHeight = 0f;
for (int i = 0; i < tags.Count; i++)
{
var tag = tags[i];
if (!tag.HasContent)
continue;
var tagSize = ProfileTagRenderer.MeasureTag(tag, scale, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger);
float tagWidth = tagSize.X;
float tagHeight = tagSize.Y;
if (cursorLocalX > baseLocal.X && cursorLocalX + tagWidth > baseLocal.X + availableWidth)
break;
var tagScreenPos = new Vector2(cursorScreenX, baseScreen.Y);
ImGui.SetCursorScreenPos(tagScreenPos);
ImGui.InvisibleButton($"##profileTagInline_{i}", tagSize);
ProfileTagRenderer.RenderTag(tag, tagScreenPos, scale, drawList, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger);
cursorLocalX += tagWidth + style.ItemSpacing.X;
cursorScreenX += tagWidth + style.ItemSpacing.X;
rowHeight = MathF.Max(rowHeight, tagHeight);
}
ImGui.SetCursorPos(new Vector2(baseLocal.X, baseLocal.Y + rowHeight));
float widthUsed = cursorLocalX - baseLocal.X;
return (widthUsed, rowHeight);
}
private static string TruncateTextToWidth(string text, float maxWidth)
{
if (string.IsNullOrEmpty(text))
return text;
const string ellipsis = "...";
float ellipsisWidth = ImGui.CalcTextSize(ellipsis).X;
if (maxWidth <= ellipsisWidth)
return ellipsis;
int low = 0;
int high = text.Length;
string best = ellipsis;
while (low <= high)
{
int mid = (low + high) / 2;
string candidate = string.Concat(text.AsSpan(0, mid), ellipsis);
float width = ImGui.CalcTextSize(candidate).X;
if (width <= maxWidth)
{
best = candidate;
low = mid + 1;
}
else
{
high = mid - 1;
}
}
return best;
}
private IDalamudTextureWrap? GetIconWrap(uint iconId)
{
try
{
if (_uiSharedService.TryGetIcon(iconId, out var wrap) && wrap != null)
return wrap;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to resolve icon {IconId} for profile tags", iconId);
}
return null;
}
private void DrawConfirmation()
{
if (_joinDto != null && _joinInfo != null)
{
ImGui.Separator();
ImGui.TextUnformatted($"Join Syncshell: {_joinDto.Group.AliasOrGID} by {_joinInfo.OwnerAliasOrUID}");
ImGuiHelpers.ScaledDummy(2f);
ImGui.TextUnformatted("Suggested Syncshell Permissions:");
DrawPermissionRow("Sounds", _joinInfo.GroupPermissions.IsPreferDisableSounds(), _ownPermissions.DisableGroupSounds, v => _ownPermissions.DisableGroupSounds = v);
DrawPermissionRow("Animations", _joinInfo.GroupPermissions.IsPreferDisableAnimations(), _ownPermissions.DisableGroupAnimations, v => _ownPermissions.DisableGroupAnimations = v);
DrawPermissionRow("VFX", _joinInfo.GroupPermissions.IsPreferDisableVFX(), _ownPermissions.DisableGroupVFX, v => _ownPermissions.DisableGroupVFX = v);
ImGui.NewLine();
ImGui.NewLine();
if (_uiSharedService.IconTextButton(Dalamud.Interface.FontAwesomeIcon.Plus, $"Finalize and join {_joinDto.Group.AliasOrGID}"))
{
var finalPermissions = GroupUserPreferredPermissions.NoneSet;
finalPermissions.SetDisableSounds(_ownPermissions.DisableGroupSounds);
finalPermissions.SetDisableAnimations(_ownPermissions.DisableGroupAnimations);
finalPermissions.SetDisableVFX(_ownPermissions.DisableGroupVFX);
_ = _apiController.GroupJoinFinalize(new GroupJoinDto(_joinDto.Group, _joinDto.Password, finalPermissions));
_recentlyJoined.Add(_joinDto.Group.GID);
_joinDto = null;
_joinInfo = null;
}
}
}
private void DrawPermissionRow(string label, bool suggested, bool current, Action<bool> apply)
{
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted($"- {label}");
ImGui.SameLine(150 * ImGuiHelpers.GlobalScale);
ImGui.TextUnformatted("Current:");
ImGui.SameLine();
_uiSharedService.BooleanToColoredIcon(!current);
ImGui.SameLine(300 * ImGuiHelpers.GlobalScale);
ImGui.TextUnformatted("Suggested:");
ImGui.SameLine();
_uiSharedService.BooleanToColoredIcon(!suggested);
ImGui.SameLine(450 * ImGuiHelpers.GlobalScale);
using var id = ImRaii.PushId(label);
if (current != suggested)
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Apply"))
apply(suggested);
}
ImGui.NewLine();
}
private async Task RefreshSyncshellsAsync(string? gid = null)
{
var syncshellBroadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts();
var snapshot = _pairUiService.GetSnapshot();
_currentSyncshells = [.. snapshot.GroupPairs.Keys];
_recentlyJoined.RemoveWhere(gid =>
_currentSyncshells.Exists(s => string.Equals(s.GID, gid, StringComparison.Ordinal)));
List<GroupJoinDto>? updatedList = [];
if (_useTestSyncshells)
{
updatedList = BuildTestSyncshells();
}
else
{
if (syncshellBroadcasts.Count == 0)
{
ClearSyncshells();
return;
}
try
{
var groups = await _apiController.GetBroadcastedGroups(syncshellBroadcasts)
.ConfigureAwait(false);
updatedList = groups?.DistinctBy(g => g.Group.GID).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh broadcasted syncshells.");
return;
}
}
if (updatedList == null || updatedList.Count == 0)
{
ClearSyncshells();
return;
}
if (gid != null && _recentlyJoined.Contains(gid))
{
_recentlyJoined.Clear();
}
var previousGid = GetSelectedGid();
_nearbySyncshells.Clear();
_nearbySyncshells.AddRange(updatedList);
if (previousGid != null)
{
var newIndex = _nearbySyncshells.FindIndex(s =>
string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal));
if (newIndex >= 0)
{
_selectedNearbyIndex = newIndex;
return;
}
}
ClearSelection();
}
private static List<GroupJoinDto> BuildTestSyncshells()
{
var testGroup1 = new GroupData("TEST-ALPHA", "Alpha Shell");
var testGroup2 = new GroupData("TEST-BETA", "Beta Shell");
var testGroup3 = new GroupData("TEST-GAMMA", "Gamma Shell");
var testGroup4 = new GroupData("TEST-DELTA", "Delta Shell");
var testGroup5 = new GroupData("TEST-CHARLIE", "Charlie Shell");
var testGroup6 = new GroupData("TEST-OMEGA", "Omega Shell");
var testGroup7 = new GroupData("TEST-POINT", "Point Shell");
var testGroup8 = new GroupData("TEST-HOTEL", "Hotel Shell");
return
[
new(testGroup1, "", GroupUserPreferredPermissions.NoneSet),
new(testGroup2, "", GroupUserPreferredPermissions.NoneSet),
new(testGroup3, "", GroupUserPreferredPermissions.NoneSet),
new(testGroup4, "", GroupUserPreferredPermissions.NoneSet),
new(testGroup5, "", GroupUserPreferredPermissions.NoneSet),
new(testGroup6, "", GroupUserPreferredPermissions.NoneSet),
new(testGroup7, "", GroupUserPreferredPermissions.NoneSet),
new(testGroup8, "", GroupUserPreferredPermissions.NoneSet),
];
}
private void ClearSyncshells()
{
if (_nearbySyncshells.Count == 0)
return;
_nearbySyncshells.Clear();
ClearSelection();
}
private void ClearSelection()
{
_selectedNearbyIndex = -1;
_syncshellPageIndex = 0;
_joinDto = null;
_joinInfo = null;
}
private string? GetSelectedGid()
{
if (_selectedNearbyIndex < 0 || _selectedNearbyIndex >= _nearbySyncshells.Count)
return null;
return _nearbySyncshells[_selectedNearbyIndex].Group.GID;
}
}

View File

@@ -162,24 +162,32 @@ public class TopTabMenu
ImGui.SameLine(); ImGui.SameLine();
using (ImRaii.PushFont(UiBuilder.IconFont)) using (ImRaii.PushFont(UiBuilder.IconFont))
{ {
var x = ImGui.GetCursorScreenPos();
if (ImGui.Button(FontAwesomeIcon.Compass.ToIconString(), buttonSize)) if (ImGui.Button(FontAwesomeIcon.Compass.ToIconString(), buttonSize))
{ {
TabSelection = TabSelection == SelectedTab.Lightfinder ? SelectedTab.None : SelectedTab.Lightfinder; _lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
} }
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
{ {
Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding);
} }
ImGui.SameLine();
var xAfter = ImGui.GetCursorScreenPos();
if (TabSelection == SelectedTab.Lightfinder)
drawList.AddLine(x with { Y = x.Y + buttonSize.Y + spacing.Y },
xAfter with { Y = xAfter.Y + buttonSize.Y + spacing.Y, X = xAfter.X - spacing.X },
underlineColor, 2);
} }
UiSharedService.AttachToolTip("Lightfinder");
var nearbyCount = GetNearbySyncshellCount();
if (nearbyCount > 0)
{
var buttonMax = ImGui.GetItemRectMax();
var badgeRadius = 8f * ImGuiHelpers.GlobalScale;
var badgeCenter = new Vector2(buttonMax.X - badgeRadius * 1.3f, buttonMax.Y - buttonSize.Y + badgeRadius * 0.5f);
var badgeText = nearbyCount > 99 ? "99+" : nearbyCount.ToString();
var textSize = ImGui.CalcTextSize(badgeText);
drawList.AddCircleFilled(badgeCenter, badgeRadius + 1f, ImGui.GetColorU32(new Vector4(0, 0, 0, 0.6f)));
drawList.AddCircleFilled(badgeCenter, badgeRadius, ImGui.GetColorU32(UIColors.Get("LightlessPurple")));
var textPos = new Vector2(badgeCenter.X - textSize.X * 0.45f, badgeCenter.Y - textSize.Y * 0.55f);
drawList.AddText(textPos, ImGui.GetColorU32(new Vector4(1, 1, 1, 1)), badgeText);
}
UiSharedService.AttachToolTip(nearbyCount > 0 ? $"Lightfinder ({nearbyCount} nearby)" : "Open Lightfinder");
ImGui.SameLine(); ImGui.SameLine();
using (ImRaii.PushFont(UiBuilder.IconFont)) using (ImRaii.PushFont(UiBuilder.IconFont))
@@ -234,10 +242,7 @@ public class TopTabMenu
DrawSyncshellMenu(availableWidth, spacing.X); DrawSyncshellMenu(availableWidth, spacing.X);
DrawGlobalSyncshellButtons(availableWidth, spacing.X); DrawGlobalSyncshellButtons(availableWidth, spacing.X);
} }
else if (TabSelection == SelectedTab.Lightfinder)
{
DrawLightfinderMenu(availableWidth, spacing.X);
}
else if (TabSelection == SelectedTab.UserConfig) else if (TabSelection == SelectedTab.UserConfig)
{ {
DrawUserConfig(availableWidth, spacing.X); DrawUserConfig(availableWidth, spacing.X);
@@ -776,53 +781,22 @@ public class TopTabMenu
} }
} }
} }
private void DrawLightfinderMenu(float availableWidth, float spacingX) private int GetNearbySyncshellCount()
{
var buttonX = (availableWidth - (spacingX)) / 2f;
var lightFinderLabel = GetLightfinderFinderLabel();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.PersonCirclePlus, lightFinderLabel, buttonX, center: true))
{
_lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
}
ImGui.SameLine();
var syncshellFinderLabel = GetSyncshellFinderLabel();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Globe, syncshellFinderLabel, buttonX, center: true))
{
_lightlessMediator.Publish(new UiToggleMessage(typeof(SyncshellFinderUI)));
}
}
private string GetLightfinderFinderLabel()
{
string label = "Lightfinder";
if (_lightFinderService.IsBroadcasting)
{
var hashExclude = _dalamudUtilService.GetCID().ToString().GetHash256();
var nearbyCount = _lightFinderScannerService.GetActiveBroadcasts(hashExclude).Count;
return $"{label} ({nearbyCount})";
}
return label;
}
private string GetSyncshellFinderLabel()
{ {
if (!_lightFinderService.IsBroadcasting) if (!_lightFinderService.IsBroadcasting)
return "Syncshell Finder"; return 0;
var nearbyCount = _lightFinderScannerService var myHashedCid = _dalamudUtilService.GetCID().ToString().GetHash256();
.GetActiveSyncshellBroadcasts(excludeLocal: true)
.Where(b => !string.IsNullOrEmpty(b.GID)) return _lightFinderScannerService
.GetActiveSyncshellBroadcasts()
.Where(b =>
!string.IsNullOrEmpty(b.GID) &&
!string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal))
.Select(b => b.GID!) .Select(b => b.GID!)
.Distinct(StringComparer.Ordinal) .Distinct(StringComparer.Ordinal)
.Count(); .Count();
return nearbyCount > 0 ? $"Syncshell Finder ({nearbyCount})" : "Syncshell Finder";
} }
private void DrawUserConfig(float availableWidth, float spacingX) private void DrawUserConfig(float availableWidth, float spacingX)

View File

@@ -6,7 +6,7 @@ namespace LightlessSync.UI
{ {
internal static class UIColors internal static class UIColors
{ {
private static readonly Dictionary<string, string> DefaultHexColors = new(StringComparer.OrdinalIgnoreCase) private static readonly Dictionary<string, string> _defaultHexColors = new(StringComparer.OrdinalIgnoreCase)
{ {
{ "LightlessPurple", "#ad8af5" }, { "LightlessPurple", "#ad8af5" },
{ "LightlessPurpleActive", "#be9eff" }, { "LightlessPurpleActive", "#be9eff" },
@@ -31,6 +31,12 @@ namespace LightlessSync.UI
{ "ProfileBodyGradientTop", "#2f283fff" }, { "ProfileBodyGradientTop", "#2f283fff" },
{ "ProfileBodyGradientBottom", "#372d4d00" }, { "ProfileBodyGradientBottom", "#372d4d00" },
{ "HeaderGradientTop", "#140D26FF" },
{ "HeaderGradientBottom", "#1F1433FF" },
{ "HeaderStaticStar", "#FFFFFFFF" },
{ "HeaderShootingStar", "#66CCFFFF" },
}; };
private static LightlessConfigService? _configService; private static LightlessConfigService? _configService;
@@ -45,7 +51,7 @@ namespace LightlessSync.UI
if (_configService?.Current.CustomUIColors.TryGetValue(name, out var customColorHex) == true) if (_configService?.Current.CustomUIColors.TryGetValue(name, out var customColorHex) == true)
return HexToRgba(customColorHex); return HexToRgba(customColorHex);
if (!DefaultHexColors.TryGetValue(name, out var hex)) if (!_defaultHexColors.TryGetValue(name, out var hex))
throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name)); throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name));
return HexToRgba(hex); return HexToRgba(hex);
@@ -53,7 +59,7 @@ namespace LightlessSync.UI
public static void Set(string name, Vector4 color) public static void Set(string name, Vector4 color)
{ {
if (!DefaultHexColors.ContainsKey(name)) if (!_defaultHexColors.ContainsKey(name))
throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name)); throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name));
if (_configService != null) if (_configService != null)
@@ -83,7 +89,7 @@ namespace LightlessSync.UI
public static Vector4 GetDefault(string name) public static Vector4 GetDefault(string name)
{ {
if (!DefaultHexColors.TryGetValue(name, out var hex)) if (!_defaultHexColors.TryGetValue(name, out var hex))
throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name)); throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name));
return HexToRgba(hex); return HexToRgba(hex);
@@ -96,7 +102,7 @@ namespace LightlessSync.UI
public static IEnumerable<string> GetColorNames() public static IEnumerable<string> GetColorNames()
{ {
return DefaultHexColors.Keys; return _defaultHexColors.Keys;
} }
public static Vector4 HexToRgba(string hexColor) public static Vector4 HexToRgba(string hexColor)

View File

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

View File

@@ -40,6 +40,7 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase
logger.LogInformation("UpdateNotesUi constructor called"); logger.LogInformation("UpdateNotesUi constructor called");
_uiShared = uiShared; _uiShared = uiShared;
_configService = configService; _configService = configService;
_animatedHeader.EnableParticles = _configService.Current.EnableParticleEffects;
RespectCloseHotkey = true; RespectCloseHotkey = true;
ShowCloseButton = true; ShowCloseButton = true;
@@ -48,7 +49,8 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase
ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoMove; ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoMove;
PositionCondition = ImGuiCond.Always; PositionCondition = ImGuiCond.Always;
WindowBuilder.For(this) WindowBuilder.For(this)
.AllowPinning(false) .AllowPinning(false)
.AllowClickthrough(false) .AllowClickthrough(false)

View File

@@ -29,6 +29,10 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
private readonly TextureMetadataHelper _textureMetadataHelper; private readonly TextureMetadataHelper _textureMetadataHelper;
private readonly ConcurrentDictionary<ThrottledStream, byte> _activeDownloadStreams; private readonly ConcurrentDictionary<ThrottledStream, byte> _activeDownloadStreams;
private readonly SemaphoreSlim _decompressGate =
new(Math.Max(1, Environment.ProcessorCount / 2), Math.Max(1, Environment.ProcessorCount / 2));
private readonly ConcurrentQueue<string> _deferredCompressionQueue = new();
private volatile bool _disableDirectDownloads; private volatile bool _disableDirectDownloads;
private int _consecutiveDirectDownloadFailures; private int _consecutiveDirectDownloadFailures;
@@ -393,76 +397,32 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
private async Task WaitForDownloadReady(List<DownloadFileTransfer> downloadFileTransfer, Guid requestId, CancellationToken downloadCt) private async Task WaitForDownloadReady(List<DownloadFileTransfer> downloadFileTransfer, Guid requestId, CancellationToken downloadCt)
{ {
bool alreadyCancelled = false; while (true)
try
{ {
CancellationTokenSource localTimeoutCts = new(); downloadCt.ThrowIfCancellationRequested();
localTimeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
CancellationTokenSource composite = CancellationTokenSource.CreateLinkedTokenSource(downloadCt, localTimeoutCts.Token);
while (!_orchestrator.IsDownloadReady(requestId)) if (_orchestrator.IsDownloadReady(requestId))
break;
using var resp = await _orchestrator.SendRequestAsync(
HttpMethod.Get,
LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId),
downloadFileTransfer.Select(t => t.Hash).ToList(),
downloadCt).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
var body = (await resp.Content.ReadAsStringAsync(downloadCt).ConfigureAwait(false)).Trim();
if (string.Equals(body, "true", StringComparison.OrdinalIgnoreCase) ||
body.Contains("\"ready\":true", StringComparison.OrdinalIgnoreCase))
{ {
try break;
{
await Task.Delay(250, composite.Token).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
if (downloadCt.IsCancellationRequested) throw;
var req = await _orchestrator.SendRequestAsync(
HttpMethod.Get,
LightlessFiles.RequestCheckQueueFullPath(downloadFileTransfer[0].DownloadUri, requestId),
downloadFileTransfer.Select(c => c.Hash).ToList(),
downloadCt).ConfigureAwait(false);
req.EnsureSuccessStatusCode();
localTimeoutCts.Dispose();
composite.Dispose();
localTimeoutCts = new();
localTimeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
composite = CancellationTokenSource.CreateLinkedTokenSource(downloadCt, localTimeoutCts.Token);
}
} }
localTimeoutCts.Dispose(); await Task.Delay(250, downloadCt).ConfigureAwait(false);
composite.Dispose();
Logger.LogDebug("Download {requestId} ready", requestId);
} }
catch (TaskCanceledException)
{
try
{
await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCancelFullPath(downloadFileTransfer[0].DownloadUri, requestId))
.ConfigureAwait(false);
alreadyCancelled = true;
}
catch
{
// ignore
}
throw; _orchestrator.ClearDownloadRequest(requestId);
}
finally
{
if (downloadCt.IsCancellationRequested && !alreadyCancelled)
{
try
{
await _orchestrator.SendRequestAsync(HttpMethod.Get, LightlessFiles.RequestCancelFullPath(downloadFileTransfer[0].DownloadUri, requestId))
.ConfigureAwait(false);
}
catch
{
// ignore
}
}
_orchestrator.ClearDownloadRequest(requestId);
}
} }
private async Task DownloadQueuedBlockFileAsync( private async Task DownloadQueuedBlockFileAsync(
@@ -515,11 +475,9 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
try try
{ {
// sanity check length
if (fileLengthBytes < 0 || fileLengthBytes > int.MaxValue) if (fileLengthBytes < 0 || fileLengthBytes > int.MaxValue)
throw new InvalidDataException($"Invalid block entry length: {fileLengthBytes}"); throw new InvalidDataException($"Invalid block entry length: {fileLengthBytes}");
// safe cast after check
var len = checked((int)fileLengthBytes); var len = checked((int)fileLengthBytes);
if (!replacementLookup.TryGetValue(fileHash, out var repl)) if (!replacementLookup.TryGetValue(fileHash, out var repl))
@@ -531,11 +489,9 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
continue; continue;
} }
// decompress
var filePath = _fileDbManager.GetCacheFilePath(fileHash, repl.Extension); var filePath = _fileDbManager.GetCacheFilePath(fileHash, repl.Extension);
Logger.LogTrace("{dlName}: Decompressing {file}:{len} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath); Logger.LogTrace("{dlName}: Decompressing {file}:{len} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath);
// read compressed data
var compressed = new byte[len]; var compressed = new byte[len];
await ReadExactlyAsync(fileBlockStream, compressed.AsMemory(0, len), ct).ConfigureAwait(false); await ReadExactlyAsync(fileBlockStream, compressed.AsMemory(0, len), ct).ConfigureAwait(false);
@@ -547,13 +503,36 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
&& expectedRawSize > 0 && expectedRawSize > 0
&& decompressed.LongLength != expectedRawSize) && decompressed.LongLength != expectedRawSize)
{ {
Logger.LogWarning("{dlName}: Decompressed size mismatch for {fileHash} (expected {expected}, got {actual})", await _fileCompactor.WriteAllBytesAsync(filePath, Array.Empty<byte>(), ct).ConfigureAwait(false);
downloadLabel, fileHash, expectedRawSize, decompressed.LongLength); PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale);
continue; continue;
} }
await _fileCompactor.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false); MungeBuffer(compressed);
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation);
await _decompressGate.WaitAsync(ct).ConfigureAwait(false);
try
{
// offload CPU-intensive decompression to threadpool to free up worker
await Task.Run(async () =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
// decompress
var decompressed = LZ4Wrapper.Unwrap(compressed);
Logger.LogTrace("{dlName}: Unwrap {fileHash} took {ms}ms (compressed {c} bytes, decompressed {d} bytes)",
downloadLabel, fileHash, sw.ElapsedMilliseconds, compressed.Length, decompressed?.Length ?? -1);
// write to file without compacting during download
await File.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false);
PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale);
}, ct).ConfigureAwait(false);
}
finally
{
_decompressGate.Release();
}
} }
catch (EndOfStreamException) catch (EndOfStreamException)
{ {
@@ -736,8 +715,16 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
if (gameObjectHandler is not null) if (gameObjectHandler is not null)
Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus)); Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus));
// work based on cpu count and slots
var coreCount = Environment.ProcessorCount;
var baseWorkers = Math.Min(slots, coreCount);
// only add buffer if decompression has capacity AND we have cores to spare
var availableDecompressSlots = _decompressGate.CurrentCount;
var extraWorkers = (availableDecompressSlots > 0 && coreCount >= 6) ? 2 : 0;
// allow some extra workers so downloads can continue while earlier items decompress. // allow some extra workers so downloads can continue while earlier items decompress.
var workerDop = Math.Clamp(slots * 2, 2, 16); var workerDop = Math.Clamp(baseWorkers + extraWorkers, 2, coreCount);
// batch downloads // batch downloads
Task batchTask = batchChunks.Length == 0 Task batchTask = batchChunks.Length == 0
@@ -753,6 +740,9 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
await Task.WhenAll(batchTask, directTask).ConfigureAwait(false); await Task.WhenAll(batchTask, directTask).ConfigureAwait(false);
// process deferred compressions after all downloads complete
await ProcessDeferredCompressionsAsync(ct).ConfigureAwait(false);
Logger.LogDebug("Download end: {id}", objectName); Logger.LogDebug("Download end: {id}", objectName);
ClearDownload(); ClearDownload();
} }
@@ -783,7 +773,6 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
try try
{ {
// download (with slot)
var progress = CreateInlineProgress(bytes => AddTransferredBytes(statusKey, bytes)); var progress = CreateInlineProgress(bytes => AddTransferredBytes(statusKey, bytes));
// Download slot held on get // Download slot held on get
@@ -979,14 +968,12 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
if (!_orchestrator.IsInitialized) if (!_orchestrator.IsInitialized)
throw new InvalidOperationException("FileTransferManager is not initialized"); throw new InvalidOperationException("FileTransferManager is not initialized");
// batch request
var response = await _orchestrator.SendRequestAsync( var response = await _orchestrator.SendRequestAsync(
HttpMethod.Get, HttpMethod.Get,
LightlessFiles.ServerFilesGetSizesFullPath(_orchestrator.FilesCdnUri!), LightlessFiles.ServerFilesGetSizesFullPath(_orchestrator.FilesCdnUri!),
hashes, hashes,
ct).ConfigureAwait(false); ct).ConfigureAwait(false);
// ensure success
return await response.Content.ReadFromJsonAsync<List<DownloadFileDto>>(cancellationToken: ct).ConfigureAwait(false) ?? []; return await response.Content.ReadFromJsonAsync<List<DownloadFileDto>>(cancellationToken: ct).ConfigureAwait(false) ?? [];
} }
@@ -1006,6 +993,10 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
fi.LastAccessTime = DateTime.Today; fi.LastAccessTime = DateTime.Today;
fi.LastWriteTime = RandomDayInThePast().Invoke(); fi.LastWriteTime = RandomDayInThePast().Invoke();
// queue file for deferred compression instead of compressing immediately
if (_configService.Current.UseCompactor)
_deferredCompressionQueue.Enqueue(filePath);
try try
{ {
var entry = _fileDbManager.CreateCacheEntryWithKnownHash(filePath, fileHash); var entry = _fileDbManager.CreateCacheEntryWithKnownHash(filePath, fileHash);
@@ -1040,6 +1031,52 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
private static IProgress<long> CreateInlineProgress(Action<long> callback) => new InlineProgress(callback); private static IProgress<long> CreateInlineProgress(Action<long> callback) => new InlineProgress(callback);
private async Task ProcessDeferredCompressionsAsync(CancellationToken ct)
{
if (_deferredCompressionQueue.IsEmpty)
return;
var filesToCompress = new List<string>();
while (_deferredCompressionQueue.TryDequeue(out var filePath))
{
if (File.Exists(filePath))
filesToCompress.Add(filePath);
}
if (filesToCompress.Count == 0)
return;
Logger.LogDebug("Starting deferred compression of {count} files", filesToCompress.Count);
var compressionWorkers = Math.Clamp(Environment.ProcessorCount / 4, 2, 4);
await Parallel.ForEachAsync(filesToCompress,
new ParallelOptions
{
MaxDegreeOfParallelism = compressionWorkers,
CancellationToken = ct
},
async (filePath, token) =>
{
try
{
await Task.Yield();
if (_configService.Current.UseCompactor && File.Exists(filePath))
{
var bytes = await File.ReadAllBytesAsync(filePath, token).ConfigureAwait(false);
await _fileCompactor.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false);
Logger.LogTrace("Compressed file: {filePath}", filePath);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to compress file: {filePath}", filePath);
}
}).ConfigureAwait(false);
Logger.LogDebug("Completed deferred compression of {count} files", filesToCompress.Count);
}
private sealed class InlineProgress : IProgress<long> private sealed class InlineProgress : IProgress<long>
{ {
private readonly Action<long> _callback; private readonly Action<long> _callback;

View File

@@ -76,6 +76,19 @@
"Microsoft.AspNetCore.SignalR.Common": "10.0.1" "Microsoft.AspNetCore.SignalR.Common": "10.0.1"
} }
}, },
"Microsoft.Extensions.Caching.Memory": {
"type": "Direct",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "NxqSP0Ky4dZ5ybszdZCqs1X2C70s+dXflqhYBUh/vhcQVTIooNCXIYnLVbafoAFGZMs51d9+rHxveXs0ZC3SQQ==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "10.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1",
"Microsoft.Extensions.Logging.Abstractions": "10.0.1",
"Microsoft.Extensions.Options": "10.0.1",
"Microsoft.Extensions.Primitives": "10.0.1"
}
},
"Microsoft.Extensions.Hosting": { "Microsoft.Extensions.Hosting": {
"type": "Direct", "type": "Direct",
"requested": "[10.0.1, )", "requested": "[10.0.1, )",
@@ -233,6 +246,14 @@
"Microsoft.AspNetCore.SignalR.Common": "10.0.1" "Microsoft.AspNetCore.SignalR.Common": "10.0.1"
} }
}, },
"Microsoft.Extensions.Caching.Abstractions": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "Vb1vVAQDxHpXVdL9fpOX2BzeV7bbhzG4pAcIKRauRl0/VfkE8mq0f+fYC+gWICh3dlzTZInJ/cTeBS2MgU/XvQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.1"
}
},
"Microsoft.Extensions.Configuration": { "Microsoft.Extensions.Configuration": {
"type": "Transitive", "type": "Transitive",
"resolved": "10.0.1", "resolved": "10.0.1",
@@ -618,7 +639,7 @@
"FlatSharp.Compiler": "[7.9.0, )", "FlatSharp.Compiler": "[7.9.0, )",
"FlatSharp.Runtime": "[7.9.0, )", "FlatSharp.Runtime": "[7.9.0, )",
"OtterGui": "[1.0.0, )", "OtterGui": "[1.0.0, )",
"Penumbra.Api": "[5.13.0, )", "Penumbra.Api": "[5.13.1, )",
"Penumbra.String": "[1.0.7, )" "Penumbra.String": "[1.0.7, )"
} }
}, },