This commit is contained in:
2025-11-25 07:14:59 +09:00
parent 9c794137c1
commit ef592032b3
111 changed files with 20622 additions and 3476 deletions

15
.gitmodules vendored
View File

@@ -1,6 +1,15 @@
[submodule "LightlessAPI"]
path = LightlessAPI
url = https://git.lightless-sync.org/Lightless-Sync/LightlessAPI.git
[submodule "PenumbraAPI"]
path = PenumbraAPI
url = https://github.com/Ottermandias/Penumbra.Api.git
[submodule "Penumbra.GameData"]
path = Penumbra.GameData
url = https://github.com/Ottermandias/Penumbra.GameData
[submodule "Penumbra.Api"]
path = Penumbra.Api
url = https://github.com/Ottermandias/Penumbra.Api
[submodule "Penumbra.String"]
path = Penumbra.String
url = https://github.com/Ottermandias/Penumbra.String
[submodule "OtterGui"]
path = OtterGui
url = https://github.com/Ottermandias/OtterGui

View File

@@ -1,4 +1,3 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.1.32328.378
@@ -12,7 +11,17 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessSync", "LightlessS
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessSync.API", "LightlessAPI\LightlessSyncAPI\LightlessSync.API.csproj", "{A4E42AFA-5045-7E81-937F-3A320AC52987}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.Api", "PenumbraAPI\Penumbra.Api.csproj", "{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.GameData", "Penumbra.GameData\Penumbra.GameData.csproj", "{CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.Api", "Penumbra.Api\Penumbra.Api.csproj", "{65ACC53A-1D72-40D4-A99E-7D451D87E182}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.String", "Penumbra.String\Penumbra.String.csproj", "{4D466894-0F1E-4808-A3E8-3FC9DE954AC6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OtterGui", "OtterGui\OtterGui.csproj", "{719723E1-8218-495A-98BA-4D0BDF7822EB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "OtterGui", "OtterGui", "{F30CFB00-531B-5698-C50F-38FBF3471340}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OtterGuiInternal", "OtterGui\OtterGuiInternal\OtterGuiInternal.csproj", "{DF590F45-F26C-4337-98DE-367C97253125}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -22,34 +31,70 @@ Global
Release|x64 = Release|x64
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.ActiveCfg = Release|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.Build.0 = Release|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.ActiveCfg = Debug|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.Build.0 = Debug|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|x64.ActiveCfg = Debug|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|x64.Build.0 = Debug|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|Any CPU.ActiveCfg = Release|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|Any CPU.Build.0 = Release|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x64.ActiveCfg = Release|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x64.Build.0 = Release|x64
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.ActiveCfg = Release|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.Build.0 = Release|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|x64.ActiveCfg = Debug|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|x64.Build.0 = Debug|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|Any CPU.Build.0 = Release|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x64.ActiveCfg = Release|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x64.Build.0 = Release|Any CPU
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|Any CPU.ActiveCfg = Debug|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|Any CPU.Build.0 = Debug|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|x64.ActiveCfg = Debug|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|x64.Build.0 = Debug|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|Any CPU.ActiveCfg = Release|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|Any CPU.Build.0 = Release|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|x64.ActiveCfg = Release|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|x64.Build.0 = Release|x64
{CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Debug|Any CPU.ActiveCfg = Debug|x64
{CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Debug|Any CPU.Build.0 = Debug|x64
{CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Debug|x64.ActiveCfg = Debug|x64
{CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Debug|x64.Build.0 = Debug|x64
{CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Release|Any CPU.ActiveCfg = Release|x64
{CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Release|Any CPU.Build.0 = Release|x64
{CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Release|x64.ActiveCfg = Release|x64
{CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Release|x64.Build.0 = Release|x64
{65ACC53A-1D72-40D4-A99E-7D451D87E182}.Debug|Any CPU.ActiveCfg = Debug|x64
{65ACC53A-1D72-40D4-A99E-7D451D87E182}.Debug|Any CPU.Build.0 = Debug|x64
{65ACC53A-1D72-40D4-A99E-7D451D87E182}.Debug|x64.ActiveCfg = Debug|x64
{65ACC53A-1D72-40D4-A99E-7D451D87E182}.Debug|x64.Build.0 = Debug|x64
{65ACC53A-1D72-40D4-A99E-7D451D87E182}.Release|Any CPU.ActiveCfg = Release|x64
{65ACC53A-1D72-40D4-A99E-7D451D87E182}.Release|Any CPU.Build.0 = Release|x64
{65ACC53A-1D72-40D4-A99E-7D451D87E182}.Release|x64.ActiveCfg = Release|x64
{65ACC53A-1D72-40D4-A99E-7D451D87E182}.Release|x64.Build.0 = Release|x64
{4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Debug|Any CPU.ActiveCfg = Debug|x64
{4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Debug|Any CPU.Build.0 = Debug|x64
{4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Debug|x64.ActiveCfg = Debug|x64
{4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Debug|x64.Build.0 = Debug|x64
{4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Release|Any CPU.ActiveCfg = Release|x64
{4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Release|Any CPU.Build.0 = Release|x64
{4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Release|x64.ActiveCfg = Release|x64
{4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Release|x64.Build.0 = Release|x64
{719723E1-8218-495A-98BA-4D0BDF7822EB}.Debug|Any CPU.ActiveCfg = Debug|x64
{719723E1-8218-495A-98BA-4D0BDF7822EB}.Debug|Any CPU.Build.0 = Debug|x64
{719723E1-8218-495A-98BA-4D0BDF7822EB}.Debug|x64.ActiveCfg = Debug|x64
{719723E1-8218-495A-98BA-4D0BDF7822EB}.Debug|x64.Build.0 = Debug|x64
{719723E1-8218-495A-98BA-4D0BDF7822EB}.Release|Any CPU.ActiveCfg = Release|x64
{719723E1-8218-495A-98BA-4D0BDF7822EB}.Release|Any CPU.Build.0 = Release|x64
{719723E1-8218-495A-98BA-4D0BDF7822EB}.Release|x64.ActiveCfg = Release|x64
{719723E1-8218-495A-98BA-4D0BDF7822EB}.Release|x64.Build.0 = Release|x64
{DF590F45-F26C-4337-98DE-367C97253125}.Debug|Any CPU.ActiveCfg = Debug|x64
{DF590F45-F26C-4337-98DE-367C97253125}.Debug|Any CPU.Build.0 = Debug|x64
{DF590F45-F26C-4337-98DE-367C97253125}.Debug|x64.ActiveCfg = Debug|x64
{DF590F45-F26C-4337-98DE-367C97253125}.Debug|x64.Build.0 = Debug|x64
{DF590F45-F26C-4337-98DE-367C97253125}.Release|Any CPU.ActiveCfg = Release|x64
{DF590F45-F26C-4337-98DE-367C97253125}.Release|Any CPU.Build.0 = Release|x64
{DF590F45-F26C-4337-98DE-367C97253125}.Release|x64.ActiveCfg = Release|x64
{DF590F45-F26C-4337-98DE-367C97253125}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{719723E1-8218-495A-98BA-4D0BDF7822EB} = {F30CFB00-531B-5698-C50F-38FBF3471340}
{DF590F45-F26C-4337-98DE-367C97253125} = {F30CFB00-531B-5698-C50F-38FBF3471340}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {B17E85B1-5F60-4440-9F9A-3DDE877E8CDF}
EndGlobalSection

View File

@@ -823,6 +823,8 @@ public sealed class FileCacheManager : IHostedService
_logger.LogInformation("Started FileCacheManager");
_lightlessMediator.Publish(new FileCacheInitializedMessage());
return Task.CompletedTask;
}

View File

@@ -3,11 +3,17 @@ using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Configurations;
using LightlessSync.PlayerData.Data;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Factories;
using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Linq;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
namespace LightlessSync.FileCache;
@@ -17,21 +23,29 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
private readonly HashSet<string> _cachedHandledPaths = new(StringComparer.Ordinal);
private readonly TransientConfigService _configurationService;
private readonly DalamudUtilService _dalamudUtil;
private readonly ActorObjectService _actorObjectService;
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
private readonly object _ownedHandlerLock = new();
private readonly string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk", "kdb"];
private readonly string[] _handledRecordingFileTypes = ["tex", "mdl", "mtrl"];
private readonly HashSet<GameObjectHandler> _playerRelatedPointers = [];
private ConcurrentDictionary<IntPtr, ObjectKind> _cachedFrameAddresses = [];
private readonly Dictionary<nint, GameObjectHandler> _ownedHandlers = new();
private ConcurrentDictionary<nint, ObjectKind> _cachedFrameAddresses = new();
private ConcurrentDictionary<ObjectKind, HashSet<string>>? _semiTransientResources = null;
private uint _lastClassJobId = uint.MaxValue;
public bool IsTransientRecording { get; private set; } = false;
public TransientResourceManager(ILogger<TransientResourceManager> logger, TransientConfigService configurationService,
DalamudUtilService dalamudUtil, LightlessMediator mediator) : base(logger, mediator)
DalamudUtilService dalamudUtil, LightlessMediator mediator, ActorObjectService actorObjectService, GameObjectHandlerFactory gameObjectHandlerFactory) : base(logger, mediator)
{
_configurationService = configurationService;
_dalamudUtil = dalamudUtil;
_actorObjectService = actorObjectService;
_gameObjectHandlerFactory = gameObjectHandlerFactory;
Mediator.Subscribe<PenumbraResourceLoadMessage>(this, Manager_PenumbraResourceLoadEvent);
Mediator.Subscribe<ActorTrackedMessage>(this, msg => HandleActorTracked(msg.Descriptor));
Mediator.Subscribe<ActorUntrackedMessage>(this, msg => HandleActorUntracked(msg.Descriptor));
Mediator.Subscribe<PenumbraModSettingChangedMessage>(this, (_) => Manager_PenumbraModSettingChanged());
Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, (_) => DalamudUtil_FrameworkUpdate());
Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
@@ -44,6 +58,11 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
if (!msg.OwnedObject) return;
_playerRelatedPointers.Remove(msg.GameObjectHandler);
});
foreach (var descriptor in _actorObjectService.PlayerDescriptors)
{
HandleActorTracked(descriptor);
}
}
private TransientConfig.TransientPlayerConfig PlayerConfig
@@ -241,16 +260,46 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
TransientResources.Clear();
SemiTransientResources.Clear();
lock (_ownedHandlerLock)
{
foreach (var handler in _ownedHandlers.Values)
{
handler.Dispose();
}
_ownedHandlers.Clear();
}
}
private void DalamudUtil_FrameworkUpdate()
{
_cachedFrameAddresses = new(_playerRelatedPointers.Where(k => k.Address != nint.Zero).ToDictionary(c => c.Address, c => c.ObjectKind));
lock (_cacheAdditionLock)
{
_cachedHandledPaths.Clear();
}
var activeDescriptors = new Dictionary<nint, ObjectKind>();
foreach (var descriptor in _actorObjectService.PlayerDescriptors)
{
if (TryResolveObjectKind(descriptor, out var resolvedKind))
{
activeDescriptors[descriptor.Address] = resolvedKind;
}
}
foreach (var address in _cachedFrameAddresses.Keys.ToList())
{
if (!activeDescriptors.ContainsKey(address))
{
_cachedFrameAddresses.TryRemove(address, out _);
}
}
foreach (var descriptor in activeDescriptors)
{
_cachedFrameAddresses[descriptor.Key] = descriptor.Value;
}
if (_lastClassJobId != _dalamudUtil.ClassJobId)
{
_lastClassJobId = _dalamudUtil.ClassJobId;
@@ -259,16 +308,15 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
value?.Clear();
}
// reload config for current new classjob
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] = [.. petSpecificData ?? []];
}
foreach (var kind in Enum.GetValues(typeof(ObjectKind)))
foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast<ObjectKind>())
{
if (!_cachedFrameAddresses.Any(k => k.Value == (ObjectKind)kind) && TransientResources.Remove((ObjectKind)kind, out _))
if (!_cachedFrameAddresses.Any(k => k.Value == kind) && TransientResources.Remove(kind, out _))
{
Logger.LogDebug("Object not present anymore: {kind}", kind.ToString());
}
@@ -292,6 +340,116 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
_semiTransientResources = null;
}
private static bool TryResolveObjectKind(ActorObjectService.ActorDescriptor descriptor, out ObjectKind resolvedKind)
{
if (descriptor.OwnedKind is ObjectKind ownedKind)
{
resolvedKind = ownedKind;
return true;
}
if (descriptor.ObjectKind == DalamudObjectKind.Player)
{
resolvedKind = ObjectKind.Player;
return true;
}
resolvedKind = default;
return false;
}
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
{
if (!TryResolveObjectKind(descriptor, out var resolvedKind))
return;
if (Logger.IsEnabled(LogLevel.Debug))
{
Logger.LogDebug("ActorObject tracked: {kind} addr={address:X} name={name}", resolvedKind, descriptor.Address, descriptor.Name);
}
_cachedFrameAddresses[descriptor.Address] = resolvedKind;
if (descriptor.OwnedKind is not ObjectKind ownedKind)
return;
lock (_ownedHandlerLock)
{
if (_ownedHandlers.ContainsKey(descriptor.Address))
return;
_ = CreateOwnedHandlerAsync(descriptor, ownedKind);
}
}
private void HandleActorUntracked(ActorObjectService.ActorDescriptor descriptor)
{
if (Logger.IsEnabled(LogLevel.Debug))
{
var kindLabel = descriptor.OwnedKind?.ToString()
?? (descriptor.ObjectKind == DalamudObjectKind.Player ? ObjectKind.Player.ToString() : "<none>");
Logger.LogDebug("ActorObject untracked: addr={address:X} name={name} kind={kind}", descriptor.Address, descriptor.Name, kindLabel);
}
_cachedFrameAddresses.TryRemove(descriptor.Address, out _);
if (descriptor.OwnedKind is not ObjectKind)
return;
lock (_ownedHandlerLock)
{
if (_ownedHandlers.Remove(descriptor.Address, out var handler))
{
handler.Dispose();
}
}
}
private async Task CreateOwnedHandlerAsync(ActorObjectService.ActorDescriptor descriptor, ObjectKind kind)
{
try
{
var handler = await _gameObjectHandlerFactory.Create(
kind,
() =>
{
if (!string.IsNullOrEmpty(descriptor.HashedContentId) &&
_actorObjectService.TryGetActorByHash(descriptor.HashedContentId, out var current) &&
current.OwnedKind == kind)
{
return current.Address;
}
return descriptor.Address;
},
true).ConfigureAwait(false);
if (handler.Address == IntPtr.Zero)
{
handler.Dispose();
return;
}
lock (_ownedHandlerLock)
{
if (!_cachedFrameAddresses.ContainsKey(descriptor.Address))
{
Logger.LogDebug("ActorObject handler discarded (stale): addr={address:X}", descriptor.Address);
handler.Dispose();
return;
}
_ownedHandlers[descriptor.Address] = handler;
}
Logger.LogDebug("ActorObject handler created: {kind} addr={address:X}", kind, descriptor.Address);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to create owned handler for {kind} at {address:X}", kind, descriptor.Address);
}
}
private void Manager_PenumbraResourceLoadEvent(PenumbraResourceLoadMessage msg)
{
var gamePath = msg.GamePath.ToLowerInvariant();
@@ -383,21 +541,30 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
private void SendTransients(nint gameObject, ObjectKind objectKind)
{
_sendTransientCts.Cancel();
_sendTransientCts = new();
var token = _sendTransientCts.Token;
_ = Task.Run(async () =>
{
_sendTransientCts?.Cancel();
_sendTransientCts?.Dispose();
_sendTransientCts = new();
var token = _sendTransientCts.Token;
await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false);
foreach (var kvp in TransientResources)
try
{
await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false);
if (TransientResources.TryGetValue(objectKind, out var values) && values.Any())
{
Logger.LogTrace("Sending Transients for {kind}", objectKind);
Mediator.Publish(new TransientResourceChangedMessage(gameObject));
}
}
catch (TaskCanceledException)
{
}
catch (System.OperationCanceledException)
{
}
});
}

View File

@@ -2,12 +2,18 @@
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using Penumbra.Api.Enums;
using Penumbra.Api.Helpers;
using Penumbra.Api.IpcSubscribers;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace LightlessSync.Interop.Ipc;
@@ -17,6 +23,7 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
private readonly DalamudUtilService _dalamudUtil;
private readonly LightlessMediator _lightlessMediator;
private readonly RedrawManager _redrawManager;
private readonly ActorObjectService _actorObjectService;
private bool _shownPenumbraUnavailable = false;
private string? _penumbraModDirectory;
public string? ModDirectory
@@ -33,6 +40,7 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
}
private readonly ConcurrentDictionary<IntPtr, bool> _penumbraRedrawRequests = new();
private readonly ConcurrentDictionary<IntPtr, byte> _trackedActors = new();
private readonly EventSubscriber _penumbraDispose;
private readonly EventSubscriber<nint, string, string> _penumbraGameObjectResourcePathResolved;
@@ -52,14 +60,19 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
private readonly GetModDirectory _penumbraResolveModDir;
private readonly ResolvePlayerPathsAsync _penumbraResolvePaths;
private readonly GetGameObjectResourcePaths _penumbraResourcePaths;
//private readonly GetPlayerResourcePaths _penumbraPlayerResourcePaths;
private readonly GetCollections _penumbraGetCollections;
private readonly ConcurrentDictionary<Guid, string> _activeTemporaryCollections = new();
private int _performedInitialCleanup;
public IpcCallerPenumbra(ILogger<IpcCallerPenumbra> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil,
LightlessMediator lightlessMediator, RedrawManager redrawManager) : base(logger, lightlessMediator)
LightlessMediator lightlessMediator, RedrawManager redrawManager, ActorObjectService actorObjectService) : base(logger, lightlessMediator)
{
_pi = pi;
_dalamudUtil = dalamudUtil;
_lightlessMediator = lightlessMediator;
_redrawManager = redrawManager;
_actorObjectService = actorObjectService;
_penumbraInit = Initialized.Subscriber(pi, PenumbraInit);
_penumbraDispose = Disposed.Subscriber(pi, PenumbraDispose);
_penumbraResolveModDir = new GetModDirectory(pi);
@@ -71,6 +84,7 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
_penumbraCreateNamedTemporaryCollection = new CreateTemporaryCollection(pi);
_penumbraRemoveTemporaryCollection = new DeleteTemporaryCollection(pi);
_penumbraAssignTemporaryCollection = new AssignTemporaryCollection(pi);
_penumbraGetCollections = new GetCollections(pi);
_penumbraResolvePaths = new ResolvePlayerPathsAsync(pi);
_penumbraEnabled = new GetEnabledState(pi);
_penumbraModSettingChanged = ModSettingChanged.Subscriber(pi, (change, arg1, arg, b) =>
@@ -80,6 +94,7 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
});
_penumbraConvertTextureFile = new ConvertTextureFile(pi);
_penumbraResourcePaths = new GetGameObjectResourcePaths(pi);
//_penumbraPlayerResourcePaths = new GetPlayerResourcePaths(pi);
_penumbraGameObjectResourcePathResolved = GameObjectResourcePathResolved.Subscriber(pi, ResourceLoaded);
@@ -92,6 +107,46 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
});
Mediator.Subscribe<DalamudLoginMessage>(this, (msg) => _shownPenumbraUnavailable = false);
Mediator.Subscribe<ActorTrackedMessage>(this, msg =>
{
if (msg.Descriptor.Address != nint.Zero)
{
_trackedActors[(IntPtr)msg.Descriptor.Address] = 0;
}
});
Mediator.Subscribe<ActorUntrackedMessage>(this, msg =>
{
if (msg.Descriptor.Address != nint.Zero)
{
_trackedActors.TryRemove((IntPtr)msg.Descriptor.Address, out _);
}
});
Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, msg =>
{
if (msg.GameObjectHandler.Address != nint.Zero)
{
_trackedActors[(IntPtr)msg.GameObjectHandler.Address] = 0;
}
});
Mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, msg =>
{
if (msg.GameObjectHandler.Address != nint.Zero)
{
_trackedActors.TryRemove((IntPtr)msg.GameObjectHandler.Address, out _);
}
});
foreach (var descriptor in _actorObjectService.PlayerDescriptors)
{
if (descriptor.Address != nint.Zero)
{
_trackedActors[(IntPtr)descriptor.Address] = 0;
}
}
}
public bool APIAvailable { get; private set; } = false;
@@ -130,6 +185,11 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
NotificationType.Error));
}
}
if (APIAvailable)
{
ScheduleTemporaryCollectionCleanup();
}
}
public void CheckModDirectory()
@@ -144,6 +204,56 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
}
}
private void ScheduleTemporaryCollectionCleanup()
{
if (Interlocked.Exchange(ref _performedInitialCleanup, 1) != 0)
return;
_ = Task.Run(CleanupTemporaryCollectionsAsync);
}
private async Task CleanupTemporaryCollectionsAsync()
{
if (!APIAvailable)
return;
try
{
var collections = await _dalamudUtil.RunOnFrameworkThread(() => _penumbraGetCollections.Invoke()).ConfigureAwait(false);
foreach (var (collectionId, name) in collections)
{
if (!IsLightlessCollectionName(name))
continue;
if (_activeTemporaryCollections.ContainsKey(collectionId))
continue;
Logger.LogDebug("Cleaning up stale temporary collection {CollectionName} ({CollectionId})", name, collectionId);
var deleteResult = await _dalamudUtil.RunOnFrameworkThread(() =>
{
var result = (PenumbraApiEc)_penumbraRemoveTemporaryCollection.Invoke(collectionId);
Logger.LogTrace("Cleanup RemoveTemporaryCollection result for {CollectionName} ({CollectionId}): {Result}", name, collectionId, result);
return result;
}).ConfigureAwait(false);
if (deleteResult == PenumbraApiEc.Success)
{
_activeTemporaryCollections.TryRemove(collectionId, out _);
}
else
{
Logger.LogDebug("Skipped removing temporary collection {CollectionName} ({CollectionId}). Result: {Result}", name, collectionId, deleteResult);
}
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to clean up Penumbra temporary collections");
}
}
private static bool IsLightlessCollectionName(string? name)
=> !string.IsNullOrEmpty(name) && name.StartsWith("Lightless_", StringComparison.Ordinal);
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
@@ -169,58 +279,91 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
}).ConfigureAwait(false);
}
public async Task ConvertTextureFiles(ILogger logger, Dictionary<string, string[]> textures, IProgress<(string, int)> progress, CancellationToken token)
public async Task ConvertTextureFiles(ILogger logger, IReadOnlyList<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? progress, CancellationToken token)
{
if (!APIAvailable) return;
if (!APIAvailable || jobs.Count == 0)
{
return;
}
_lightlessMediator.Publish(new HaltScanMessage(nameof(ConvertTextureFiles)));
int currentTexture = 0;
foreach (var texture in textures)
var totalJobs = jobs.Count;
var completedJobs = 0;
try
{
if (token.IsCancellationRequested) break;
progress.Report((texture.Key, ++currentTexture));
logger.LogInformation("Converting Texture {path} to {type}", texture.Key, TextureType.Bc7Tex);
var convertTask = _penumbraConvertTextureFile.Invoke(texture.Key, texture.Key, TextureType.Bc7Tex, mipMaps: true);
await convertTask.ConfigureAwait(false);
if (convertTask.IsCompletedSuccessfully && texture.Value.Any())
foreach (var job in jobs)
{
foreach (var duplicatedTexture in texture.Value)
if (token.IsCancellationRequested)
{
logger.LogInformation("Migrating duplicate {dup}", duplicatedTexture);
try
break;
}
progress?.Report(new TextureConversionProgress(completedJobs, totalJobs, job));
logger.LogInformation("Converting texture {Input} -> {Output} ({Target})", job.InputFile, job.OutputFile, job.TargetType);
var convertTask = _penumbraConvertTextureFile.Invoke(job.InputFile, job.OutputFile, job.TargetType, job.IncludeMipMaps);
await convertTask.ConfigureAwait(false);
if (convertTask.IsCompletedSuccessfully && job.DuplicateTargets is { Count: > 0 })
{
foreach (var duplicate in job.DuplicateTargets)
{
File.Copy(texture.Key, duplicatedTexture, overwrite: true);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to copy duplicate {dup}", duplicatedTexture);
logger.LogInformation("Synchronizing duplicate {Duplicate}", duplicate);
try
{
File.Copy(job.OutputFile, duplicate, overwrite: true);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to copy duplicate {Duplicate}", duplicate);
}
}
}
completedJobs++;
}
}
_lightlessMediator.Publish(new ResumeScanMessage(nameof(ConvertTextureFiles)));
await _dalamudUtil.RunOnFrameworkThread(async () =>
finally
{
var gameObject = await _dalamudUtil.CreateGameObjectAsync(await _dalamudUtil.GetPlayerPointerAsync().ConfigureAwait(false)).ConfigureAwait(false);
_penumbraRedraw.Invoke(gameObject!.ObjectIndex, setting: RedrawType.Redraw);
}).ConfigureAwait(false);
_lightlessMediator.Publish(new ResumeScanMessage(nameof(ConvertTextureFiles)));
}
if (completedJobs > 0 && !token.IsCancellationRequested)
{
await _dalamudUtil.RunOnFrameworkThread(async () =>
{
var player = await _dalamudUtil.GetPlayerPointerAsync().ConfigureAwait(false);
if (player == null)
{
return;
}
var gameObject = await _dalamudUtil.CreateGameObjectAsync(player).ConfigureAwait(false);
_penumbraRedraw.Invoke(gameObject!.ObjectIndex, setting: RedrawType.Redraw);
}).ConfigureAwait(false);
}
}
public async Task<Guid> CreateTemporaryCollectionAsync(ILogger logger, string uid)
{
if (!APIAvailable) return Guid.Empty;
return await _dalamudUtil.RunOnFrameworkThread(() =>
var (collectionId, collectionName) = await _dalamudUtil.RunOnFrameworkThread(() =>
{
var collName = "Lightless_" + uid;
_penumbraCreateNamedTemporaryCollection.Invoke(collName, collName, out var collId);
logger.LogTrace("Creating Temp Collection {collName}, GUID: {collId}", collName, collId);
return collId;
return (collId, collName);
}).ConfigureAwait(false);
if (collectionId != Guid.Empty)
{
_activeTemporaryCollections[collectionId] = collectionName;
}
return collectionId;
}
public async Task<Dictionary<string, HashSet<string>>?> GetCharacterData(ILogger logger, GameObjectHandler handler)
@@ -270,6 +413,10 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
var ret2 = _penumbraRemoveTemporaryCollection.Invoke(collId);
logger.LogTrace("[{applicationId}] RemoveTemporaryCollection: {ret2}", applicationId, ret2);
}).ConfigureAwait(false);
if (collId != Guid.Empty)
{
_activeTemporaryCollections.TryRemove(collId, out _);
}
}
public async Task<(string[] forward, string[][] reverse)> ResolvePathsAsync(string[] forward, string[] reverse)
@@ -277,6 +424,31 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
return await _penumbraResolvePaths.Invoke(forward, reverse).ConfigureAwait(false);
}
public async Task ConvertTextureFileDirectAsync(TextureConversionJob job, CancellationToken token)
{
if (!APIAvailable) return;
token.ThrowIfCancellationRequested();
await _penumbraConvertTextureFile.Invoke(job.InputFile, job.OutputFile, job.TargetType, job.IncludeMipMaps)
.ConfigureAwait(false);
if (job.DuplicateTargets is { Count: > 0 })
{
foreach (var duplicate in job.DuplicateTargets)
{
try
{
File.Copy(job.OutputFile, duplicate, overwrite: true);
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed to copy duplicate {Duplicate} for texture conversion", duplicate);
}
}
}
}
public async Task SetManipulationDataAsync(ILogger logger, Guid applicationId, Guid collId, string manipulationData)
{
if (!APIAvailable) return;
@@ -321,10 +493,26 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
private void ResourceLoaded(IntPtr ptr, string arg1, string arg2)
{
if (ptr != IntPtr.Zero && string.Compare(arg1, arg2, ignoreCase: true, System.Globalization.CultureInfo.InvariantCulture) != 0)
if (ptr == IntPtr.Zero)
return;
if (!_trackedActors.ContainsKey(ptr))
{
_lightlessMediator.Publish(new PenumbraResourceLoadMessage(ptr, arg1, arg2));
var descriptor = _actorObjectService.PlayerDescriptors.FirstOrDefault(d => d.Address == ptr);
if (descriptor.Address != nint.Zero)
{
_trackedActors[ptr] = 0;
}
else
{
return;
}
}
if (string.Compare(arg1, arg2, ignoreCase: true, System.Globalization.CultureInfo.InvariantCulture) == 0)
return;
_lightlessMediator.Publish(new PenumbraResourceLoadMessage(ptr, arg1, arg2));
}
private void PenumbraDispose()
@@ -338,6 +526,7 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
APIAvailable = true;
ModDirectory = _penumbraResolveModDir.Invoke();
_lightlessMediator.Publish(new PenumbraInitializedMessage());
ScheduleTemporaryCollectionCleanup();
_penumbraRedraw!.Invoke(0, setting: RedrawType.Redraw);
}
}

View File

@@ -0,0 +1,21 @@
using Penumbra.Api.Enums;
namespace LightlessSync.Interop.Ipc;
/// <summary>
/// Represents a single texture conversion request, including optional duplicate targets.
/// </summary>
public sealed record TextureConversionJob(
string InputFile,
string OutputFile,
TextureType TargetType,
bool IncludeMipMaps = true,
IReadOnlyList<string>? DuplicateTargets = null);
/// <summary>
/// Progress payload for a texture conversion batch.
/// </summary>
/// <param name="Completed">Number of completed conversions.</param>
/// <param name="Total">Total number of conversions scheduled.</param>
/// <param name="CurrentJob">The job currently being processed.</param>
public sealed record TextureConversionProgress(int Completed, int Total, TextureConversionJob CurrentJob);

View File

@@ -0,0 +1,14 @@
using LightlessSync.LightlessConfiguration.Configurations;
namespace LightlessSync.LightlessConfiguration;
public sealed class ChatConfigService : ConfigurationServiceBase<ChatConfig>
{
public const string ConfigName = "chatconfig.json";
public ChatConfigService(string configDir) : base(configDir)
{
}
public override string ConfigurationName => ConfigName;
}

View File

@@ -0,0 +1,15 @@
using System;
namespace LightlessSync.LightlessConfiguration.Configurations;
[Serializable]
public sealed class ChatConfig : ILightlessConfiguration
{
public int Version { get; set; } = 1;
public bool AutoEnableChatOnLogin { get; set; } = false;
public bool ShowRulesOverlayOnOpen { get; set; } = true;
public bool ShowMessageTimestamps { get; set; } = true;
public float ChatWindowOpacity { get; set; } = .97f;
public bool IsWindowPinned { get; set; } = false;
public bool AutoOpenChatOnPluginLoad { get; set; } = false;
}

View File

@@ -2,6 +2,7 @@ using Dalamud.Game.Text;
using LightlessSync.UtilsEnum.Enum;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.UI;
using LightlessSync.UI.Models;
using Microsoft.Extensions.Logging;
namespace LightlessSync.LightlessConfiguration.Configurations;
@@ -48,6 +49,7 @@ public class LightlessConfig : ILightlessConfiguration
public int DownloadSpeedLimitInBytes { get; set; } = 0;
public DownloadSpeeds DownloadSpeedType { get; set; } = DownloadSpeeds.MBps;
public bool PreferNotesOverNamesForVisible { get; set; } = false;
public VisiblePairSortMode VisiblePairSortMode { get; set; } = VisiblePairSortMode.Default;
public float ProfileDelay { get; set; } = 1.5f;
public bool ProfilePopoutRight { get; set; } = false;
public bool ProfilesAllowNsfw { get; set; } = false;

View File

@@ -4,6 +4,7 @@ public class PlayerPerformanceConfig : ILightlessConfiguration
{
public int Version { get; set; } = 1;
public bool ShowPerformanceIndicator { get; set; } = true;
public bool ShowPerformanceUsageNextToName { get; set; } = false;
public bool WarnOnExceedingThresholds { get; set; } = true;
public bool WarnOnPreferredPermissionsExceedingThresholds { get; set; } = false;
public int VRAMSizeWarningThresholdMiB { get; set; } = 375;
@@ -16,4 +17,9 @@ public class PlayerPerformanceConfig : ILightlessConfiguration
public bool PauseInInstanceDuty { get; set; } = false;
public bool PauseWhilePerforming { get; set; } = true;
public bool PauseInCombat { get; set; } = true;
public bool EnableNonIndexTextureMipTrim { get; set; } = false;
public bool EnableIndexTextureDownscale { get; set; } = false;
public int TextureDownscaleMaxDimension { get; set; } = 2048;
public bool OnlyDownscaleUncompressedTextures { get; set; } = true;
public bool KeepOriginalTextureFiles { get; set; } = false;
}

View File

@@ -27,7 +27,6 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="DalamudPackager" Version="13.0.0" />
<PackageReference Include="Downloader" Version="4.0.3" />
<PackageReference Include="K4os.Compression.LZ4.Legacy" Version="1.3.8" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.212">
@@ -39,7 +38,6 @@
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.3" />
<PackageReference Include="Glamourer.Api" Version="2.6.0" />
<PackageReference Include="NReco.Logging.File" Version="1.2.2" />
<PackageReference Include="Penumbra.String" Version="1.0.5" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.7.0.110445">
<PrivateAssets>all</PrivateAssets>
@@ -77,7 +75,23 @@
<ItemGroup>
<ProjectReference Include="..\LightlessAPI\LightlessSyncAPI\LightlessSync.API.csproj" />
<ProjectReference Include="..\PenumbraAPI\Penumbra.Api.csproj" />
<ProjectReference Include="..\Penumbra.Api\Penumbra.Api.csproj" />
<ProjectReference Include="..\Penumbra.GameData\Penumbra.GameData.csproj" />
<ProjectReference Include="..\Penumbra.String\Penumbra.String.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="OtterTex">
<HintPath>lib\OtterTex.dll</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<None Include="lib\DirectXTexC.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<TargetPath>DirectXTexC.dll</TargetPath>
</None>
</ItemGroup>
</Project>

View File

@@ -1,4 +1,7 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data;
using System;
using System.Collections.Generic;
using System.Linq;
namespace LightlessSync.PlayerData.Data;
@@ -13,37 +16,42 @@ public class FileReplacementDataComparer : IEqualityComparer<FileReplacementData
public bool Equals(FileReplacementData? x, FileReplacementData? y)
{
if (x == null || y == null) return false;
return x.Hash.Equals(y.Hash) && CompareHashSets(x.GamePaths.ToHashSet(StringComparer.Ordinal), y.GamePaths.ToHashSet(StringComparer.Ordinal)) && string.Equals(x.FileSwapPath, y.FileSwapPath, StringComparison.Ordinal);
if (ReferenceEquals(x, y))
return true;
if (x is null || y is null)
return false;
return string.Equals(x.Hash, y.Hash, StringComparison.OrdinalIgnoreCase)
&& ComparePathSets(x.GamePaths, y.GamePaths)
&& string.Equals(x.FileSwapPath ?? string.Empty, y.FileSwapPath ?? string.Empty, StringComparison.Ordinal);
}
public int GetHashCode(FileReplacementData obj)
{
return HashCode.Combine(obj.Hash.GetHashCode(StringComparison.OrdinalIgnoreCase), GetOrderIndependentHashCode(obj.GamePaths), StringComparer.Ordinal.GetHashCode(obj.FileSwapPath));
if (obj is null)
return 0;
var hash = StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Hash ?? string.Empty);
hash = HashCode.Combine(hash, GetSetHashCode(obj.GamePaths));
hash = HashCode.Combine(hash, StringComparer.OrdinalIgnoreCase.GetHashCode(obj.FileSwapPath ?? string.Empty));
return hash;
}
private static bool CompareHashSets(HashSet<string> list1, HashSet<string> list2)
private static bool ComparePathSets(IEnumerable<string> first, IEnumerable<string> second)
{
if (list1.Count != list2.Count)
return false;
for (int i = 0; i < list1.Count; i++)
{
if (!string.Equals(list1.ElementAt(i), list2.ElementAt(i), StringComparison.OrdinalIgnoreCase))
return false;
}
return true;
var left = new HashSet<string>(first ?? Enumerable.Empty<string>(), StringComparer.OrdinalIgnoreCase);
var right = new HashSet<string>(second ?? Enumerable.Empty<string>(), StringComparer.OrdinalIgnoreCase);
return left.SetEquals(right);
}
private static int GetOrderIndependentHashCode<T>(IEnumerable<T> source) where T : notnull
private static int GetSetHashCode(IEnumerable<string> paths)
{
int hash = 0;
foreach (T element in source)
foreach (var element in paths ?? Enumerable.Empty<string>())
{
hash = unchecked(hash +
EqualityComparer<T>.Default.GetHashCode(element));
hash = unchecked(hash + StringComparer.OrdinalIgnoreCase.GetHashCode(element));
}
return hash;
}
}

View File

@@ -2,6 +2,7 @@ using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.TextureCompression;
using LightlessSync.WebAPI.Files;
using Microsoft.Extensions.Logging;
@@ -9,13 +10,15 @@ namespace LightlessSync.PlayerData.Factories;
public class FileDownloadManagerFactory
{
private readonly FileCacheManager _fileCacheManager;
private readonly FileCompactor _fileCompactor;
private readonly FileTransferOrchestrator _fileTransferOrchestrator;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMediator _lightlessMediator;
private readonly FileTransferOrchestrator _fileTransferOrchestrator;
private readonly FileCacheManager _fileCacheManager;
private readonly FileCompactor _fileCompactor;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly LightlessConfigService _configService;
private readonly TextureDownscaleService _textureDownscaleService;
private readonly TextureMetadataHelper _textureMetadataHelper;
public FileDownloadManagerFactory(
ILoggerFactory loggerFactory,
@@ -24,7 +27,9 @@ public class FileDownloadManagerFactory
FileCacheManager fileCacheManager,
FileCompactor fileCompactor,
PairProcessingLimiter pairProcessingLimiter,
LightlessConfigService configService)
LightlessConfigService configService,
TextureDownscaleService textureDownscaleService,
TextureMetadataHelper textureMetadataHelper)
{
_loggerFactory = loggerFactory;
_lightlessMediator = lightlessMediator;
@@ -33,6 +38,8 @@ public class FileDownloadManagerFactory
_fileCompactor = fileCompactor;
_pairProcessingLimiter = pairProcessingLimiter;
_configService = configService;
_textureDownscaleService = textureDownscaleService;
_textureMetadataHelper = textureMetadataHelper;
}
public FileDownloadManager Create()
@@ -44,6 +51,8 @@ public class FileDownloadManagerFactory
_fileCacheManager,
_fileCompactor,
_pairProcessingLimiter,
_configService);
_configService,
_textureDownscaleService,
_textureMetadataHelper);
}
}

View File

@@ -2,29 +2,40 @@
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace LightlessSync.PlayerData.Factories;
public class GameObjectHandlerFactory
{
private readonly DalamudUtilService _dalamudUtilService;
private readonly IServiceProvider _serviceProvider;
private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMediator _lightlessMediator;
private readonly PerformanceCollectorService _performanceCollectorService;
public GameObjectHandlerFactory(ILoggerFactory loggerFactory, PerformanceCollectorService performanceCollectorService, LightlessMediator lightlessMediator,
DalamudUtilService dalamudUtilService)
public GameObjectHandlerFactory(
ILoggerFactory loggerFactory,
PerformanceCollectorService performanceCollectorService,
LightlessMediator lightlessMediator,
IServiceProvider serviceProvider)
{
_loggerFactory = loggerFactory;
_performanceCollectorService = performanceCollectorService;
_lightlessMediator = lightlessMediator;
_dalamudUtilService = dalamudUtilService;
_serviceProvider = serviceProvider;
}
public async Task<GameObjectHandler> Create(ObjectKind objectKind, Func<nint> getAddressFunc, bool isWatched = false)
{
return await _dalamudUtilService.RunOnFrameworkThread(() => new GameObjectHandler(_loggerFactory.CreateLogger<GameObjectHandler>(),
_performanceCollectorService, _lightlessMediator, _dalamudUtilService, objectKind, getAddressFunc, isWatched)).ConfigureAwait(false);
var dalamudUtilService = _serviceProvider.GetRequiredService<DalamudUtilService>();
return await dalamudUtilService.RunOnFrameworkThread(() => new GameObjectHandler(
_loggerFactory.CreateLogger<GameObjectHandler>(),
_performanceCollectorService,
_lightlessMediator,
dalamudUtilService,
objectKind,
getAddressFunc,
isWatched)).ConfigureAwait(false);
}
}

View File

@@ -1,35 +1,86 @@
using LightlessSync.API.Dto.User;
using System;
using System.Collections.Generic;
using System.Linq;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto.User;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Models;
using Microsoft.Extensions.Logging;
using LightlessSync.WebAPI;
namespace LightlessSync.PlayerData.Factories;
public class PairFactory
{
private readonly PairHandlerFactory _cachedPlayerFactory;
private readonly PairLedger _pairLedger;
private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMediator _lightlessMediator;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly Lazy<ServerConfigurationManager> _serverConfigurationManager;
private readonly Lazy<ApiController> _apiController;
public PairFactory(ILoggerFactory loggerFactory, PairHandlerFactory cachedPlayerFactory,
LightlessMediator lightlessMediator, ServerConfigurationManager serverConfigurationManager)
public PairFactory(
ILoggerFactory loggerFactory,
PairLedger pairLedger,
LightlessMediator lightlessMediator,
Lazy<ServerConfigurationManager> serverConfigurationManager,
Lazy<ApiController> apiController)
{
_loggerFactory = loggerFactory;
_cachedPlayerFactory = cachedPlayerFactory;
_pairLedger = pairLedger;
_lightlessMediator = lightlessMediator;
_serverConfigurationManager = serverConfigurationManager;
_apiController = apiController;
}
public Pair Create(UserFullPairDto userPairDto)
{
return new Pair(_loggerFactory.CreateLogger<Pair>(), userPairDto, _cachedPlayerFactory, _lightlessMediator, _serverConfigurationManager);
return CreateInternal(userPairDto);
}
public Pair Create(UserPairDto userPairDto)
{
return new Pair(_loggerFactory.CreateLogger<Pair>(), new(userPairDto.User, userPairDto.IndividualPairStatus, [], userPairDto.OwnPermissions, userPairDto.OtherPermissions),
_cachedPlayerFactory, _lightlessMediator, _serverConfigurationManager);
var full = new UserFullPairDto(
userPairDto.User,
userPairDto.IndividualPairStatus,
new List<string>(),
userPairDto.OwnPermissions,
userPairDto.OtherPermissions);
return CreateInternal(full);
}
}
public Pair? Create(PairDisplayEntry entry)
{
var dto = new UserFullPairDto(
entry.User,
entry.PairStatus ?? IndividualPairStatus.None,
entry.Groups.Select(g => g.Group.GID).Distinct(StringComparer.Ordinal).ToList(),
entry.SelfPermissions,
entry.OtherPermissions);
return CreateInternal(dto);
}
public Pair? Create(PairUniqueIdentifier ident)
{
if (!_pairLedger.TryGetEntry(ident, out var entry) || entry is null)
{
return null;
}
return Create(entry);
}
private Pair CreateInternal(UserFullPairDto dto)
{
return new Pair(
_loggerFactory.CreateLogger<Pair>(),
dto,
_pairLedger,
_lightlessMediator,
_serverConfigurationManager.Value,
_apiController);
}
}

View File

@@ -1,55 +0,0 @@
using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace LightlessSync.PlayerData.Factories;
public class PairHandlerFactory
{
private readonly DalamudUtilService _dalamudUtilService;
private readonly FileCacheManager _fileCacheManager;
private readonly FileDownloadManagerFactory _fileDownloadManagerFactory;
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly IpcManager _ipcManager;
private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMediator _lightlessMediator;
private readonly PlayerPerformanceService _playerPerformanceService;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ServerConfigurationManager _serverConfigManager;
private readonly PluginWarningNotificationService _pluginWarningNotificationManager;
public PairHandlerFactory(ILoggerFactory loggerFactory, GameObjectHandlerFactory gameObjectHandlerFactory, IpcManager ipcManager,
FileDownloadManagerFactory fileDownloadManagerFactory, DalamudUtilService dalamudUtilService,
PluginWarningNotificationService pluginWarningNotificationManager, IHostApplicationLifetime hostApplicationLifetime,
FileCacheManager fileCacheManager, LightlessMediator lightlessMediator, PlayerPerformanceService playerPerformanceService,
PairProcessingLimiter pairProcessingLimiter,
ServerConfigurationManager serverConfigManager)
{
_loggerFactory = loggerFactory;
_gameObjectHandlerFactory = gameObjectHandlerFactory;
_ipcManager = ipcManager;
_fileDownloadManagerFactory = fileDownloadManagerFactory;
_dalamudUtilService = dalamudUtilService;
_pluginWarningNotificationManager = pluginWarningNotificationManager;
_hostApplicationLifetime = hostApplicationLifetime;
_fileCacheManager = fileCacheManager;
_lightlessMediator = lightlessMediator;
_playerPerformanceService = playerPerformanceService;
_pairProcessingLimiter = pairProcessingLimiter;
_serverConfigManager = serverConfigManager;
}
public PairHandler Create(Pair pair)
{
return new PairHandler(_loggerFactory.CreateLogger<PairHandler>(), pair, _gameObjectHandlerFactory,
_ipcManager, _fileDownloadManagerFactory.Create(), _pluginWarningNotificationManager, _dalamudUtilService, _hostApplicationLifetime,
_fileCacheManager, _lightlessMediator, _playerPerformanceService, _pairProcessingLimiter, _serverConfigManager);
}
}

View File

@@ -1,775 +0,0 @@
using LightlessSync.API.Data;
using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
using LightlessSync.Services.Events;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Utils;
using LightlessSync.WebAPI.Files;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Diagnostics;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.PlayerData.Handlers;
public sealed class PairHandler : DisposableMediatorSubscriberBase
{
private sealed record CombatData(Guid ApplicationId, CharacterData CharacterData, bool Forced);
private readonly DalamudUtilService _dalamudUtil;
private readonly FileDownloadManager _downloadManager;
private readonly FileCacheManager _fileDbManager;
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
private readonly IpcManager _ipcManager;
private readonly IHostApplicationLifetime _lifetime;
private readonly PlayerPerformanceService _playerPerformanceService;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ServerConfigurationManager _serverConfigManager;
private readonly PluginWarningNotificationService _pluginWarningNotificationManager;
private CancellationTokenSource? _applicationCancellationTokenSource = new();
private Guid _applicationId;
private Task? _applicationTask;
private CharacterData? _cachedData = null;
private GameObjectHandler? _charaHandler;
private readonly Dictionary<ObjectKind, Guid?> _customizeIds = [];
private CombatData? _dataReceivedInDowntime;
private CancellationTokenSource? _downloadCancellationTokenSource = new();
private bool _forceApplyMods = false;
private bool _isVisible;
private Guid _penumbraCollection;
private bool _redrawOnNextApplication = false;
public PairHandler(ILogger<PairHandler> logger, Pair pair,
GameObjectHandlerFactory gameObjectHandlerFactory,
IpcManager ipcManager, FileDownloadManager transferManager,
PluginWarningNotificationService pluginWarningNotificationManager,
DalamudUtilService dalamudUtil, IHostApplicationLifetime lifetime,
FileCacheManager fileDbManager, LightlessMediator mediator,
PlayerPerformanceService playerPerformanceService,
PairProcessingLimiter pairProcessingLimiter,
ServerConfigurationManager serverConfigManager) : base(logger, mediator)
{
Pair = pair;
_gameObjectHandlerFactory = gameObjectHandlerFactory;
_ipcManager = ipcManager;
_downloadManager = transferManager;
_pluginWarningNotificationManager = pluginWarningNotificationManager;
_dalamudUtil = dalamudUtil;
_lifetime = lifetime;
_fileDbManager = fileDbManager;
_playerPerformanceService = playerPerformanceService;
_pairProcessingLimiter = pairProcessingLimiter;
_serverConfigManager = serverConfigManager;
_penumbraCollection = _ipcManager.Penumbra.CreateTemporaryCollectionAsync(logger, Pair.UserData.UID).ConfigureAwait(false).GetAwaiter().GetResult();
Mediator.Subscribe<FrameworkUpdateMessage>(this, (_) => FrameworkUpdate());
Mediator.Subscribe<ZoneSwitchStartMessage>(this, (_) =>
{
_downloadCancellationTokenSource?.CancelDispose();
_charaHandler?.Invalidate();
IsVisible = false;
});
Mediator.Subscribe<PenumbraInitializedMessage>(this, (_) =>
{
_penumbraCollection = _ipcManager.Penumbra.CreateTemporaryCollectionAsync(logger, Pair.UserData.UID).ConfigureAwait(false).GetAwaiter().GetResult();
if (!IsVisible && _charaHandler != null)
{
PlayerName = string.Empty;
_charaHandler.Dispose();
_charaHandler = null;
}
});
Mediator.Subscribe<ClassJobChangedMessage>(this, (msg) =>
{
if (msg.GameObjectHandler == _charaHandler)
{
_redrawOnNextApplication = true;
}
});
Mediator.Subscribe<CombatEndMessage>(this, (msg) =>
{
EnableSync();
});
Mediator.Subscribe<CombatStartMessage>(this, _ =>
{
DisableSync();
});
Mediator.Subscribe<PerformanceEndMessage>(this, (msg) =>
{
EnableSync();
});
Mediator.Subscribe<PerformanceStartMessage>(this, _ =>
{
DisableSync();
});
Mediator.Subscribe<InstanceOrDutyStartMessage>(this, _ =>
{
DisableSync();
});
Mediator.Subscribe<InstanceOrDutyEndMessage>(this, (msg) =>
{
EnableSync();
});
LastAppliedDataBytes = -1;
}
public bool IsVisible
{
get => _isVisible;
private set
{
if (_isVisible != value)
{
_isVisible = value;
string text = "User Visibility Changed, now: " + (_isVisible ? "Is Visible" : "Is not Visible");
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler),
EventSeverity.Informational, text)));
Mediator.Publish(new RefreshUiMessage());
Mediator.Publish(new VisibilityChange());
}
}
}
public long LastAppliedDataBytes { get; private set; }
public Pair Pair { get; private set; }
public nint PlayerCharacter => _charaHandler?.Address ?? nint.Zero;
public unsafe uint PlayerCharacterId => (_charaHandler?.Address ?? nint.Zero) == nint.Zero
? uint.MaxValue
: ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)_charaHandler!.Address)->EntityId;
public string? PlayerName { get; private set; }
public string PlayerNameHash => Pair.Ident;
public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = false)
{
if (_dalamudUtil.IsInCombat)
{
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning,
"Cannot apply character data: you are in combat, deferring application")));
Logger.LogDebug("[BASE-{appBase}] Received data but player is in combat", applicationBase);
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
SetUploading(isUploading: false);
return;
}
if (_dalamudUtil.IsPerforming)
{
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning,
"Cannot apply character data: you are performing music, deferring application")));
Logger.LogDebug("[BASE-{appBase}] Received data but player is performing", applicationBase);
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
SetUploading(isUploading: false);
return;
}
if (_dalamudUtil.IsInInstance)
{
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning,
"Cannot apply character data: you are in an instance, deferring application")));
Logger.LogDebug("[BASE-{appBase}] Received data but player is in instance", applicationBase);
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
SetUploading(isUploading: false);
return;
}
if (_charaHandler == null || (PlayerCharacter == IntPtr.Zero))
{
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning,
"Cannot apply character data: Receiving Player is in an invalid state, deferring application")));
Logger.LogDebug("[BASE-{appBase}] Received data but player was in invalid state, charaHandlerIsNull: {charaIsNull}, playerPointerIsNull: {ptrIsNull}",
applicationBase, _charaHandler == null, PlayerCharacter == IntPtr.Zero);
var hasDiffMods = characterData.CheckUpdatedData(applicationBase, _cachedData, Logger,
this, forceApplyCustomization, forceApplyMods: false)
.Any(p => p.Value.Contains(PlayerChanges.ModManip) || p.Value.Contains(PlayerChanges.ModFiles));
_forceApplyMods = hasDiffMods || _forceApplyMods || (PlayerCharacter == IntPtr.Zero && _cachedData == null);
_cachedData = characterData;
Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods);
return;
}
SetUploading(isUploading: false);
Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, this, forceApplyCustomization, _forceApplyMods);
Logger.LogDebug("[BASE-{appbase}] Hash for data is {newHash}, current cache hash is {oldHash}", applicationBase, characterData.DataHash.Value, _cachedData?.DataHash.Value ?? "NODATA");
if (string.Equals(characterData.DataHash.Value, _cachedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal) && !forceApplyCustomization) return;
if (_dalamudUtil.IsInCutscene || _dalamudUtil.IsInGpose || !_ipcManager.Penumbra.APIAvailable || !_ipcManager.Glamourer.APIAvailable)
{
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning,
"Cannot apply character data: you are in GPose, a Cutscene or Penumbra/Glamourer is not available")));
Logger.LogInformation("[BASE-{appbase}] Application of data for {player} while in cutscene/gpose or Penumbra/Glamourer unavailable, returning", applicationBase, this);
return;
}
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Informational,
"Applying Character Data")));
_forceApplyMods |= forceApplyCustomization;
var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, forceApplyCustomization, _forceApplyMods);
if (_charaHandler != null && _forceApplyMods)
{
_forceApplyMods = false;
}
if (_redrawOnNextApplication && charaDataToUpdate.TryGetValue(ObjectKind.Player, out var player))
{
player.Add(PlayerChanges.ForcedRedraw);
_redrawOnNextApplication = false;
}
if (charaDataToUpdate.TryGetValue(ObjectKind.Player, out var playerChanges))
{
_pluginWarningNotificationManager.NotifyForMissingPlugins(Pair.UserData, PlayerName!, playerChanges);
}
Logger.LogDebug("[BASE-{appbase}] Downloading and applying character for {name}", applicationBase, this);
DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate);
}
public override string ToString()
{
return Pair == null
? base.ToString() ?? string.Empty
: Pair.UserData.AliasOrUID + ":" + PlayerName + ":" + (PlayerCharacter != nint.Zero ? "HasChar" : "NoChar");
}
internal void SetUploading(bool isUploading = true)
{
Logger.LogTrace("Setting {this} uploading {uploading}", this, isUploading);
if (_charaHandler != null)
{
Mediator.Publish(new PlayerUploadingMessage(_charaHandler, isUploading));
}
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
SetUploading(isUploading: false);
var name = PlayerName;
Logger.LogDebug("Disposing {name} ({user})", name, Pair);
try
{
Guid applicationId = Guid.NewGuid();
_applicationCancellationTokenSource?.CancelDispose();
_applicationCancellationTokenSource = null;
_downloadCancellationTokenSource?.CancelDispose();
_downloadCancellationTokenSource = null;
_downloadManager.Dispose();
_charaHandler?.Dispose();
_charaHandler = null;
if (!string.IsNullOrEmpty(name))
{
Mediator.Publish(new EventMessage(new Event(name, Pair.UserData, nameof(PairHandler), EventSeverity.Informational, "Disposing User")));
}
if (_lifetime.ApplicationStopping.IsCancellationRequested) return;
if (_dalamudUtil is { IsZoning: false, IsInCutscene: false } && !string.IsNullOrEmpty(name))
{
Logger.LogTrace("[{applicationId}] Restoring state for {name} ({OnlineUser})", applicationId, name, Pair.UserPair);
Logger.LogDebug("[{applicationId}] Removing Temp Collection for {name} ({user})", applicationId, name, Pair.UserPair);
_ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, _penumbraCollection).GetAwaiter().GetResult();
if (!IsVisible)
{
Logger.LogDebug("[{applicationId}] Restoring Glamourer for {name} ({user})", applicationId, name, Pair.UserPair);
_ipcManager.Glamourer.RevertByNameAsync(Logger, name, applicationId).GetAwaiter().GetResult();
}
else
{
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(60));
Logger.LogInformation("[{applicationId}] CachedData is null {isNull}, contains things: {contains}", applicationId, _cachedData == null, _cachedData?.FileReplacements.Any() ?? false);
foreach (KeyValuePair<ObjectKind, List<FileReplacementData>> item in _cachedData?.FileReplacements ?? [])
{
try
{
RevertCustomizationDataAsync(item.Key, name, applicationId, cts.Token).GetAwaiter().GetResult();
}
catch (InvalidOperationException ex)
{
Logger.LogWarning(ex, "Failed disposing player (not present anymore?)");
break;
}
}
}
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Error on disposal of {name}", name);
}
finally
{
PlayerName = null;
_cachedData = null;
Logger.LogDebug("Disposing {name} complete", name);
}
}
private async Task ApplyCustomizationDataAsync(Guid applicationId, KeyValuePair<ObjectKind, HashSet<PlayerChanges>> changes, CharacterData charaData, CancellationToken token)
{
if (PlayerCharacter == nint.Zero) return;
var ptr = PlayerCharacter;
var handler = changes.Key switch
{
ObjectKind.Player => _charaHandler!,
ObjectKind.Companion => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetCompanionPtr(ptr), isWatched: false).ConfigureAwait(false),
ObjectKind.MinionOrMount => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetMinionOrMountPtr(ptr), isWatched: false).ConfigureAwait(false),
ObjectKind.Pet => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetPetPtr(ptr), isWatched: false).ConfigureAwait(false),
_ => throw new NotSupportedException("ObjectKind not supported: " + changes.Key)
};
try
{
if (handler.Address == nint.Zero)
{
return;
}
Logger.LogDebug("[{applicationId}] Applying Customization Data for {handler}", applicationId, handler);
await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handler, applicationId, 30000, token).ConfigureAwait(false);
token.ThrowIfCancellationRequested();
foreach (var change in changes.Value.OrderBy(p => (int)p))
{
Logger.LogDebug("[{applicationId}] Processing {change} for {handler}", applicationId, change, handler);
switch (change)
{
case PlayerChanges.Customize:
if (charaData.CustomizePlusData.TryGetValue(changes.Key, out var customizePlusData))
{
_customizeIds[changes.Key] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(handler.Address, customizePlusData).ConfigureAwait(false);
}
else if (_customizeIds.TryGetValue(changes.Key, out var customizeId))
{
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
_customizeIds.Remove(changes.Key);
}
break;
case PlayerChanges.Heels:
await _ipcManager.Heels.SetOffsetForPlayerAsync(handler.Address, charaData.HeelsData).ConfigureAwait(false);
break;
case PlayerChanges.Honorific:
await _ipcManager.Honorific.SetTitleAsync(handler.Address, charaData.HonorificData).ConfigureAwait(false);
break;
case PlayerChanges.Glamourer:
if (charaData.GlamourerData.TryGetValue(changes.Key, out var glamourerData))
{
await _ipcManager.Glamourer.ApplyAllAsync(Logger, handler, glamourerData, applicationId, token).ConfigureAwait(false);
}
break;
case PlayerChanges.Moodles:
await _ipcManager.Moodles.SetStatusAsync(handler.Address, charaData.MoodlesData).ConfigureAwait(false);
break;
case PlayerChanges.PetNames:
await _ipcManager.PetNames.SetPlayerData(handler.Address, charaData.PetNamesData).ConfigureAwait(false);
break;
case PlayerChanges.ForcedRedraw:
await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false);
break;
default:
break;
}
token.ThrowIfCancellationRequested();
}
}
finally
{
if (handler != _charaHandler) handler.Dispose();
}
}
private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData)
{
if (!updatedData.Any())
{
Logger.LogDebug("[BASE-{appBase}] Nothing to update for {obj}", applicationBase, this);
return;
}
var updateModdedPaths = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModFiles));
var updateManip = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModManip));
_downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource();
var downloadToken = _downloadCancellationTokenSource.Token;
_ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, downloadToken).ConfigureAwait(false);
}
private Task? _pairDownloadTask;
private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData,
bool updateModdedPaths, bool updateManip, CancellationToken downloadToken)
{
await using var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false);
Dictionary<(string GamePath, string? Hash), string> moddedPaths = [];
if (updateModdedPaths)
{
int attempts = 0;
List<FileReplacementData> toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken);
while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested)
{
if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted)
{
Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData);
await _pairDownloadTask.ConfigureAwait(false);
}
Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData);
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Informational,
$"Starting download for {toDownloadReplacements.Count} files")));
var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false);
if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles))
{
_downloadManager.ClearDownload();
return;
}
_pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false));
await _pairDownloadTask.ConfigureAwait(false);
if (downloadToken.IsCancellationRequested)
{
Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase);
return;
}
toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken);
if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal))))
{
break;
}
await Task.Delay(TimeSpan.FromSeconds(2), downloadToken).ConfigureAwait(false);
}
if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false))
return;
}
downloadToken.ThrowIfCancellationRequested();
var appToken = _applicationCancellationTokenSource?.Token;
while ((!_applicationTask?.IsCompleted ?? false)
&& !downloadToken.IsCancellationRequested
&& (!appToken?.IsCancellationRequested ?? false))
{
// block until current application is done
Logger.LogDebug("[BASE-{appBase}] Waiting for current data application (Id: {id}) for player ({handler}) to finish", applicationBase, _applicationId, PlayerName);
await Task.Delay(250).ConfigureAwait(false);
}
if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false)) return;
_applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource();
var token = _applicationCancellationTokenSource.Token;
_applicationTask = ApplyCharacterDataAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, token);
}
private async Task ApplyCharacterDataAsync(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData, bool updateModdedPaths, bool updateManip,
Dictionary<(string GamePath, string? Hash), string> moddedPaths, CancellationToken token)
{
try
{
_applicationId = Guid.NewGuid();
Logger.LogDebug("[BASE-{applicationId}] Starting application task for {this}: {appId}", applicationBase, this, _applicationId);
Logger.LogDebug("[{applicationId}] Waiting for initial draw for for {handler}", _applicationId, _charaHandler);
await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, _charaHandler!, _applicationId, 30000, token).ConfigureAwait(false);
token.ThrowIfCancellationRequested();
if (updateModdedPaths)
{
// ensure collection is set
var objIndex = await _dalamudUtil.RunOnFrameworkThread(() => _charaHandler!.GetGameObject()!.ObjectIndex).ConfigureAwait(false);
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, _penumbraCollection, objIndex).ConfigureAwait(false);
await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, _applicationId, _penumbraCollection,
moddedPaths.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)).ConfigureAwait(false);
LastAppliedDataBytes = -1;
foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists))
{
if (LastAppliedDataBytes == -1) LastAppliedDataBytes = 0;
LastAppliedDataBytes += path.Length;
}
}
if (updateManip)
{
await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, _applicationId, _penumbraCollection, charaData.ManipulationData).ConfigureAwait(false);
}
token.ThrowIfCancellationRequested();
foreach (var kind in updatedData)
{
await ApplyCustomizationDataAsync(_applicationId, kind, charaData, token).ConfigureAwait(false);
token.ThrowIfCancellationRequested();
}
_cachedData = charaData;
Logger.LogDebug("[{applicationId}] Application finished", _applicationId);
}
catch (Exception ex)
{
if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException))
{
IsVisible = false;
_forceApplyMods = true;
_cachedData = charaData;
Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId);
}
else
{
Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId);
}
}
}
private void FrameworkUpdate()
{
if (string.IsNullOrEmpty(PlayerName))
{
var pc = _dalamudUtil.FindPlayerByNameHash(Pair.Ident);
if (pc == default((string, nint))) return;
Logger.LogDebug("One-Time Initializing {this}", this);
Initialize(pc.Name);
Logger.LogDebug("One-Time Initialized {this}", this);
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Informational,
$"Initializing User For Character {pc.Name}")));
}
if (_charaHandler?.Address != nint.Zero && !IsVisible)
{
Guid appData = Guid.NewGuid();
IsVisible = true;
if (_cachedData != null)
{
Logger.LogTrace("[BASE-{appBase}] {this} visibility changed, now: {visi}, cached data exists", appData, this, IsVisible);
_ = Task.Run(() =>
{
ApplyCharacterData(appData, _cachedData!, forceApplyCustomization: true);
});
}
else
{
Logger.LogTrace("{this} visibility changed, now: {visi}, no cached data exists", this, IsVisible);
}
}
else if (_charaHandler?.Address == nint.Zero && IsVisible)
{
IsVisible = false;
_charaHandler.Invalidate();
_downloadCancellationTokenSource?.CancelDispose();
_downloadCancellationTokenSource = null;
Logger.LogTrace("{this} visibility changed, now: {visi}", this, IsVisible);
}
}
private void Initialize(string name)
{
PlayerName = name;
_charaHandler = _gameObjectHandlerFactory.Create(ObjectKind.Player, () => _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Pair.Ident), isWatched: false).GetAwaiter().GetResult();
_serverConfigManager.AutoPopulateNoteForUid(Pair.UserData.UID, name);
Mediator.Subscribe<HonorificReadyMessage>(this, async (_) =>
{
if (string.IsNullOrEmpty(_cachedData?.HonorificData)) return;
Logger.LogTrace("Reapplying Honorific data for {this}", this);
await _ipcManager.Honorific.SetTitleAsync(PlayerCharacter, _cachedData.HonorificData).ConfigureAwait(false);
});
Mediator.Subscribe<PetNamesReadyMessage>(this, async (_) =>
{
if (string.IsNullOrEmpty(_cachedData?.PetNamesData)) return;
Logger.LogTrace("Reapplying Pet Names data for {this}", this);
await _ipcManager.PetNames.SetPlayerData(PlayerCharacter, _cachedData.PetNamesData).ConfigureAwait(false);
});
_ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, _penumbraCollection, _charaHandler.GetGameObject()!.ObjectIndex).GetAwaiter().GetResult();
}
private async Task RevertCustomizationDataAsync(ObjectKind objectKind, string name, Guid applicationId, CancellationToken cancelToken)
{
nint address = _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Pair.Ident);
if (address == nint.Zero) return;
Logger.LogDebug("[{applicationId}] Reverting all Customization for {alias}/{name} {objectKind}", applicationId, Pair.UserData.AliasOrUID, name, objectKind);
if (_customizeIds.TryGetValue(objectKind, out var customizeId))
{
_customizeIds.Remove(objectKind);
}
if (objectKind == ObjectKind.Player)
{
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Player, () => address, isWatched: false).ConfigureAwait(false);
tempHandler.CompareNameAndThrow(name);
Logger.LogDebug("[{applicationId}] Restoring Customization and Equipment for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
tempHandler.CompareNameAndThrow(name);
Logger.LogDebug("[{applicationId}] Restoring Heels for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
await _ipcManager.Heels.RestoreOffsetForPlayerAsync(address).ConfigureAwait(false);
tempHandler.CompareNameAndThrow(name);
Logger.LogDebug("[{applicationId}] Restoring C+ for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
tempHandler.CompareNameAndThrow(name);
Logger.LogDebug("[{applicationId}] Restoring Honorific for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
await _ipcManager.Honorific.ClearTitleAsync(address).ConfigureAwait(false);
Logger.LogDebug("[{applicationId}] Restoring Moodles for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
await _ipcManager.Moodles.RevertStatusAsync(address).ConfigureAwait(false);
Logger.LogDebug("[{applicationId}] Restoring Pet Nicknames for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
await _ipcManager.PetNames.ClearPlayerData(address).ConfigureAwait(false);
}
else if (objectKind == ObjectKind.MinionOrMount)
{
var minionOrMount = await _dalamudUtil.GetMinionOrMountAsync(address).ConfigureAwait(false);
if (minionOrMount != nint.Zero)
{
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.MinionOrMount, () => minionOrMount, isWatched: false).ConfigureAwait(false);
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
}
}
else if (objectKind == ObjectKind.Pet)
{
var pet = await _dalamudUtil.GetPetAsync(address).ConfigureAwait(false);
if (pet != nint.Zero)
{
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Pet, () => pet, isWatched: false).ConfigureAwait(false);
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
}
}
else if (objectKind == ObjectKind.Companion)
{
var companion = await _dalamudUtil.GetCompanionAsync(address).ConfigureAwait(false);
if (companion != nint.Zero)
{
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Pet, () => companion, isWatched: false).ConfigureAwait(false);
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
}
}
}
private List<FileReplacementData> TryCalculateModdedDictionary(Guid applicationBase, CharacterData charaData, out Dictionary<(string GamePath, string? Hash), string> moddedDictionary, CancellationToken token)
{
Stopwatch st = Stopwatch.StartNew();
ConcurrentBag<FileReplacementData> missingFiles = [];
moddedDictionary = [];
ConcurrentDictionary<(string GamePath, string? Hash), string> outputDict = new();
bool hasMigrationChanges = false;
try
{
var replacementList = charaData.FileReplacements.SelectMany(k => k.Value.Where(v => string.IsNullOrEmpty(v.FileSwapPath))).ToList();
Parallel.ForEach(replacementList, new ParallelOptions()
{
CancellationToken = token,
MaxDegreeOfParallelism = 4
},
(item) =>
{
token.ThrowIfCancellationRequested();
var fileCache = _fileDbManager.GetFileCacheByHash(item.Hash);
if (fileCache != null)
{
if (string.IsNullOrEmpty(new FileInfo(fileCache.ResolvedFilepath).Extension))
{
hasMigrationChanges = true;
fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, item.GamePaths[0].Split(".")[^1]);
}
foreach (var gamePath in item.GamePaths)
{
outputDict[(gamePath, item.Hash)] = fileCache.ResolvedFilepath;
}
}
else
{
Logger.LogTrace("Missing file: {hash}", item.Hash);
missingFiles.Add(item);
}
});
moddedDictionary = outputDict.ToDictionary(k => k.Key, k => k.Value);
foreach (var item in charaData.FileReplacements.SelectMany(k => k.Value.Where(v => !string.IsNullOrEmpty(v.FileSwapPath))).ToList())
{
foreach (var gamePath in item.GamePaths)
{
Logger.LogTrace("[BASE-{appBase}] Adding file swap for {path}: {fileSwap}", applicationBase, gamePath, item.FileSwapPath);
moddedDictionary[(gamePath, null)] = item.FileSwapPath;
}
}
}
catch (OperationCanceledException)
{
Logger.LogTrace("[BASE-{appBase}] Modded path calculation cancelled", applicationBase);
throw;
}
catch (Exception ex)
{
Logger.LogError(ex, "[BASE-{appBase}] Something went wrong during calculation replacements", applicationBase);
}
if (hasMigrationChanges) _fileDbManager.WriteOutFullCsv();
st.Stop();
Logger.LogDebug("[BASE-{appBase}] ModdedPaths calculated in {time}ms, missing files: {count}, total files: {total}", applicationBase, st.ElapsedMilliseconds, missingFiles.Count, moddedDictionary.Keys.Count);
return [.. missingFiles];
}
private void DisableSync()
{
_dataReceivedInDowntime = null;
_downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate();
_applicationCancellationTokenSource = _applicationCancellationTokenSource?.CancelRecreate();
}
private void EnableSync()
{
if (IsVisible && _dataReceivedInDowntime != null)
{
ApplyCharacterData(_dataReceivedInDowntime.ApplicationId,
_dataReceivedInDowntime.CharacterData, _dataReceivedInDowntime.Forced);
_dataReceivedInDowntime = null;
}
}
}

View File

@@ -0,0 +1,16 @@
using LightlessSync.API.Data;
namespace LightlessSync.PlayerData.Pairs;
public interface IPairPerformanceSubject
{
string Ident { get; }
string PlayerName { get; }
UserData UserData { get; }
bool IsPaused { get; }
bool IsDirectlyPaired { get; }
bool HasStickyPermissions { get; }
long LastAppliedApproximateVRAMBytes { get; set; }
long LastAppliedApproximateEffectiveVRAMBytes { get; set; }
long LastAppliedDataTris { get; set; }
}

View File

@@ -1,103 +1,133 @@
using Dalamud.Game.Gui.ContextMenu;
using System;
using System.Linq;
using Dalamud.Game.Gui.ContextMenu;
using Dalamud.Game.Text.SeStringHandling;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.User;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using LightlessSync.WebAPI;
namespace LightlessSync.PlayerData.Pairs;
public class Pair
{
private readonly PairHandlerFactory _cachedPlayerFactory;
private readonly SemaphoreSlim _creationSemaphore = new(1);
private readonly PairLedger _pairLedger;
private readonly ILogger<Pair> _logger;
private readonly LightlessMediator _mediator;
private readonly ServerConfigurationManager _serverConfigurationManager;
private CancellationTokenSource _applicationCts = new();
private OnlineUserIdentDto? _onlineUserIdentDto = null;
private readonly Lazy<ApiController> _apiController;
public Pair(ILogger<Pair> logger, UserFullPairDto userPair, PairHandlerFactory cachedPlayerFactory,
LightlessMediator mediator, ServerConfigurationManager serverConfigurationManager)
public Pair(
ILogger<Pair> logger,
UserFullPairDto userPair,
PairLedger pairLedger,
LightlessMediator mediator,
ServerConfigurationManager serverConfigurationManager,
Lazy<ApiController> apiController)
{
_logger = logger;
UserPair = userPair;
_cachedPlayerFactory = cachedPlayerFactory;
_pairLedger = pairLedger;
_mediator = mediator;
_serverConfigurationManager = serverConfigurationManager;
_apiController = apiController;
}
public bool HasCachedPlayer => CachedPlayer != null && !string.IsNullOrEmpty(CachedPlayer.PlayerName) && _onlineUserIdentDto != null;
private PairUniqueIdentifier PairIdent => UniqueIdent;
private IPairHandlerAdapter? TryGetHandler()
{
return _pairLedger.GetHandler(PairIdent);
}
private PairConnection? TryGetConnection()
{
return _pairLedger.TryGetEntry(PairIdent, out var entry) && entry is not null
? entry.Connection
: null;
}
public bool HasCachedPlayer => TryGetHandler() is not null;
public IndividualPairStatus IndividualPairStatus => UserPair.IndividualPairStatus;
public bool IsDirectlyPaired => IndividualPairStatus != IndividualPairStatus.None;
public bool IsOneSidedPair => IndividualPairStatus == IndividualPairStatus.OneSided;
public bool IsOnline => CachedPlayer != null;
public bool IsOnline => TryGetConnection()?.IsOnline ?? false;
public bool IsPaired => IndividualPairStatus == IndividualPairStatus.Bidirectional || UserPair.Groups.Any();
public bool IsPaused => UserPair.OwnPermissions.IsPaused();
public bool IsVisible => CachedPlayer?.IsVisible ?? false;
public CharacterData? LastReceivedCharacterData { get; set; }
public string? PlayerName => CachedPlayer?.PlayerName ?? string.Empty;
public long LastAppliedDataBytes => CachedPlayer?.LastAppliedDataBytes ?? -1;
public long LastAppliedDataTris { get; set; } = -1;
public long LastAppliedApproximateVRAMBytes { get; set; } = -1;
public string Ident => _onlineUserIdentDto?.Ident ?? string.Empty;
public uint PlayerCharacterId => CachedPlayer?.PlayerCharacterId ?? uint.MaxValue;
public bool IsVisible => _pairLedger.IsPairVisible(PairIdent);
public CharacterData? LastReceivedCharacterData => TryGetHandler()?.LastReceivedCharacterData;
public string? PlayerName => TryGetHandler()?.PlayerName ?? UserPair.User.AliasOrUID;
public long LastAppliedDataBytes => TryGetHandler()?.LastAppliedDataBytes ?? -1;
public long LastAppliedDataTris => TryGetHandler()?.LastAppliedDataTris ?? -1;
public long LastAppliedApproximateVRAMBytes => TryGetHandler()?.LastAppliedApproximateVRAMBytes ?? -1;
public long LastAppliedApproximateEffectiveVRAMBytes => TryGetHandler()?.LastAppliedApproximateEffectiveVRAMBytes ?? -1;
public string Ident => TryGetHandler()?.Ident ?? TryGetConnection()?.Ident ?? string.Empty;
public uint PlayerCharacterId => TryGetHandler()?.PlayerCharacterId ?? uint.MaxValue;
public PairUniqueIdentifier UniqueIdent => new(UserData.UID);
public UserData UserData => UserPair.User;
public UserFullPairDto UserPair { get; set; }
private PairHandler? CachedPlayer { get; set; }
public void AddContextMenu(IMenuOpenedArgs args)
{
if (CachedPlayer == null || (args.Target is not MenuTargetDefault target) || target.TargetObjectId != CachedPlayer.PlayerCharacterId || IsPaused) return;
var handler = TryGetHandler();
if (handler is null)
{
return;
}
SeStringBuilder seStringBuilder = new();
SeStringBuilder seStringBuilder2 = new();
SeStringBuilder seStringBuilder3 = new();
SeStringBuilder seStringBuilder4 = new();
var openProfileSeString = seStringBuilder.AddText("Open Profile").Build();
var reapplyDataSeString = seStringBuilder2.AddText("Reapply last data").Build();
var cyclePauseState = seStringBuilder3.AddText("Cycle pause state").Build();
var changePermissions = seStringBuilder4.AddText("Change Permissions").Build();
args.AddMenuItem(new MenuItem()
if (args.Target is not MenuTargetDefault target || target.TargetObjectId != handler.PlayerCharacterId || IsPaused)
{
return;
}
var openProfileSeString = new SeStringBuilder().AddText("Open Profile").Build();
var reapplyDataSeString = new SeStringBuilder().AddText("Reapply last data").Build();
var cyclePauseState = new SeStringBuilder().AddText("Cycle pause state").Build();
var changePermissions = new SeStringBuilder().AddText("Change Permissions").Build();
args.AddMenuItem(new MenuItem
{
Name = openProfileSeString,
OnClicked = (a) => _mediator.Publish(new ProfileOpenStandaloneMessage(this)),
OnClicked = _ => _mediator.Publish(new ProfileOpenStandaloneMessage(this)),
UseDefaultPrefix = false,
PrefixChar = 'L',
PrefixColor = 708
});
args.AddMenuItem(new MenuItem()
args.AddMenuItem(new MenuItem
{
Name = reapplyDataSeString,
OnClicked = (a) => ApplyLastReceivedData(forced: true),
OnClicked = _ => ApplyLastReceivedData(forced: true),
UseDefaultPrefix = false,
PrefixChar = 'L',
PrefixColor = 708
});
args.AddMenuItem(new MenuItem()
args.AddMenuItem(new MenuItem
{
Name = changePermissions,
OnClicked = (a) => _mediator.Publish(new OpenPermissionWindow(this)),
OnClicked = _ => _mediator.Publish(new OpenPermissionWindow(this)),
UseDefaultPrefix = false,
PrefixChar = 'L',
PrefixColor = 708
});
args.AddMenuItem(new MenuItem()
args.AddMenuItem(new MenuItem
{
Name = cyclePauseState,
OnClicked = (a) => _mediator.Publish(new CyclePauseMessage(UserData)),
OnClicked = _ =>
{
TriggerCyclePause();
},
UseDefaultPrefix = false,
PrefixChar = 'L',
PrefixColor = 708
@@ -106,68 +136,38 @@ public class Pair
public void ApplyData(OnlineUserCharaDataDto data)
{
_applicationCts = _applicationCts.CancelRecreate();
LastReceivedCharacterData = data.CharaData;
_logger.LogTrace("Character data received for {Uid}; handler will process via registry.", UserData.UID);
}
if (CachedPlayer == null)
{
_logger.LogDebug("Received Data for {uid} but CachedPlayer does not exist, waiting", data.User.UID);
_ = Task.Run(async () =>
{
using var timeoutCts = new CancellationTokenSource();
timeoutCts.CancelAfter(TimeSpan.FromSeconds(120));
var appToken = _applicationCts.Token;
using var combined = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, appToken);
while (CachedPlayer == null && !combined.Token.IsCancellationRequested)
{
await Task.Delay(250, combined.Token).ConfigureAwait(false);
}
if (!combined.IsCancellationRequested)
{
_logger.LogDebug("Applying delayed data for {uid}", data.User.UID);
ApplyLastReceivedData();
}
});
return;
}
ApplyLastReceivedData();
private void TriggerCyclePause()
{
_ = _apiController.Value.CyclePauseAsync(this);
}
public void ApplyLastReceivedData(bool forced = false)
{
if (CachedPlayer == null) return;
if (LastReceivedCharacterData == null) return;
var handler = TryGetHandler();
if (handler is null)
{
_logger.LogTrace("ApplyLastReceivedData skipped for {Uid}: handler missing.", UserData.UID);
return;
}
CachedPlayer.ApplyCharacterData(Guid.NewGuid(), RemoveNotSyncedFiles(LastReceivedCharacterData.DeepClone())!, forced);
handler.ApplyLastReceivedData(forced);
}
public void CreateCachedPlayer(OnlineUserIdentDto? dto = null)
{
try
var handler = TryGetHandler();
if (handler is null)
{
_creationSemaphore.Wait();
if (CachedPlayer != null) return;
if (dto == null && _onlineUserIdentDto == null)
{
CachedPlayer?.Dispose();
CachedPlayer = null;
return;
}
if (dto != null)
{
_onlineUserIdentDto = dto;
}
CachedPlayer?.Dispose();
CachedPlayer = _cachedPlayerFactory.Create(this);
_logger.LogTrace("CreateCachedPlayer skipped for {Uid}: handler unavailable.", UserData.UID);
return;
}
finally
if (!handler.Initialized)
{
_creationSemaphore.Release();
handler.Initialize();
}
}
@@ -178,7 +178,7 @@ public class Pair
public string GetPlayerNameHash()
{
return CachedPlayer?.PlayerNameHash ?? string.Empty;
return TryGetHandler()?.PlayerNameHash ?? string.Empty;
}
public bool HasAnyConnection()
@@ -188,21 +188,7 @@ public class Pair
public void MarkOffline(bool wait = true)
{
try
{
if (wait)
_creationSemaphore.Wait();
LastReceivedCharacterData = null;
var player = CachedPlayer;
CachedPlayer = null;
player?.Dispose();
_onlineUserIdentDto = null;
}
finally
{
if (wait)
_creationSemaphore.Release();
}
_logger.LogTrace("MarkOffline invoked for {Uid} (wait: {Wait}). New registry handles handler disposal.", UserData.UID, wait);
}
public void SetNote(string note)
@@ -212,47 +198,12 @@ public class Pair
internal void SetIsUploading()
{
CachedPlayer?.SetUploading();
}
private CharacterData? RemoveNotSyncedFiles(CharacterData? data)
{
_logger.LogTrace("Removing not synced files");
if (data == null)
var handler = TryGetHandler();
if (handler is null)
{
_logger.LogTrace("Nothing to remove");
return data;
return;
}
bool disableIndividualAnimations = (UserPair.OtherPermissions.IsDisableAnimations() || UserPair.OwnPermissions.IsDisableAnimations());
bool disableIndividualVFX = (UserPair.OtherPermissions.IsDisableVFX() || UserPair.OwnPermissions.IsDisableVFX());
bool disableIndividualSounds = (UserPair.OtherPermissions.IsDisableSounds() || UserPair.OwnPermissions.IsDisableSounds());
_logger.LogTrace("Disable: Sounds: {disableIndividualSounds}, Anims: {disableIndividualAnims}; " +
"VFX: {disableGroupSounds}",
disableIndividualSounds, disableIndividualAnimations, disableIndividualVFX);
if (disableIndividualAnimations || disableIndividualSounds || disableIndividualVFX)
{
_logger.LogTrace("Data cleaned up: Animations disabled: {disableAnimations}, Sounds disabled: {disableSounds}, VFX disabled: {disableVFX}",
disableIndividualAnimations, disableIndividualSounds, disableIndividualVFX);
foreach (var objectKind in data.FileReplacements.Select(k => k.Key))
{
if (disableIndividualSounds)
data.FileReplacements[objectKind] = data.FileReplacements[objectKind]
.Where(f => !f.GamePaths.Any(p => p.EndsWith("scd", StringComparison.OrdinalIgnoreCase)))
.ToList();
if (disableIndividualAnimations)
data.FileReplacements[objectKind] = data.FileReplacements[objectKind]
.Where(f => !f.GamePaths.Any(p => p.EndsWith("tmb", StringComparison.OrdinalIgnoreCase) || p.EndsWith("pap", StringComparison.OrdinalIgnoreCase)))
.ToList();
if (disableIndividualVFX)
data.FileReplacements[objectKind] = data.FileReplacements[objectKind]
.Where(f => !f.GamePaths.Any(p => p.EndsWith("atex", StringComparison.OrdinalIgnoreCase) || p.EndsWith("avfx", StringComparison.OrdinalIgnoreCase)))
.ToList();
}
}
return data;
handler.SetUploading(true);
}
}
}

View File

@@ -0,0 +1,553 @@
using System;
using System.Collections.Concurrent;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.CharaData;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.Events;
using LightlessSync.Services.ServerConfiguration;
using Microsoft.Extensions.Logging;
namespace LightlessSync.PlayerData.Pairs;
public sealed class PairCoordinator : MediatorSubscriberBase
{
private readonly ILogger<PairCoordinator> _logger;
private readonly LightlessConfigService _configService;
private readonly LightlessMediator _mediator;
private readonly PairHandlerRegistry _handlerRegistry;
private readonly PairManager _pairManager;
private readonly PairLedger _pairLedger;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly ConcurrentDictionary<string, OnlineUserCharaDataDto> _pendingCharacterData = new(StringComparer.Ordinal);
public PairCoordinator(
ILogger<PairCoordinator> logger,
LightlessConfigService configService,
LightlessMediator mediator,
PairHandlerRegistry handlerRegistry,
PairManager pairManager,
PairLedger pairLedger,
ServerConfigurationManager serverConfigurationManager)
: base(logger, mediator)
{
_logger = logger;
_configService = configService;
_mediator = mediator;
_handlerRegistry = handlerRegistry;
_pairManager = pairManager;
_pairLedger = pairLedger;
_serverConfigurationManager = serverConfigurationManager;
mediator.Subscribe<ActiveServerChangedMessage>(this, msg => HandleActiveServerChange(msg.ServerUrl));
mediator.Subscribe<DisconnectedMessage>(this, _ => HandleDisconnected());
}
internal PairLedger Ledger => _pairLedger;
private void PublishPairDataChanged(bool groupChanged = false)
{
_mediator.Publish(new RefreshUiMessage());
_mediator.Publish(new PairDataChangedMessage());
if (groupChanged)
{
_mediator.Publish(new GroupCollectionChangedMessage());
}
}
private void NotifyUserOnline(PairConnection? connection, bool sendNotification)
{
if (connection is null)
{
return;
}
var config = _configService.Current;
if (config.ShowOnlineNotifications && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Pair {Uid} marked online", connection.User.UID);
}
if (!sendNotification || !config.ShowOnlineNotifications)
{
return;
}
if (config.ShowOnlineNotificationsOnlyForIndividualPairs &&
(!connection.IsDirectlyPaired || connection.IsOneSided))
{
return;
}
var note = _serverConfigurationManager.GetNoteForUid(connection.User.UID);
if (config.ShowOnlineNotificationsOnlyForNamedPairs &&
string.IsNullOrEmpty(note))
{
return;
}
var message = !string.IsNullOrEmpty(note)
? $"{note} ({connection.User.AliasOrUID}) is now online"
: $"{connection.User.AliasOrUID} is now online";
_mediator.Publish(new NotificationMessage("User online", message, NotificationType.Info, TimeSpan.FromSeconds(5)));
}
private void ReapplyLastKnownData(string userId, string ident, bool forced = false)
{
var result = _handlerRegistry.ApplyLastReceivedData(new PairUniqueIdentifier(userId), ident, forced);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to reapply cached data for {Uid}: {Error}", userId, result.Error);
}
}
public void HandleGroupChangePermissions(GroupPermissionDto dto)
{
var result = _pairManager.UpdateGroupPermissions(dto);
if (!result.Success)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update permissions for group {GroupId}: {Error}", dto.Group.GID, result.Error);
}
return;
}
PublishPairDataChanged(groupChanged: true);
}
public void HandleGroupFullInfo(GroupFullInfoDto dto)
{
var result = _pairManager.AddGroup(dto);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to add group {GroupId}: {Error}", dto.Group.GID, result.Error);
return;
}
PublishPairDataChanged(groupChanged: true);
}
public void HandleGroupPairJoined(GroupPairFullInfoDto dto)
{
var result = _pairManager.AddOrUpdateGroupPair(dto);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to add group pair {Uid}/{Group}: {Error}", dto.User.UID, dto.Group.GID, result.Error);
return;
}
PublishPairDataChanged(groupChanged: true);
}
private void HandleActiveServerChange(string serverUrl)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Active server changed to {Server}", serverUrl);
}
ResetPairState();
}
private void HandleDisconnected()
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Lightless disconnected, clearing pair state");
}
ResetPairState();
}
private void ResetPairState()
{
_handlerRegistry.ResetAllHandlers();
_pairManager.ClearAll();
_pendingCharacterData.Clear();
_mediator.Publish(new ClearProfileUserDataMessage());
_mediator.Publish(new ClearProfileGroupDataMessage());
PublishPairDataChanged(groupChanged: true);
}
public void HandleGroupPairLeft(GroupPairDto dto)
{
var deregistration = _pairManager.RemoveGroupPair(dto);
if (deregistration.Success && deregistration.Value is { } registration && registration.CharacterIdent is not null)
{
_ = _handlerRegistry.DeregisterOfflinePair(registration, forceDisposal: true);
}
else if (!deregistration.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("RemoveGroupPair failed for {Uid}: {Error}", dto.User.UID, deregistration.Error);
}
if (deregistration.Success)
{
PublishPairDataChanged(groupChanged: true);
}
}
public void HandleGroupRemoved(GroupDto dto)
{
var removalResult = _pairManager.RemoveGroup(dto.Group.GID);
if (removalResult.Success)
{
foreach (var registration in removalResult.Value)
{
if (registration.CharacterIdent is not null)
{
_ = _handlerRegistry.DeregisterOfflinePair(registration, forceDisposal: true);
}
}
}
else if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to remove group {Group}: {Error}", dto.Group.GID, removalResult.Error);
}
if (removalResult.Success)
{
PublishPairDataChanged(groupChanged: true);
}
}
public void HandleGroupInfoUpdate(GroupInfoDto dto)
{
var result = _pairManager.UpdateGroupInfo(dto);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update group info for {Group}: {Error}", dto.Group.GID, result.Error);
return;
}
PublishPairDataChanged(groupChanged: true);
}
public void HandleGroupPairPermissions(GroupPairUserPermissionDto dto)
{
var result = _pairManager.UpdateGroupPairPermissions(dto);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update group pair permissions for {Group}: {Error}", dto.Group.GID, result.Error);
return;
}
PublishPairDataChanged(groupChanged: true);
}
public void HandleGroupPairStatus(GroupPairUserInfoDto dto, bool isSelf)
{
PairOperationResult result;
if (isSelf)
{
result = _pairManager.UpdateGroupStatus(dto);
}
else
{
result = _pairManager.UpdateGroupPairStatus(dto);
}
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update group status for {Group}:{Uid}: {Error}", dto.GID, dto.UID, result.Error);
return;
}
PublishPairDataChanged(groupChanged: true);
}
public void HandleUserAddPair(UserPairDto dto, bool addToLastAddedUser = true)
{
var result = _pairManager.AddOrUpdateIndividual(dto, addToLastAddedUser);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to add/update pair {Uid}: {Error}", dto.User.UID, result.Error);
return;
}
PublishPairDataChanged();
}
public void HandleUserAddPair(UserFullPairDto dto)
{
var result = _pairManager.AddOrUpdateIndividual(dto);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to add/update full pair {Uid}: {Error}", dto.User.UID, result.Error);
return;
}
PublishPairDataChanged();
}
public void HandleUserRemovePair(UserDto dto)
{
var removal = _pairManager.RemoveIndividual(dto);
if (removal.Success && removal.Value is { } registration && registration.CharacterIdent is not null)
{
_ = _handlerRegistry.DeregisterOfflinePair(registration, forceDisposal: true);
}
else if (!removal.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("RemoveIndividual failed for {Uid}: {Error}", dto.User.UID, removal.Error);
}
if (removal.Success)
{
_pendingCharacterData.TryRemove(dto.User.UID, out _);
PublishPairDataChanged();
}
}
public void HandleUserStatus(UserIndividualPairStatusDto dto)
{
var result = _pairManager.SetIndividualStatus(dto);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update individual pair status for {Uid}: {Error}", dto.User.UID, result.Error);
return;
}
PublishPairDataChanged();
}
public void HandleUserOnline(OnlineUserIdentDto dto, bool sendNotification)
{
var wasOnline = false;
PairConnection? previousConnection = null;
if (_pairManager.TryGetPair(dto.User.UID, out var existingConnection))
{
previousConnection = existingConnection;
wasOnline = existingConnection.IsOnline;
}
var registrationResult = _pairManager.MarkOnline(dto);
if (!registrationResult.Success)
{
_logger.LogDebug("MarkOnline failed for {Uid}: {Error}", dto.User.UID, registrationResult.Error);
return;
}
var registration = registrationResult.Value;
if (registration.CharacterIdent is null)
{
_logger.LogDebug("Online registration for {Uid} missing ident.", dto.User.UID);
}
else
{
var handlerResult = _handlerRegistry.RegisterOnlinePair(registration);
if (!handlerResult.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("RegisterOnlinePair failed for {Uid}: {Error}", dto.User.UID, handlerResult.Error);
}
}
var connectionResult = _pairManager.GetPair(dto.User.UID);
var connection = connectionResult.Success ? connectionResult.Value : previousConnection;
if (connection is not null)
{
_mediator.Publish(new ClearProfileUserDataMessage(connection.User));
}
else
{
_mediator.Publish(new ClearProfileUserDataMessage(dto.User));
}
if (!wasOnline)
{
NotifyUserOnline(connection, sendNotification);
}
if (registration.CharacterIdent is not null &&
_pendingCharacterData.TryRemove(dto.User.UID, out var pendingData))
{
var pendingRegistration = new PairRegistration(new PairUniqueIdentifier(dto.User.UID), registration.CharacterIdent);
var pendingApply = _handlerRegistry.ApplyCharacterData(pendingRegistration, pendingData);
if (!pendingApply.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Applying pending character data for {Uid} failed: {Error}", dto.User.UID, pendingApply.Error);
}
}
PublishPairDataChanged();
}
public void HandleUserOffline(UserData user)
{
var registrationResult = _pairManager.MarkOffline(user);
if (registrationResult.Success)
{
_pendingCharacterData.TryRemove(user.UID, out _);
if (registrationResult.Value.CharacterIdent is not null)
{
_ = _handlerRegistry.DeregisterOfflinePair(registrationResult.Value);
}
_mediator.Publish(new ClearProfileUserDataMessage(user));
PublishPairDataChanged();
}
else if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("MarkOffline failed for {Uid}: {Error}", user.UID, registrationResult.Error);
}
}
public void HandleUserPermissions(UserPermissionsDto dto)
{
var pairResult = _pairManager.GetPair(dto.User.UID);
if (!pairResult.Success)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Permission update received for unknown pair {Uid}", dto.User.UID);
}
return;
}
var connection = pairResult.Value;
var previous = connection.OtherToSelfPermissions;
var updateResult = _pairManager.UpdateOtherPermissions(dto);
if (!updateResult.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update permissions for {Uid}: {Error}", dto.User.UID, updateResult.Error);
return;
}
PublishPairDataChanged();
if (previous.IsPaused() != dto.Permissions.IsPaused())
{
_mediator.Publish(new ClearProfileUserDataMessage(dto.User));
if (connection.Ident is not null)
{
var pauseResult = _handlerRegistry.SetPausedState(new PairUniqueIdentifier(dto.User.UID), connection.Ident, dto.Permissions.IsPaused());
if (!pauseResult.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update pause state for {Uid}: {Error}", dto.User.UID, pauseResult.Error);
}
}
}
if (!connection.IsPaused && connection.Ident is not null)
{
ReapplyLastKnownData(dto.User.UID, connection.Ident);
}
}
public void HandleSelfPermissions(UserPermissionsDto dto)
{
var pairResult = _pairManager.GetPair(dto.User.UID);
if (!pairResult.Success)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Self permission update received for unknown pair {Uid}", dto.User.UID);
}
return;
}
var connection = pairResult.Value;
var previous = connection.SelfToOtherPermissions;
var updateResult = _pairManager.UpdateSelfPermissions(dto);
if (!updateResult.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update self permissions for {Uid}: {Error}", dto.User.UID, updateResult.Error);
return;
}
PublishPairDataChanged();
if (previous.IsPaused() != dto.Permissions.IsPaused())
{
_mediator.Publish(new ClearProfileUserDataMessage(dto.User));
if (connection.Ident is not null)
{
var pauseResult = _handlerRegistry.SetPausedState(new PairUniqueIdentifier(dto.User.UID), connection.Ident, dto.Permissions.IsPaused());
if (!pauseResult.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update pause state for {Uid}: {Error}", dto.User.UID, pauseResult.Error);
}
}
}
if (!connection.IsPaused && connection.Ident is not null)
{
ReapplyLastKnownData(dto.User.UID, connection.Ident);
}
}
public void HandleUploadStatus(UserDto dto)
{
var pairResult = _pairManager.GetPair(dto.User.UID);
if (!pairResult.Success)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Upload status received for unknown pair {Uid}", dto.User.UID);
}
return;
}
var connection = pairResult.Value;
if (connection.Ident is null)
{
return;
}
var setResult = _handlerRegistry.SetUploading(new PairUniqueIdentifier(dto.User.UID), connection.Ident, true);
if (!setResult.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to set uploading for {Uid}: {Error}", dto.User.UID, setResult.Error);
}
}
public void HandleCharacterData(OnlineUserCharaDataDto dto)
{
var pairResult = _pairManager.GetPair(dto.User.UID);
if (!pairResult.Success)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Character data received for unknown pair {Uid}, queued for later.", dto.User.UID);
}
_pendingCharacterData[dto.User.UID] = dto;
return;
}
var connection = pairResult.Value;
_mediator.Publish(new EventMessage(new Event(connection.User, nameof(PairCoordinator), EventSeverity.Informational, "Received Character Data")));
if (connection.Ident is null)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Character data received for {Uid} without ident, queued for later.", dto.User.UID);
}
_pendingCharacterData[dto.User.UID] = dto;
return;
}
_pendingCharacterData.TryRemove(dto.User.UID, out _);
var registration = new PairRegistration(new PairUniqueIdentifier(dto.User.UID), connection.Ident);
var applyResult = _handlerRegistry.ApplyCharacterData(registration, dto);
if (!applyResult.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("ApplyCharacterData queued for {Uid}: {Error}", dto.User.UID, applyResult.Error);
}
}
public void HandleProfile(UserDto dto)
{
_mediator.Publish(new ClearProfileUserDataMessage(dto.User));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,493 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.CharaData;
using LightlessSync.API.Dto.User;
using Microsoft.Extensions.Logging;
namespace LightlessSync.PlayerData.Pairs;
public sealed class PairHandlerRegistry : IDisposable
{
private readonly object _gate = new();
private readonly Dictionary<string, IPairHandlerAdapter> _identToHandler = new(StringComparer.Ordinal);
private readonly Dictionary<IPairHandlerAdapter, HashSet<PairUniqueIdentifier>> _handlerToPairs = new();
private readonly Dictionary<string, CancellationTokenSource> _waitingRequests = new(StringComparer.Ordinal);
private readonly IPairHandlerAdapterFactory _handlerFactory;
private readonly PairManager _pairManager;
private readonly PairStateCache _pairStateCache;
private readonly ILogger<PairHandlerRegistry> _logger;
private readonly TimeSpan _deletionGracePeriod = TimeSpan.FromMinutes(5);
private readonly TimeSpan _waitForHandlerGracePeriod = TimeSpan.FromMinutes(2);
public PairHandlerRegistry(
IPairHandlerAdapterFactory handlerFactory,
PairManager pairManager,
PairStateCache pairStateCache,
ILogger<PairHandlerRegistry> logger)
{
_handlerFactory = handlerFactory;
_pairManager = pairManager;
_pairStateCache = pairStateCache;
_logger = logger;
}
public int GetVisibleUsersCount()
{
lock (_gate)
{
return _handlerToPairs.Keys.Count(handler => handler.IsVisible);
}
}
public bool IsIdentVisible(string ident)
{
lock (_gate)
{
return _identToHandler.TryGetValue(ident, out var handler) && handler.IsVisible;
}
}
public PairOperationResult<PairUniqueIdentifier> RegisterOnlinePair(PairRegistration registration)
{
if (registration.CharacterIdent is null)
{
return PairOperationResult<PairUniqueIdentifier>.Fail($"Registration for {registration.PairIdent.UserId} missing ident.");
}
IPairHandlerAdapter handler;
lock (_gate)
{
handler = GetOrAddHandler(registration.CharacterIdent);
handler.ScheduledForDeletion = false;
if (!_handlerToPairs.TryGetValue(handler, out var set))
{
set = new HashSet<PairUniqueIdentifier>();
_handlerToPairs[handler] = set;
}
set.Add(registration.PairIdent);
}
ApplyPauseStateForHandler(handler);
if (handler.LastReceivedCharacterData is null)
{
var cachedData = _pairStateCache.TryLoad(registration.CharacterIdent);
if (cachedData is not null)
{
handler.LoadCachedCharacterData(cachedData);
}
}
if (handler.LastReceivedCharacterData is not null &&
(handler.LastAppliedApproximateVRAMBytes < 0 || handler.LastAppliedDataTris < 0))
{
handler.ApplyLastReceivedData(forced: true);
}
return PairOperationResult<PairUniqueIdentifier>.Ok(registration.PairIdent);
}
public PairOperationResult<PairUniqueIdentifier> DeregisterOfflinePair(PairRegistration registration, bool forceDisposal = false)
{
if (registration.CharacterIdent is null)
{
return PairOperationResult<PairUniqueIdentifier>.Fail($"Deregister for {registration.PairIdent.UserId} missing ident.");
}
IPairHandlerAdapter? handler = null;
bool shouldScheduleRemoval = false;
bool shouldDisposeImmediately = false;
lock (_gate)
{
if (!_identToHandler.TryGetValue(registration.CharacterIdent, out handler))
{
return PairOperationResult<PairUniqueIdentifier>.Fail($"Ident {registration.CharacterIdent} not registered.");
}
if (_handlerToPairs.TryGetValue(handler, out var set))
{
set.Remove(registration.PairIdent);
if (set.Count == 0)
{
if (forceDisposal)
{
shouldDisposeImmediately = true;
}
else
{
shouldScheduleRemoval = true;
handler.ScheduledForDeletion = true;
}
}
}
}
if (shouldDisposeImmediately && handler is not null)
{
if (TryFinalizeHandlerRemoval(handler))
{
handler.Dispose();
}
}
else if (shouldScheduleRemoval && handler is not null)
{
_ = RemoveAfterGracePeriodAsync(handler);
}
return PairOperationResult<PairUniqueIdentifier>.Ok(registration.PairIdent);
}
public PairOperationResult ApplyCharacterData(PairRegistration registration, OnlineUserCharaDataDto dto)
{
if (registration.CharacterIdent is null)
{
return PairOperationResult.Fail($"Character data received without ident for {registration.PairIdent.UserId}.");
}
IPairHandlerAdapter? handler;
lock (_gate)
{
_identToHandler.TryGetValue(registration.CharacterIdent, out handler);
}
if (handler is null)
{
var registerResult = RegisterOnlinePair(registration);
if (!registerResult.Success)
{
return PairOperationResult.Fail(registerResult.Error);
}
lock (_gate)
{
_identToHandler.TryGetValue(registration.CharacterIdent, out handler);
}
}
if (handler is null)
{
return PairOperationResult.Fail($"Handler not ready for {registration.PairIdent.UserId}.");
}
handler.ApplyData(dto.CharaData);
return PairOperationResult.Ok();
}
public PairOperationResult ApplyLastReceivedData(PairUniqueIdentifier pairIdent, string ident, bool forced = false)
{
IPairHandlerAdapter? handler;
lock (_gate)
{
_identToHandler.TryGetValue(ident, out handler);
}
if (handler is null)
{
return PairOperationResult.Fail($"Cannot reapply data: handler for {pairIdent.UserId} not found.");
}
handler.ApplyLastReceivedData(forced);
return PairOperationResult.Ok();
}
public PairOperationResult SetUploading(PairUniqueIdentifier pairIdent, string ident, bool uploading)
{
IPairHandlerAdapter? handler;
lock (_gate)
{
_identToHandler.TryGetValue(ident, out handler);
}
if (handler is null)
{
return PairOperationResult.Fail($"Cannot set uploading for {pairIdent.UserId}: handler not found.");
}
handler.SetUploading(uploading);
return PairOperationResult.Ok();
}
public PairOperationResult SetPausedState(PairUniqueIdentifier pairIdent, string ident, bool paused)
{
IPairHandlerAdapter? handler;
lock (_gate)
{
_identToHandler.TryGetValue(ident, out handler);
}
if (handler is null)
{
return PairOperationResult.Fail($"Cannot update pause state for {pairIdent.UserId}: handler not found.");
}
_ = paused; // value reflected in pair manager already
// Recalculate pause state against all registered pairs to ensure consistency across contexts.
ApplyPauseStateForHandler(handler);
return PairOperationResult.Ok();
}
public PairOperationResult<IReadOnlyList<(PairUniqueIdentifier Ident, PairConnection Pair)>> GetPairConnections(string ident)
{
IPairHandlerAdapter? handler;
HashSet<PairUniqueIdentifier>? identifiers = null;
lock (_gate)
{
_identToHandler.TryGetValue(ident, out handler);
if (handler is not null)
{
_handlerToPairs.TryGetValue(handler, out identifiers);
}
}
if (handler is null || identifiers is null)
{
return PairOperationResult<IReadOnlyList<(PairUniqueIdentifier Ident, PairConnection Pair)>>.Fail($"No handler registered for {ident}.");
}
var list = new List<(PairUniqueIdentifier, PairConnection)>();
foreach (var pairIdent in identifiers)
{
var result = _pairManager.GetPair(pairIdent.UserId);
if (result.Success)
{
list.Add((pairIdent, result.Value));
}
}
return PairOperationResult<IReadOnlyList<(PairUniqueIdentifier Ident, PairConnection Pair)>>.Ok(list);
}
private void ApplyPauseStateForHandler(IPairHandlerAdapter handler)
{
var pairs = _pairManager.GetPairsByIdent(handler.Ident);
bool paused = pairs.Any(p => p.SelfToOtherPermissions.IsPaused() || p.OtherToSelfPermissions.IsPaused());
handler.SetPaused(paused);
}
internal bool TryGetHandler(string ident, out IPairHandlerAdapter? handler)
{
lock (_gate)
{
var success = _identToHandler.TryGetValue(ident, out var resolved);
handler = resolved;
return success;
}
}
internal IReadOnlyList<IPairHandlerAdapter> GetHandlerSnapshot()
{
lock (_gate)
{
return _identToHandler.Values.Distinct().ToList();
}
}
internal IReadOnlyCollection<PairUniqueIdentifier> GetRegisteredPairs(IPairHandlerAdapter handler)
{
lock (_gate)
{
if (_handlerToPairs.TryGetValue(handler, out var pairs))
{
return pairs.ToList();
}
}
return Array.Empty<PairUniqueIdentifier>();
}
internal void ReapplyAll(bool forced = false)
{
var handlers = GetHandlerSnapshot();
foreach (var handler in handlers)
{
try
{
handler.ApplyLastReceivedData(forced);
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug(ex, "Failed to reapply cached data for {Ident}", handler.Ident);
}
}
}
}
internal void ResetAllHandlers()
{
List<IPairHandlerAdapter> handlers;
lock (_gate)
{
handlers = _identToHandler.Values.Distinct().ToList();
_identToHandler.Clear();
_handlerToPairs.Clear();
foreach (var pending in _waitingRequests.Values)
{
pending.Cancel();
pending.Dispose();
}
_waitingRequests.Clear();
}
foreach (var handler in handlers)
{
try
{
handler.Dispose();
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug(ex, "Failed to dispose handler for {Ident}", handler.Ident);
}
}
}
}
public void Dispose()
{
List<IPairHandlerAdapter> handlers;
lock (_gate)
{
handlers = _identToHandler.Values.Distinct().ToList();
_identToHandler.Clear();
_handlerToPairs.Clear();
foreach (var kv in _waitingRequests.Values)
{
kv.Cancel();
}
_waitingRequests.Clear();
}
foreach (var handler in handlers)
{
handler.Dispose();
}
}
private IPairHandlerAdapter GetOrAddHandler(string ident)
{
if (_identToHandler.TryGetValue(ident, out var handler))
{
return handler;
}
handler = _handlerFactory.Create(ident);
_identToHandler[ident] = handler;
_handlerToPairs[handler] = new HashSet<PairUniqueIdentifier>();
return handler;
}
private void EnsureInitialized(IPairHandlerAdapter handler)
{
if (handler.Initialized)
{
return;
}
try
{
handler.Initialize();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize handler for {Ident}", handler.Ident);
}
}
private async Task RemoveAfterGracePeriodAsync(IPairHandlerAdapter handler)
{
try
{
await Task.Delay(_deletionGracePeriod).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
return;
}
if (TryFinalizeHandlerRemoval(handler))
{
handler.Dispose();
}
}
private bool TryFinalizeHandlerRemoval(IPairHandlerAdapter handler)
{
lock (_gate)
{
if (!_handlerToPairs.TryGetValue(handler, out var set) || set.Count > 0)
{
handler.ScheduledForDeletion = false;
return false;
}
_handlerToPairs.Remove(handler);
_identToHandler.Remove(handler.Ident);
if (_waitingRequests.TryGetValue(handler.Ident, out var cts))
{
cts.Cancel();
cts.Dispose();
_waitingRequests.Remove(handler.Ident);
}
return true;
}
}
private async Task WaitThenApplyDataAsync(PairRegistration registration, OnlineUserCharaDataDto dto, CancellationTokenSource cts)
{
var token = cts.Token;
try
{
while (!token.IsCancellationRequested)
{
IPairHandlerAdapter? handler;
lock (_gate)
{
_identToHandler.TryGetValue(registration.CharacterIdent!, out handler);
}
if (handler is not null && handler.Initialized)
{
handler.ApplyData(dto.CharaData);
break;
}
await Task.Delay(TimeSpan.FromMilliseconds(500), token).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// expected
}
finally
{
lock (_gate)
{
if (_waitingRequests.TryGetValue(registration.CharacterIdent!, out var existing) && existing == cts)
{
_waitingRequests.Remove(registration.CharacterIdent!);
}
}
cts.Dispose();
}
}
}

View File

@@ -0,0 +1,293 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using LightlessSync.API.Data;
using LightlessSync.API.Dto.Group;
using LightlessSync.Services.Events;
using LightlessSync.Services.Mediator;
using LightlessSync.UI.Models;
using Microsoft.Extensions.Logging;
namespace LightlessSync.PlayerData.Pairs;
public sealed class PairLedger : DisposableMediatorSubscriberBase
{
private readonly PairManager _pairManager;
private readonly PairHandlerRegistry _registry;
private readonly ILogger<PairLedger> _logger;
private readonly object _metricsGate = new();
private CancellationTokenSource? _ensureMetricsCts;
public PairLedger(
ILogger<PairLedger> logger,
LightlessMediator mediator,
PairManager pairManager,
PairHandlerRegistry registry) : base(logger, mediator)
{
_pairManager = pairManager;
_registry = registry;
_logger = logger;
Mediator.Subscribe<CutsceneEndMessage>(this, _ => ReapplyAll(forced: true));
Mediator.Subscribe<GposeEndMessage>(this, _ => ReapplyAll());
Mediator.Subscribe<PenumbraInitializedMessage>(this, _ => ReapplyAll(forced: true));
Mediator.Subscribe<FileCacheInitializedMessage>(this, _ => ReapplyAll(forced: true));
Mediator.Subscribe<DisconnectedMessage>(this, _ => Reset());
Mediator.Subscribe<ConnectedMessage>(this, _ => ScheduleEnsureMetrics(TimeSpan.FromSeconds(2)));
Mediator.Subscribe<HubReconnectedMessage>(this, _ => ScheduleEnsureMetrics(TimeSpan.FromSeconds(2)));
Mediator.Subscribe<DalamudLoginMessage>(this, _ => ScheduleEnsureMetrics(TimeSpan.FromSeconds(2)));
Mediator.Subscribe<VisibilityChange>(this, _ => EnsureMetricsForVisiblePairs());
}
public bool IsPairVisible(PairUniqueIdentifier pairIdent)
{
var connectionResult = _pairManager.GetPair(pairIdent.UserId);
if (!connectionResult.Success)
{
return false;
}
var connection = connectionResult.Value;
if (connection.Ident is null)
{
return false;
}
return _registry.IsIdentVisible(connection.Ident);
}
public IPairHandlerAdapter? GetHandler(PairUniqueIdentifier pairIdent)
{
var connectionResult = _pairManager.GetPair(pairIdent.UserId);
if (!connectionResult.Success)
{
return null;
}
var connection = connectionResult.Value;
if (connection.Ident is null)
{
return null;
}
return _registry.TryGetHandler(connection.Ident, out var handler) ? handler : null;
}
public IReadOnlyList<PairConnection> GetVisiblePairs()
{
return _pairManager.GetAllPairs()
.Select(kv => kv.Value)
.Where(connection => connection.Ident is not null && _registry.IsIdentVisible(connection.Ident))
.ToList();
}
public IReadOnlyList<GroupFullInfoDto> GetAllGroupInfos()
{
return _pairManager.GetAllGroups()
.Select(kv => kv.Value.GroupFullInfo)
.ToList();
}
public IReadOnlyDictionary<string, Syncshell> GetAllSyncshells()
{
return _pairManager.GetAllGroups();
}
public void ReapplyAll(bool forced = false)
{
if (_logger.IsEnabled(LogLevel.Trace))
{
_logger.LogTrace("Reapplying cached data for all handlers (forced: {Forced})", forced);
}
_registry.ReapplyAll(forced);
}
public void ReapplyPair(PairUniqueIdentifier pairIdent, bool forced = false)
{
var connectionResult = _pairManager.GetPair(pairIdent.UserId);
if (!connectionResult.Success)
{
return;
}
var connection = connectionResult.Value;
if (connection.Ident is null)
{
return;
}
var result = _registry.ApplyLastReceivedData(pairIdent, connection.Ident, forced);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to reapply data for {UserId}: {Error}", pairIdent.UserId, result.Error);
}
}
private void Reset()
{
if (_logger.IsEnabled(LogLevel.Trace))
{
_logger.LogTrace("Resetting pair handlers after disconnect.");
}
CancelScheduledMetrics();
}
public IReadOnlyList<PairDisplayEntry> GetAllEntries()
{
var groups = _pairManager.GetAllGroups();
var list = new List<PairDisplayEntry>();
foreach (var (userId, connection) in _pairManager.GetAllPairs())
{
var ident = new PairUniqueIdentifier(userId);
IPairHandlerAdapter? handler = null;
if (connection.Ident is not null)
{
_registry.TryGetHandler(connection.Ident, out handler);
}
var groupInfos = connection.Groups.Keys
.Select(gid =>
{
if (groups.TryGetValue(gid, out var shell))
{
return shell.GroupFullInfo;
}
return null;
})
.Where(dto => dto is not null)
.Cast<GroupFullInfoDto>()
.ToList();
list.Add(new PairDisplayEntry(ident, connection, groupInfos, handler));
}
return list;
}
public bool TryGetEntry(PairUniqueIdentifier ident, out PairDisplayEntry? entry)
{
entry = null;
var connectionResult = _pairManager.GetPair(ident.UserId);
if (!connectionResult.Success)
{
return false;
}
var connection = connectionResult.Value;
var groups = connection.Groups.Keys
.Select(gid =>
{
var groupResult = _pairManager.GetGroup(gid);
return groupResult.Success ? groupResult.Value.GroupFullInfo : null;
})
.Where(dto => dto is not null)
.Cast<GroupFullInfoDto>()
.ToList();
IPairHandlerAdapter? handler = null;
if (connection.Ident is not null)
{
_registry.TryGetHandler(connection.Ident, out handler);
}
entry = new PairDisplayEntry(ident, connection, groups, handler);
return true;
}
private void ScheduleEnsureMetrics(TimeSpan? delay = null)
{
lock (_metricsGate)
{
_ensureMetricsCts?.Cancel();
var cts = new CancellationTokenSource();
_ensureMetricsCts = cts;
_ = Task.Run(async () =>
{
try
{
if (delay is { } d && d > TimeSpan.Zero)
{
await Task.Delay(d, cts.Token).ConfigureAwait(false);
}
EnsureMetricsForVisiblePairs();
}
catch (OperationCanceledException)
{
// ignored
}
finally
{
lock (_metricsGate)
{
if (_ensureMetricsCts == cts)
{
_ensureMetricsCts = null;
}
}
cts.Dispose();
}
});
}
}
private void CancelScheduledMetrics()
{
lock (_metricsGate)
{
_ensureMetricsCts?.Cancel();
_ensureMetricsCts = null;
}
}
private void EnsureMetricsForVisiblePairs()
{
var handlers = _registry.GetHandlerSnapshot();
foreach (var handler in handlers)
{
if (!handler.IsVisible)
{
continue;
}
if (handler.LastReceivedCharacterData is null)
{
continue;
}
if (handler.LastAppliedApproximateVRAMBytes >= 0
&& handler.LastAppliedDataTris >= 0
&& handler.LastAppliedApproximateEffectiveVRAMBytes >= 0)
{
continue;
}
try
{
handler.ApplyLastReceivedData(forced: true);
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug(ex, "Failed to ensure performance metrics for {Ident}", handler.Ident);
}
}
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
CancelScheduledMetrics();
}
base.Dispose(disposing);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group;
namespace LightlessSync.PlayerData.Pairs;
public readonly struct PairOperationResult
{
private PairOperationResult(bool success, string? error)
{
Success = success;
Error = error;
}
public bool Success { get; }
public string? Error { get; }
public static PairOperationResult Ok() => new(true, null);
public static PairOperationResult Fail(string error) => new(false, error);
}
public readonly struct PairOperationResult<T>
{
private PairOperationResult(bool success, T value, string? error)
{
Success = success;
Value = value;
Error = error;
}
public bool Success { get; }
public T Value { get; }
public string? Error { get; }
public static PairOperationResult<T> Ok(T value) => new(true, value, null);
public static PairOperationResult<T> Fail(string error) => new(false, default!, error);
}
public sealed record PairRegistration(PairUniqueIdentifier PairIdent, string? CharacterIdent);
public sealed class GroupPairRelationship
{
public GroupPairRelationship(string groupId, GroupPairUserInfo? info)
{
GroupId = groupId;
UserInfo = info;
}
public string GroupId { get; }
public GroupPairUserInfo? UserInfo { get; private set; }
public void SetUserInfo(GroupPairUserInfo? info)
{
UserInfo = info;
}
}
public sealed class PairConnection
{
public PairConnection(UserData user)
{
User = user;
Groups = new Dictionary<string, GroupPairRelationship>(StringComparer.Ordinal);
}
public UserData User { get; }
public bool IsOnline { get; private set; }
public string? Ident { get; private set; }
public UserPermissions SelfToOtherPermissions { get; private set; } = UserPermissions.NoneSet;
public UserPermissions OtherToSelfPermissions { get; private set; } = UserPermissions.NoneSet;
public IndividualPairStatus? IndividualPairStatus { get; private set; }
public Dictionary<string, GroupPairRelationship> Groups { get; }
public bool IsPaused => SelfToOtherPermissions.IsPaused();
public bool IsDirectlyPaired => IndividualPairStatus is not null && IndividualPairStatus != API.Data.Enum.IndividualPairStatus.None;
public bool IsOneSided => IndividualPairStatus == API.Data.Enum.IndividualPairStatus.OneSided;
public bool HasAnyConnection => IsDirectlyPaired || Groups.Count > 0;
public void SetOnline(string? ident)
{
IsOnline = true;
Ident = ident;
}
public void SetOffline()
{
IsOnline = false;
}
public void UpdatePermissions(UserPermissions own, UserPermissions other)
{
SelfToOtherPermissions = own;
OtherToSelfPermissions = other;
}
public void UpdateStatus(IndividualPairStatus? status)
{
IndividualPairStatus = status;
}
public void EnsureGroupRelationship(string groupId, GroupPairUserInfo? info)
{
if (Groups.TryGetValue(groupId, out var relationship))
{
relationship.SetUserInfo(info);
}
else
{
Groups[groupId] = new GroupPairRelationship(groupId, info);
}
}
public void RemoveGroupRelationship(string groupId)
{
Groups.Remove(groupId);
}
}
public sealed class Syncshell
{
public Syncshell(GroupFullInfoDto dto)
{
GroupFullInfo = dto;
Users = new Dictionary<string, PairConnection>(StringComparer.Ordinal);
}
public GroupFullInfoDto GroupFullInfo { get; private set; }
public Dictionary<string, PairConnection> Users { get; }
public void Update(GroupFullInfoDto dto)
{
GroupFullInfo = dto;
}
}
public sealed class PairState
{
public CharacterData? CharacterData { get; set; }
public Guid? TemporaryCollectionId { get; set; }
public bool IsEmpty => CharacterData is null && (TemporaryCollectionId is null || TemporaryCollectionId == Guid.Empty);
}
public readonly record struct PairUniqueIdentifier(string UserId);

View File

@@ -0,0 +1,118 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using LightlessSync.API.Data;
using LightlessSync.Utils;
namespace LightlessSync.PlayerData.Pairs;
public sealed class PairStateCache
{
private readonly ConcurrentDictionary<string, PairState> _cache = new(StringComparer.Ordinal);
public void Store(string ident, CharacterData data)
{
if (string.IsNullOrEmpty(ident) || data is null)
{
return;
}
var state = _cache.GetOrAdd(ident, _ => new PairState());
state.CharacterData = data.DeepClone();
}
public CharacterData? TryLoad(string ident)
{
if (string.IsNullOrEmpty(ident))
{
return null;
}
if (_cache.TryGetValue(ident, out var state) && state.CharacterData is not null)
{
return state.CharacterData.DeepClone();
}
return null;
}
public Guid? TryGetTemporaryCollection(string ident)
{
if (string.IsNullOrEmpty(ident))
{
return null;
}
if (_cache.TryGetValue(ident, out var state))
{
return state.TemporaryCollectionId;
}
return null;
}
public Guid? StoreTemporaryCollection(string ident, Guid collection)
{
if (string.IsNullOrEmpty(ident) || collection == Guid.Empty)
{
return null;
}
var state = _cache.GetOrAdd(ident, _ => new PairState());
state.TemporaryCollectionId = collection;
return collection;
}
public Guid? ClearTemporaryCollection(string ident)
{
if (string.IsNullOrEmpty(ident))
{
return null;
}
if (_cache.TryGetValue(ident, out var state))
{
var existing = state.TemporaryCollectionId;
state.TemporaryCollectionId = null;
TryRemoveIfEmpty(ident, state);
return existing;
}
return null;
}
public IReadOnlyList<Guid> ClearAllTemporaryCollections()
{
var removed = new List<Guid>();
foreach (var (ident, state) in _cache)
{
if (state.TemporaryCollectionId is { } guid && guid != Guid.Empty)
{
removed.Add(guid);
state.TemporaryCollectionId = null;
}
TryRemoveIfEmpty(ident, state);
}
return removed;
}
public void Clear(string ident)
{
if (string.IsNullOrEmpty(ident))
{
return;
}
_cache.TryRemove(ident, out _);
}
private void TryRemoveIfEmpty(string ident, PairState state)
{
if (state.IsEmpty)
{
_cache.TryRemove(ident, out _);
}
}
}

View File

@@ -1,10 +1,17 @@
using System;
using LightlessSync.API.Data;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.API.Data.Comparer;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Utils;
using LightlessSync.Services.Mediator;
using LightlessSync.Services;
using LightlessSync.WebAPI;
using LightlessSync.WebAPI.Files;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace LightlessSync.PlayerData.Pairs;
@@ -13,22 +20,24 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
private readonly ApiController _apiController;
private readonly DalamudUtilService _dalamudUtil;
private readonly FileUploadManager _fileTransferManager;
private readonly PairManager _pairManager;
private readonly PairLedger _pairLedger;
private CharacterData? _lastCreatedData;
private CharacterData? _uploadingCharacterData = null;
private readonly List<UserData> _previouslyVisiblePlayers = [];
private Task<CharacterData>? _fileUploadTask = null;
private readonly HashSet<UserData> _usersToPushDataTo = [];
private readonly HashSet<UserData> _usersToPushDataTo = new(UserDataComparer.Instance);
private readonly SemaphoreSlim _pushDataSemaphore = new(1, 1);
private readonly CancellationTokenSource _runtimeCts = new();
private readonly Dictionary<string, string> _lastPushedHashes = new(StringComparer.Ordinal);
private readonly object _pushSync = new();
public VisibleUserDataDistributor(ILogger<VisibleUserDataDistributor> logger, ApiController apiController, DalamudUtilService dalamudUtil,
PairManager pairManager, LightlessMediator mediator, FileUploadManager fileTransferManager) : base(logger, mediator)
PairLedger pairLedger, LightlessMediator mediator, FileUploadManager fileTransferManager) : base(logger, mediator)
{
_apiController = apiController;
_dalamudUtil = dalamudUtil;
_pairManager = pairManager;
_pairLedger = pairLedger;
_fileTransferManager = fileTransferManager;
Mediator.Subscribe<DelayedFrameworkUpdateMessage>(this, (_) => FrameworkOnUpdate());
Mediator.Subscribe<CharacterDataCreatedMessage>(this, (msg) =>
@@ -47,7 +56,7 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
});
Mediator.Subscribe<ConnectedMessage>(this, (_) => PushToAllVisibleUsers());
Mediator.Subscribe<DisconnectedMessage>(this, (_) => _previouslyVisiblePlayers.Clear());
Mediator.Subscribe<DisconnectedMessage>(this, (_) => HandleDisconnected());
}
protected override void Dispose(bool disposing)
@@ -63,15 +72,18 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
private void PushToAllVisibleUsers(bool forced = false)
{
foreach (var user in _pairManager.GetVisibleUsers())
lock (_pushSync)
{
_usersToPushDataTo.Add(user);
}
foreach (var user in GetVisibleUsers())
{
_usersToPushDataTo.Add(user);
}
if (_usersToPushDataTo.Count > 0)
{
Logger.LogDebug("Pushing data {hash} for {count} visible players", _lastCreatedData?.DataHash.Value ?? "UNKNOWN", _usersToPushDataTo.Count);
PushCharacterData(forced);
if (_usersToPushDataTo.Count > 0)
{
Logger.LogDebug("Pushing data {hash} for {count} visible players", _lastCreatedData?.DataHash.Value ?? "UNKNOWN", _usersToPushDataTo.Count);
PushCharacterData_internalLocked(forced);
}
}
}
@@ -79,8 +91,10 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
{
if (!_dalamudUtil.GetIsPlayerPresent() || !_apiController.IsConnected) return;
var allVisibleUsers = _pairManager.GetVisibleUsers();
var newVisibleUsers = allVisibleUsers.Except(_previouslyVisiblePlayers).ToList();
var allVisibleUsers = GetVisibleUsers();
var newVisibleUsers = allVisibleUsers
.Except(_previouslyVisiblePlayers, UserDataComparer.Instance)
.ToList();
_previouslyVisiblePlayers.Clear();
_previouslyVisiblePlayers.AddRange(allVisibleUsers);
if (newVisibleUsers.Count == 0) return;
@@ -88,56 +102,144 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
Logger.LogDebug("Scheduling character data push of {data} to {users}",
_lastCreatedData?.DataHash.Value ?? string.Empty,
string.Join(", ", newVisibleUsers.Select(k => k.AliasOrUID)));
foreach (var user in newVisibleUsers)
lock (_pushSync)
{
_usersToPushDataTo.Add(user);
foreach (var user in newVisibleUsers)
{
_usersToPushDataTo.Add(user);
}
PushCharacterData_internalLocked();
}
PushCharacterData();
}
private void PushCharacterData(bool forced = false)
{
lock (_pushSync)
{
PushCharacterData_internalLocked(forced);
}
}
private void PushCharacterData_internalLocked(bool forced = false)
{
if (_lastCreatedData == null || _usersToPushDataTo.Count == 0) return;
if (!_apiController.IsConnected || !_fileTransferManager.IsReady)
{
Logger.LogTrace("Skipping character push. Connected: {connected}, UploadManagerReady: {ready}",
_apiController.IsConnected, _fileTransferManager.IsReady);
return;
}
_ = Task.Run(async () =>
{
try
{
forced |= _uploadingCharacterData?.DataHash != _lastCreatedData.DataHash;
Task<CharacterData>? uploadTask;
bool forcedPush;
lock (_pushSync)
{
if (_lastCreatedData == null || _usersToPushDataTo.Count == 0) return;
forcedPush = forced | (_uploadingCharacterData?.DataHash != _lastCreatedData.DataHash);
if (_fileUploadTask == null || (_fileUploadTask?.IsCompleted ?? false) || forced)
{
_uploadingCharacterData = _lastCreatedData.DeepClone();
Logger.LogDebug("Starting UploadTask for {hash}, Reason: TaskIsNull: {task}, TaskIsCompleted: {taskCpl}, Forced: {frc}",
_lastCreatedData.DataHash, _fileUploadTask == null, _fileUploadTask?.IsCompleted ?? false, forced);
_fileUploadTask = _fileTransferManager.UploadFiles(_uploadingCharacterData, [.. _usersToPushDataTo]);
}
if (_fileUploadTask == null || (_fileUploadTask?.IsCompleted ?? false) || forcedPush)
{
_uploadingCharacterData = _lastCreatedData.DeepClone();
Logger.LogDebug("Starting UploadTask for {hash}, Reason: TaskIsNull: {task}, TaskIsCompleted: {taskCpl}, Forced: {frc}",
_lastCreatedData.DataHash, _fileUploadTask == null, _fileUploadTask?.IsCompleted ?? false, forcedPush);
_fileUploadTask = _fileTransferManager.UploadFiles(_uploadingCharacterData, [.. _usersToPushDataTo]);
}
if (_fileUploadTask != null)
{
var dataToSend = await _fileUploadTask.ConfigureAwait(false);
uploadTask = _fileUploadTask;
}
var dataToSend = await uploadTask.ConfigureAwait(false);
var dataHash = dataToSend.DataHash.Value;
await _pushDataSemaphore.WaitAsync(_runtimeCts.Token).ConfigureAwait(false);
try
{
if (_usersToPushDataTo.Count == 0) return;
Logger.LogDebug("Pushing {data} to {users}", dataToSend.DataHash, string.Join(", ", _usersToPushDataTo.Select(k => k.AliasOrUID)));
await _apiController.PushCharacterData(dataToSend, [.. _usersToPushDataTo]).ConfigureAwait(false);
_usersToPushDataTo.Clear();
List<UserData> recipients;
bool shouldSkip = false;
lock (_pushSync)
{
if (_usersToPushDataTo.Count == 0) return;
recipients = forcedPush
? _usersToPushDataTo.ToList()
: _usersToPushDataTo
.Where(user => !_lastPushedHashes.TryGetValue(user.UID, out var sentHash) || !string.Equals(sentHash, dataHash, StringComparison.Ordinal))
.ToList();
if (recipients.Count == 0 && !forcedPush)
{
Logger.LogTrace("All recipients already have character data hash {hash}, skipping push.", dataHash);
_usersToPushDataTo.Clear();
shouldSkip = true;
}
}
if (shouldSkip)
return;
Logger.LogDebug("Pushing {data} to {users}", dataHash, string.Join(", ", recipients.Select(k => k.AliasOrUID)));
await _apiController.PushCharacterData(dataToSend, recipients).ConfigureAwait(false);
lock (_pushSync)
{
foreach (var user in recipients)
{
_lastPushedHashes[user.UID] = dataHash;
_usersToPushDataTo.Remove(user);
}
if (!forcedPush && _usersToPushDataTo.Count > 0)
{
foreach (var satisfied in _usersToPushDataTo
.Where(user => _lastPushedHashes.TryGetValue(user.UID, out var sentHash) && string.Equals(sentHash, dataHash, StringComparison.Ordinal))
.ToList())
{
_usersToPushDataTo.Remove(satisfied);
}
}
if (forcedPush)
{
_usersToPushDataTo.Clear();
}
}
}
finally
{
_pushDataSemaphore.Release();
}
}
}
catch (OperationCanceledException) when (_runtimeCts.IsCancellationRequested)
{
Logger.LogDebug("PushCharacterData cancelled");
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to push character data");
}
catch (OperationCanceledException) when (_runtimeCts.IsCancellationRequested)
{
Logger.LogDebug("PushCharacterData cancelled");
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to push character data");
}
});
}
}
private void HandleDisconnected()
{
_fileTransferManager.CancelUpload();
_previouslyVisiblePlayers.Clear();
lock (_pushSync)
{
_usersToPushDataTo.Clear();
_lastPushedHashes.Clear();
_uploadingCharacterData = null;
_fileUploadTask = null;
}
}
private List<UserData> GetVisibleUsers()
{
return _pairLedger.GetVisiblePairs()
.Select(connection => connection.User)
.ToList();
}
}

View File

@@ -20,6 +20,7 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase
private readonly CancellationTokenSource _runtimeCts = new();
private CancellationTokenSource _creationCts = new();
private CancellationTokenSource _debounceCts = new();
private string? _lastPublishedHash;
private bool _haltCharaDataCreation;
private bool _isZoning = false;
@@ -183,7 +184,18 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase
{
if (_isZoning || _haltCharaDataCreation) return;
if (_cachesToCreate.Count == 0) return;
bool hasCaches;
_cacheCreateLock.Wait();
try
{
hasCaches = _cachesToCreate.Count > 0;
}
finally
{
_cacheCreateLock.Release();
}
if (!hasCaches) return;
if (_playerRelatedObjects.Any(p => p.Value.CurrentDrawCondition is
not (GameObjectHandler.DrawCondition.None or GameObjectHandler.DrawCondition.DrawObjectZero or GameObjectHandler.DrawCondition.ObjectZero)))
@@ -197,6 +209,11 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase
_creationCts = new();
_cacheCreateLock.Wait(_creationCts.Token);
var objectKindsToCreate = _cachesToCreate.ToList();
if (objectKindsToCreate.Count == 0)
{
_cacheCreateLock.Release();
return;
}
foreach (var creationObj in objectKindsToCreate)
{
_currentlyCreating.Add(creationObj);
@@ -225,8 +242,17 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase
_playerData.SetFragment(kvp.Key, kvp.Value);
}
Mediator.Publish(new CharacterDataCreatedMessage(_playerData.ToAPI()));
_currentlyCreating.Clear();
var apiData = _playerData.ToAPI();
var currentHash = apiData.DataHash.Value;
if (string.Equals(_lastPublishedHash, currentHash, StringComparison.Ordinal))
{
Logger.LogTrace("Cache creation produced identical character data ({hash}), skipping publish.", currentHash);
}
else
{
_lastPublishedHash = currentHash;
Mediator.Publish(new CharacterDataCreatedMessage(apiData));
}
}
catch (OperationCanceledException)
{
@@ -238,6 +264,7 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase
}
finally
{
_currentlyCreating.Clear();
Logger.LogDebug("Cache Creation complete");
}
});

View File

@@ -13,14 +13,19 @@ using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.PlayerData.Services;
using LightlessSync.Services;
using LightlessSync.Services.Chat;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.CharaData;
using LightlessSync.Services.Events;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Services.TextureCompression;
using LightlessSync.UI;
using LightlessSync.UI.Components;
using LightlessSync.UI.Components.Popup;
using LightlessSync.UI.Handlers;
using LightlessSync.UI.Tags;
using LightlessSync.UI.Services;
using LightlessSync.WebAPI;
using LightlessSync.WebAPI.Files;
using LightlessSync.WebAPI.SignalR;
@@ -28,8 +33,11 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NReco.Logging.File;
using System;
using System.IO;
using System.Net.Http.Headers;
using System.Reflection;
using OtterTex;
namespace LightlessSync;
@@ -43,6 +51,7 @@ public sealed class Plugin : IDalamudPlugin
ITextureProvider textureProvider, IContextMenu contextMenu, IGameInteropProvider gameInteropProvider, IGameConfig gameConfig,
ISigScanner sigScanner, INamePlateGui namePlateGui, IAddonLifecycle addonLifecycle)
{
NativeDll.Initialize(pluginInterface.AssemblyLocation.DirectoryName);
if (!Directory.Exists(pluginInterface.ConfigDirectory.FullName))
Directory.CreateDirectory(pluginInterface.ConfigDirectory.FullName);
var traceDir = Path.Join(pluginInterface.ConfigDirectory.FullName, "tracelog");
@@ -96,6 +105,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<LightlessMediator>();
collection.AddSingleton<FileCacheManager>();
collection.AddSingleton<ServerConfigurationManager>();
collection.AddSingleton<ProfileTagService>();
collection.AddSingleton<ApiController>();
collection.AddSingleton<PerformanceCollectorService>();
collection.AddSingleton<HubFactory>();
@@ -103,11 +113,22 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<FileTransferOrchestrator>();
collection.AddSingleton<LightlessPlugin>();
collection.AddSingleton<LightlessProfileManager>();
collection.AddSingleton<TextureCompressionService>();
collection.AddSingleton<TextureMetadataHelper>(s =>
{
var logger = s.GetRequiredService<ILogger<TextureMetadataHelper>>();
return new TextureMetadataHelper(logger, gameData);
});
collection.AddSingleton<TextureDownscaleService>();
collection.AddSingleton<GameObjectHandlerFactory>();
collection.AddSingleton<FileDownloadManagerFactory>();
collection.AddSingleton<PairHandlerFactory>();
collection.AddSingleton<PairProcessingLimiter>();
collection.AddSingleton<PairFactory>();
collection.AddSingleton(s => new PairFactory(
s.GetRequiredService<ILoggerFactory>(),
s.GetRequiredService<PairLedger>(),
s.GetRequiredService<LightlessMediator>(),
new Lazy<ServerConfigurationManager>(() => s.GetRequiredService<ServerConfigurationManager>()),
s.GetRequiredService<Lazy<ApiController>>()));
collection.AddSingleton<XivDataAnalyzer>();
collection.AddSingleton<CharacterAnalyzer>();
collection.AddSingleton<TokenProvider>();
@@ -116,9 +137,15 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<TagHandler>();
collection.AddSingleton(s => new Lazy<ApiController>(() => s.GetRequiredService<ApiController>()));
collection.AddSingleton<PairRequestService>();
collection.AddSingleton<ZoneChatService>();
collection.AddSingleton<IdDisplayHandler>();
collection.AddSingleton<PlayerPerformanceService>();
collection.AddSingleton<TransientResourceManager>();
collection.AddSingleton(s => new TransientResourceManager(s.GetRequiredService<ILogger<TransientResourceManager>>(),
s.GetRequiredService<TransientConfigService>(),
s.GetRequiredService<DalamudUtilService>(),
s.GetRequiredService<LightlessMediator>(),
s.GetRequiredService<ActorObjectService>(),
s.GetRequiredService<GameObjectHandlerFactory>()));
collection.AddSingleton<CharaDataManager>();
collection.AddSingleton<CharaDataFileHandler>();
@@ -141,30 +168,53 @@ public sealed class Plugin : IDalamudPlugin
s.GetRequiredService<ILogger<EventAggregator>>(), s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton((s) => new DalamudUtilService(s.GetRequiredService<ILogger<DalamudUtilService>>(),
clientState, objectTable, framework, gameGui, condition, gameData, targetManager, gameConfig,
s.GetRequiredService<BlockedCharacterHandler>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(),
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<PlayerPerformanceConfigService>()));
s.GetRequiredService<ActorObjectService>(), s.GetRequiredService<BlockedCharacterHandler>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(),
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<PlayerPerformanceConfigService>(), new Lazy<PairFactory>(() => s.GetRequiredService<PairFactory>())));
collection.AddSingleton<PairManager>();
collection.AddSingleton<PairStateCache>();
collection.AddSingleton<IPairHandlerAdapterFactory, PairHandlerAdapterFactory>();
collection.AddSingleton(s => new PairHandlerRegistry(
s.GetRequiredService<IPairHandlerAdapterFactory>(),
s.GetRequiredService<PairManager>(),
s.GetRequiredService<PairStateCache>(),
s.GetRequiredService<ILogger<PairHandlerRegistry>>()));
collection.AddSingleton<PairLedger>();
collection.AddSingleton<PairUiService>();
collection.AddSingleton((s) => new DtrEntry(
s.GetRequiredService<ILogger<DtrEntry>>(),
dtrBar,
s.GetRequiredService<LightlessConfigService>(),
s.GetRequiredService<LightlessMediator>(),
s.GetRequiredService<PairManager>(),
s.GetRequiredService<PairUiService>(),
s.GetRequiredService<PairRequestService>(),
s.GetRequiredService<ApiController>(),
s.GetRequiredService<ServerConfigurationManager>(),
s.GetRequiredService<BroadcastService>(),
s.GetRequiredService<BroadcastScannerService>(),
s.GetRequiredService<DalamudUtilService>()));
collection.AddSingleton(s => new PairManager(s.GetRequiredService<ILogger<PairManager>>(), s.GetRequiredService<PairFactory>(),
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), contextMenu, s.GetRequiredService<PairProcessingLimiter>()));
collection.AddSingleton(s => new PairCoordinator(
s.GetRequiredService<ILogger<PairCoordinator>>(),
s.GetRequiredService<LightlessConfigService>(),
s.GetRequiredService<LightlessMediator>(),
s.GetRequiredService<PairHandlerRegistry>(),
s.GetRequiredService<PairManager>(),
s.GetRequiredService<PairLedger>(),
s.GetRequiredService<ServerConfigurationManager>()));
collection.AddSingleton<RedrawManager>();
collection.AddSingleton<BroadcastService>();
collection.AddSingleton(addonLifecycle);
collection.AddSingleton(p => new ContextMenuService(contextMenu, pluginInterface, gameData,
p.GetRequiredService<ILogger<ContextMenuService>>(), p.GetRequiredService<DalamudUtilService>(), p.GetRequiredService<ApiController>(), objectTable,
p.GetRequiredService<LightlessConfigService>(), p.GetRequiredService<PairRequestService>(), p.GetRequiredService<PairManager>(), clientState));
collection.AddSingleton(p => new ContextMenuService(contextMenu, pluginInterface, gameData, p.GetRequiredService<ILogger<ContextMenuService>>(), p.GetRequiredService<DalamudUtilService>(), p.GetRequiredService<ApiController>(), objectTable,
p.GetRequiredService<LightlessConfigService>(),
p.GetRequiredService<PairRequestService>(),
p.GetRequiredService<PairUiService>(),
clientState,
p.GetRequiredService<BroadcastScannerService>(),
p.GetRequiredService<BroadcastService>(),
p.GetRequiredService<LightlessProfileManager>(),
p.GetRequiredService<LightlessMediator>()));
collection.AddSingleton((s) => new IpcCallerPenumbra(s.GetRequiredService<ILogger<IpcCallerPenumbra>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<RedrawManager>()));
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<RedrawManager>(),
s.GetRequiredService<ActorObjectService>()));
collection.AddSingleton((s) => new IpcCallerGlamourer(s.GetRequiredService<ILogger<IpcCallerGlamourer>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<RedrawManager>()));
collection.AddSingleton((s) => new IpcCallerCustomize(s.GetRequiredService<ILogger<IpcCallerCustomize>>(), pluginInterface,
@@ -190,7 +240,9 @@ public sealed class Plugin : IDalamudPlugin
notificationManager,
chatGui,
s.GetRequiredService<LightlessMediator>(),
s.GetRequiredService<PairRequestService>()));
s.GetRequiredService<PairRequestService>(),
s.GetRequiredService<PairUiService>(),
s.GetRequiredService<PairFactory>()));
collection.AddSingleton((s) =>
{
var httpClient = new HttpClient();
@@ -199,6 +251,7 @@ public sealed class Plugin : IDalamudPlugin
return httpClient;
});
collection.AddSingleton((s) => new UiThemeConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new ChatConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) =>
{
var cfg = new LightlessConfigService(pluginInterface.ConfigDirectory.FullName);
@@ -216,6 +269,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton((s) => new CharaDataConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<LightlessConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<UiThemeConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<ChatConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<ServerConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<NotesConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<PairTagConfigService>());
@@ -226,8 +280,15 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<CharaDataConfigService>());
collection.AddSingleton<ConfigurationMigrator>();
collection.AddSingleton<ConfigurationSaveService>();
collection.AddSingleton(sp => new ActorObjectService(
sp.GetRequiredService<ILogger<ActorObjectService>>(),
framework,
gameInteropProvider,
objectTable,
clientState,
sp.GetRequiredService<LightlessMediator>()));
collection.AddSingleton<HubFactory>();
collection.AddSingleton(s => new BroadcastScannerService( s.GetRequiredService<ILogger<BroadcastScannerService>>(), clientState, objectTable, framework, s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<NameplateHandler>(), s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessConfigService>()));
collection.AddSingleton(s => new BroadcastScannerService( s.GetRequiredService<ILogger<BroadcastScannerService>>(), framework, s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<NameplateHandler>(), s.GetRequiredService<ActorObjectService>()));
// add scoped services
@@ -247,13 +308,14 @@ public sealed class Plugin : IDalamudPlugin
collection.AddScoped<WindowMediatorSubscriberBase, EventViewerUI>();
collection.AddScoped<WindowMediatorSubscriberBase, CharaDataHubUi>();
collection.AddScoped<WindowMediatorSubscriberBase, UpdateNotesUi>();
collection.AddScoped<WindowMediatorSubscriberBase, ZoneChatUi>();
collection.AddScoped<WindowMediatorSubscriberBase, EditProfileUi>((s) => new EditProfileUi(s.GetRequiredService<ILogger<EditProfileUi>>(),
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<FileDialogManager>(),
s.GetRequiredService<LightlessProfileManager>(), s.GetRequiredService<PerformanceCollectorService>()));
s.GetRequiredService<LightlessProfileManager>(), s.GetRequiredService<ProfileTagService>(), s.GetRequiredService<PerformanceCollectorService>()));
collection.AddScoped<WindowMediatorSubscriberBase, PopupHandler>();
collection.AddScoped<WindowMediatorSubscriberBase, BroadcastUI>((s) => new BroadcastUI(s.GetRequiredService<ILogger<BroadcastUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>()));
collection.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>((s) => new SyncshellFinderUI(s.GetRequiredService<ILogger<SyncshellFinderUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>(), s.GetRequiredService<PairManager>(), s.GetRequiredService<DalamudUtilService>()));
collection.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>((s) => new SyncshellFinderUI(s.GetRequiredService<ILogger<SyncshellFinderUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>(), s.GetRequiredService<PairUiService>(), s.GetRequiredService<DalamudUtilService>()));
collection.AddScoped<IPopupHandler, BanUserPopupHandler>();
collection.AddScoped<WindowMediatorSubscriberBase, LightlessNotificationUi>((s) =>
new LightlessNotificationUi(
@@ -268,8 +330,9 @@ public sealed class Plugin : IDalamudPlugin
collection.AddScoped((s) => new UiService(s.GetRequiredService<ILogger<UiService>>(), pluginInterface.UiBuilder, s.GetRequiredService<LightlessConfigService>(),
s.GetRequiredService<WindowSystem>(), s.GetServices<WindowMediatorSubscriberBase>(),
s.GetRequiredService<UiFactory>(),
s.GetRequiredService<FileDialogManager>(),
s.GetRequiredService<LightlessMediator>()));
s.GetRequiredService<FileDialogManager>(),
s.GetRequiredService<LightlessMediator>(),
s.GetRequiredService<PairFactory>()));
collection.AddScoped((s) => new CommandManagerService(commandManager, s.GetRequiredService<PerformanceCollectorService>(),
s.GetRequiredService<ServerConfigurationManager>(), s.GetRequiredService<CacheMonitor>(), s.GetRequiredService<ApiController>(),
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<LightlessConfigService>()));
@@ -278,12 +341,14 @@ public sealed class Plugin : IDalamudPlugin
pluginInterface, textureProvider, s.GetRequiredService<Dalamud.Localization>(), s.GetRequiredService<ServerConfigurationManager>(), s.GetRequiredService<TokenProvider>(),
s.GetRequiredService<LightlessMediator>()));
collection.AddScoped((s) => new NameplateService(s.GetRequiredService<ILogger<NameplateService>>(), s.GetRequiredService<LightlessConfigService>(), namePlateGui, clientState,
s.GetRequiredService<PairManager>(), s.GetRequiredService<LightlessMediator>()));
s.GetRequiredService<PairUiService>(), s.GetRequiredService<LightlessMediator>()));
collection.AddScoped((s) => new NameplateHandler(s.GetRequiredService<ILogger<NameplateHandler>>(), addonLifecycle, gameGui, s.GetRequiredService<DalamudUtilService>(),
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), clientState, s.GetRequiredService<PairManager>()));
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), clientState, s.GetRequiredService<PairUiService>()));
collection.AddHostedService(p => p.GetRequiredService<ConfigurationSaveService>());
collection.AddHostedService(p => p.GetRequiredService<ActorObjectService>());
collection.AddHostedService(p => p.GetRequiredService<LightlessMediator>());
collection.AddHostedService(p => p.GetRequiredService<ZoneChatService>());
collection.AddHostedService(p => p.GetRequiredService<NotificationService>());
collection.AddHostedService(p => p.GetRequiredService<FileCacheManager>());
collection.AddHostedService(p => p.GetRequiredService<ConfigurationMigrator>());

View File

@@ -0,0 +1,754 @@
using LightlessSync;
using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Dalamud.Game;
using Dalamud.Game.ClientState;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Hooking;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using FFXIVClientStructs.Interop;
using System.Threading;
namespace LightlessSync.Services.ActorTracking;
public sealed unsafe class ActorObjectService : IHostedService, IDisposable
{
public readonly record struct ActorDescriptor(
string Name,
string HashedContentId,
nint Address,
ushort ObjectIndex,
bool IsLocalPlayer,
bool IsInGpose,
DalamudObjectKind ObjectKind,
LightlessObjectKind? OwnedKind,
uint OwnerEntityId);
private readonly ILogger<ActorObjectService> _logger;
private readonly IFramework _framework;
private readonly IGameInteropProvider _interop;
private readonly IObjectTable _objectTable;
private readonly IClientState _clientState;
private readonly LightlessMediator _mediator;
private readonly ConcurrentDictionary<nint, ActorDescriptor> _activePlayers = new();
private readonly ConcurrentDictionary<string, ActorDescriptor> _actorsByHash = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ConcurrentDictionary<nint, ActorDescriptor>> _actorsByName = new(StringComparer.Ordinal);
private ActorDescriptor[] _playerCharacterSnapshot = Array.Empty<ActorDescriptor>();
private nint[] _playerAddressSnapshot = Array.Empty<nint>();
private readonly HashSet<nint> _renderedPlayers = new();
private readonly HashSet<nint> _renderedCompanions = new();
private readonly Dictionary<nint, LightlessObjectKind> _ownedObjects = new();
private nint[] _renderedPlayerSnapshot = Array.Empty<nint>();
private nint[] _renderedCompanionSnapshot = Array.Empty<nint>();
private nint[] _ownedObjectSnapshot = Array.Empty<nint>();
private IReadOnlyDictionary<nint, LightlessObjectKind> _ownedObjectMapSnapshot = new Dictionary<nint, LightlessObjectKind>();
private nint _localPlayerAddress = nint.Zero;
private nint _localPetAddress = nint.Zero;
private nint _localMinionMountAddress = nint.Zero;
private nint _localCompanionAddress = nint.Zero;
private Hook<Character.Delegates.OnInitialize>? _onInitializeHook;
private Hook<Character.Delegates.Terminate>? _onTerminateHook;
private Hook<Character.Delegates.Dtor>? _onDestructorHook;
private Hook<Companion.Delegates.OnInitialize>? _onCompanionInitializeHook;
private Hook<Companion.Delegates.Terminate>? _onCompanionTerminateHook;
private bool _hooksActive;
private static readonly TimeSpan SnapshotRefreshInterval = TimeSpan.FromSeconds(1);
private DateTime _nextRefreshAllowed = DateTime.MinValue;
public ActorObjectService(
ILogger<ActorObjectService> logger,
IFramework framework,
IGameInteropProvider interop,
IObjectTable objectTable,
IClientState clientState,
LightlessMediator mediator)
{
_logger = logger;
_framework = framework;
_interop = interop;
_objectTable = objectTable;
_clientState = clientState;
_mediator = mediator;
}
public IReadOnlyList<nint> PlayerAddresses => Volatile.Read(ref _playerAddressSnapshot);
public IEnumerable<ActorDescriptor> PlayerDescriptors => _activePlayers.Values;
public IReadOnlyList<ActorDescriptor> PlayerCharacterDescriptors => Volatile.Read(ref _playerCharacterSnapshot);
public bool TryGetActorByHash(string hash, out ActorDescriptor descriptor) => _actorsByHash.TryGetValue(hash, out descriptor);
public bool TryGetPlayerByName(string name, out ActorDescriptor descriptor)
{
descriptor = default;
if (!_actorsByName.TryGetValue(name, out var entries) || entries.IsEmpty)
return false;
ActorDescriptor? best = null;
foreach (var candidate in entries.Values)
{
if (best is null || IsBetterNameMatch(candidate, best.Value))
{
best = candidate;
}
}
if (best is { } selected)
{
descriptor = selected;
return true;
}
return false;
}
public bool HooksActive => _hooksActive;
public IReadOnlyList<nint> RenderedPlayerAddresses => Volatile.Read(ref _renderedPlayerSnapshot);
public IReadOnlyList<nint> RenderedCompanionAddresses => Volatile.Read(ref _renderedCompanionSnapshot);
public IReadOnlyList<nint> OwnedObjectAddresses => Volatile.Read(ref _ownedObjectSnapshot);
public IReadOnlyDictionary<nint, LightlessObjectKind> OwnedObjects => Volatile.Read(ref _ownedObjectMapSnapshot);
public nint LocalPlayerAddress => Volatile.Read(ref _localPlayerAddress);
public nint LocalPetAddress => Volatile.Read(ref _localPetAddress);
public nint LocalMinionOrMountAddress => Volatile.Read(ref _localMinionMountAddress);
public nint LocalCompanionAddress => Volatile.Read(ref _localCompanionAddress);
public bool TryGetOwnedObject(LightlessObjectKind kind, out nint address)
{
address = kind switch
{
LightlessObjectKind.Player => Volatile.Read(ref _localPlayerAddress),
LightlessObjectKind.Pet => Volatile.Read(ref _localPetAddress),
LightlessObjectKind.MinionOrMount => Volatile.Read(ref _localMinionMountAddress),
LightlessObjectKind.Companion => Volatile.Read(ref _localCompanionAddress),
_ => nint.Zero
};
return address != nint.Zero;
}
public bool TryGetOwnedActor(uint ownerEntityId, LightlessObjectKind? kindFilter, out ActorDescriptor descriptor)
{
descriptor = default;
foreach (var candidate in _activePlayers.Values)
{
if (candidate.OwnerEntityId != ownerEntityId)
continue;
if (kindFilter.HasValue && candidate.OwnedKind != kindFilter)
continue;
descriptor = candidate;
return true;
}
return false;
}
public bool TryGetPlayerAddressByHash(string hash, out nint address)
{
if (TryGetActorByHash(hash, out var descriptor) && descriptor.Address != nint.Zero)
{
address = descriptor.Address;
return true;
}
address = nint.Zero;
return false;
}
public void RefreshTrackedActors(bool force = false)
{
var now = DateTime.UtcNow;
if (!force && _hooksActive)
{
if (now < _nextRefreshAllowed)
return;
_nextRefreshAllowed = now + SnapshotRefreshInterval;
}
if (_framework.IsInFrameworkUpdateThread)
{
RefreshTrackedActorsInternal();
}
else
{
_framework.RunOnFrameworkThread(RefreshTrackedActorsInternal);
}
}
public Task StartAsync(CancellationToken cancellationToken)
{
try
{
InitializeHooks();
var warmupTask = WarmupExistingActors();
return warmupTask;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize ActorObjectService hooks, falling back to empty cache.");
DisposeHooks();
return Task.CompletedTask;
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
DisposeHooks();
_activePlayers.Clear();
_actorsByHash.Clear();
_actorsByName.Clear();
Volatile.Write(ref _playerCharacterSnapshot, Array.Empty<ActorDescriptor>());
Volatile.Write(ref _playerAddressSnapshot, Array.Empty<nint>());
Volatile.Write(ref _renderedPlayerSnapshot, Array.Empty<nint>());
Volatile.Write(ref _renderedCompanionSnapshot, Array.Empty<nint>());
Volatile.Write(ref _ownedObjectSnapshot, Array.Empty<nint>());
Volatile.Write(ref _ownedObjectMapSnapshot, new Dictionary<nint, LightlessObjectKind>());
Volatile.Write(ref _localPlayerAddress, nint.Zero);
Volatile.Write(ref _localPetAddress, nint.Zero);
Volatile.Write(ref _localMinionMountAddress, nint.Zero);
Volatile.Write(ref _localCompanionAddress, nint.Zero);
_renderedPlayers.Clear();
_renderedCompanions.Clear();
_ownedObjects.Clear();
return Task.CompletedTask;
}
private void InitializeHooks()
{
if (_hooksActive)
return;
_onInitializeHook = _interop.HookFromAddress<Character.Delegates.OnInitialize>(
(nint)Character.StaticVirtualTablePointer->OnInitialize,
OnCharacterInitialized);
_onTerminateHook = _interop.HookFromAddress<Character.Delegates.Terminate>(
(nint)Character.StaticVirtualTablePointer->Terminate,
OnCharacterTerminated);
_onDestructorHook = _interop.HookFromAddress<Character.Delegates.Dtor>(
(nint)Character.StaticVirtualTablePointer->Dtor,
OnCharacterDisposed);
_onCompanionInitializeHook = _interop.HookFromAddress<Companion.Delegates.OnInitialize>(
(nint)Companion.StaticVirtualTablePointer->OnInitialize,
OnCompanionInitialized);
_onCompanionTerminateHook = _interop.HookFromAddress<Companion.Delegates.Terminate>(
(nint)Companion.StaticVirtualTablePointer->Terminate,
OnCompanionTerminated);
_onInitializeHook.Enable();
_onTerminateHook.Enable();
_onDestructorHook.Enable();
_onCompanionInitializeHook.Enable();
_onCompanionTerminateHook.Enable();
_hooksActive = true;
_logger.LogDebug("ActorObjectService hooks enabled.");
}
private Task WarmupExistingActors()
{
return _framework.RunOnFrameworkThread(() =>
{
RefreshTrackedActorsInternal();
_nextRefreshAllowed = DateTime.UtcNow + SnapshotRefreshInterval;
});
}
private void OnCharacterInitialized(Character* chara)
{
try
{
_onInitializeHook!.Original(chara);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking original character initialize.");
}
QueueFrameworkUpdate(() => TrackGameObject((GameObject*)chara));
}
private void OnCharacterTerminated(Character* chara)
{
var address = (nint)chara;
QueueFrameworkUpdate(() => UntrackGameObject(address));
try
{
_onTerminateHook!.Original(chara);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking original character terminate.");
}
}
private GameObject* OnCharacterDisposed(Character* chara, byte freeMemory)
{
var address = (nint)chara;
QueueFrameworkUpdate(() => UntrackGameObject(address));
try
{
return _onDestructorHook!.Original(chara, freeMemory);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking original character destructor.");
return null;
}
}
private void TrackGameObject(GameObject* gameObject)
{
if (gameObject == null)
return;
var objectKind = (DalamudObjectKind)gameObject->ObjectKind;
if (!IsSupportedObjectKind(objectKind))
return;
if (BuildDescriptor(gameObject, objectKind) is not { } descriptor)
return;
if (descriptor.ObjectKind != DalamudObjectKind.Player && descriptor.OwnedKind is null)
return;
if (_activePlayers.TryGetValue(descriptor.Address, out var existing))
{
RemoveDescriptorFromIndexes(existing);
RemoveDescriptorFromCollections(existing);
}
_activePlayers[descriptor.Address] = descriptor;
IndexDescriptor(descriptor);
AddDescriptorToCollections(descriptor);
RebuildSnapshots();
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Actor tracked: {Name} addr={Address:X} idx={Index} owned={OwnedKind} local={Local} gpose={Gpose}",
descriptor.Name,
descriptor.Address,
descriptor.ObjectIndex,
descriptor.OwnedKind?.ToString() ?? "<none>",
descriptor.IsLocalPlayer,
descriptor.IsInGpose);
}
_mediator.Publish(new ActorTrackedMessage(descriptor));
}
private ActorDescriptor? BuildDescriptor(GameObject* gameObject, DalamudObjectKind objectKind)
{
if (gameObject == null)
return null;
var address = (nint)gameObject;
string name = string.Empty;
ushort objectIndex = (ushort)gameObject->ObjectIndex;
bool isInGpose = objectIndex >= 200;
bool isLocal = _clientState.LocalPlayer?.Address == address;
string hashedCid = string.Empty;
if (_objectTable.CreateObjectReference(address) is IPlayerCharacter playerCharacter)
{
name = playerCharacter.Name.TextValue ?? string.Empty;
objectIndex = playerCharacter.ObjectIndex;
isInGpose = objectIndex >= 200;
isLocal = playerCharacter.Address == _clientState.LocalPlayer?.Address;
}
else
{
name = gameObject->NameString ?? string.Empty;
}
if (objectKind == DalamudObjectKind.Player)
{
hashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address);
}
var (ownedKind, ownerEntityId) = DetermineOwnedKind(gameObject, objectKind, isLocal);
return new ActorDescriptor(name, hashedCid, address, objectIndex, isLocal, isInGpose, objectKind, ownedKind, ownerEntityId);
}
private (LightlessObjectKind? OwnedKind, uint OwnerEntityId) DetermineOwnedKind(GameObject* gameObject, DalamudObjectKind objectKind, bool isLocalPlayer)
{
if (gameObject == null)
return (null, 0);
if (objectKind == DalamudObjectKind.Player)
{
var entityId = ((Character*)gameObject)->EntityId;
return (isLocalPlayer ? LightlessObjectKind.Player : null, entityId);
}
if (isLocalPlayer)
{
var entityId = ((Character*)gameObject)->EntityId;
return (LightlessObjectKind.Player, entityId);
}
if (_clientState.LocalPlayer is not { } localPlayer)
return (null, 0);
var ownerId = gameObject->OwnerId;
if (ownerId == 0)
{
var character = (Character*)gameObject;
if (character != null)
{
ownerId = character->CompanionOwnerId;
if (ownerId == 0)
{
var parent = character->GetParentCharacter();
if (parent != null)
{
ownerId = parent->EntityId;
}
}
}
}
if (ownerId == 0 || ownerId != localPlayer.EntityId)
return (null, ownerId);
var ownedKind = objectKind switch
{
DalamudObjectKind.MountType => LightlessObjectKind.MinionOrMount,
DalamudObjectKind.Companion => LightlessObjectKind.MinionOrMount,
DalamudObjectKind.BattleNpc => gameObject->BattleNpcSubKind switch
{
BattleNpcSubKind.Buddy => LightlessObjectKind.Companion,
BattleNpcSubKind.Pet => LightlessObjectKind.Pet,
_ => (LightlessObjectKind?)null,
},
_ => (LightlessObjectKind?)null,
};
return (ownedKind, ownerId);
}
private void UntrackGameObject(nint address)
{
if (address == nint.Zero)
return;
if (_activePlayers.TryRemove(address, out var descriptor))
{
RemoveDescriptorFromIndexes(descriptor);
RemoveDescriptorFromCollections(descriptor);
RebuildSnapshots();
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Actor untracked: {Name} addr={Address:X} idx={Index} owned={OwnedKind}",
descriptor.Name,
descriptor.Address,
descriptor.ObjectIndex,
descriptor.OwnedKind?.ToString() ?? "<none>");
}
_mediator.Publish(new ActorUntrackedMessage(descriptor));
}
}
private void RefreshTrackedActorsInternal()
{
var addresses = EnumerateActiveCharacterAddresses();
HashSet<nint> seen = new(addresses.Count);
foreach (var address in addresses)
{
if (address == nint.Zero)
continue;
if (!seen.Add(address))
continue;
if (_activePlayers.ContainsKey(address))
continue;
TrackGameObject((GameObject*)address);
}
var stale = _activePlayers.Keys.Where(addr => !seen.Contains(addr)).ToList();
foreach (var staleAddress in stale)
{
UntrackGameObject(staleAddress);
}
if (_hooksActive)
{
_nextRefreshAllowed = DateTime.UtcNow + SnapshotRefreshInterval;
}
}
private void IndexDescriptor(ActorDescriptor descriptor)
{
if (!string.IsNullOrEmpty(descriptor.HashedContentId))
{
_actorsByHash[descriptor.HashedContentId] = descriptor;
}
if (descriptor.ObjectKind == DalamudObjectKind.Player && !string.IsNullOrEmpty(descriptor.Name))
{
var bucket = _actorsByName.GetOrAdd(descriptor.Name, _ => new ConcurrentDictionary<nint, ActorDescriptor>());
bucket[descriptor.Address] = descriptor;
}
}
private static bool IsBetterNameMatch(ActorDescriptor candidate, ActorDescriptor current)
{
if (!candidate.IsInGpose && current.IsInGpose)
return true;
if (candidate.IsInGpose && !current.IsInGpose)
return false;
return candidate.ObjectIndex < current.ObjectIndex;
}
private void OnCompanionInitialized(Companion* companion)
{
try
{
_onCompanionInitializeHook!.Original(companion);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking original companion initialize.");
}
QueueFrameworkUpdate(() => TrackGameObject((GameObject*)companion));
}
private void OnCompanionTerminated(Companion* companion)
{
var address = (nint)companion;
QueueFrameworkUpdate(() => UntrackGameObject(address));
try
{
_onCompanionTerminateHook!.Original(companion);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking original companion terminate.");
}
}
private void RemoveDescriptorFromIndexes(ActorDescriptor descriptor)
{
if (!string.IsNullOrEmpty(descriptor.HashedContentId))
{
_actorsByHash.TryRemove(descriptor.HashedContentId, out _);
}
if (descriptor.ObjectKind == DalamudObjectKind.Player && !string.IsNullOrEmpty(descriptor.Name))
{
if (_actorsByName.TryGetValue(descriptor.Name, out var bucket))
{
bucket.TryRemove(descriptor.Address, out _);
if (bucket.IsEmpty)
{
_actorsByName.TryRemove(descriptor.Name, out _);
}
}
}
}
private void AddDescriptorToCollections(ActorDescriptor descriptor)
{
if (descriptor.ObjectKind == DalamudObjectKind.Player)
{
_renderedPlayers.Add(descriptor.Address);
if (descriptor.IsLocalPlayer)
{
Volatile.Write(ref _localPlayerAddress, descriptor.Address);
}
}
else if (descriptor.ObjectKind == DalamudObjectKind.Companion)
{
_renderedCompanions.Add(descriptor.Address);
}
if (descriptor.OwnedKind is { } ownedKind)
{
_ownedObjects[descriptor.Address] = ownedKind;
switch (ownedKind)
{
case LightlessObjectKind.Player:
Volatile.Write(ref _localPlayerAddress, descriptor.Address);
break;
case LightlessObjectKind.Pet:
Volatile.Write(ref _localPetAddress, descriptor.Address);
break;
case LightlessObjectKind.MinionOrMount:
Volatile.Write(ref _localMinionMountAddress, descriptor.Address);
break;
case LightlessObjectKind.Companion:
Volatile.Write(ref _localCompanionAddress, descriptor.Address);
break;
}
}
}
private void RemoveDescriptorFromCollections(ActorDescriptor descriptor)
{
if (descriptor.ObjectKind == DalamudObjectKind.Player)
{
_renderedPlayers.Remove(descriptor.Address);
if (descriptor.IsLocalPlayer && Volatile.Read(ref _localPlayerAddress) == descriptor.Address)
{
Volatile.Write(ref _localPlayerAddress, nint.Zero);
}
}
else if (descriptor.ObjectKind == DalamudObjectKind.Companion)
{
_renderedCompanions.Remove(descriptor.Address);
if (Volatile.Read(ref _localCompanionAddress) == descriptor.Address)
{
Volatile.Write(ref _localCompanionAddress, nint.Zero);
}
}
if (descriptor.OwnedKind is { } ownedKind)
{
_ownedObjects.Remove(descriptor.Address);
switch (ownedKind)
{
case LightlessObjectKind.Player when Volatile.Read(ref _localPlayerAddress) == descriptor.Address:
Volatile.Write(ref _localPlayerAddress, nint.Zero);
break;
case LightlessObjectKind.Pet when Volatile.Read(ref _localPetAddress) == descriptor.Address:
Volatile.Write(ref _localPetAddress, nint.Zero);
break;
case LightlessObjectKind.MinionOrMount when Volatile.Read(ref _localMinionMountAddress) == descriptor.Address:
Volatile.Write(ref _localMinionMountAddress, nint.Zero);
break;
case LightlessObjectKind.Companion when Volatile.Read(ref _localCompanionAddress) == descriptor.Address:
Volatile.Write(ref _localCompanionAddress, nint.Zero);
break;
}
}
}
private void RebuildSnapshots()
{
var playerDescriptors = _activePlayers.Values
.Where(descriptor => descriptor.ObjectKind == DalamudObjectKind.Player)
.ToArray();
Volatile.Write(ref _playerCharacterSnapshot, playerDescriptors);
Volatile.Write(ref _playerAddressSnapshot, playerDescriptors.Select(d => d.Address).ToArray());
Volatile.Write(ref _renderedPlayerSnapshot, _renderedPlayers.ToArray());
Volatile.Write(ref _renderedCompanionSnapshot, _renderedCompanions.ToArray());
Volatile.Write(ref _ownedObjectSnapshot, _ownedObjects.Keys.ToArray());
Volatile.Write(ref _ownedObjectMapSnapshot, new Dictionary<nint, LightlessObjectKind>(_ownedObjects));
}
private void QueueFrameworkUpdate(Action action)
{
if (action == null)
return;
if (_framework.IsInFrameworkUpdateThread)
{
action();
return;
}
_framework.RunOnFrameworkThread(action);
}
private void DisposeHooks()
{
var hadHooks = _hooksActive
|| _onInitializeHook is not null
|| _onTerminateHook is not null
|| _onDestructorHook is not null
|| _onCompanionInitializeHook is not null
|| _onCompanionTerminateHook is not null;
_onInitializeHook?.Disable();
_onTerminateHook?.Disable();
_onDestructorHook?.Disable();
_onCompanionInitializeHook?.Disable();
_onCompanionTerminateHook?.Disable();
_onInitializeHook?.Dispose();
_onTerminateHook?.Dispose();
_onDestructorHook?.Dispose();
_onCompanionInitializeHook?.Dispose();
_onCompanionTerminateHook?.Dispose();
_onInitializeHook = null;
_onTerminateHook = null;
_onDestructorHook = null;
_onCompanionInitializeHook = null;
_onCompanionTerminateHook = null;
_hooksActive = false;
if (hadHooks)
{
_logger.LogDebug("ActorObjectService hooks disabled.");
}
}
public void Dispose()
{
DisposeHooks();
GC.SuppressFinalize(this);
}
private static bool IsSupportedObjectKind(DalamudObjectKind objectKind) =>
objectKind is DalamudObjectKind.Player
or DalamudObjectKind.BattleNpc
or DalamudObjectKind.Companion
or DalamudObjectKind.MountType;
private static List<nint> EnumerateActiveCharacterAddresses()
{
var results = new List<nint>(64);
var manager = GameObjectManager.Instance();
if (manager == null)
return results;
const int objectLimit = 200;
unsafe
{
for (var i = 0; i < objectLimit; i++)
{
Pointer<GameObject> objPtr = manager->Objects.IndexSorted[i];
var obj = objPtr.Value;
if (obj == null)
continue;
var objectKind = (DalamudObjectKind)obj->ObjectKind;
if (!IsSupportedObjectKind(objectKind))
continue;
results.Add((nint)obj);
}
}
return results;
}
}

View File

@@ -1,7 +1,7 @@
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin.Services;
using Dalamud.Plugin.Services;
using LightlessSync.API.Dto.User;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
@@ -11,7 +11,7 @@ namespace LightlessSync.Services;
public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDisposable
{
private readonly ILogger<BroadcastScannerService> _logger;
private readonly IObjectTable _objectTable;
private readonly ActorObjectService _actorTracker;
private readonly IFramework _framework;
private readonly BroadcastService _broadcastService;
@@ -40,17 +40,14 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID);
public BroadcastScannerService(ILogger<BroadcastScannerService> logger,
IClientState clientState,
IObjectTable objectTable,
IFramework framework,
BroadcastService broadcastService,
LightlessMediator mediator,
NameplateHandler nameplateHandler,
DalamudUtilService dalamudUtil,
LightlessConfigService configService) : base(logger, mediator)
ActorObjectService actorTracker) : base(logger, mediator)
{
_logger = logger;
_objectTable = objectTable;
_actorTracker = actorTracker;
_broadcastService = broadcastService;
_nameplateHandler = nameplateHandler;
@@ -76,12 +73,12 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
var now = DateTime.UtcNow;
foreach (var obj in _objectTable)
foreach (var address in _actorTracker.PlayerAddresses)
{
if (obj is not IPlayerCharacter player || player.Address == IntPtr.Zero)
if (address == nint.Zero)
continue;
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(player.Address);
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address);
var isStale = !_broadcastCache.TryGetValue(cid, out var entry) || entry.ExpiryTime <= now;
if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < MaxQueueSize)
@@ -237,6 +234,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
_framework.Update -= OnFrameworkUpdate;
_cleanupCts.Cancel();
_cleanupTask?.Wait(100);
_cleanupCts.Dispose();
_nameplateHandler.Uninit();
}
}

View File

@@ -6,9 +6,9 @@ using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.CharaData.Models;
using LightlessSync.Services.Mediator;
using LightlessSync.UI.Services;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using Microsoft.Extensions.Logging;
@@ -28,7 +28,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
private readonly List<CharaDataMetaInfoExtendedDto> _nearbyData = [];
private readonly CharaDataNearbyManager _nearbyManager;
private readonly CharaDataCharacterHandler _characterHandler;
private readonly PairManager _pairManager;
private readonly PairUiService _pairUiService;
private readonly Dictionary<string, CharaDataFullExtendedDto> _ownCharaData = [];
private readonly Dictionary<string, Task> _sharedMetaInfoTimeoutTasks = [];
private readonly Dictionary<UserData, List<CharaDataMetaInfoExtendedDto>> _sharedWithYouData = [];
@@ -45,7 +45,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
LightlessMediator lightlessMediator, IpcManager ipcManager, DalamudUtilService dalamudUtilService,
FileDownloadManagerFactory fileDownloadManagerFactory,
CharaDataConfigService charaDataConfigService, CharaDataNearbyManager charaDataNearbyManager,
CharaDataCharacterHandler charaDataCharacterHandler, PairManager pairManager) : base(logger, lightlessMediator)
CharaDataCharacterHandler charaDataCharacterHandler, PairUiService pairUiService) : base(logger, lightlessMediator)
{
_apiController = apiController;
_fileHandler = charaDataFileHandler;
@@ -54,7 +54,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
_configService = charaDataConfigService;
_nearbyManager = charaDataNearbyManager;
_characterHandler = charaDataCharacterHandler;
_pairManager = pairManager;
_pairUiService = pairUiService;
lightlessMediator.Subscribe<ConnectedMessage>(this, (msg) =>
{
_connectCts?.Cancel();
@@ -421,9 +421,10 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
});
var result = await GetSharedWithYouTask.ConfigureAwait(false);
var snapshot = _pairUiService.GetSnapshot();
foreach (var grouping in result.GroupBy(r => r.Uploader))
{
var pair = _pairManager.GetPairByUID(grouping.Key.UID);
snapshot.PairsByUid.TryGetValue(grouping.Key.UID, out var pair);
if (pair?.IsPaused ?? false) continue;
List<CharaDataMetaInfoExtendedDto> newList = new();
foreach (var item in grouping)

View File

@@ -1,4 +1,4 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.FileCache;
using LightlessSync.Services.Mediator;
@@ -40,21 +40,16 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
public int TotalFiles { get; internal set; }
internal Dictionary<ObjectKind, Dictionary<string, FileDataEntry>> LastAnalysis { get; } = [];
public CharacterAnalysisSummary LatestSummary => _latestSummary;
public void CancelAnalyze()
{
_analysisCts?.CancelDispose();
_analysisCts = null;
}
public async Task ComputeAnalysis(bool print = true, bool recalculate = false)
{
Logger.LogDebug("=== Calculating Character Analysis ===");
_analysisCts = _analysisCts?.CancelRecreate() ?? new();
var cancelToken = _analysisCts.Token;
var allFiles = LastAnalysis.SelectMany(v => v.Value.Select(d => d.Value)).ToList();
if (allFiles.Exists(c => !c.IsComputed || recalculate))
{
@@ -62,7 +57,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
TotalFiles = remaining.Count;
CurrentFile = 1;
Logger.LogDebug("=== Computing {amount} remaining files ===", remaining.Count);
Mediator.Publish(new HaltScanMessage(nameof(CharacterAnalyzer)));
try
{
@@ -72,9 +66,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
await file.ComputeSizes(_fileCacheManager, cancelToken).ConfigureAwait(false);
CurrentFile++;
}
_fileCacheManager.WriteOutFullCsv();
}
catch (Exception ex)
{
@@ -87,36 +79,49 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
}
RecalculateSummary();
Mediator.Publish(new CharacterDataAnalyzedMessage());
_analysisCts.CancelDispose();
_analysisCts = null;
if (print) PrintAnalysis();
}
public void Dispose()
{
_analysisCts.CancelDispose();
}
public async Task UpdateFileEntriesAsync(IEnumerable<string> filePaths, CancellationToken token)
{
var normalized = new HashSet<string>(
filePaths.Where(path => !string.IsNullOrWhiteSpace(path)),
StringComparer.OrdinalIgnoreCase);
if (normalized.Count == 0)
{
return;
}
foreach (var objectEntries in LastAnalysis.Values)
{
foreach (var entry in objectEntries.Values)
{
if (!entry.FilePaths.Any(path => normalized.Contains(path)))
{
continue;
}
token.ThrowIfCancellationRequested();
await entry.ComputeSizes(_fileCacheManager, token).ConfigureAwait(false);
}
}
}
private async Task BaseAnalysis(CharacterData charaData, CancellationToken token)
{
if (string.Equals(charaData.DataHash.Value, _lastDataHash, StringComparison.Ordinal)) return;
LastAnalysis.Clear();
foreach (var obj in charaData.FileReplacements)
{
Dictionary<string, FileDataEntry> data = new(StringComparer.OrdinalIgnoreCase);
foreach (var fileEntry in obj.Value)
{
token.ThrowIfCancellationRequested();
var fileCacheEntries = _fileCacheManager.GetAllFileCachesByHash(fileEntry.Hash, ignoreCacheEntries: true, validate: false).ToList();
if (fileCacheEntries.Count == 0) continue;
var filePath = fileCacheEntries[0].ResolvedFilepath;
FileInfo fi = new(filePath);
string ext = "unk?";
@@ -128,9 +133,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
{
Logger.LogWarning(ex, "Could not identify extension for {path}", filePath);
}
var tris = await _xivDataAnalyzer.GetTrianglesByHash(fileEntry.Hash).ConfigureAwait(false);
foreach (var entry in fileCacheEntries)
{
data[fileEntry.Hash] = new FileDataEntry(fileEntry.Hash, ext,
@@ -141,17 +144,13 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
tris);
}
}
LastAnalysis[obj.Key] = data;
}
RecalculateSummary();
Mediator.Publish(new CharacterDataAnalyzedMessage());
_lastDataHash = charaData.DataHash.Value;
}
private void RecalculateSummary()
{
var builder = ImmutableDictionary.CreateBuilder<ObjectKind, CharacterAnalysisObjectSummary>();
@@ -177,7 +176,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
_latestSummary = new CharacterAnalysisSummary(builder.ToImmutable());
}
private void PrintAnalysis()
{
if (LastAnalysis.Count == 0) return;
@@ -186,7 +184,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
int fileCounter = 1;
int totalFiles = kvp.Value.Count;
Logger.LogInformation("=== Analysis for {obj} ===", kvp.Key);
foreach (var entry in kvp.Value.OrderBy(b => b.Value.GamePaths.OrderBy(p => p, StringComparer.Ordinal).First(), StringComparer.Ordinal))
{
Logger.LogInformation("File {x}/{y}: {hash}", fileCounter++, totalFiles, entry.Key);
@@ -215,7 +212,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
Logger.LogInformation("Total files: {count}, size extracted: {size}, size compressed: {sizeComp}", kvp.Value.Count,
UiSharedService.ByteToString(kvp.Value.Sum(v => v.Value.OriginalSize)), UiSharedService.ByteToString(kvp.Value.Sum(v => v.Value.CompressedSize)));
}
Logger.LogInformation("=== Total summary for all currently present objects ===");
Logger.LogInformation("Total files: {count}, size extracted: {size}, size compressed: {sizeComp}",
LastAnalysis.Values.Sum(v => v.Values.Count),
@@ -223,7 +219,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
UiSharedService.ByteToString(LastAnalysis.Values.Sum(c => c.Values.Sum(v => v.CompressedSize))));
Logger.LogInformation("IMPORTANT NOTES:\n\r- For Lightless up- and downloads only the compressed size is relevant.\n\r- An unusually high total files count beyond 200 and up will also increase your download time to others significantly.");
}
internal sealed record FileDataEntry(string Hash, string FileType, List<string> GamePaths, List<string> FilePaths, long OriginalSize, long CompressedSize, long Triangles)
{
public bool IsComputed => OriginalSize > 0 && CompressedSize > 0;
@@ -243,7 +238,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
public long OriginalSize { get; private set; } = OriginalSize;
public long CompressedSize { get; private set; } = CompressedSize;
public long Triangles { get; private set; } = Triangles;
public Lazy<string> Format = new(() =>
{
switch (FileType)

View File

@@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using LightlessSync.API.Dto.Chat;
namespace LightlessSync.Services.Chat;
public sealed record ChatMessageEntry(
ChatMessageDto Payload,
string DisplayName,
bool FromSelf,
DateTime ReceivedAtUtc);
public readonly record struct ChatChannelSnapshot(
string Key,
ChatChannelDescriptor Descriptor,
string DisplayName,
ChatChannelType Type,
bool IsConnected,
bool IsAvailable,
string? StatusText,
bool HasUnread,
int UnreadCount,
IReadOnlyList<ChatMessageEntry> Messages);

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,15 @@
using LightlessSync;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.Gui.ContextMenu;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using Lumina.Excel.Sheets;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -20,11 +23,15 @@ internal class ContextMenuService : IHostedService
private readonly ILogger<ContextMenuService> _logger;
private readonly DalamudUtilService _dalamudUtil;
private readonly IClientState _clientState;
private readonly PairManager _pairManager;
private readonly PairUiService _pairUiService;
private readonly PairRequestService _pairRequestService;
private readonly ApiController _apiController;
private readonly IObjectTable _objectTable;
private readonly LightlessConfigService _configService;
private readonly BroadcastScannerService _broadcastScannerService;
private readonly BroadcastService _broadcastService;
private readonly LightlessProfileManager _lightlessProfileManager;
private readonly LightlessMediator _mediator;
public ContextMenuService(
IContextMenu contextMenu,
@@ -36,8 +43,12 @@ internal class ContextMenuService : IHostedService
IObjectTable objectTable,
LightlessConfigService configService,
PairRequestService pairRequestService,
PairManager pairManager,
IClientState clientState)
PairUiService pairUiService,
IClientState clientState,
BroadcastScannerService broadcastScannerService,
BroadcastService broadcastService,
LightlessProfileManager lightlessProfileManager,
LightlessMediator mediator)
{
_contextMenu = contextMenu;
_pluginInterface = pluginInterface;
@@ -47,9 +58,13 @@ internal class ContextMenuService : IHostedService
_apiController = apiController;
_objectTable = objectTable;
_configService = configService;
_pairManager = pairManager;
_pairUiService = pairUiService;
_pairRequestService = pairRequestService;
_clientState = clientState;
_broadcastScannerService = broadcastScannerService;
_broadcastService = broadcastService;
_lightlessProfileManager = lightlessProfileManager;
_mediator = mediator;
}
public Task StartAsync(CancellationToken cancellationToken)
@@ -78,42 +93,67 @@ internal class ContextMenuService : IHostedService
private void OnMenuOpened(IMenuOpenedArgs args)
{
if (!_pluginInterface.UiBuilder.ShouldModifyUi)
return;
if (args.AddonName != null)
return;
//Check if target is not menutargetdefault.
if (args.Target is not MenuTargetDefault target)
return;
//Check if name or target id isnt null/zero
if (string.IsNullOrEmpty(target.TargetName) || target.TargetObjectId == 0 || target.TargetHomeWorld.RowId == 0)
return;
//Check if it is a real target.
IPlayerCharacter? targetData = GetPlayerFromObjectTable(target);
if (targetData == null || targetData.Address == nint.Zero)
return;
//Check if user is directly paired or is own.
if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _clientState.LocalPlayer.GameObjectId == target.TargetObjectId)
if (!_configService.Current.EnableRightClickMenus)
return;
var snapshot = _pairUiService.GetSnapshot();
var pair = snapshot.PairsByUid.Values.FirstOrDefault(p =>
p.IsVisible &&
p.PlayerCharacterId != uint.MaxValue &&
(ulong)p.PlayerCharacterId == target.TargetObjectId);
if (pair is not null)
{
pair.AddContextMenu(args);
return;
}
//Check if user is directly paired or is own.
if (VisibleUserIds.Contains(target.TargetObjectId) || (_clientState.LocalPlayer?.GameObjectId ?? 0) == target.TargetObjectId)
return;
//Check if in PVP or GPose
if (_clientState.IsPvPExcludingDen || _clientState.IsGPosing)
return;
//Check for valid world.
var world = GetWorld(target.TargetHomeWorld.RowId);
if (!IsWorldValid(world))
return;
if (!_configService.Current.EnableRightClickMenus)
return;
string? targetHashedCid = null;
if (_broadcastService.IsBroadcasting)
{
targetHashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address);
}
if (!string.IsNullOrEmpty(targetHashedCid) && CanOpenLightfinderProfile(targetHashedCid))
{
var hashedCid = targetHashedCid;
args.AddMenuItem(new MenuItem
{
Name = "Open Lightless Profile",
PrefixChar = 'L',
UseDefaultPrefix = false,
PrefixColor = 708,
OnClicked = async _ => await HandleLightfinderProfileSelection(hashedCid!).ConfigureAwait(false)
});
}
args.AddMenuItem(new MenuItem
{
Name = "Send Direct Pair Request",
@@ -124,6 +164,12 @@ internal class ContextMenuService : IHostedService
});
}
private HashSet<ulong> VisibleUserIds =>
_pairUiService.GetSnapshot().PairsByUid.Values
.Where(p => p.IsVisible && p.PlayerCharacterId != uint.MaxValue)
.Select(p => (ulong)p.PlayerCharacterId)
.ToHashSet();
private async Task HandleSelection(IMenuArgs args)
{
if (args.Target is not MenuTargetDefault target)
@@ -159,9 +205,48 @@ internal class ContextMenuService : IHostedService
}
}
private HashSet<ulong> VisibleUserIds => [.. _pairManager.DirectPairs
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)];
private async Task HandleLightfinderProfileSelection(string hashedCid)
{
if (string.IsNullOrWhiteSpace(hashedCid))
return;
if (!_broadcastService.IsBroadcasting)
{
Notify("Lightfinder inactive", "Enable Lightfinder to open broadcaster profiles.", NotificationType.Warning, 6);
return;
}
if (!_broadcastScannerService.BroadcastCache.TryGetValue(hashedCid, out var entry) || !entry.IsBroadcasting || entry.ExpiryTime <= DateTime.UtcNow)
{
Notify("Broadcaster unavailable", "That player is not currently using Lightfinder.", NotificationType.Info, 5);
return;
}
var result = await _lightlessProfileManager.GetLightfinderProfileAsync(hashedCid).ConfigureAwait(false);
if (result == null)
{
Notify("Profile unavailable", "Unable to load Lightless profile for that player.", NotificationType.Error, 6);
return;
}
_mediator.Publish(new OpenLightfinderProfileMessage(result.Value.User, result.Value.ProfileData, hashedCid));
}
private void Notify(string title, string message, NotificationType type, double durationSeconds)
{
_mediator.Publish(new NotificationMessage(title, message, type, TimeSpan.FromSeconds(durationSeconds)));
}
private bool CanOpenLightfinderProfile(string hashedCid)
{
if (!_broadcastService.IsBroadcasting)
return false;
if (!_broadcastScannerService.BroadcastCache.TryGetValue(hashedCid, out var entry))
return false;
return entry.IsBroadcasting && entry.ExpiryTime > DateTime.UtcNow;
}
private IPlayerCharacter? GetPlayerFromObjectTable(MenuTargetDefault target)
{

View File

@@ -12,15 +12,20 @@ using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using LightlessSync.API.Dto.CharaData;
using LightlessSync.Interop;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
using Lumina.Excel.Sheets;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Text;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
namespace LightlessSync.Services;
@@ -37,23 +42,24 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private readonly IGameGui _gameGui;
private readonly ILogger<DalamudUtilService> _logger;
private readonly IObjectTable _objectTable;
private readonly ActorObjectService _actorObjectService;
private readonly PerformanceCollectorService _performanceCollector;
private readonly LightlessConfigService _configService;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly Lazy<PairFactory> _pairFactory;
private uint? _classJobId = 0;
private DateTime _delayedFrameworkUpdateCheck = DateTime.UtcNow;
private string _lastGlobalBlockPlayer = string.Empty;
private string _lastGlobalBlockReason = string.Empty;
private ushort _lastZone = 0;
private readonly Dictionary<string, (string Name, nint Address)> _playerCharas = new(StringComparer.Ordinal);
private readonly List<string> _notUpdatedCharas = [];
private ushort _lastWorldId = 0;
private bool _sentBetweenAreas = false;
private Lazy<ulong> _cid;
public DalamudUtilService(ILogger<DalamudUtilService> logger, IClientState clientState, IObjectTable objectTable, IFramework framework,
IGameGui gameGui, ICondition condition, IDataManager gameData, ITargetManager targetManager, IGameConfig gameConfig,
BlockedCharacterHandler blockedCharacterHandler, LightlessMediator mediator, PerformanceCollectorService performanceCollector,
LightlessConfigService configService, PlayerPerformanceConfigService playerPerformanceConfigService)
ActorObjectService actorObjectService, BlockedCharacterHandler blockedCharacterHandler, LightlessMediator mediator, PerformanceCollectorService performanceCollector,
LightlessConfigService configService, PlayerPerformanceConfigService playerPerformanceConfigService, Lazy<PairFactory> pairFactory)
{
_logger = logger;
_clientState = clientState;
@@ -63,11 +69,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_condition = condition;
_gameData = gameData;
_gameConfig = gameConfig;
_actorObjectService = actorObjectService;
_blockedCharacterHandler = blockedCharacterHandler;
Mediator = mediator;
_performanceCollector = performanceCollector;
_configService = configService;
_playerPerformanceConfigService = playerPerformanceConfigService;
_pairFactory = pairFactory;
WorldData = new(() =>
{
return gameData.GetExcelSheet<Lumina.Excel.Sheets.World>(Dalamud.Game.ClientLanguage.English)!
@@ -119,9 +127,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
mediator.Subscribe<TargetPairMessage>(this, (msg) =>
{
if (clientState.IsPvP) return;
var name = msg.Pair.PlayerName;
var pair = _pairFactory.Value.Create(msg.Pair.UniqueIdent) ?? msg.Pair;
var name = pair.PlayerName;
if (string.IsNullOrEmpty(name)) return;
var addr = _playerCharas.FirstOrDefault(f => string.Equals(f.Value.Name, name, StringComparison.Ordinal)).Value.Address;
if (!_actorObjectService.TryGetPlayerByName(name, out var descriptor))
return;
var addr = descriptor.Address;
if (addr == nint.Zero) return;
var useFocusTarget = _configService.Current.UseFocusTarget;
_ = RunOnFrameworkThread(() =>
@@ -194,7 +205,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
{
EnsureIsOnFramework();
var objTableObj = _objectTable[index];
if (objTableObj!.ObjectKind != Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player) return null;
if (objTableObj!.ObjectKind != DalamudObjectKind.Player) return null;
return (ICharacter)objTableObj;
}
@@ -226,7 +237,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public IEnumerable<ICharacter?> GetGposeCharactersFromObjectTable()
{
return _objectTable.Where(o => o.ObjectIndex > 200 && o.ObjectKind == Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player).Cast<ICharacter>();
foreach (var actor in _actorObjectService.PlayerDescriptors
.Where(a => a.ObjectKind == DalamudObjectKind.Player && a.ObjectIndex > 200))
{
var character = _objectTable.CreateObjectReference(actor.Address) as ICharacter;
if (character != null)
yield return character;
}
}
public bool GetIsPlayerPresent()
@@ -281,7 +298,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public IntPtr GetPlayerCharacterFromCachedTableByIdent(string characterName)
{
if (_playerCharas.TryGetValue(characterName, out var pchar)) return pchar.Address;
if (_actorObjectService.TryGetActorByHash(characterName, out var actor))
return actor.Address;
return IntPtr.Zero;
}
@@ -552,8 +570,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
internal (string Name, nint Address) FindPlayerByNameHash(string ident)
{
_playerCharas.TryGetValue(ident, out var result);
return result;
if (_actorObjectService.TryGetActorByHash(ident, out var descriptor))
{
return (descriptor.Name, descriptor.Address);
}
return default;
}
public string? GetWorldNameFromPlayerAddress(nint address)
@@ -639,37 +661,43 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_performanceCollector.LogPerformance(this, $"FrameworkOnUpdateInternal+{(isNormalFrameworkUpdate ? "Regular" : "Delayed")}", () =>
{
IsAnythingDrawing = false;
_performanceCollector.LogPerformance(this, $"ObjTableToCharas",
_performanceCollector.LogPerformance(this, $"TrackedActorsToState",
() =>
{
_notUpdatedCharas.AddRange(_playerCharas.Keys);
_actorObjectService.RefreshTrackedActors();
for (int i = 0; i < 200; i += 2)
var playerDescriptors = _actorObjectService.PlayerCharacterDescriptors;
for (var i = 0; i < playerDescriptors.Count; i++)
{
var chara = _objectTable[i];
if (chara == null || chara.ObjectKind != Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player)
var actor = playerDescriptors[i];
var playerAddress = actor.Address;
if (playerAddress == nint.Zero)
continue;
if (_blockedCharacterHandler.IsCharacterBlocked(chara.Address, out bool firstTime) && firstTime)
if (actor.ObjectIndex >= 200)
continue;
if (_blockedCharacterHandler.IsCharacterBlocked(playerAddress, out bool firstTime) && firstTime)
{
_logger.LogTrace("Skipping character {addr}, blocked/muted", chara.Address.ToString("X"));
_logger.LogTrace("Skipping character {addr}, blocked/muted", playerAddress.ToString("X"));
continue;
}
var charaName = ((GameObject*)chara.Address)->NameString;
var hash = GetHashedCIDFromPlayerPointer(chara.Address);
if (!IsAnythingDrawing)
CheckCharacterForDrawing(chara.Address, charaName);
_notUpdatedCharas.Remove(hash);
_playerCharas[hash] = (charaName, chara.Address);
{
var gameObj = (GameObject*)playerAddress;
var currentName = gameObj != null ? gameObj->NameString ?? string.Empty : string.Empty;
var charaName = string.IsNullOrEmpty(currentName) ? actor.Name : currentName;
CheckCharacterForDrawing(playerAddress, charaName);
if (IsAnythingDrawing)
break;
}
else
{
break;
}
}
foreach (var notUpdatedChara in _notUpdatedCharas)
{
_playerCharas.Remove(notUpdatedChara);
}
_notUpdatedCharas.Clear();
});
if (!IsAnythingDrawing && !string.IsNullOrEmpty(_lastGlobalBlockPlayer))
@@ -786,6 +814,18 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
if (localPlayer != null)
{
_classJobId = localPlayer.ClassJob.RowId;
var currentWorldId = (ushort)localPlayer.CurrentWorld.RowId;
if (currentWorldId != _lastWorldId)
{
var previousWorldId = _lastWorldId;
_lastWorldId = currentWorldId;
Mediator.Publish(new WorldChangedMessage(previousWorldId, currentWorldId));
}
}
else if (_lastWorldId != 0)
{
_lastWorldId = 0;
}
if (!IsInCombat || !IsPerforming || !IsInInstance)
@@ -801,6 +841,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_logger.LogDebug("Logged in");
IsLoggedIn = true;
_lastZone = _clientState.TerritoryType;
_lastWorldId = (ushort)localPlayer.CurrentWorld.RowId;
_cid = RebuildCID();
Mediator.Publish(new DalamudLoginMessage());
}
@@ -808,6 +849,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
{
_logger.LogDebug("Logged out");
IsLoggedIn = false;
_lastWorldId = 0;
Mediator.Publish(new DalamudLogoutMessage());
}

View File

@@ -1,6 +1,20 @@
namespace LightlessSync.Services;
using System;
using System.Collections.Generic;
public record LightlessGroupProfileData(string Base64ProfilePicture, string Description, int[] Tags, bool IsNsfw, bool IsDisabled)
namespace LightlessSync.Services;
public record LightlessGroupProfileData(
bool IsDisabled,
bool IsNsfw,
string Base64ProfilePicture,
string Base64BannerPicture,
string Description,
IReadOnlyList<int> Tags)
{
public Lazy<byte[]> ImageData { get; } = new Lazy<byte[]>(Convert.FromBase64String(Base64ProfilePicture));
public Lazy<byte[]> ProfileImageData { get; } = new(() => ConvertSafe(Base64ProfilePicture));
public Lazy<byte[]> BannerImageData { get; } = new(() => ConvertSafe(Base64BannerPicture));
private static byte[] ConvertSafe(string value) => string.IsNullOrEmpty(value)
? Array.Empty<byte>()
: Convert.FromBase64String(value);
}

View File

@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
namespace LightlessSync.Services;
public record LightlessProfileData(
bool IsFlagged,
bool IsNSFW,
string Base64ProfilePicture,
string Base64SupporterPicture,
string Base64BannerPicture,
string Description,
IReadOnlyList<int> Tags)
{
public Lazy<byte[]> ImageData { get; } = new(() => ConvertSafe(Base64ProfilePicture));
public Lazy<byte[]> SupporterImageData { get; } = new(() => ConvertSafe(Base64SupporterPicture));
public Lazy<byte[]> BannerImageData { get; } = new(() => ConvertSafe(Base64BannerPicture));
private static byte[] ConvertSafe(string value) => string.IsNullOrEmpty(value) ? Array.Empty<byte>() : Convert.FromBase64String(value);
}

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,20 @@
namespace LightlessSync.Services;
using System;
using System.Collections.Generic;
public record LightlessUserProfileData(bool IsFlagged, bool IsNSFW, string Base64ProfilePicture, string Base64SupporterPicture, string Description)
namespace LightlessSync.Services;
public record LightlessUserProfileData(
bool IsFlagged,
bool IsNSFW,
string Base64ProfilePicture,
string Base64SupporterPicture,
string Base64BannerPicture,
string Description,
IReadOnlyList<int> Tags)
{
public Lazy<byte[]> ImageData { get; } = new Lazy<byte[]>(Convert.FromBase64String(Base64ProfilePicture));
public Lazy<byte[]> SupporterImageData { get; } = new Lazy<byte[]>(string.IsNullOrEmpty(Base64SupporterPicture) ? [] : Convert.FromBase64String(Base64SupporterPicture));
public Lazy<byte[]> ImageData { get; } = new(() => ConvertSafe(Base64ProfilePicture));
public Lazy<byte[]> SupporterImageData { get; } = new(() => ConvertSafe(Base64SupporterPicture));
public Lazy<byte[]> BannerImageData { get; } = new(() => ConvertSafe(Base64BannerPicture));
private static byte[] ConvertSafe(string value) => string.IsNullOrEmpty(value) ? Array.Empty<byte>() : Convert.FromBase64String(value);
}

View File

@@ -6,9 +6,12 @@ using LightlessSync.API.Dto.Group;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Chat;
using LightlessSync.Services.Events;
using LightlessSync.WebAPI.Files.Models;
using System.Numerics;
using LightlessSync.UI.Models;
namespace LightlessSync.Services.Mediator;
@@ -20,12 +23,15 @@ public record OpenSettingsUiMessage : MessageBase;
public record OpenLightfinderSettingsMessage : MessageBase;
public record DalamudLoginMessage : MessageBase;
public record DalamudLogoutMessage : MessageBase;
public record ActorTrackedMessage(ActorObjectService.ActorDescriptor Descriptor) : SameThreadMessage;
public record ActorUntrackedMessage(ActorObjectService.ActorDescriptor Descriptor) : SameThreadMessage;
public record PriorityFrameworkUpdateMessage : SameThreadMessage;
public record FrameworkUpdateMessage : SameThreadMessage;
public record ClassJobChangedMessage(GameObjectHandler GameObjectHandler) : MessageBase;
public record DelayedFrameworkUpdateMessage : SameThreadMessage;
public record ZoneSwitchStartMessage : MessageBase;
public record ZoneSwitchEndMessage : MessageBase;
public record WorldChangedMessage(ushort PreviousWorldId, ushort CurrentWorldId) : MessageBase;
public record CutsceneStartMessage : MessageBase;
public record GposeStartMessage : SameThreadMessage;
public record GposeEndMessage : MessageBase;
@@ -65,6 +71,7 @@ public record HubReconnectingMessage(Exception? Exception) : SameThreadMessage;
public record HubReconnectedMessage(string? Arg) : SameThreadMessage;
public record HubClosedMessage(Exception? Exception) : SameThreadMessage;
public record ResumeScanMessage(string Source) : MessageBase;
public record FileCacheInitializedMessage : MessageBase;
public record DownloadReadyMessage(Guid RequestId) : MessageBase;
public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase;
public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase;
@@ -72,11 +79,18 @@ public record UiToggleMessage(Type UiType) : MessageBase;
public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : MessageBase;
public record ClearProfileUserDataMessage(UserData? UserData = null) : MessageBase;
public record ClearProfileGroupDataMessage(GroupData? GroupData = null) : MessageBase;
public record CyclePauseMessage(UserData UserData) : MessageBase;
public record CyclePauseMessage(Pair Pair) : MessageBase;
public record PauseMessage(UserData UserData) : MessageBase;
public record ProfilePopoutToggle(Pair? Pair) : MessageBase;
public record CompactUiChange(Vector2 Size, Vector2 Position) : MessageBase;
public record ProfileOpenStandaloneMessage(Pair Pair) : MessageBase;
public record GroupProfileOpenStandaloneMessage(GroupFullInfoDto Group) : MessageBase;
public record OpenGroupProfileEditorMessage(GroupFullInfoDto Group) : MessageBase;
public record CloseGroupProfilePreviewMessage(GroupFullInfoDto Group) : MessageBase;
public record ActiveServerChangedMessage(string ServerUrl) : MessageBase;
public record OpenSelfProfilePreviewMessage(UserData User) : MessageBase;
public record CloseSelfProfilePreviewMessage(UserData User) : MessageBase;
public record OpenLightfinderProfileMessage(UserData User, LightlessProfileData ProfileData, string HashedCid) : MessageBase;
public record RemoveWindowMessage(WindowMediatorSubscriberBase Window) : MessageBase;
public record RefreshUiMessage : MessageBase;
public record OpenBanUserPopupMessage(Pair PairToBan, GroupFullInfoDto GroupFullInfoDto) : MessageBase;
@@ -85,6 +99,8 @@ public record OpenSyncshellAdminPanel(GroupFullInfoDto GroupInfo) : MessageBase;
public record OpenPermissionWindow(Pair Pair) : MessageBase;
public record DownloadLimitChangedMessage() : SameThreadMessage;
public record PairProcessingLimitChangedMessage : SameThreadMessage;
public record PairDataChangedMessage : MessageBase;
public record PairUiUpdatedMessage(PairUiSnapshot Snapshot) : MessageBase;
public record CensusUpdateMessage(byte Gender, byte RaceId, byte TribeId) : MessageBase;
public record TargetPairMessage(Pair Pair) : MessageBase;
public record CombatStartMessage : MessageBase;
@@ -112,5 +128,10 @@ public record PairRequestReceivedMessage(string HashedCid, string Message) : Mes
public record PairRequestsUpdatedMessage : MessageBase;
public record PairDownloadStatusMessage(List<(string PlayerName, float Progress, string Status)> DownloadStatus, int QueueWaiting) : MessageBase;
public record VisibilityChange : MessageBase;
public record ChatChannelsUpdated : MessageBase;
public record ChatChannelMessageAdded(string ChannelKey, ChatMessageEntry Message) : MessageBase;
public record ChatChannelHistoryCleared(string ChannelKey) : MessageBase;
public record GroupCollectionChangedMessage : MessageBase;
public record OpenUserProfileMessage(UserData User) : MessageBase;
#pragma warning restore S2094
#pragma warning restore MA0048 // File name must match type name

View File

@@ -7,9 +7,9 @@ using FFXIVClientStructs.FFXIV.Client.System.Framework;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.UI;
using LightlessSync.UI.Services;
using LightlessSync.Utils;
using LightlessSync.UtilsEnum.Enum;
@@ -30,7 +30,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
private readonly IClientState _clientState;
private readonly DalamudUtilService _dalamudUtil;
private readonly LightlessConfigService _configService;
private readonly PairManager _pairManager;
private readonly PairUiService _pairUiService;
private readonly LightlessMediator _mediator;
public LightlessMediator Mediator => _mediator;
@@ -51,7 +51,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
private ImmutableHashSet<string> _activeBroadcastingCids = [];
public NameplateHandler(ILogger<NameplateHandler> logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessConfigService configService, LightlessMediator mediator, IClientState clientState, PairManager pairManager)
public NameplateHandler(ILogger<NameplateHandler> logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessConfigService configService, LightlessMediator mediator, IClientState clientState, PairUiService pairUiService)
{
_logger = logger;
_addonLifecycle = addonLifecycle;
@@ -60,7 +60,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
_configService = configService;
_mediator = mediator;
_clientState = clientState;
_pairManager = pairManager;
_pairUiService = pairUiService;
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
}
@@ -493,7 +493,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
int centrePos = (nameplateWidth - nodeWidth) / 2;
int staticMargin = 24;
int calcMargin = (int)(nameplateWidth * 0.08f);
switch (config.LabelAlignment)
{
case LabelAlignment.Left:
@@ -515,7 +515,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
positionX = 58 + config.LightfinderLabelOffsetX;
alignment = AlignmentType.Bottom;
}
positionY += config.LightfinderLabelOffsetY;
alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8);
@@ -533,7 +533,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
pNode->EdgeColor.B = (byte)(edgeColor.Z * 255);
pNode->EdgeColor.A = (byte)(edgeColor.W * 255);
if(!config.LightfinderLabelUseIcon)
{
pNode->AlignmentType = AlignmentType.Bottom;
@@ -551,7 +551,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
pNode->CharSpacing = 1;
pNode->TextFlags = config.LightfinderLabelUseIcon
? TextFlags.Edge | TextFlags.Glare | TextFlags.AutoAdjustNodeSize
: TextFlags.Edge | TextFlags.Glare;
: TextFlags.Edge | TextFlags.Glare;
}
}
@@ -653,8 +653,8 @@ public unsafe class NameplateHandler : IMediatorSubscriber
var nameplateObject = GetNameplateObject(i);
return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null;
}
private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs()
private HashSet<ulong> VisibleUserIds
=> [.. _pairUiService.GetSnapshot().PairsByUid.Values
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)];

View File

@@ -4,9 +4,9 @@ using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.UI;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Services;
@@ -17,20 +17,20 @@ public class NameplateService : DisposableMediatorSubscriberBase
private readonly LightlessConfigService _configService;
private readonly IClientState _clientState;
private readonly INamePlateGui _namePlateGui;
private readonly PairManager _pairManager;
private readonly PairUiService _pairUiService;
public NameplateService(ILogger<NameplateService> logger,
LightlessConfigService configService,
INamePlateGui namePlateGui,
IClientState clientState,
PairManager pairManager,
PairUiService pairUiService,
LightlessMediator lightlessMediator) : base(logger, lightlessMediator)
{
_logger = logger;
_configService = configService;
_namePlateGui = namePlateGui;
_clientState = clientState;
_pairManager = pairManager;
_pairUiService = pairUiService;
_namePlateGui.OnNamePlateUpdate += OnNamePlateUpdate;
_namePlateGui.RequestRedraw();
@@ -42,7 +42,8 @@ public class NameplateService : DisposableMediatorSubscriberBase
if (!_configService.Current.IsNameplateColorsEnabled || (_configService.Current.IsNameplateColorsEnabled && _clientState.IsPvPExcludingDen))
return;
var visibleUsersIds = _pairManager.GetOnlineUserPairs()
var snapshot = _pairUiService.GetSnapshot();
var visibleUsersIds = snapshot.PairsByUid.Values
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)
.ToHashSet();
@@ -74,7 +75,7 @@ public class NameplateService : DisposableMediatorSubscriberBase
bool hasActualFcTag = playerCharacter.CompanyTag.TextValue.Length > 0;
bool isFromDifferentRealm = playerCharacter.HomeWorld.RowId != playerCharacter.CurrentWorld.RowId;
bool shouldColorFcArea = hasActualFcTag || (!hasActualFcTag && isFromDifferentRealm);
if (shouldColorFcArea)
{
handler.FreeCompanyTagParts.OuterWrap = CreateTextWrap(colors);

View File

@@ -4,9 +4,14 @@ using Dalamud.Interface.ImGuiNotification;
using Dalamud.Plugin.Services;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.UI;
using LightlessSync.UI.Models;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using FFXIVClientStructs.FFXIV.Client.UI;
@@ -24,6 +29,8 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
private readonly IChatGui _chatGui;
private readonly PairRequestService _pairRequestService;
private readonly HashSet<string> _shownPairRequestNotifications = new();
private readonly PairUiService _pairUiService;
private readonly PairFactory _pairFactory;
public NotificationService(
ILogger<NotificationService> logger,
@@ -32,7 +39,9 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
INotificationManager notificationManager,
IChatGui chatGui,
LightlessMediator mediator,
PairRequestService pairRequestService) : base(logger, mediator)
PairRequestService pairRequestService,
PairUiService pairUiService,
PairFactory pairFactory) : base(logger, mediator)
{
_logger = logger;
_configService = configService;
@@ -40,6 +49,8 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
_notificationManager = notificationManager;
_chatGui = chatGui;
_pairRequestService = pairRequestService;
_pairUiService = pairUiService;
_pairFactory = pairFactory;
}
public Task StartAsync(CancellationToken cancellationToken)
@@ -391,6 +402,17 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
_logger.LogWarning(ex, "Failed to play notification sound effect {SoundId}", soundEffectId);
}
}
private Pair? ResolvePair(UserData userData)
{
var snapshot = _pairUiService.GetSnapshot();
if (snapshot.PairsByUid.TryGetValue(userData.UID, out var pair))
{
return pair;
}
var ident = new PairUniqueIdentifier(userData.UID);
return _pairFactory.Create(ident);
}
private void HandleNotificationMessage(NotificationMessage msg)
{
@@ -659,7 +681,14 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
{
try
{
Mediator.Publish(new CyclePauseMessage(userData));
var pair = ResolvePair(userData);
if (pair == null)
{
_logger.LogWarning("Cannot cycle pause {uid} because pair is missing", userData.UID);
throw new InvalidOperationException("Pair not available");
}
Mediator.Publish(new CyclePauseMessage(pair));
DismissNotification(notification);
var displayName = GetUserDisplayName(userData, playerName);

View File

@@ -1,6 +1,6 @@
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Services;
@@ -8,10 +8,11 @@ namespace LightlessSync.Services;
public sealed class PairRequestService : DisposableMediatorSubscriberBase
{
private readonly DalamudUtilService _dalamudUtil;
private readonly PairManager _pairManager;
private readonly PairUiService _pairUiService;
private readonly Lazy<WebAPI.ApiController> _apiController;
private readonly Lock _syncRoot = new();
private readonly List<PairRequestEntry> _requests = [];
private readonly Dictionary<string, string> _displayNameCache = new(StringComparer.Ordinal);
private static readonly TimeSpan _expiration = TimeSpan.FromMinutes(5);
@@ -19,12 +20,12 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
ILogger<PairRequestService> logger,
LightlessMediator mediator,
DalamudUtilService dalamudUtil,
PairManager pairManager,
PairUiService pairUiService,
Lazy<WebAPI.ApiController> apiController)
: base(logger, mediator)
{
_dalamudUtil = dalamudUtil;
_pairManager = pairManager;
_pairUiService = pairUiService;
_apiController = apiController;
Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, _ =>
@@ -96,6 +97,10 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
lock (_syncRoot)
{
removed = _requests.RemoveAll(r => string.Equals(r.HashedCid, hashedCid, StringComparison.Ordinal)) > 0;
if (removed)
{
_displayNameCache.Remove(hashedCid);
}
}
if (removed)
@@ -129,6 +134,23 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
return string.Empty;
}
if (TryGetCachedDisplayName(hashedCid, out var cached))
{
return cached;
}
var resolved = ResolveDisplayNameInternal(hashedCid);
if (!string.IsNullOrWhiteSpace(resolved))
{
CacheDisplayName(hashedCid, resolved);
return resolved;
}
return string.Empty;
}
private string ResolveDisplayNameInternal(string hashedCid)
{
var (name, address) = _dalamudUtil.FindPlayerByNameHash(hashedCid);
if (!string.IsNullOrWhiteSpace(name))
{
@@ -138,8 +160,9 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
: name;
}
var pair = _pairManager
.GetOnlineUserPairs()
var snapshot = _pairUiService.GetSnapshot();
var pair = snapshot.PairsByUid.Values
.Where(p => !string.IsNullOrEmpty(p.GetPlayerNameHash()))
.FirstOrDefault(p => string.Equals(p.Ident, hashedCid, StringComparison.Ordinal));
if (pair != null)
@@ -185,7 +208,21 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
}
var now = DateTime.UtcNow;
return _requests.RemoveAll(r => now - r.ReceivedAt > _expiration) > 0;
var removedAny = false;
for (var i = _requests.Count - 1; i >= 0; i--)
{
var entry = _requests[i];
if (now - entry.ReceivedAt <= _expiration)
{
continue;
}
_displayNameCache.Remove(entry.HashedCid);
_requests.RemoveAt(i);
removedAny = true;
}
return removedAny;
}
public void AcceptPairRequest(string hashedCid, string displayName)
@@ -229,4 +266,32 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
private record struct PairRequestEntry(string HashedCid, string MessageTemplate, DateTime ReceivedAt);
public readonly record struct PairRequestDisplay(string HashedCid, string DisplayName, string Message, DateTime ReceivedAt);
private bool TryGetCachedDisplayName(string hashedCid, out string displayName)
{
lock (_syncRoot)
{
if (!string.IsNullOrWhiteSpace(hashedCid) && _displayNameCache.TryGetValue(hashedCid, out var cached))
{
displayName = cached;
return true;
}
}
displayName = string.Empty;
return false;
}
private void CacheDisplayName(string hashedCid, string displayName)
{
if (string.IsNullOrWhiteSpace(hashedCid) || string.IsNullOrWhiteSpace(displayName) || string.Equals(hashedCid, displayName, StringComparison.Ordinal))
{
return;
}
lock (_syncRoot)
{
_displayNameCache[hashedCid] = displayName;
}
}
}

View File

@@ -1,9 +1,13 @@
using System;
using System.IO;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Extensions;
using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Events;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.TextureCompression;
using LightlessSync.UI;
using LightlessSync.WebAPI.Files.Models;
using Microsoft.Extensions.Logging;
@@ -17,20 +21,22 @@ public class PlayerPerformanceService
private readonly ILogger<PlayerPerformanceService> _logger;
private readonly LightlessMediator _mediator;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly TextureDownscaleService _textureDownscaleService;
private readonly Dictionary<string, bool> _warnedForPlayers = new(StringComparer.Ordinal);
public PlayerPerformanceService(ILogger<PlayerPerformanceService> logger, LightlessMediator mediator,
PlayerPerformanceConfigService playerPerformanceConfigService, FileCacheManager fileCacheManager,
XivDataAnalyzer xivDataAnalyzer)
XivDataAnalyzer xivDataAnalyzer, TextureDownscaleService textureDownscaleService)
{
_logger = logger;
_mediator = mediator;
_playerPerformanceConfigService = playerPerformanceConfigService;
_fileCacheManager = fileCacheManager;
_xivDataAnalyzer = xivDataAnalyzer;
_textureDownscaleService = textureDownscaleService;
}
public async Task<bool> CheckBothThresholds(PairHandler pairHandler, CharacterData charaData)
public async Task<bool> CheckBothThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData)
{
var config = _playerPerformanceConfigService.Current;
bool notPausedAfterVram = ComputeAndAutoPauseOnVRAMUsageThresholds(pairHandler, charaData, []);
@@ -39,37 +45,37 @@ public class PlayerPerformanceService
if (!notPausedAfterTris) return false;
if (config.UIDsToIgnore
.Exists(uid => string.Equals(uid, pairHandler.Pair.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pairHandler.Pair.UserData.UID, StringComparison.Ordinal)))
.Exists(uid => string.Equals(uid, pairHandler.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pairHandler.UserData.UID, StringComparison.Ordinal)))
return true;
var vramUsage = pairHandler.Pair.LastAppliedApproximateVRAMBytes;
var triUsage = pairHandler.Pair.LastAppliedDataTris;
var vramUsage = pairHandler.LastAppliedApproximateVRAMBytes;
var triUsage = pairHandler.LastAppliedDataTris;
bool isPrefPerm = pairHandler.Pair.UserPair.OwnPermissions.HasFlag(API.Data.Enum.UserPermissions.Sticky);
bool isPrefPerm = pairHandler.HasStickyPermissions;
bool exceedsTris = CheckForThreshold(config.WarnOnExceedingThresholds, config.TrisWarningThresholdThousands * 1000,
bool exceedsTris = CheckForThreshold(config.WarnOnExceedingThresholds, config.TrisWarningThresholdThousands * 1000L,
triUsage, config.WarnOnPreferredPermissionsExceedingThresholds, isPrefPerm);
bool exceedsVram = CheckForThreshold(config.WarnOnExceedingThresholds, config.VRAMSizeWarningThresholdMiB * 1024 * 1024,
bool exceedsVram = CheckForThreshold(config.WarnOnExceedingThresholds, config.VRAMSizeWarningThresholdMiB * 1024L * 1024L,
vramUsage, config.WarnOnPreferredPermissionsExceedingThresholds, isPrefPerm);
if (_warnedForPlayers.TryGetValue(pairHandler.Pair.UserData.UID, out bool hadWarning) && hadWarning)
if (_warnedForPlayers.TryGetValue(pairHandler.UserData.UID, out bool hadWarning) && hadWarning)
{
_warnedForPlayers[pairHandler.Pair.UserData.UID] = exceedsTris || exceedsVram;
_warnedForPlayers[pairHandler.UserData.UID] = exceedsTris || exceedsVram;
return true;
}
_warnedForPlayers[pairHandler.Pair.UserData.UID] = exceedsTris || exceedsVram;
_warnedForPlayers[pairHandler.UserData.UID] = exceedsTris || exceedsVram;
if (exceedsVram)
{
_mediator.Publish(new EventMessage(new Event(pairHandler.Pair.PlayerName, pairHandler.Pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
_mediator.Publish(new EventMessage(new Event(pairHandler.PlayerName, pairHandler.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
$"Exceeds VRAM threshold: ({UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeWarningThresholdMiB} MiB)")));
}
if (exceedsTris)
{
_mediator.Publish(new EventMessage(new Event(pairHandler.Pair.PlayerName, pairHandler.Pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
_mediator.Publish(new EventMessage(new Event(pairHandler.PlayerName, pairHandler.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
$"Exceeds triangle threshold: ({triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles)")));
}
@@ -78,41 +84,40 @@ public class PlayerPerformanceService
string warningText = string.Empty;
if (exceedsTris && !exceedsVram)
{
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured triangle warning threshold\n" +
warningText = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeds your configured triangle warning threshold\n" +
$"{triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles";
}
else if (!exceedsTris)
{
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured VRAM warning threshold\n" +
warningText = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeds your configured VRAM warning threshold\n" +
$"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB";
}
else
{
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds both VRAM warning threshold and triangle warning threshold\n" +
warningText = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeds both VRAM warning threshold and triangle warning threshold\n" +
$"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB and {triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles";
}
_mediator.Publish(new PerformanceNotificationMessage(
$"{pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds performance threshold(s)",
$"{pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeds performance threshold(s)",
warningText,
pairHandler.Pair.UserData,
pairHandler.Pair.IsPaused,
pairHandler.Pair.PlayerName));
pairHandler.UserData,
pairHandler.IsPaused,
pairHandler.PlayerName));
}
return true;
}
public async Task<bool> CheckTriangleUsageThresholds(PairHandler pairHandler, CharacterData charaData)
public async Task<bool> CheckTriangleUsageThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData)
{
var config = _playerPerformanceConfigService.Current;
var pair = pairHandler.Pair;
long triUsage = 0;
if (!charaData.FileReplacements.TryGetValue(API.Data.Enum.ObjectKind.Player, out List<FileReplacementData>? playerReplacements))
{
pair.LastAppliedDataTris = 0;
pairHandler.LastAppliedDataTris = 0;
return true;
}
@@ -126,35 +131,35 @@ public class PlayerPerformanceService
triUsage += await _xivDataAnalyzer.GetTrianglesByHash(hash).ConfigureAwait(false);
}
pair.LastAppliedDataTris = triUsage;
pairHandler.LastAppliedDataTris = triUsage;
_logger.LogDebug("Calculated VRAM usage for {p}", pairHandler);
// no warning of any kind on ignored pairs
if (config.UIDsToIgnore
.Exists(uid => string.Equals(uid, pair.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pair.UserData.UID, StringComparison.Ordinal)))
.Exists(uid => string.Equals(uid, pairHandler.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pairHandler.UserData.UID, StringComparison.Ordinal)))
return true;
bool isPrefPerm = pair.UserPair.OwnPermissions.HasFlag(API.Data.Enum.UserPermissions.Sticky);
bool isPrefPerm = pairHandler.HasStickyPermissions;
// now check auto pause
if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.TrisAutoPauseThresholdThousands * 1000,
if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.TrisAutoPauseThresholdThousands * 1000L,
triUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm))
{
var message = $"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured triangle auto pause threshold and has been automatically paused\n" +
var message = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeded your configured triangle auto pause threshold and has been automatically paused\n" +
$"{triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles";
_mediator.Publish(new PerformanceNotificationMessage(
$"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
$"{pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) automatically paused",
message,
pair.UserData,
pairHandler.UserData,
true,
pair.PlayerName));
pairHandler.PlayerName));
_mediator.Publish(new EventMessage(new Event(pair.PlayerName, pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
_mediator.Publish(new EventMessage(new Event(pairHandler.PlayerName, pairHandler.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
$"Exceeds triangle threshold: automatically paused ({triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles)")));
_mediator.Publish(new PauseMessage(pair.UserData));
_mediator.Publish(new PauseMessage(pairHandler.UserData));
return false;
}
@@ -162,16 +167,18 @@ public class PlayerPerformanceService
return true;
}
public bool ComputeAndAutoPauseOnVRAMUsageThresholds(PairHandler pairHandler, CharacterData charaData, List<DownloadFileTransfer> toDownloadFiles)
public bool ComputeAndAutoPauseOnVRAMUsageThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData, List<DownloadFileTransfer> toDownloadFiles)
{
var config = _playerPerformanceConfigService.Current;
var pair = pairHandler.Pair;
bool skipDownscale = pairHandler.IsDirectlyPaired && pairHandler.HasStickyPermissions;
long vramUsage = 0;
long effectiveVramUsage = 0;
if (!charaData.FileReplacements.TryGetValue(API.Data.Enum.ObjectKind.Player, out List<FileReplacementData>? playerReplacements))
{
pair.LastAppliedApproximateVRAMBytes = 0;
pairHandler.LastAppliedApproximateVRAMBytes = 0;
pairHandler.LastAppliedApproximateEffectiveVRAMBytes = 0;
return true;
}
@@ -183,11 +190,13 @@ public class PlayerPerformanceService
foreach (var hash in moddedTextureHashes)
{
long fileSize = 0;
long effectiveSize = 0;
var download = toDownloadFiles.Find(f => string.Equals(hash, f.Hash, StringComparison.OrdinalIgnoreCase));
if (download != null)
{
fileSize = download.TotalRaw;
effectiveSize = fileSize;
}
else
{
@@ -201,39 +210,63 @@ public class PlayerPerformanceService
}
fileSize = fileEntry.Size.Value;
effectiveSize = fileSize;
if (!skipDownscale)
{
var preferredPath = _textureDownscaleService.GetPreferredPath(hash, fileEntry.ResolvedFilepath);
if (!string.IsNullOrEmpty(preferredPath) && File.Exists(preferredPath))
{
try
{
effectiveSize = new FileInfo(preferredPath).Length;
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to read size for preferred texture path {Path}", preferredPath);
effectiveSize = fileSize;
}
}
else
{
effectiveSize = fileSize;
}
}
}
vramUsage += fileSize;
effectiveVramUsage += effectiveSize;
}
pair.LastAppliedApproximateVRAMBytes = vramUsage;
pairHandler.LastAppliedApproximateVRAMBytes = vramUsage;
pairHandler.LastAppliedApproximateEffectiveVRAMBytes = effectiveVramUsage;
_logger.LogDebug("Calculated VRAM usage for {p}", pairHandler);
// no warning of any kind on ignored pairs
if (config.UIDsToIgnore
.Exists(uid => string.Equals(uid, pair.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pair.UserData.UID, StringComparison.Ordinal)))
.Exists(uid => string.Equals(uid, pairHandler.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pairHandler.UserData.UID, StringComparison.Ordinal)))
return true;
bool isPrefPerm = pair.UserPair.OwnPermissions.HasFlag(API.Data.Enum.UserPermissions.Sticky);
bool isPrefPerm = pairHandler.HasStickyPermissions;
// now check auto pause
if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.VRAMSizeAutoPauseThresholdMiB * 1024 * 1024,
if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.VRAMSizeAutoPauseThresholdMiB * 1024L * 1024L,
vramUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm))
{
var message = $"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured VRAM auto pause threshold and has been automatically paused\n" +
var message = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeded your configured VRAM auto pause threshold and has been automatically paused\n" +
$"{UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB}MiB";
_mediator.Publish(new PerformanceNotificationMessage(
$"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
$"{pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) automatically paused",
message,
pair.UserData,
pairHandler.UserData,
true,
pair.PlayerName));
pairHandler.PlayerName));
_mediator.Publish(new PauseMessage(pair.UserData));
_mediator.Publish(new PauseMessage(pairHandler.UserData));
_mediator.Publish(new EventMessage(new Event(pair.PlayerName, pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
_mediator.Publish(new EventMessage(new Event(pairHandler.PlayerName, pairHandler.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
$"Exceeds VRAM threshold: automatically paused ({UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB} MiB)")));
return false;

View File

@@ -252,9 +252,16 @@ public class ServerConfigurationManager
public void SelectServer(int idx)
{
var previousIndex = _configService.Current.CurrentServer;
_configService.Current.CurrentServer = idx;
CurrentServer!.FullPause = false;
Save();
if (previousIndex != idx)
{
var serverUrl = CurrentServer.ServerUri;
_lightlessMediator.Publish(new ActiveServerChangedMessage(serverUrl));
}
}
internal void AddCurrentCharacterToServer(int serverSelectionIndex = -1)

View File

@@ -0,0 +1,282 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using Lumina.Data.Files;
using OtterTex;
namespace LightlessSync.Services.TextureCompression;
// base taken from penumbra mostly
internal static class TexFileHelper
{
private const int HeaderSize = 80;
private const int MaxMipLevels = 13;
public static ScratchImage Load(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
return Load(stream);
}
public static ScratchImage Load(Stream stream)
{
using var reader = new BinaryReader(stream, System.Text.Encoding.UTF8, leaveOpen: true);
var header = ReadHeader(reader);
var meta = CreateMeta(header);
meta.MipLevels = ComputeMipCount(stream.Length, header, meta);
if (meta.MipLevels == 0)
{
throw new InvalidOperationException("TEX file does not contain a valid mip chain.");
}
var scratch = ScratchImage.Initialize(meta);
ReadPixelData(reader, scratch);
return scratch;
}
public static void Save(string path, ScratchImage image)
{
var header = BuildHeader(image);
if (header.Format == TexFile.TextureFormat.Unknown)
{
throw new InvalidOperationException($"Unable to export TEX file with unsupported format {image.Meta.Format}.");
}
var mode = File.Exists(path) ? FileMode.Truncate : FileMode.CreateNew;
using var stream = new FileStream(path, mode, FileAccess.Write, FileShare.Read);
using var writer = new BinaryWriter(stream);
WriteHeader(writer, header);
writer.Write(image.Pixels);
GC.KeepAlive(image);
}
private static TexFile.TexHeader ReadHeader(BinaryReader reader)
{
Span<byte> buffer = stackalloc byte[HeaderSize];
var read = reader.Read(buffer);
if (read != HeaderSize)
{
throw new EndOfStreamException($"Incomplete TEX header: expected {HeaderSize} bytes, read {read} bytes.");
}
return MemoryMarshal.Read<TexFile.TexHeader>(buffer);
}
private static TexMeta CreateMeta(in TexFile.TexHeader header)
{
var meta = new TexMeta
{
Width = header.Width,
Height = header.Height,
Depth = Math.Max(header.Depth, (ushort)1),
ArraySize = 1,
MipLevels = header.MipCount,
Format = header.Format.ToDxgi(),
Dimension = header.Type.ToDimension(),
MiscFlags = header.Type.HasFlag(TexFile.Attribute.TextureTypeCube) ? D3DResourceMiscFlags.TextureCube : 0,
MiscFlags2 = 0,
};
if (meta.Format == DXGIFormat.Unknown)
{
throw new InvalidOperationException($"TEX format {header.Format} cannot be mapped to DXGI.");
}
if (meta.Dimension == TexDimension.Unknown)
{
throw new InvalidOperationException($"Unrecognised TEX dimension attribute {header.Type}.");
}
return meta;
}
private static unsafe int ComputeMipCount(long totalLength, in TexFile.TexHeader header, in TexMeta meta)
{
var width = Math.Max(meta.Width, 1);
var height = Math.Max(meta.Height, 1);
var minSide = meta.Format.IsCompressed() ? 4 : 1;
var bitsPerPixel = meta.Format.BitsPerPixel();
var expectedOffset = HeaderSize;
var remaining = totalLength - HeaderSize;
for (var level = 0; level < MaxMipLevels; level++)
{
var declaredOffset = header.OffsetToSurface[level];
if (declaredOffset == 0)
{
return level;
}
if (declaredOffset != expectedOffset || remaining <= 0)
{
return level;
}
var mipSize = (int)((long)width * height * bitsPerPixel / 8);
if (mipSize > remaining)
{
return level;
}
expectedOffset += mipSize;
remaining -= mipSize;
if (width <= minSide && height <= minSide)
{
return level + 1;
}
width = Math.Max(width / 2, minSide);
height = Math.Max(height / 2, minSide);
}
return MaxMipLevels;
}
private static unsafe void ReadPixelData(BinaryReader reader, ScratchImage image)
{
fixed (byte* destination = image.Pixels)
{
var span = new Span<byte>(destination, image.Pixels.Length);
var read = reader.Read(span);
if (read < span.Length)
{
throw new InvalidDataException($"TEX pixel buffer is truncated (read {read} of {span.Length} bytes).");
}
}
}
private static TexFile.TexHeader BuildHeader(ScratchImage image)
{
var meta = image.Meta;
var header = new TexFile.TexHeader
{
Width = (ushort)meta.Width,
Height = (ushort)meta.Height,
Depth = (ushort)Math.Max(meta.Depth, 1),
MipCount = (byte)Math.Min(meta.MipLevels, MaxMipLevels),
Format = meta.Format.ToTex(),
Type = meta.Dimension switch
{
_ when meta.IsCubeMap => TexFile.Attribute.TextureTypeCube,
TexDimension.Tex1D => TexFile.Attribute.TextureType1D,
TexDimension.Tex2D => TexFile.Attribute.TextureType2D,
TexDimension.Tex3D => TexFile.Attribute.TextureType3D,
_ => 0,
},
};
PopulateOffsets(ref header, image);
return header;
}
private static unsafe void PopulateOffsets(ref TexFile.TexHeader header, ScratchImage image)
{
var index = 0;
fixed (byte* basePtr = image.Pixels)
{
foreach (var mip in image.Images)
{
if (index >= MaxMipLevels)
{
break;
}
var byteOffset = (byte*)mip.Pixels - basePtr;
header.OffsetToSurface[index++] = HeaderSize + (uint)byteOffset;
}
}
while (index < MaxMipLevels)
{
header.OffsetToSurface[index++] = 0;
}
header.LodOffset[0] = 0;
header.LodOffset[1] = (byte)Math.Min(header.MipCount - 1, 1);
header.LodOffset[2] = (byte)Math.Min(header.MipCount - 1, 2);
}
private static unsafe void WriteHeader(BinaryWriter writer, in TexFile.TexHeader header)
{
writer.Write((uint)header.Type);
writer.Write((uint)header.Format);
writer.Write(header.Width);
writer.Write(header.Height);
writer.Write(header.Depth);
writer.Write((byte)(header.MipCount | (header.MipUnknownFlag ? 0x80 : 0)));
writer.Write(header.ArraySize);
writer.Write(header.LodOffset[0]);
writer.Write(header.LodOffset[1]);
writer.Write(header.LodOffset[2]);
for (var i = 0; i < MaxMipLevels; i++)
{
writer.Write(header.OffsetToSurface[i]);
}
}
private static TexDimension ToDimension(this TexFile.Attribute attribute)
=> (attribute & TexFile.Attribute.TextureTypeMask) switch
{
TexFile.Attribute.TextureType1D => TexDimension.Tex1D,
TexFile.Attribute.TextureType2D => TexDimension.Tex2D,
TexFile.Attribute.TextureType3D => TexDimension.Tex3D,
_ => TexDimension.Unknown,
};
private static DXGIFormat ToDxgi(this TexFile.TextureFormat format)
=> format switch
{
TexFile.TextureFormat.L8 => DXGIFormat.R8UNorm,
TexFile.TextureFormat.A8 => DXGIFormat.A8UNorm,
TexFile.TextureFormat.B4G4R4A4 => DXGIFormat.B4G4R4A4UNorm,
TexFile.TextureFormat.B5G5R5A1 => DXGIFormat.B5G5R5A1UNorm,
TexFile.TextureFormat.B8G8R8A8 => DXGIFormat.B8G8R8A8UNorm,
TexFile.TextureFormat.B8G8R8X8 => DXGIFormat.B8G8R8X8UNorm,
TexFile.TextureFormat.R32F => DXGIFormat.R32Float,
TexFile.TextureFormat.R16G16F => DXGIFormat.R16G16Float,
TexFile.TextureFormat.R32G32F => DXGIFormat.R32G32Float,
TexFile.TextureFormat.R16G16B16A16F => DXGIFormat.R16G16B16A16Float,
TexFile.TextureFormat.R32G32B32A32F => DXGIFormat.R32G32B32A32Float,
TexFile.TextureFormat.BC1 => DXGIFormat.BC1UNorm,
TexFile.TextureFormat.BC2 => DXGIFormat.BC2UNorm,
TexFile.TextureFormat.BC3 => DXGIFormat.BC3UNorm,
(TexFile.TextureFormat)0x6120 => DXGIFormat.BC4UNorm,
TexFile.TextureFormat.BC5 => DXGIFormat.BC5UNorm,
(TexFile.TextureFormat)0x6330 => DXGIFormat.BC6HSF16,
TexFile.TextureFormat.BC7 => DXGIFormat.BC7UNorm,
TexFile.TextureFormat.D16 => DXGIFormat.R16G16B16A16Typeless,
TexFile.TextureFormat.D24S8 => DXGIFormat.R24G8Typeless,
TexFile.TextureFormat.Shadow16 => DXGIFormat.R16Typeless,
TexFile.TextureFormat.Shadow24 => DXGIFormat.R24G8Typeless,
_ => DXGIFormat.Unknown,
};
private static TexFile.TextureFormat ToTex(this DXGIFormat format)
=> format switch
{
DXGIFormat.R8UNorm => TexFile.TextureFormat.L8,
DXGIFormat.A8UNorm => TexFile.TextureFormat.A8,
DXGIFormat.B4G4R4A4UNorm => TexFile.TextureFormat.B4G4R4A4,
DXGIFormat.B5G5R5A1UNorm => TexFile.TextureFormat.B5G5R5A1,
DXGIFormat.B8G8R8A8UNorm => TexFile.TextureFormat.B8G8R8A8,
DXGIFormat.B8G8R8X8UNorm => TexFile.TextureFormat.B8G8R8X8,
DXGIFormat.R32Float => TexFile.TextureFormat.R32F,
DXGIFormat.R16G16Float => TexFile.TextureFormat.R16G16F,
DXGIFormat.R32G32Float => TexFile.TextureFormat.R32G32F,
DXGIFormat.R16G16B16A16Float => TexFile.TextureFormat.R16G16B16A16F,
DXGIFormat.R32G32B32A32Float => TexFile.TextureFormat.R32G32B32A32F,
DXGIFormat.BC1UNorm => TexFile.TextureFormat.BC1,
DXGIFormat.BC2UNorm => TexFile.TextureFormat.BC2,
DXGIFormat.BC3UNorm => TexFile.TextureFormat.BC3,
DXGIFormat.BC4UNorm => (TexFile.TextureFormat)0x6120,
DXGIFormat.BC5UNorm => TexFile.TextureFormat.BC5,
DXGIFormat.BC6HSF16 => (TexFile.TextureFormat)0x6330,
DXGIFormat.BC7UNorm => TexFile.TextureFormat.BC7,
DXGIFormat.R16G16B16A16Typeless => TexFile.TextureFormat.D16,
DXGIFormat.R24G8Typeless => TexFile.TextureFormat.D24S8,
DXGIFormat.R16Typeless => TexFile.TextureFormat.Shadow16,
_ => TexFile.TextureFormat.Unknown,
};
}

View File

@@ -0,0 +1,58 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using Penumbra.Api.Enums;
namespace LightlessSync.Services.TextureCompression;
internal static class TextureCompressionCapabilities
{
private static readonly ImmutableDictionary<TextureCompressionTarget, TextureType> TexTargets =
new Dictionary<TextureCompressionTarget, TextureType>
{
[TextureCompressionTarget.BC7] = TextureType.Bc7Tex,
[TextureCompressionTarget.BC3] = TextureType.Bc3Tex,
}.ToImmutableDictionary();
private static readonly ImmutableDictionary<TextureCompressionTarget, TextureType> DdsTargets =
new Dictionary<TextureCompressionTarget, TextureType>
{
[TextureCompressionTarget.BC7] = TextureType.Bc7Dds,
[TextureCompressionTarget.BC3] = TextureType.Bc3Dds,
}.ToImmutableDictionary();
private static readonly TextureCompressionTarget[] SelectableTargetsCache = TexTargets
.Select(kvp => kvp.Key)
.OrderBy(t => t)
.ToArray();
private static readonly HashSet<TextureCompressionTarget> SelectableTargetSet = SelectableTargetsCache.ToHashSet();
public static IReadOnlyList<TextureCompressionTarget> SelectableTargets => SelectableTargetsCache;
public static TextureCompressionTarget DefaultTarget => TextureCompressionTarget.BC7;
public static bool IsSelectable(TextureCompressionTarget target) => SelectableTargetSet.Contains(target);
public static TextureCompressionTarget Normalize(TextureCompressionTarget? desired)
{
if (desired.HasValue && IsSelectable(desired.Value))
{
return desired.Value;
}
return DefaultTarget;
}
public static bool TryGetPenumbraTarget(TextureCompressionTarget target, string? outputPath, out TextureType textureType)
{
if (!string.IsNullOrWhiteSpace(outputPath) &&
string.Equals(Path.GetExtension(outputPath), ".dds", StringComparison.OrdinalIgnoreCase))
{
return DdsTargets.TryGetValue(target, out textureType);
}
return TexTargets.TryGetValue(target, out textureType);
}
}

View File

@@ -0,0 +1,8 @@
using System.Collections.Generic;
namespace LightlessSync.Services.TextureCompression;
public sealed record TextureCompressionRequest(
string PrimaryFilePath,
IReadOnlyList<string> DuplicateFilePaths,
TextureCompressionTarget Target);

View File

@@ -0,0 +1,330 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using LightlessSync.Interop.Ipc;
using LightlessSync.FileCache;
using Microsoft.Extensions.Logging;
using Penumbra.Api.Enums;
namespace LightlessSync.Services.TextureCompression;
public sealed class TextureCompressionService
{
private readonly ILogger<TextureCompressionService> _logger;
private readonly IpcManager _ipcManager;
private readonly FileCacheManager _fileCacheManager;
public IReadOnlyList<TextureCompressionTarget> SelectableTargets => TextureCompressionCapabilities.SelectableTargets;
public TextureCompressionTarget DefaultTarget => TextureCompressionCapabilities.DefaultTarget;
public TextureCompressionService(
ILogger<TextureCompressionService> logger,
IpcManager ipcManager,
FileCacheManager fileCacheManager)
{
_logger = logger;
_ipcManager = ipcManager;
_fileCacheManager = fileCacheManager;
}
public async Task ConvertTexturesAsync(
IReadOnlyList<TextureCompressionRequest> requests,
IProgress<TextureConversionProgress>? progress,
CancellationToken token)
{
if (requests.Count == 0)
{
return;
}
var total = requests.Count;
var completed = 0;
foreach (var request in requests)
{
token.ThrowIfCancellationRequested();
if (!TextureCompressionCapabilities.TryGetPenumbraTarget(request.Target, request.PrimaryFilePath, out var textureType))
{
_logger.LogWarning("Unsupported compression target {Target} requested.", request.Target);
completed++;
continue;
}
await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token).ConfigureAwait(false);
completed++;
}
}
public bool IsTargetSelectable(TextureCompressionTarget target) => TextureCompressionCapabilities.IsSelectable(target);
public TextureCompressionTarget NormalizeTarget(TextureCompressionTarget? desired) =>
TextureCompressionCapabilities.Normalize(desired);
private async Task RunPenumbraConversionAsync(
TextureCompressionRequest request,
TextureType targetType,
int total,
int completedBefore,
IProgress<TextureConversionProgress>? progress,
CancellationToken token)
{
var primaryPath = request.PrimaryFilePath;
var displayJob = new TextureConversionJob(
primaryPath,
primaryPath,
targetType,
IncludeMipMaps: true,
request.DuplicateFilePaths);
var backupPath = CreateBackupCopy(primaryPath);
var conversionJob = displayJob with { InputFile = backupPath };
progress?.Report(new TextureConversionProgress(completedBefore, total, displayJob));
try
{
WaitForAccess(primaryPath);
await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { conversionJob }, null, token).ConfigureAwait(false);
if (!IsValidConversionResult(displayJob.OutputFile))
{
throw new InvalidOperationException($"Penumbra conversion produced no output for {displayJob.OutputFile}.");
}
UpdateFileCache(displayJob);
progress?.Report(new TextureConversionProgress(completedBefore + 1, total, displayJob));
}
catch (Exception ex)
{
RestoreFromBackup(backupPath, displayJob.OutputFile, displayJob.DuplicateTargets, ex);
throw;
}
finally
{
CleanupBackup(backupPath);
}
}
private void UpdateFileCache(TextureConversionJob job)
{
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
job.OutputFile
};
if (job.DuplicateTargets is { Count: > 0 })
{
foreach (var duplicate in job.DuplicateTargets)
{
paths.Add(duplicate);
}
}
if (paths.Count == 0)
{
return;
}
var cacheEntries = _fileCacheManager.GetFileCachesByPaths(paths.ToArray());
foreach (var path in paths)
{
if (!cacheEntries.TryGetValue(path, out var entry) || entry is null)
{
entry = _fileCacheManager.CreateFileEntry(path);
if (entry is null)
{
_logger.LogWarning("Unable to locate cache entry for {Path}; skipping hash refresh", path);
continue;
}
}
try
{
_fileCacheManager.UpdateHashedFile(entry);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to refresh file cache entry for {Path}", path);
}
}
}
private static readonly string WorkingDirectory =
Path.Combine(Path.GetTempPath(), "LightlessSync.TextureCompression");
private static string CreateBackupCopy(string filePath)
{
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"Cannot back up missing texture file {filePath}.", filePath);
}
Directory.CreateDirectory(WorkingDirectory);
var extension = Path.GetExtension(filePath);
if (string.IsNullOrEmpty(extension))
{
extension = ".tmp";
}
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(filePath);
var backupName = $"{fileNameWithoutExtension}.backup.{Guid.NewGuid():N}{extension}";
var backupPath = Path.Combine(WorkingDirectory, backupName);
WaitForAccess(filePath);
File.Copy(filePath, backupPath, overwrite: false);
return backupPath;
}
private const int MaxAccessRetries = 10;
private static readonly TimeSpan AccessRetryDelay = TimeSpan.FromMilliseconds(200);
private static void WaitForAccess(string filePath)
{
if (!File.Exists(filePath))
{
return;
}
try
{
File.SetAttributes(filePath, FileAttributes.Normal);
}
catch
{
// ignore attribute changes here
}
Exception? lastException = null;
for (var attempt = 0; attempt < MaxAccessRetries; attempt++)
{
try
{
using var stream = new FileStream(
filePath,
FileMode.Open,
FileAccess.Read,
FileShare.None);
return;
}
catch (IOException ex) when (IsSharingViolation(ex))
{
lastException = ex;
}
Thread.Sleep(AccessRetryDelay);
}
if (lastException != null)
{
throw lastException;
}
}
private static bool IsSharingViolation(IOException ex) =>
ex.HResult == unchecked((int)0x80070020);
private void RestoreFromBackup(
string backupPath,
string destinationPath,
IReadOnlyList<string>? duplicateTargets,
Exception reason)
{
if (string.IsNullOrEmpty(backupPath))
{
_logger.LogWarning(reason, "Conversion failed for {File}, but no backup was available to restore.", destinationPath);
return;
}
if (!File.Exists(backupPath))
{
_logger.LogWarning(reason, "Conversion failed for {File}, but backup path {Backup} no longer exists.", destinationPath, backupPath);
return;
}
try
{
TryReplaceFile(backupPath, destinationPath);
}
catch (Exception restoreEx)
{
_logger.LogError(restoreEx, "Failed to restore texture {File} after conversion failure.", destinationPath);
return;
}
if (duplicateTargets is { Count: > 0 })
{
foreach (var duplicate in duplicateTargets)
{
if (string.Equals(destinationPath, duplicate, StringComparison.OrdinalIgnoreCase))
{
continue;
}
try
{
File.Copy(destinationPath, duplicate, overwrite: true);
}
catch (Exception duplicateEx)
{
_logger.LogDebug(duplicateEx, "Failed to restore duplicate {Duplicate} after conversion failure.", duplicate);
}
}
}
_logger.LogWarning(reason, "Restored original texture {File} after conversion failure.", destinationPath);
}
private static void TryReplaceFile(string sourcePath, string destinationPath)
{
WaitForAccess(destinationPath);
var destinationDirectory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrEmpty(destinationDirectory))
{
Directory.CreateDirectory(destinationDirectory);
}
File.Copy(sourcePath, destinationPath, overwrite: true);
}
private static void CleanupBackup(string backupPath)
{
if (string.IsNullOrEmpty(backupPath))
{
return;
}
try
{
if (File.Exists(backupPath))
{
File.Delete(backupPath);
}
}
catch
{
// avoid killing successful conversions on cleanup failure
}
}
private static bool IsValidConversionResult(string path)
{
try
{
var fileInfo = new FileInfo(path);
return fileInfo.Exists && fileInfo.Length > 0;
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,10 @@
namespace LightlessSync.Services.TextureCompression;
public enum TextureCompressionTarget
{
BC1,
BC3,
BC4,
BC5,
BC7
}

View File

@@ -0,0 +1,955 @@
using System;
using System.Collections.Concurrent;
using System.Buffers.Binary;
using System.Globalization;
using System.Numerics;
using System.IO;
using OtterTex;
using OtterImage = OtterTex.Image;
using LightlessSync.LightlessConfiguration;
using LightlessSync.FileCache;
using Microsoft.Extensions.Logging;
using Lumina.Data.Files;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
/*
* Index upscaler code (converted/reversed for downscaling purposes) provided by Ny
* OtterTex made by Ottermandias
* thank you!!
*/
namespace LightlessSync.Services.TextureCompression;
public sealed class TextureDownscaleService
{
private const int DefaultTargetMaxDimension = 2048;
private const int MaxSupportedTargetDimension = 8192;
private const int BlockMultiple = 4;
private readonly ILogger<TextureDownscaleService> _logger;
private readonly LightlessConfigService _configService;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly FileCacheManager _fileCacheManager;
private readonly ConcurrentDictionary<string, Task> _activeJobs = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _downscaledPaths = new(StringComparer.OrdinalIgnoreCase);
private static readonly IReadOnlyDictionary<int, TextureCompressionTarget> BlockCompressedFormatMap =
new Dictionary<int, TextureCompressionTarget>
{
[70] = TextureCompressionTarget.BC1, // DXGI_FORMAT_BC1_TYPELESS
[71] = TextureCompressionTarget.BC1, // DXGI_FORMAT_BC1_UNORM
[72] = TextureCompressionTarget.BC1, // DXGI_FORMAT_BC1_UNORM_SRGB
[73] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC2_TYPELESS
[74] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC2_UNORM
[75] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC2_UNORM_SRGB
[76] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC3_TYPELESS
[77] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC3_UNORM
[78] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC3_UNORM_SRGB
[79] = TextureCompressionTarget.BC4, // DXGI_FORMAT_BC4_TYPELESS
[80] = TextureCompressionTarget.BC4, // DXGI_FORMAT_BC4_UNORM
[81] = TextureCompressionTarget.BC4, // DXGI_FORMAT_BC4_SNORM
[82] = TextureCompressionTarget.BC5, // DXGI_FORMAT_BC5_TYPELESS
[83] = TextureCompressionTarget.BC5, // DXGI_FORMAT_BC5_UNORM
[84] = TextureCompressionTarget.BC5, // DXGI_FORMAT_BC5_SNORM
[94] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC6H_TYPELESS (treated as BC7 for block detection)
[95] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC6H_UF16
[96] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC6H_SF16
[97] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC7_TYPELESS
[98] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC7_UNORM
[99] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC7_UNORM_SRGB
};
public TextureDownscaleService(
ILogger<TextureDownscaleService> logger,
LightlessConfigService configService,
PlayerPerformanceConfigService playerPerformanceConfigService,
FileCacheManager fileCacheManager)
{
_logger = logger;
_configService = configService;
_playerPerformanceConfigService = playerPerformanceConfigService;
_fileCacheManager = fileCacheManager;
}
public void ScheduleDownscale(string hash, string filePath, TextureMapKind mapKind)
{
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return;
if (_activeJobs.ContainsKey(hash)) return;
_activeJobs[hash] = Task.Run(() => DownscaleInternalAsync(hash, filePath, mapKind), CancellationToken.None);
}
public string GetPreferredPath(string hash, string originalPath)
{
if (_downscaledPaths.TryGetValue(hash, out var existing) && File.Exists(existing))
{
return existing;
}
var resolved = GetExistingDownscaledPath(hash);
if (!string.IsNullOrEmpty(resolved))
{
_downscaledPaths[hash] = resolved;
return resolved;
}
return originalPath;
}
private async Task DownscaleInternalAsync(string hash, string sourcePath, TextureMapKind mapKind)
{
TexHeaderInfo? headerInfo = null;
string? destination = null;
int targetMaxDimension = 0;
bool onlyDownscaleUncompressed = false;
bool? isIndexTexture = null;
try
{
if (!File.Exists(sourcePath))
{
_logger.LogWarning("Cannot downscale texture {Hash}; source path missing: {Path}", hash, sourcePath);
return;
}
headerInfo = TryReadTexHeader(sourcePath, out var header)
? header
: (TexHeaderInfo?)null;
var performanceConfig = _playerPerformanceConfigService.Current;
targetMaxDimension = ResolveTargetMaxDimension();
onlyDownscaleUncompressed = performanceConfig.OnlyDownscaleUncompressedTextures;
destination = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex");
if (File.Exists(destination))
{
RegisterDownscaledTexture(hash, sourcePath, destination);
return;
}
var indexTexture = IsIndexMap(mapKind);
isIndexTexture = indexTexture;
if (!indexTexture)
{
if (performanceConfig.EnableNonIndexTextureMipTrim
&& await TryDropTopMipAsync(hash, sourcePath, destination, targetMaxDimension, onlyDownscaleUncompressed, headerInfo).ConfigureAwait(false))
{
return;
}
if (!performanceConfig.EnableNonIndexTextureMipTrim)
{
_logger.LogTrace("Skipping mip trim for non-index texture {Hash}; feature disabled.", hash);
}
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for non-index texture {Hash}; no mip reduction required.", hash);
return;
}
if (!performanceConfig.EnableIndexTextureDownscale)
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; feature disabled.", hash);
return;
}
if (onlyDownscaleUncompressed && headerInfo.HasValue && IsBlockCompressedFormat(headerInfo.Value.Format))
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; block compressed format {Format}.", hash, headerInfo.Value.Format);
return;
}
using var sourceScratch = TexFileHelper.Load(sourcePath);
using var rgbaScratch = sourceScratch.GetRGBA(out var rgbaInfo).ThrowIfError(rgbaInfo);
var bytesPerPixel = rgbaInfo.Meta.Format.BitsPerPixel() / 8;
var width = rgbaInfo.Meta.Width;
var height = rgbaInfo.Meta.Height;
var requiredLength = width * height * bytesPerPixel;
var rgbaPixels = rgbaScratch.Pixels[..requiredLength].ToArray();
using var originalImage = SixLabors.ImageSharp.Image.LoadPixelData<Rgba32>(rgbaPixels, width, height);
var targetSize = CalculateTargetSize(originalImage.Width, originalImage.Height, targetMaxDimension);
if (targetSize.width == originalImage.Width && targetSize.height == originalImage.Height)
{
return;
}
using var resized = ReduceIndexTexture(originalImage, targetSize.width, targetSize.height);
var resizedPixels = new byte[targetSize.width * targetSize.height * 4];
resized.CopyPixelDataTo(resizedPixels);
using var resizedScratch = ScratchImage.FromRGBA(resizedPixels, targetSize.width, targetSize.height, out var creationInfo).ThrowIfError(creationInfo);
using var finalScratch = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm);
TexFileHelper.Save(destination, finalScratch);
RegisterDownscaledTexture(hash, sourcePath, destination);
}
catch (Exception ex)
{
TryDelete(destination);
_logger.LogWarning(
ex,
"Texture downscale failed for {Hash} ({MapKind}) from {SourcePath} -> {Destination}. TargetMax={TargetMaxDimension}, OnlyUncompressed={OnlyDownscaleUncompressed}, IsIndex={IsIndexTexture}, HeaderFormat={HeaderFormat}",
hash,
mapKind,
sourcePath,
destination ?? "<unresolved>",
targetMaxDimension,
onlyDownscaleUncompressed,
isIndexTexture,
headerInfo?.Format);
}
finally
{
_activeJobs.TryRemove(hash, out _);
}
}
private static (int width, int height) CalculateTargetSize(int width, int height, int targetMaxDimension)
{
var resultWidth = width;
var resultHeight = height;
while (Math.Max(resultWidth, resultHeight) > targetMaxDimension)
{
resultWidth = Math.Max(BlockMultiple, resultWidth / 2);
resultHeight = Math.Max(BlockMultiple, resultHeight / 2);
}
return (resultWidth, resultHeight);
}
private static bool IsIndexMap(TextureMapKind kind)
=> kind is TextureMapKind.Mask
or TextureMapKind.Index
or TextureMapKind.Ui;
private Task<bool> TryDropTopMipAsync(
string hash,
string sourcePath,
string destination,
int targetMaxDimension,
bool onlyDownscaleUncompressed,
TexHeaderInfo? headerInfo = null)
{
TexHeaderInfo? header = headerInfo;
int dropCount = -1;
int originalWidth = 0;
int originalHeight = 0;
int originalMipLevels = 0;
try
{
if (!File.Exists(sourcePath))
{
_logger.LogWarning("Cannot trim mip levels for texture {Hash}; source path missing: {Path}", hash, sourcePath);
return Task.FromResult(false);
}
if (header is null && TryReadTexHeader(sourcePath, out var discoveredHeader))
{
header = discoveredHeader;
}
if (header is TexHeaderInfo info)
{
if (onlyDownscaleUncompressed && IsBlockCompressedFormat(info.Format))
{
_logger.LogTrace("Skipping mip trim for texture {Hash}; block compressed format {Format}.", hash, info.Format);
return Task.FromResult(false);
}
if (info.MipLevels <= 1)
{
return Task.FromResult(false);
}
var headerDepth = info.Depth == 0 ? 1 : info.Depth;
if (!ShouldTrimDimensions(info.Width, info.Height, headerDepth, targetMaxDimension))
{
return Task.FromResult(false);
}
}
using var original = TexFileHelper.Load(sourcePath);
var meta = original.Meta;
originalWidth = meta.Width;
originalHeight = meta.Height;
originalMipLevels = meta.MipLevels;
if (meta.MipLevels <= 1)
{
return Task.FromResult(false);
}
if (!ShouldTrim(meta, targetMaxDimension))
{
return Task.FromResult(false);
}
var targetSize = CalculateTargetSize(meta.Width, meta.Height, targetMaxDimension);
dropCount = CalculateDropCount(meta, targetSize.width, targetSize.height);
if (dropCount <= 0)
{
return Task.FromResult(false);
}
using var trimmed = TrimMipChain(original, dropCount);
TexFileHelper.Save(destination, trimmed);
RegisterDownscaledTexture(hash, sourcePath, destination);
_logger.LogDebug("Trimmed {DropCount} top mip level(s) for texture {Hash} -> {Path}", dropCount, hash, destination);
return Task.FromResult(true);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to trim mips for texture {Hash} from {SourcePath} -> {Destination}. TargetMax={TargetMaxDimension}, OnlyUncompressed={OnlyDownscaleUncompressed}, HeaderFormat={HeaderFormat}, OriginalSize={OriginalWidth}x{OriginalHeight}, OriginalMipLevels={OriginalMipLevels}, DropAttempt={DropCount}",
hash,
sourcePath,
destination,
targetMaxDimension,
onlyDownscaleUncompressed,
header?.Format,
originalWidth,
originalHeight,
originalMipLevels,
dropCount);
TryDelete(destination);
return Task.FromResult(false);
}
}
private static int CalculateDropCount(in TexMeta meta, int targetWidth, int targetHeight)
{
var drop = 0;
var width = meta.Width;
var height = meta.Height;
while ((width > targetWidth || height > targetHeight) && drop + 1 < meta.MipLevels)
{
drop++;
width = ReduceDimension(width);
height = ReduceDimension(height);
}
return drop;
}
private static ScratchImage TrimMipChain(ScratchImage source, int dropCount)
{
var meta = source.Meta;
var newMeta = meta;
newMeta.MipLevels = meta.MipLevels - dropCount;
newMeta.Width = ReduceDimension(meta.Width, dropCount);
newMeta.Height = ReduceDimension(meta.Height, dropCount);
if (meta.Dimension == TexDimension.Tex3D)
{
newMeta.Depth = ReduceDimension(meta.Depth, dropCount);
}
var result = ScratchImage.Initialize(newMeta);
CopyMipChainData(source, result, dropCount, meta);
return result;
}
private static unsafe void CopyMipChainData(ScratchImage source, ScratchImage destination, int dropCount, in TexMeta sourceMeta)
{
var destinationMeta = destination.Meta;
var arraySize = Math.Max(1, sourceMeta.ArraySize);
var isCube = sourceMeta.IsCubeMap;
var isVolume = sourceMeta.Dimension == TexDimension.Tex3D;
for (var item = 0; item < arraySize; item++)
{
for (var mip = 0; mip < destinationMeta.MipLevels; mip++)
{
var sourceMip = mip + dropCount;
var sliceCount = GetSliceCount(sourceMeta, sourceMip, isCube, isVolume);
for (var slice = 0; slice < sliceCount; slice++)
{
var srcImage = source.GetImage(sourceMip, item, slice);
var dstImage = destination.GetImage(mip, item, slice);
CopyImage(srcImage, dstImage);
}
}
}
}
private static int GetSliceCount(in TexMeta meta, int mip, bool isCube, bool isVolume)
{
if (isCube)
{
return 6;
}
if (isVolume)
{
return Math.Max(1, meta.Depth >> mip);
}
return 1;
}
private static unsafe void CopyImage(in OtterImage source, in OtterImage destination)
{
var srcPtr = (byte*)source.Pixels;
var dstPtr = (byte*)destination.Pixels;
var bytesToCopy = Math.Min(source.SlicePitch, destination.SlicePitch);
Buffer.MemoryCopy(srcPtr, dstPtr, destination.SlicePitch, bytesToCopy);
}
private static int ReduceDimension(int value, int iterations)
{
var result = value;
for (var i = 0; i < iterations; i++)
{
result = ReduceDimension(result);
}
return result;
}
private static int ReduceDimension(int value)
=> value <= 1 ? 1 : Math.Max(1, value / 2);
private static Image<Rgba32> ReduceIndexTexture(Image<Rgba32> source, int targetWidth, int targetHeight)
{
var current = source.Clone();
while (current.Width > targetWidth || current.Height > targetHeight)
{
var nextWidth = Math.Max(targetWidth, Math.Max(BlockMultiple, current.Width / 2));
var nextHeight = Math.Max(targetHeight, Math.Max(BlockMultiple, current.Height / 2));
var next = new Image<Rgba32>(nextWidth, nextHeight);
for (int y = 0; y < nextHeight; y++)
{
var srcY = Math.Min(current.Height - 1, y * 2);
for (int x = 0; x < nextWidth; x++)
{
var srcX = Math.Min(current.Width - 1, x * 2);
var topLeft = current[srcX, srcY];
var topRight = current[Math.Min(current.Width - 1, srcX + 1), srcY];
var bottomLeft = current[srcX, Math.Min(current.Height - 1, srcY + 1)];
var bottomRight = current[Math.Min(current.Width - 1, srcX + 1), Math.Min(current.Height - 1, srcY + 1)];
next[x, y] = DownscaleIndexBlock(topLeft, topRight, bottomLeft, bottomRight);
}
}
current.Dispose();
current = next;
}
return current;
}
private static Image<Rgba32> ReduceLinearTexture(Image<Rgba32> source, int targetWidth, int targetHeight)
{
var clone = source.Clone();
while (clone.Width > targetWidth || clone.Height > targetHeight)
{
var nextWidth = Math.Max(targetWidth, Math.Max(BlockMultiple, clone.Width / 2));
var nextHeight = Math.Max(targetHeight, Math.Max(BlockMultiple, clone.Height / 2));
clone.Mutate(ctx => ctx.Resize(nextWidth, nextHeight, KnownResamplers.Lanczos3));
}
return clone;
}
private static Rgba32 DownscaleIndexBlock(in Rgba32 topLeft, in Rgba32 topRight, in Rgba32 bottomLeft, in Rgba32 bottomRight)
{
Span<Rgba32> ordered = stackalloc Rgba32[4]
{
bottomLeft,
bottomRight,
topRight,
topLeft
};
Span<float> weights = stackalloc float[4];
var hasContribution = false;
foreach (var sample in SampleOffsets)
{
if (TryAccumulateSampleWeights(ordered, sample, weights))
{
hasContribution = true;
}
}
if (hasContribution)
{
var bestIndex = IndexOfMax(weights);
if (bestIndex >= 0 && weights[bestIndex] > 0f)
{
return ordered[bestIndex];
}
}
Span<Rgba32> fallback = stackalloc Rgba32[4] { topLeft, topRight, bottomLeft, bottomRight };
return PickMajorityColor(fallback);
}
private static readonly Vector2[] SampleOffsets =
{
new(0.25f, 0.25f),
new(0.75f, 0.25f),
new(0.25f, 0.75f),
new(0.75f, 0.75f),
};
private static bool TryAccumulateSampleWeights(ReadOnlySpan<Rgba32> colors, in Vector2 sampleUv, Span<float> weights)
{
var red = new Vector4(
colors[0].R / 255f,
colors[1].R / 255f,
colors[2].R / 255f,
colors[3].R / 255f);
var symbols = QuantizeSymbols(red);
var cellUv = ComputeShiftedUv(sampleUv);
Span<int> order = stackalloc int[4];
order[0] = 0;
order[1] = 1;
order[2] = 2;
order[3] = 3;
ApplySymmetry(ref symbols, ref cellUv, order);
var equality = BuildEquality(symbols, symbols.W);
var selector = BuildSelector(equality, symbols, cellUv);
const uint lut = 0x00000C07u;
if (((lut >> (int)selector) & 1u) != 0u)
{
weights[order[3]] += 1f;
return true;
}
if (selector == 3u)
{
equality = BuildEquality(symbols, symbols.Z);
}
var weight = ComputeWeight(equality, cellUv);
if (weight <= 1e-6f)
{
return false;
}
var factor = 1f / weight;
var wW = equality.W * (1f - cellUv.X) * (1f - cellUv.Y) * factor;
var wX = equality.X * (1f - cellUv.X) * cellUv.Y * factor;
var wZ = equality.Z * cellUv.X * (1f - cellUv.Y) * factor;
var wY = equality.Y * cellUv.X * cellUv.Y * factor;
var contributed = false;
if (wW > 0f)
{
weights[order[3]] += wW;
contributed = true;
}
if (wX > 0f)
{
weights[order[0]] += wX;
contributed = true;
}
if (wZ > 0f)
{
weights[order[2]] += wZ;
contributed = true;
}
if (wY > 0f)
{
weights[order[1]] += wY;
contributed = true;
}
return contributed;
}
private static Vector4 QuantizeSymbols(in Vector4 channel)
=> new(
Quantize(channel.X),
Quantize(channel.Y),
Quantize(channel.Z),
Quantize(channel.W));
private static float Quantize(float value)
{
var clamped = Math.Clamp(value, 0f, 1f);
return (MathF.Round(clamped * 16f) + 0.5f) / 16f;
}
private static void ApplySymmetry(ref Vector4 symbols, ref Vector2 cellUv, Span<int> order)
{
if (cellUv.X >= 0.5f)
{
symbols = SwapYxwz(symbols, order);
cellUv.X = 1f - cellUv.X;
}
if (cellUv.Y >= 0.5f)
{
symbols = SwapWzyx(symbols, order);
cellUv.Y = 1f - cellUv.Y;
}
}
private static Vector4 BuildEquality(in Vector4 symbols, float reference)
=> new(
AreEqual(symbols.X, reference) ? 1f : 0f,
AreEqual(symbols.Y, reference) ? 1f : 0f,
AreEqual(symbols.Z, reference) ? 1f : 0f,
AreEqual(symbols.W, reference) ? 1f : 0f);
private static uint BuildSelector(in Vector4 equality, in Vector4 symbols, in Vector2 cellUv)
{
uint selector = 0;
if (equality.X > 0.5f) selector |= 4u;
if (equality.Y > 0.5f) selector |= 8u;
if (equality.Z > 0.5f) selector |= 16u;
if (AreEqual(symbols.X, symbols.Z)) selector |= 2u;
if (cellUv.X + cellUv.Y >= 0.5f) selector |= 1u;
return selector;
}
private static float ComputeWeight(in Vector4 equality, in Vector2 cellUv)
=> equality.W * (1f - cellUv.X) * (1f - cellUv.Y)
+ equality.X * (1f - cellUv.X) * cellUv.Y
+ equality.Z * cellUv.X * (1f - cellUv.Y)
+ equality.Y * cellUv.X * cellUv.Y;
private static Vector2 ComputeShiftedUv(in Vector2 uv)
{
var shifted = new Vector2(
uv.X - MathF.Floor(uv.X),
uv.Y - MathF.Floor(uv.Y));
shifted.X -= 0.5f;
if (shifted.X < 0f)
{
shifted.X += 1f;
}
shifted.Y -= 0.5f;
if (shifted.Y < 0f)
{
shifted.Y += 1f;
}
return shifted;
}
private static Vector4 SwapYxwz(in Vector4 v, Span<int> order)
{
var o0 = order[0];
var o1 = order[1];
var o2 = order[2];
var o3 = order[3];
order[0] = o1;
order[1] = o0;
order[2] = o3;
order[3] = o2;
return new Vector4(v.Y, v.X, v.W, v.Z);
}
private static Vector4 SwapWzyx(in Vector4 v, Span<int> order)
{
var o0 = order[0];
var o1 = order[1];
var o2 = order[2];
var o3 = order[3];
order[0] = o3;
order[1] = o2;
order[2] = o1;
order[3] = o0;
return new Vector4(v.W, v.Z, v.Y, v.X);
}
private static int IndexOfMax(ReadOnlySpan<float> values)
{
var bestIndex = -1;
var bestValue = 0f;
for (var i = 0; i < values.Length; i++)
{
if (values[i] > bestValue)
{
bestValue = values[i];
bestIndex = i;
}
}
return bestIndex;
}
private static bool AreEqual(float a, float b) => MathF.Abs(a - b) <= 1e-5f;
private static Rgba32 PickMajorityColor(ReadOnlySpan<Rgba32> colors)
{
var counts = new Dictionary<Rgba32, int>(colors.Length);
foreach (var color in colors)
{
if (counts.TryGetValue(color, out var count))
{
counts[color] = count + 1;
}
else
{
counts[color] = 1;
}
}
return counts
.OrderByDescending(kvp => kvp.Value)
.ThenByDescending(kvp => kvp.Key.A)
.ThenByDescending(kvp => kvp.Key.R)
.ThenByDescending(kvp => kvp.Key.G)
.ThenByDescending(kvp => kvp.Key.B)
.First().Key;
}
private static bool ShouldTrim(in TexMeta meta, int targetMaxDimension)
{
var depth = meta.Dimension == TexDimension.Tex3D ? Math.Max(1, meta.Depth) : 1;
return ShouldTrimDimensions(meta.Width, meta.Height, depth, targetMaxDimension);
}
private static bool ShouldTrimDimensions(int width, int height, int depth, int targetMaxDimension)
{
if (width <= targetMaxDimension || height <= targetMaxDimension)
{
return false;
}
if (depth > 1 && depth <= targetMaxDimension)
{
return false;
}
return true;
}
private int ResolveTargetMaxDimension()
{
var configured = _playerPerformanceConfigService.Current.TextureDownscaleMaxDimension;
if (configured <= 0)
{
return DefaultTargetMaxDimension;
}
return Math.Clamp(configured, BlockMultiple, MaxSupportedTargetDimension);
}
private readonly struct TexHeaderInfo
{
public TexHeaderInfo(ushort width, ushort height, ushort depth, ushort mipLevels, TexFile.TextureFormat format)
{
Width = width;
Height = height;
Depth = depth;
MipLevels = mipLevels;
Format = format;
}
public ushort Width { get; }
public ushort Height { get; }
public ushort Depth { get; }
public ushort MipLevels { get; }
public TexFile.TextureFormat Format { get; }
}
private static bool TryReadTexHeader(string path, out TexHeaderInfo header)
{
header = default;
try
{
Span<byte> buffer = stackalloc byte[16];
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
var read = stream.Read(buffer);
if (read < buffer.Length)
{
return false;
}
var formatValue = BinaryPrimitives.ReadInt32LittleEndian(buffer[4..8]);
var format = (TexFile.TextureFormat)formatValue;
var width = BinaryPrimitives.ReadUInt16LittleEndian(buffer[8..10]);
var height = BinaryPrimitives.ReadUInt16LittleEndian(buffer[10..12]);
var depth = BinaryPrimitives.ReadUInt16LittleEndian(buffer[12..14]);
var mipLevels = BinaryPrimitives.ReadUInt16LittleEndian(buffer[14..16]);
header = new TexHeaderInfo(width, height, depth, mipLevels, format);
return true;
}
catch
{
return false;
}
}
private static bool IsBlockCompressedFormat(TexFile.TextureFormat format)
=> TryGetCompressionTarget(format, out _);
private static bool TryGetCompressionTarget(TexFile.TextureFormat format, out TextureCompressionTarget target)
{
if (BlockCompressedFormatMap.TryGetValue(unchecked((int)format), out var mapped))
{
target = mapped;
return true;
}
target = default;
return false;
}
private void RegisterDownscaledTexture(string hash, string sourcePath, string destination)
{
_downscaledPaths[hash] = destination;
_logger.LogDebug("Downscaled texture {Hash} -> {Path}", hash, destination);
var performanceConfig = _playerPerformanceConfigService.Current;
if (performanceConfig.KeepOriginalTextureFiles)
{
return;
}
if (string.Equals(sourcePath, destination, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (!TryReplaceCacheEntryWithDownscaled(hash, sourcePath, destination))
{
return;
}
TryDelete(sourcePath);
}
private bool TryReplaceCacheEntryWithDownscaled(string hash, string sourcePath, string destination)
{
try
{
var cacheEntry = _fileCacheManager.GetFileCacheByHash(hash);
if (cacheEntry is null || !cacheEntry.IsCacheEntry)
{
return File.Exists(sourcePath) ? false : true;
}
var cacheFolder = _configService.Current.CacheFolder;
if (string.IsNullOrEmpty(cacheFolder))
{
return false;
}
if (!destination.StartsWith(cacheFolder, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var info = new FileInfo(destination);
if (!info.Exists)
{
return false;
}
var relative = Path.GetRelativePath(cacheFolder, destination)
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
var sanitizedRelative = relative.TrimStart(Path.DirectorySeparatorChar);
var prefixed = Path.Combine(FileCacheManager.CachePrefix, sanitizedRelative);
var replacement = new FileCacheEntity(
hash,
prefixed,
info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture),
info.Length,
cacheEntry.CompressedSize);
replacement.SetResolvedFilePath(destination);
if (!string.Equals(cacheEntry.PrefixedFilePath, prefixed, StringComparison.OrdinalIgnoreCase))
{
_fileCacheManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath);
}
_fileCacheManager.UpdateHashedFile(replacement, computeProperties: false);
_fileCacheManager.WriteOutFullCsv();
_logger.LogTrace("Replaced cache entry for texture {Hash} to downscaled path {Path}", hash, destination);
return true;
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to replace cache entry for texture {Hash}", hash);
return false;
}
}
private string? GetExistingDownscaledPath(string hash)
{
var candidate = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex");
return File.Exists(candidate) ? candidate : null;
}
private string GetDownscaledDirectory()
{
var directory = Path.Combine(_configService.Current.CacheFolder, "downscaled");
if (!Directory.Exists(directory))
{
try
{
Directory.CreateDirectory(directory);
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to create downscaled directory {Directory}", directory);
}
}
return directory;
}
private static void TryDelete(string? path)
{
if (string.IsNullOrEmpty(path)) return;
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// ignored
}
}
}

View File

@@ -0,0 +1,13 @@
namespace LightlessSync.Services.TextureCompression;
public enum TextureMapKind
{
Diffuse,
Normal,
Specular,
Mask,
Index,
Emissive,
Ui,
Unknown
}

View File

@@ -0,0 +1,549 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dalamud.Plugin.Services;
using Microsoft.Extensions.Logging;
using Penumbra.Api.Enums;
using Penumbra.GameData.Files;
namespace LightlessSync.Services.TextureCompression;
// ima lie, this isn't garbage
public sealed class TextureMetadataHelper
{
private readonly ILogger<TextureMetadataHelper> _logger;
private readonly IDataManager _dataManager;
private static readonly Dictionary<TextureCompressionTarget, (string Title, string Description)> RecommendationCatalog = new()
{
[TextureCompressionTarget.BC1] = (
"BC1 (Simple Compression for Opaque RGB)",
"This offers a 8:1 compression ratio and is quick with acceptable quality, but only supports RGB, without Alpha.\n\nCan be used for diffuse maps and equipment textures to save extra space."),
[TextureCompressionTarget.BC3] = (
"BC3 (Simple Compression for RGBA)",
"This offers a 4:1 compression ratio and is quick with acceptable quality, and fully supports RGBA.\n\nGeneric format that can be used for most textures."),
[TextureCompressionTarget.BC4] = (
"BC4 (Simple Compression for Opaque Grayscale)",
"This offers a 8:1 compression ratio and has almost indistinguishable quality, but only supports Grayscale, without Alpha.\n\nCan be used for face paints and legacy marks."),
[TextureCompressionTarget.BC5] = (
"BC5 (Simple Compression for Opaque RG)",
"This offers a 4:1 compression ratio and has almost indistinguishable quality, but only supports RG, without B or Alpha.\n\nRecommended for index maps, unrecommended for normal maps."),
[TextureCompressionTarget.BC7] = (
"BC7 (Complex Compression for RGBA)",
"This offers a 4:1 compression ratio and has almost indistinguishable quality, but may take a while.\n\nGeneric format that can be used for most textures.")
};
private static readonly (TextureUsageCategory Category, string Token)[] CategoryTokens =
{
(TextureUsageCategory.Ui, "/ui/"),
(TextureUsageCategory.Ui, "/uld/"),
(TextureUsageCategory.Ui, "/icon/"),
(TextureUsageCategory.VisualEffect, "/vfx/"),
(TextureUsageCategory.Customization, "/chara/human/"),
(TextureUsageCategory.Customization, "/chara/common/"),
(TextureUsageCategory.Customization, "/chara/bibo"),
(TextureUsageCategory.Weapon, "/chara/weapon/"),
(TextureUsageCategory.Accessory, "/chara/accessory/"),
(TextureUsageCategory.Gear, "/chara/equipment/"),
(TextureUsageCategory.Monster, "/chara/monster/"),
(TextureUsageCategory.Monster, "/chara/demihuman/"),
(TextureUsageCategory.MountOrMinion, "/chara/mount/"),
(TextureUsageCategory.MountOrMinion, "/chara/battlepet/"),
(TextureUsageCategory.Companion, "/chara/companion/"),
(TextureUsageCategory.Housing, "/hou/"),
(TextureUsageCategory.Housing, "/housing/"),
(TextureUsageCategory.Housing, "/bg/"),
(TextureUsageCategory.Housing, "/bgcommon/")
};
private static readonly (TextureUsageCategory Category, string SlotToken, string SlotName)[] SlotTokens =
{
(TextureUsageCategory.Gear, "_met", "Head"),
(TextureUsageCategory.Gear, "_top", "Body"),
(TextureUsageCategory.Gear, "_glv", "Hands"),
(TextureUsageCategory.Gear, "_dwn", "Legs"),
(TextureUsageCategory.Gear, "_sho", "Feet"),
(TextureUsageCategory.Accessory, "_ear", "Ears"),
(TextureUsageCategory.Accessory, "_nek", "Neck"),
(TextureUsageCategory.Accessory, "_wrs", "Wrists"),
(TextureUsageCategory.Accessory, "_rir", "Ring"),
(TextureUsageCategory.Weapon, "_w", "Weapon"), // sussy
(TextureUsageCategory.Weapon, "weapon", "Weapon"),
};
private static readonly (TextureMapKind Kind, string Token)[] MapTokens =
{
(TextureMapKind.Normal, "_n"),
(TextureMapKind.Normal, "_normal"),
(TextureMapKind.Normal, "_norm"),
(TextureMapKind.Mask, "_m"),
(TextureMapKind.Mask, "_mask"),
(TextureMapKind.Mask, "_msk"),
(TextureMapKind.Specular, "_s"),
(TextureMapKind.Specular, "_spec"),
(TextureMapKind.Emissive, "_em"),
(TextureMapKind.Emissive, "_glow"),
(TextureMapKind.Index, "_id"),
(TextureMapKind.Index, "_idx"),
(TextureMapKind.Index, "_index"),
(TextureMapKind.Index, "_multi"),
(TextureMapKind.Diffuse, "_d"),
(TextureMapKind.Diffuse, "_diff"),
(TextureMapKind.Diffuse, "_b"),
(TextureMapKind.Diffuse, "_base")
};
private const string TextureSegment = "/texture/";
private const string MaterialSegment = "/material/";
private const uint NormalSamplerId = 0x0C5EC1F1u;
private const uint IndexSamplerId = 0x565F8FD8u;
private const uint SpecularSamplerId = 0x2B99E025u;
private const uint DiffuseSamplerId = 0x115306BEu;
private const uint MaskSamplerId = 0x8A4E82B6u;
public TextureMetadataHelper(ILogger<TextureMetadataHelper> logger, IDataManager dataManager)
{
_logger = logger;
_dataManager = dataManager;
}
public bool TryGetRecommendationInfo(TextureCompressionTarget target, out (string Title, string Description) info)
=> RecommendationCatalog.TryGetValue(target, out info);
public TextureUsageCategory DetermineCategory(string? gamePath)
{
var normalized = Normalize(gamePath);
if (string.IsNullOrEmpty(normalized))
return TextureUsageCategory.Unknown;
var fileName = Path.GetFileName(normalized);
if (!string.IsNullOrEmpty(fileName))
{
if (fileName.StartsWith("bibo", StringComparison.OrdinalIgnoreCase)
|| fileName.Contains("gen3", StringComparison.OrdinalIgnoreCase)
|| fileName.Contains("tfgen3", StringComparison.OrdinalIgnoreCase))
{
return TextureUsageCategory.Customization;
}
}
if (normalized.Contains("bibo", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("skin", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("gen3", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("tfgen3", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("body", StringComparison.OrdinalIgnoreCase))
{
return TextureUsageCategory.Customization;
}
foreach (var (category, token) in CategoryTokens)
{
if (normalized.Contains(token, StringComparison.OrdinalIgnoreCase))
return category;
}
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length >= 2 && string.Equals(segments[0], "chara", StringComparison.OrdinalIgnoreCase))
{
return segments[1] switch
{
"equipment" => TextureUsageCategory.Gear,
"accessory" => TextureUsageCategory.Accessory,
"weapon" => TextureUsageCategory.Weapon,
"human" or "common" => TextureUsageCategory.Customization,
"monster" or "demihuman" => TextureUsageCategory.Monster,
"mount" or "battlepet" => TextureUsageCategory.MountOrMinion,
"companion" => TextureUsageCategory.Companion,
_ => TextureUsageCategory.Unknown
};
}
if (normalized.StartsWith("chara/", StringComparison.OrdinalIgnoreCase)
&& (normalized.Contains("bibo", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("skin", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("body", StringComparison.OrdinalIgnoreCase)))
return TextureUsageCategory.Customization;
return TextureUsageCategory.Unknown;
}
public string DetermineSlot(TextureUsageCategory category, string? gamePath)
{
if (category == TextureUsageCategory.Customization)
return GuessCustomizationSlot(gamePath);
var normalized = Normalize(gamePath);
var fileName = Path.GetFileNameWithoutExtension(normalized);
var searchSource = $"{normalized} {fileName}".ToLowerInvariant();
foreach (var (candidateCategory, token, slot) in SlotTokens)
{
if (candidateCategory == category && searchSource.Contains(token, StringComparison.Ordinal))
return slot;
}
return category switch
{
TextureUsageCategory.Gear => "Gear",
TextureUsageCategory.Accessory => "Accessory",
TextureUsageCategory.Weapon => "Weapon",
TextureUsageCategory.Monster => "Monster",
TextureUsageCategory.MountOrMinion => "Mount / Minion",
TextureUsageCategory.Companion => "Companion",
TextureUsageCategory.VisualEffect => "VFX",
TextureUsageCategory.Housing => "Housing",
TextureUsageCategory.Ui => "UI",
_ => "General"
};
}
public TextureMapKind DetermineMapKind(string path)
=> DetermineMapKind(path, null);
public TextureMapKind DetermineMapKind(string? gamePath, string? localTexturePath)
{
if (TryDetermineFromMaterials(gamePath, localTexturePath, out var kind))
return kind;
return GuessMapFromFileName(gamePath ?? localTexturePath ?? string.Empty);
}
private bool TryDetermineFromMaterials(string? gamePath, string? localTexturePath, out TextureMapKind kind)
{
kind = TextureMapKind.Unknown;
var candidates = new List<MaterialCandidate>();
AddGameMaterialCandidates(gamePath, candidates);
AddLocalMaterialCandidates(localTexturePath, candidates);
if (candidates.Count == 0)
return false;
var normalizedGamePath = Normalize(gamePath);
var normalizedFileName = Path.GetFileName(normalizedGamePath);
foreach (var candidate in candidates)
{
if (!TryLoadMaterial(candidate, out var material))
continue;
if (TryInferKindFromMaterial(material, normalizedGamePath, normalizedFileName, out kind))
return true;
}
return false;
}
private void AddGameMaterialCandidates(string? gamePath, IList<MaterialCandidate> candidates)
{
var normalized = Normalize(gamePath);
if (string.IsNullOrEmpty(normalized))
return;
var textureIndex = normalized.IndexOf(TextureSegment, StringComparison.Ordinal);
if (textureIndex < 0)
return;
var prefix = normalized[..textureIndex];
var suffix = normalized[(textureIndex + TextureSegment.Length)..];
var baseName = Path.GetFileNameWithoutExtension(suffix);
if (string.IsNullOrEmpty(baseName))
return;
var directory = $"{prefix}{MaterialSegment}{Path.GetDirectoryName(suffix)?.Replace('\\', '/') ?? string.Empty}".TrimEnd('/');
candidates.Add(MaterialCandidate.Game($"{directory}/mt_{baseName}.mtrl"));
if (baseName.StartsWith('v') && baseName.IndexOf('_') is > 0 and var idx)
{
var trimmed = baseName[(idx + 1)..];
candidates.Add(MaterialCandidate.Game($"{directory}/mt_{trimmed}.mtrl"));
}
}
private void AddLocalMaterialCandidates(string? localTexturePath, IList<MaterialCandidate> candidates)
{
if (string.IsNullOrEmpty(localTexturePath))
return;
var normalized = localTexturePath.Replace('\\', '/');
var textureIndex = normalized.IndexOf(TextureSegment, StringComparison.OrdinalIgnoreCase);
if (textureIndex >= 0)
{
var prefix = normalized[..textureIndex];
var suffix = normalized[(textureIndex + TextureSegment.Length)..];
var folder = Path.GetDirectoryName(suffix)?.Replace('\\', '/') ?? string.Empty;
var baseName = Path.GetFileNameWithoutExtension(suffix);
if (!string.IsNullOrEmpty(baseName))
{
var materialDir = $"{prefix}{MaterialSegment}{folder}".TrimEnd('/');
candidates.Add(MaterialCandidate.Local(Path.Combine(materialDir.Replace('/', Path.DirectorySeparatorChar), $"mt_{baseName}.mtrl")));
if (baseName.StartsWith('v') && baseName.IndexOf('_') is > 0 and var idx)
{
var trimmed = baseName[(idx + 1)..];
candidates.Add(MaterialCandidate.Local(Path.Combine(materialDir.Replace('/', Path.DirectorySeparatorChar), $"mt_{trimmed}.mtrl")));
}
}
}
var textureDirectory = Path.GetDirectoryName(localTexturePath);
if (!string.IsNullOrEmpty(textureDirectory) && Directory.Exists(textureDirectory))
{
foreach (var candidate in Directory.EnumerateFiles(textureDirectory, "*.mtrl", SearchOption.TopDirectoryOnly))
candidates.Add(MaterialCandidate.Local(candidate));
}
}
private bool TryLoadMaterial(MaterialCandidate candidate, out MtrlFile material)
{
material = null!;
try
{
switch (candidate.Source)
{
case MaterialSource.Game:
var gameFile = _dataManager.GetFile(candidate.Path);
if (gameFile?.Data.Length > 0)
{
material = new MtrlFile(gameFile.Data);
return material.Valid;
}
break;
case MaterialSource.Local when File.Exists(candidate.Path):
material = new MtrlFile(File.ReadAllBytes(candidate.Path));
return material.Valid;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to load material {Path}", candidate.Path);
}
return false;
}
private static bool TryInferKindFromMaterial(MtrlFile material, string normalizedGamePath, string? fileName, out TextureMapKind kind)
{
kind = TextureMapKind.Unknown;
var targetName = fileName ?? string.Empty;
foreach (var sampler in material.ShaderPackage.Samplers)
{
if (!TryMapSamplerId(sampler.SamplerId, out var candidateKind))
continue;
if (sampler.TextureIndex < 0 || sampler.TextureIndex >= material.Textures.Length)
continue;
var texturePath = Normalize(material.Textures[sampler.TextureIndex].Path);
if (!string.IsNullOrEmpty(normalizedGamePath) && string.Equals(texturePath, normalizedGamePath, StringComparison.OrdinalIgnoreCase))
{
kind = candidateKind;
return true;
}
if (!string.IsNullOrEmpty(targetName) && string.Equals(Path.GetFileName(texturePath), targetName, StringComparison.OrdinalIgnoreCase))
{
kind = candidateKind;
return true;
}
}
return false;
}
private static TextureMapKind GuessMapFromFileName(string path)
{
var normalized = Normalize(path);
var fileName = Path.GetFileNameWithoutExtension(normalized);
if (string.IsNullOrEmpty(fileName))
return TextureMapKind.Unknown;
foreach (var (kind, token) in MapTokens)
{
if (fileName.Contains(token, StringComparison.OrdinalIgnoreCase))
return kind;
}
return TextureMapKind.Unknown;
}
public bool TryMapFormatToTarget(string? format, out TextureCompressionTarget target)
{
var normalized = (format ?? string.Empty).ToUpperInvariant();
if (normalized.Contains("BC1", StringComparison.Ordinal))
{
target = TextureCompressionTarget.BC1;
return true;
}
if (normalized.Contains("BC3", StringComparison.Ordinal))
{
target = TextureCompressionTarget.BC3;
return true;
}
if (normalized.Contains("BC4", StringComparison.Ordinal))
{
target = TextureCompressionTarget.BC4;
return true;
}
if (normalized.Contains("BC5", StringComparison.Ordinal))
{
target = TextureCompressionTarget.BC5;
return true;
}
if (normalized.Contains("BC7", StringComparison.Ordinal))
{
target = TextureCompressionTarget.BC7;
return true;
}
target = TextureCompressionTarget.BC7;
return false;
}
public (TextureCompressionTarget Target, string Reason)? GetSuggestedTarget(string? format, TextureMapKind mapKind)
{
TextureCompressionTarget? current = null;
if (TryMapFormatToTarget(format, out var mapped))
current = mapped;
var suggestion = mapKind switch
{
TextureMapKind.Normal => TextureCompressionTarget.BC7,
TextureMapKind.Mask => TextureCompressionTarget.BC4,
TextureMapKind.Index => TextureCompressionTarget.BC3,
TextureMapKind.Specular => TextureCompressionTarget.BC4,
TextureMapKind.Emissive => TextureCompressionTarget.BC3,
TextureMapKind.Diffuse => TextureCompressionTarget.BC7,
_ => TextureCompressionTarget.BC7
};
if (mapKind == TextureMapKind.Diffuse && !HasAlphaHint(format))
suggestion = TextureCompressionTarget.BC1;
if (current == suggestion)
return null;
return (suggestion, RecommendationCatalog.TryGetValue(suggestion, out var info)
? info.Description
: "Suggested to balance visual quality and file size.");
}
private static bool TryMapSamplerId(uint id, out TextureMapKind kind)
{
kind = id switch
{
NormalSamplerId => TextureMapKind.Normal,
IndexSamplerId => TextureMapKind.Index,
SpecularSamplerId => TextureMapKind.Specular,
DiffuseSamplerId => TextureMapKind.Diffuse,
MaskSamplerId => TextureMapKind.Mask,
_ => TextureMapKind.Unknown
};
return kind != TextureMapKind.Unknown;
}
private static string GuessCustomizationSlot(string? gamePath)
{
var normalized = Normalize(gamePath);
var fileName = Path.GetFileName(normalized);
if (!string.IsNullOrEmpty(fileName))
{
if (fileName.StartsWith("bibo", StringComparison.OrdinalIgnoreCase)
|| fileName.Contains("gen3", StringComparison.OrdinalIgnoreCase)
|| fileName.Contains("tfgen3", StringComparison.OrdinalIgnoreCase)
|| fileName.Contains("skin", StringComparison.OrdinalIgnoreCase))
{
return "Skin";
}
}
if (normalized.Contains("hair", StringComparison.OrdinalIgnoreCase))
return "Hair";
if (normalized.Contains("face", StringComparison.OrdinalIgnoreCase))
return "Face";
if (normalized.Contains("tail", StringComparison.OrdinalIgnoreCase))
return "Tail";
if (normalized.Contains("zear", StringComparison.OrdinalIgnoreCase))
return "Ear";
if (normalized.Contains("eye", StringComparison.OrdinalIgnoreCase))
return "Eye";
if (normalized.Contains("body", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("skin", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("bibo", StringComparison.OrdinalIgnoreCase))
return "Skin";
if (normalized.Contains("decal_face", StringComparison.OrdinalIgnoreCase))
return "Face Paint";
if (normalized.Contains("decal_equip", StringComparison.OrdinalIgnoreCase))
return "Equipment Decal";
return "Customization";
}
private static bool HasAlphaHint(string? format)
{
if (string.IsNullOrEmpty(format))
return false;
var normalized = format.ToUpperInvariant();
return normalized.Contains("A8", StringComparison.Ordinal)
|| normalized.Contains("ARGB", StringComparison.Ordinal)
|| normalized.Contains("BC3", StringComparison.Ordinal)
|| normalized.Contains("BC7", StringComparison.Ordinal);
}
private static string Normalize(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return string.Empty;
return path.Replace('\\', '/').ToLowerInvariant();
}
private readonly record struct MaterialCandidate(string Path, MaterialSource Source)
{
public static MaterialCandidate Game(string path) => new(path, MaterialSource.Game);
public static MaterialCandidate Local(string path) => new(path, MaterialSource.Local);
}
private enum MaterialSource
{
Game,
Local
}
}

View File

@@ -0,0 +1,16 @@
namespace LightlessSync.Services.TextureCompression;
public enum TextureUsageCategory
{
Gear,
Weapon,
Accessory,
Customization,
MountOrMinion,
Companion,
Monster,
Housing,
Ui,
VisualEffect,
Unknown
}

View File

@@ -1,10 +1,13 @@
using Dalamud.Interface.ImGuiFileDialog;
using Dalamud.Interface.ImGuiFileDialog;
using LightlessSync.API.Data;
using LightlessSync.API.Dto.Group;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI;
using LightlessSync.UI.Tags;
using LightlessSync.WebAPI;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Services;
@@ -15,42 +18,131 @@ public class UiFactory
private readonly LightlessMediator _lightlessMediator;
private readonly ApiController _apiController;
private readonly UiSharedService _uiSharedService;
private readonly PairManager _pairManager;
private readonly PairUiService _pairUiService;
private readonly ServerConfigurationManager _serverConfigManager;
private readonly LightlessProfileManager _lightlessProfileManager;
private readonly PerformanceCollectorService _performanceCollectorService;
private readonly FileDialogManager _fileDialogManager;
private readonly ProfileTagService _profileTagService;
public UiFactory(ILoggerFactory loggerFactory, LightlessMediator lightlessMediator, ApiController apiController,
UiSharedService uiSharedService, PairManager pairManager, ServerConfigurationManager serverConfigManager,
LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService, FileDialogManager fileDialogManager)
public UiFactory(
ILoggerFactory loggerFactory,
LightlessMediator lightlessMediator,
ApiController apiController,
UiSharedService uiSharedService,
PairUiService pairUiService,
ServerConfigurationManager serverConfigManager,
LightlessProfileManager lightlessProfileManager,
PerformanceCollectorService performanceCollectorService,
FileDialogManager fileDialogManager,
ProfileTagService profileTagService)
{
_loggerFactory = loggerFactory;
_lightlessMediator = lightlessMediator;
_apiController = apiController;
_uiSharedService = uiSharedService;
_pairManager = pairManager;
_pairUiService = pairUiService;
_serverConfigManager = serverConfigManager;
_lightlessProfileManager = lightlessProfileManager;
_performanceCollectorService = performanceCollectorService;
_fileDialogManager = fileDialogManager;
_profileTagService = profileTagService;
}
public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto)
{
return new SyncshellAdminUI(_loggerFactory.CreateLogger<SyncshellAdminUI>(), _lightlessMediator,
_apiController, _uiSharedService, _pairManager, dto, _performanceCollectorService, _lightlessProfileManager, _fileDialogManager);
return new SyncshellAdminUI(
_loggerFactory.CreateLogger<SyncshellAdminUI>(),
_lightlessMediator,
_apiController,
_uiSharedService,
_pairUiService,
dto,
_performanceCollectorService,
_lightlessProfileManager,
_fileDialogManager);
}
public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair)
{
return new StandaloneProfileUi(_loggerFactory.CreateLogger<StandaloneProfileUi>(), _lightlessMediator,
_uiSharedService, _serverConfigManager, _lightlessProfileManager, _pairManager, pair, _performanceCollectorService);
return new StandaloneProfileUi(
_loggerFactory.CreateLogger<StandaloneProfileUi>(),
_lightlessMediator,
_uiSharedService,
_serverConfigManager,
_profileTagService,
_lightlessProfileManager,
_pairUiService,
pair,
pair.UserData,
null,
false,
null,
_performanceCollectorService);
}
public StandaloneProfileUi CreateStandaloneProfileUi(UserData userData)
{
return new StandaloneProfileUi(
_loggerFactory.CreateLogger<StandaloneProfileUi>(),
_lightlessMediator,
_uiSharedService,
_serverConfigManager,
_profileTagService,
_lightlessProfileManager,
_pairUiService,
null,
userData,
null,
false,
null,
_performanceCollectorService);
}
public StandaloneProfileUi CreateLightfinderProfileUi(UserData userData, string hashedCid)
{
return new StandaloneProfileUi(
_loggerFactory.CreateLogger<StandaloneProfileUi>(),
_lightlessMediator,
_uiSharedService,
_serverConfigManager,
_profileTagService,
_lightlessProfileManager,
_pairUiService,
null,
userData,
null,
true,
hashedCid,
_performanceCollectorService);
}
public StandaloneProfileUi CreateStandaloneGroupProfileUi(GroupFullInfoDto groupInfo)
{
return new StandaloneProfileUi(
_loggerFactory.CreateLogger<StandaloneProfileUi>(),
_lightlessMediator,
_uiSharedService,
_serverConfigManager,
_profileTagService,
_lightlessProfileManager,
_pairUiService,
null,
null,
groupInfo,
false,
null,
_performanceCollectorService);
}
public PermissionWindowUI CreatePermissionPopupUi(Pair pair)
{
return new PermissionWindowUI(_loggerFactory.CreateLogger<PermissionWindowUI>(), pair,
_lightlessMediator, _uiSharedService, _apiController, _performanceCollectorService);
return new PermissionWindowUI(
_loggerFactory.CreateLogger<PermissionWindowUI>(),
pair,
_lightlessMediator,
_uiSharedService,
_apiController,
_performanceCollectorService);
}
}

View File

@@ -2,6 +2,7 @@ using Dalamud.Interface;
using Dalamud.Interface.ImGuiFileDialog;
using Dalamud.Interface.Windowing;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Factories;
using LightlessSync.Services.Mediator;
using LightlessSync.UI;
using LightlessSync.UI.Style;
@@ -18,12 +19,13 @@ public sealed class UiService : DisposableMediatorSubscriberBase
private readonly LightlessConfigService _lightlessConfigService;
private readonly WindowSystem _windowSystem;
private readonly UiFactory _uiFactory;
private readonly PairFactory _pairFactory;
public UiService(ILogger<UiService> logger, IUiBuilder uiBuilder,
LightlessConfigService lightlessConfigService, WindowSystem windowSystem,
IEnumerable<WindowMediatorSubscriberBase> windows,
UiFactory uiFactory, FileDialogManager fileDialogManager,
LightlessMediator lightlessMediator) : base(logger, lightlessMediator)
LightlessMediator lightlessMediator, PairFactory pairFactory) : base(logger, lightlessMediator)
{
_logger = logger;
_logger.LogTrace("Creating {type}", GetType().Name);
@@ -31,6 +33,7 @@ public sealed class UiService : DisposableMediatorSubscriberBase
_lightlessConfigService = lightlessConfigService;
_windowSystem = windowSystem;
_uiFactory = uiFactory;
_pairFactory = pairFactory;
_fileDialogManager = fileDialogManager;
_uiBuilder.DisableGposeUiHide = true;
@@ -45,10 +48,101 @@ public sealed class UiService : DisposableMediatorSubscriberBase
Mediator.Subscribe<ProfileOpenStandaloneMessage>(this, (msg) =>
{
var resolvedPair = _pairFactory.Create(msg.Pair.UniqueIdent) ?? msg.Pair;
if (!_createdWindows.Exists(p => p is StandaloneProfileUi ui
&& string.Equals(ui.Pair.UserData.AliasOrUID, msg.Pair.UserData.AliasOrUID, StringComparison.Ordinal)))
&& ui.Pair != null
&& ui.Pair.UniqueIdent == resolvedPair.UniqueIdent))
{
var window = _uiFactory.CreateStandaloneProfileUi(msg.Pair);
var window = _uiFactory.CreateStandaloneProfileUi(resolvedPair);
_createdWindows.Add(window);
_windowSystem.AddWindow(window);
}
});
Mediator.Subscribe<GroupProfileOpenStandaloneMessage>(this, msg =>
{
var existingWindow = _createdWindows.Find(p => p is StandaloneProfileUi ui
&& ui.IsGroupProfile
&& ui.ProfileGroupData is not null
&& string.Equals(ui.ProfileGroupData.GID, msg.Group.Group.GID, StringComparison.Ordinal));
if (existingWindow is StandaloneProfileUi existing)
{
existing.IsOpen = true;
}
else
{
var window = _uiFactory.CreateStandaloneGroupProfileUi(msg.Group);
_createdWindows.Add(window);
_windowSystem.AddWindow(window);
}
});
Mediator.Subscribe<CloseGroupProfilePreviewMessage>(this, msg =>
{
var window = _createdWindows.Find(p => p is StandaloneProfileUi ui
&& ui.IsGroupProfile
&& ui.ProfileGroupData is not null
&& string.Equals(ui.ProfileGroupData.GID, msg.Group.Group.GID, StringComparison.Ordinal));
if (window is not null)
{
_windowSystem.RemoveWindow(window);
_createdWindows.Remove(window);
window.Dispose();
}
});
Mediator.Subscribe<OpenSelfProfilePreviewMessage>(this, msg =>
{
if (!_createdWindows.Exists(p => p is StandaloneProfileUi ui
&& ui.Pair is null
&& !ui.IsGroupProfile
&& !ui.IsLightfinderContext
&& string.Equals(ui.ProfileUserData.UID, msg.User.UID, StringComparison.Ordinal)))
{
var window = _uiFactory.CreateStandaloneProfileUi(msg.User);
_createdWindows.Add(window);
_windowSystem.AddWindow(window);
}
});
Mediator.Subscribe<CloseSelfProfilePreviewMessage>(this, msg =>
{
var window = _createdWindows.Find(p => p is StandaloneProfileUi ui
&& ui.Pair is null
&& !ui.IsGroupProfile
&& !ui.IsLightfinderContext
&& string.Equals(ui.ProfileUserData.UID, msg.User.UID, StringComparison.Ordinal));
if (window is not null)
{
_windowSystem.RemoveWindow(window);
_createdWindows.Remove(window);
window.Dispose();
}
});
Mediator.Subscribe<OpenLightfinderProfileMessage>(this, msg =>
{
if (!_createdWindows.Exists(p => p is StandaloneProfileUi ui && ui.IsLightfinderContext && string.Equals(ui.LightfinderCid, msg.HashedCid, StringComparison.Ordinal)))
{
var window = _uiFactory.CreateLightfinderProfileUi(msg.User, msg.HashedCid);
_createdWindows.Add(window);
_windowSystem.AddWindow(window);
}
});
Mediator.Subscribe<OpenUserProfileMessage>(this, msg =>
{
if (!_createdWindows.Exists(p => p is StandaloneProfileUi ui
&& !ui.IsLightfinderContext
&& !ui.IsGroupProfile
&& ui.Pair is null
&& ui.ProfileUserData is not null
&& string.Equals(ui.ProfileUserData.UID, msg.User.UID, StringComparison.Ordinal)))
{
var window = _uiFactory.CreateStandaloneProfileUi(msg.User);
_createdWindows.Add(window);
_windowSystem.AddWindow(window);
}
@@ -67,10 +161,12 @@ public sealed class UiService : DisposableMediatorSubscriberBase
Mediator.Subscribe<OpenPermissionWindow>(this, (msg) =>
{
var resolvedPair = _pairFactory.Create(msg.Pair.UniqueIdent) ?? msg.Pair;
if (!_createdWindows.Exists(p => p is PermissionWindowUI ui
&& msg.Pair == ui.Pair))
&& ui.Pair is not null
&& ui.Pair.UniqueIdent == resolvedPair.UniqueIdent))
{
var window = _uiFactory.CreatePermissionPopupUi(msg.Pair);
var window = _uiFactory.CreatePermissionPopupUi(resolvedPair);
_createdWindows.Add(window);
_windowSystem.AddWindow(window);
}

View File

@@ -143,9 +143,9 @@ namespace LightlessSync.UI
_uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessPurple"),"This lets other Lightless users know you use Lightless. While enabled, you and others using Lightfinder can see each other identified as Lightless users.");
ImGui.Indent(15f);
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey);
ImGui.Text("- This is done using a 'Lightless' label above player nameplates.");
ImGui.PopStyleColor();
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey);
ImGui.Text("- This is done using a 'Lightless' label above player nameplates.");
ImGui.PopStyleColor();
ImGui.Unindent(15f);
ImGuiHelpers.ScaledDummy(3f);
@@ -369,7 +369,7 @@ namespace LightlessSync.UI
ImGui.EndTabItem();
}
#if DEBUG
#if DEBUG
if (ImGui.BeginTabItem("Debug"))
{
ImGui.Text("Broadcast Cache");
@@ -428,7 +428,7 @@ namespace LightlessSync.UI
ImGui.EndTabItem();
}
#endif
#endif
ImGui.EndTabBar();
}

View File

@@ -795,11 +795,12 @@ internal sealed partial class CharaDataHubUi
{
UiSharedService.DrawTree("Access for Specific Individuals / Syncshells", () =>
{
var snapshot = _pairUiService.GetSnapshot();
using (ImRaii.PushId("user"))
{
using (ImRaii.Group())
{
InputComboHybrid("##AliasToAdd", "##AliasToAddPicker", ref _specificIndividualAdd, _pairManager.PairsWithGroups.Keys,
InputComboHybrid("##AliasToAdd", "##AliasToAddPicker", ref _specificIndividualAdd, snapshot.PairsWithGroups.Keys,
static pair => (pair.UserData.UID, pair.UserData.Alias, pair.UserData.AliasOrUID, pair.GetNote()));
ImGui.SameLine();
using (ImRaii.Disabled(string.IsNullOrEmpty(_specificIndividualAdd)
@@ -868,8 +869,8 @@ internal sealed partial class CharaDataHubUi
{
using (ImRaii.Group())
{
InputComboHybrid("##GroupAliasToAdd", "##GroupAliasToAddPicker", ref _specificGroupAdd, _pairManager.Groups.Keys,
group => (group.GID, group.Alias, group.AliasOrGID, _serverConfigurationManager.GetNoteForGid(group.GID)));
InputComboHybrid("##GroupAliasToAdd", "##GroupAliasToAddPicker", ref _specificGroupAdd, snapshot.Groups,
group => (group.GID, group.GroupAliasOrGID, group.GroupAliasOrGID, _serverConfigurationManager.GetNoteForGid(group.GID)));
ImGui.SameLine();
using (ImRaii.Disabled(string.IsNullOrEmpty(_specificGroupAdd)
|| updateDto.GroupList.Any(f => string.Equals(f.GID, _specificGroupAdd, StringComparison.Ordinal) || string.Equals(f.Alias, _specificGroupAdd, StringComparison.Ordinal))))

View File

@@ -7,7 +7,6 @@ using Dalamud.Interface.Utility.Raii;
using LightlessSync.API.Dto.CharaData;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
using LightlessSync.Services.CharaData;
using LightlessSync.Services.CharaData.Models;
@@ -15,6 +14,8 @@ using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using LightlessSync.UI.Services;
using LightlessSync.PlayerData.Pairs;
namespace LightlessSync.UI;
@@ -26,7 +27,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
private readonly CharaDataConfigService _configService;
private readonly DalamudUtilService _dalamudUtilService;
private readonly FileDialogManager _fileDialogManager;
private readonly PairManager _pairManager;
private readonly PairUiService _pairUiService;
private readonly CharaDataGposeTogetherManager _charaDataGposeTogetherManager;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly UiSharedService _uiSharedService;
@@ -77,7 +78,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
public CharaDataHubUi(ILogger<CharaDataHubUi> logger, LightlessMediator mediator, PerformanceCollectorService performanceCollectorService,
CharaDataManager charaDataManager, CharaDataNearbyManager charaDataNearbyManager, CharaDataConfigService configService,
UiSharedService uiSharedService, ServerConfigurationManager serverConfigurationManager,
DalamudUtilService dalamudUtilService, FileDialogManager fileDialogManager, PairManager pairManager,
DalamudUtilService dalamudUtilService, FileDialogManager fileDialogManager, PairUiService pairUiService,
CharaDataGposeTogetherManager charaDataGposeTogetherManager)
: base(logger, mediator, "Lightless Sync Character Data Hub###LightlessSyncCharaDataUI", performanceCollectorService)
{
@@ -90,7 +91,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase
_serverConfigurationManager = serverConfigurationManager;
_dalamudUtilService = dalamudUtilService;
_fileDialogManager = fileDialogManager;
_pairManager = pairManager;
_pairUiService = pairUiService;
_charaDataGposeTogetherManager = charaDataGposeTogetherManager;
Mediator.Subscribe<GposeStartMessage>(this, (_) => IsOpen |= _configService.Current.OpenLightlessHubOnGposeStart);
Mediator.Subscribe<OpenCharaDataHubWithFilterMessage>(this, (msg) =>

View File

@@ -16,6 +16,8 @@ using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Components;
using LightlessSync.UI.Handlers;
using LightlessSync.UI.Models;
using LightlessSync.UI.Services;
using LightlessSync.UI.Style;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using LightlessSync.WebAPI.Files;
@@ -38,11 +40,12 @@ public class CompactUi : WindowMediatorSubscriberBase
private readonly ApiController _apiController;
private readonly LightlessConfigService _configService;
private readonly LightlessMediator _lightlessMediator;
private readonly PairLedger _pairLedger;
private readonly ConcurrentDictionary<GameObjectHandler, Dictionary<string, FileDownloadStatus>> _currentDownloads = new();
private readonly DrawEntityFactory _drawEntityFactory;
private readonly FileUploadManager _fileTransferManager;
private readonly PlayerPerformanceConfigService _playerPerformanceConfig;
private readonly PairManager _pairManager;
private readonly PairUiService _pairUiService;
private readonly SelectTagForPairUi _selectTagForPairUi;
private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi;
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
@@ -65,13 +68,15 @@ public class CompactUi : WindowMediatorSubscriberBase
private float _transferPartHeight;
private bool _wasOpen;
private float _windowContentWidth;
private readonly SeluneBrush _seluneBrush = new();
private const float ConnectButtonHighlightThickness = 14f;
public CompactUi(
ILogger<CompactUi> logger,
UiSharedService uiShared,
LightlessConfigService configService,
ApiController apiController,
PairManager pairManager,
PairUiService pairUiService,
ServerConfigurationManager serverManager,
LightlessMediator mediator,
FileUploadManager fileTransferManager,
@@ -87,12 +92,12 @@ public class CompactUi : WindowMediatorSubscriberBase
IpcManager ipcManager,
BroadcastService broadcastService,
CharacterAnalyzer characterAnalyzer,
PlayerPerformanceConfigService playerPerformanceConfig, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService) : base(logger, mediator, "###LightlessSyncMainUI", performanceCollectorService)
PlayerPerformanceConfigService playerPerformanceConfig, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService, PairLedger pairLedger) : base(logger, mediator, "###LightlessSyncMainUI", performanceCollectorService)
{
_uiSharedService = uiShared;
_configService = configService;
_apiController = apiController;
_pairManager = pairManager;
_pairUiService = pairUiService;
_serverManager = serverManager;
_fileTransferManager = fileTransferManager;
_tagHandler = tagHandler;
@@ -105,7 +110,8 @@ public class CompactUi : WindowMediatorSubscriberBase
_renamePairTagUi = renameTagUi;
_ipcManager = ipcManager;
_broadcastService = broadcastService;
_tabMenu = new TopTabMenu(Mediator, _apiController, _pairManager, _uiSharedService, pairRequestService, dalamudUtilService, lightlessNotificationService);
_pairLedger = pairLedger;
_tabMenu = new TopTabMenu(Mediator, _apiController, _uiSharedService, pairRequestService, dalamudUtilService, lightlessNotificationService);
AllowPinning = true;
AllowClickthrough = false;
@@ -176,6 +182,11 @@ public class CompactUi : WindowMediatorSubscriberBase
protected override void DrawInternal()
{
var drawList = ImGui.GetWindowDrawList();
var windowPos = ImGui.GetWindowPos();
var windowSize = ImGui.GetWindowSize();
using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize);
_windowContentWidth = UiSharedService.GetWindowContentRegionWidth();
if (!_apiController.IsCurrentVersion)
{
@@ -223,29 +234,47 @@ public class CompactUi : WindowMediatorSubscriberBase
using (ImRaii.PushId("header")) DrawUIDHeader();
_uiSharedService.RoundedSeparator(UIColors.Get("LightlessPurple"), 2.5f, 1f, 12f);
using (ImRaii.PushId("serverstatus")) DrawServerStatus();
using (ImRaii.PushId("serverstatus"))
{
DrawServerStatus();
}
selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
var style = ImGui.GetStyle();
var contentMinY = windowPos.Y + ImGui.GetWindowContentRegionMin().Y;
var gradientInset = 4f * ImGuiHelpers.GlobalScale;
var gradientTop = MathF.Max(contentMinY, ImGui.GetCursorScreenPos().Y - style.ItemSpacing.Y + gradientInset);
ImGui.Separator();
if (_apiController.ServerState is ServerState.Connected)
{
using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw();
var pairSnapshot = _pairUiService.GetSnapshot();
using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw(pairSnapshot);
using (ImRaii.PushId("pairlist")) DrawPairs();
ImGui.Separator();
var transfersTop = ImGui.GetCursorScreenPos().Y;
var gradientBottom = MathF.Max(gradientTop, transfersTop - style.ItemSpacing.Y - gradientInset);
selune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime);
float pairlistEnd = ImGui.GetCursorPosY();
using (ImRaii.PushId("transfers")) DrawTransfers();
_transferPartHeight = ImGui.GetCursorPosY() - pairlistEnd - ImGui.GetTextLineHeight();
using (ImRaii.PushId("group-pair-popup")) _selectPairsForGroupUi.Draw(_pairManager.DirectPairs);
using (ImRaii.PushId("group-syncshell-popup")) _selectSyncshellForTagUi.Draw([.. _pairManager.Groups.Values]);
using (ImRaii.PushId("group-pair-popup")) _selectPairsForGroupUi.Draw(pairSnapshot.DirectPairs);
using (ImRaii.PushId("group-syncshell-popup")) _selectSyncshellForTagUi.Draw(pairSnapshot.Groups);
using (ImRaii.PushId("group-pair-edit")) _renamePairTagUi.Draw();
using (ImRaii.PushId("group-syncshell-edit")) _renameSyncshellTagUi.Draw();
using (ImRaii.PushId("grouping-pair-popup")) _selectTagForPairUi.Draw();
using (ImRaii.PushId("grouping-syncshell-popup")) _selectTagForSyncshellUi.Draw();
}
if (_configService.Current.OpenPopupOnAdd && _pairManager.LastAddedUser != null)
else
{
_lastAddedUser = _pairManager.LastAddedUser;
_pairManager.LastAddedUser = null;
selune.Animate(ImGui.GetIO().DeltaTime);
}
var lastAddedPair = _pairUiService.GetLastAddedPair();
if (_configService.Current.OpenPopupOnAdd && lastAddedPair is not null)
{
_lastAddedUser = lastAddedPair;
_pairUiService.ClearLastAddedPair();
ImGui.OpenPopup("Set Notes for New User");
_showModalForUserAddition = true;
_lastAddedUserComment = string.Empty;
@@ -290,15 +319,17 @@ public class CompactUi : WindowMediatorSubscriberBase
: (ImGui.GetWindowContentRegionMax().Y - ImGui.GetWindowContentRegionMin().Y
+ ImGui.GetTextLineHeight() - ImGui.GetStyle().WindowPadding.Y - ImGui.GetStyle().WindowBorderSize) - _transferPartHeight - ImGui.GetCursorPosY();
ImGui.BeginChild("list", new Vector2(_windowContentWidth, ySize), border: false);
foreach (var item in _drawFolders)
if (ImGui.BeginChild("list", new Vector2(_windowContentWidth, ySize), border: false))
{
item.Draw();
foreach (var item in _drawFolders)
{
item.Draw();
}
}
ImGui.EndChild();
}
private void DrawServerStatus()
{
var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Link);
@@ -371,6 +402,19 @@ public class CompactUi : WindowMediatorSubscriberBase
}
}
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);
}
}
@@ -527,6 +571,17 @@ public class CompactUi : WindowMediatorSubscriberBase
if (ImGui.IsItemHovered())
{
var padding = new Vector2(10f * ImGuiHelpers.GlobalScale);
Selune.RegisterHighlight(
ImGui.GetItemRectMin() - padding,
ImGui.GetItemRectMax() + padding,
SeluneHighlightMode.Point,
exactSize: true,
clipToElement: true,
clipPadding: padding,
highlightColorOverride: UIColors.Get("LightlessGreen"),
highlightAlphaOverride: 0.2f);
ImGui.BeginTooltip();
ImGui.PushTextWrapPos(ImGui.GetFontSize() * 32f);
@@ -603,6 +658,20 @@ public class CompactUi : WindowMediatorSubscriberBase
}
}
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)
@@ -675,6 +744,20 @@ public class CompactUi : WindowMediatorSubscriberBase
ImGui.TextColored(GetUidColor(), _apiController.UID);
}
if (ImGui.IsItemHovered())
{
var padding = new Vector2(30f * ImGuiHelpers.GlobalScale);
Selune.RegisterHighlight(
ImGui.GetItemRectMin() - padding,
ImGui.GetItemRectMax() + padding,
SeluneHighlightMode.Point,
exactSize: true,
clipToElement: true,
clipPadding: padding,
highlightColorOverride: vanityGlowColor,
highlightAlphaOverride: 0.05f);
}
bool uidFooterClicked = ImGui.IsItemClicked();
UiSharedService.AttachToolTip("Click to copy");
if (uidFooterClicked)
@@ -696,28 +779,45 @@ public class CompactUi : WindowMediatorSubscriberBase
var drawFolders = new List<IDrawFolder>();
var filter = _tabMenu.Filter;
var allPairs = _pairManager.PairsWithGroups.ToDictionary(k => k.Key, k => k.Value);
var filteredPairs = allPairs.Where(p => PassesFilter(p.Key, filter)).ToDictionary(k => k.Key, k => k.Value);
var allEntries = _drawEntityFactory.GetAllEntries().ToList();
var filteredEntries = string.IsNullOrEmpty(filter)
? allEntries
: allEntries.Where(e => PassesFilter(e, filter)).ToList();
var syncshells = _pairLedger.GetAllSyncshells();
var groupInfos = syncshells.Values
.Select(s => s.GroupFullInfo)
.OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase)
.ToList();
var entryLookup = allEntries.ToDictionary(e => e.DisplayEntry.Ident.UserId, StringComparer.Ordinal);
var filteredEntryLookup = filteredEntries.ToDictionary(e => e.DisplayEntry.Ident.UserId, StringComparer.Ordinal);
//Filter of online/visible pairs
if (_configService.Current.ShowVisibleUsersSeparately)
{
var allVisiblePairs = ImmutablePairList(allPairs.Where(p => FilterVisibleUsers(p.Key)));
var filteredVisiblePairs = BasicSortedDictionary(filteredPairs.Where(p => FilterVisibleUsers(p.Key)));
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(TagHandler.CustomVisibleTag, filteredVisiblePairs, allVisiblePairs));
var allVisiblePairs = SortVisibleEntries(allEntries.Where(FilterVisibleUsers));
if (allVisiblePairs.Count > 0)
{
var filteredVisiblePairs = SortVisibleEntries(filteredEntries.Where(FilterVisibleUsers));
drawFolders.Add(_drawEntityFactory.CreateTagFolder(TagHandler.CustomVisibleTag, filteredVisiblePairs, allVisiblePairs));
}
}
//Filter of not foldered syncshells
var groupFolders = new List<GroupFolder>();
foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase))
foreach (var group in groupInfos)
{
GetGroups(allPairs, filteredPairs, group, out ImmutableList<Pair> allGroupPairs, out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs);
if (FilterNotTaggedSyncshells(group))
if (!FilterNotTaggedSyncshells(group))
{
groupFolders.Add(new GroupFolder(group, _drawEntityFactory.CreateDrawGroupFolder(group, filteredGroupPairs, allGroupPairs)));
continue;
}
var allGroupEntries = ResolveGroupEntries(entryLookup, syncshells, group, applyFilters: false);
var filteredGroupEntries = ResolveGroupEntries(filteredEntryLookup, syncshells, group, applyFilters: true);
// Always create the folder so empty syncshells remain visible in the UI.
var drawGroupFolder = _drawEntityFactory.CreateGroupFolder(group.Group.GID, group, filteredGroupEntries, allGroupEntries);
groupFolders.Add(new GroupFolder(group, drawGroupFolder));
}
//Filter of grouped up syncshells (All Syncshells Folder)
@@ -730,123 +830,215 @@ public class CompactUi : WindowMediatorSubscriberBase
//Filter of grouped/foldered pairs
foreach (var tag in _tagHandler.GetAllPairTagsSorted())
{
var allTagPairs = ImmutablePairList(allPairs.Where(p => FilterTagUsers(p.Key, tag)));
var filteredTagPairs = BasicSortedDictionary(filteredPairs.Where(p => FilterTagUsers(p.Key, tag) && FilterOnlineOrPausedSelf(p.Key)));
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(tag, filteredTagPairs, allTagPairs));
var allTagPairs = SortEntries(allEntries.Where(e => FilterTagUsers(e, tag)));
if (allTagPairs.Count > 0)
{
var filteredTagPairs = SortEntries(filteredEntries.Where(e => FilterTagUsers(e, tag) && FilterOnlineOrPausedSelf(e)));
drawFolders.Add(_drawEntityFactory.CreateTagFolder(tag, filteredTagPairs, allTagPairs));
}
}
//Filter of grouped/foldered syncshells
foreach (var syncshellTag in _tagHandler.GetAllSyncshellTagsSorted())
{
var syncshellFolderTags = new List<GroupFolder>();
foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase))
foreach (var group in groupInfos)
{
if (_tagHandler.HasSyncshellTag(group.GID, syncshellTag))
if (!_tagHandler.HasSyncshellTag(group.Group.GID, syncshellTag))
{
GetGroups(allPairs, filteredPairs, group,
out ImmutableList<Pair> allGroupPairs,
out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs);
syncshellFolderTags.Add(new GroupFolder(group, _drawEntityFactory.CreateDrawGroupFolder($"tag_{group.GID}", group, filteredGroupPairs, allGroupPairs)));
continue;
}
var allGroupEntries = ResolveGroupEntries(entryLookup, syncshells, group, applyFilters: false);
var filteredGroupEntries = ResolveGroupEntries(filteredEntryLookup, syncshells, group, applyFilters: true);
// Keep tagged syncshells rendered regardless of whether membership info has loaded.
var taggedGroupFolder = _drawEntityFactory.CreateGroupFolder($"tag_{group.Group.GID}", group, filteredGroupEntries, allGroupEntries);
syncshellFolderTags.Add(new GroupFolder(group, taggedGroupFolder));
}
drawFolders.Add(new DrawGroupedGroupFolder(syncshellFolderTags, _tagHandler, _apiController, _uiSharedService, _selectSyncshellForTagUi, _renameSyncshellTagUi, syncshellTag));
}
//Filter of not grouped/foldered and offline pairs
var allOnlineNotTaggedPairs = ImmutablePairList(allPairs.Where(p => FilterNotTaggedUsers(p.Key)));
var onlineNotTaggedPairs = BasicSortedDictionary(filteredPairs.Where(p => FilterNotTaggedUsers(p.Key) && FilterOnlineOrPausedSelf(p.Key)));
var allOnlineNotTaggedPairs = SortEntries(allEntries.Where(FilterNotTaggedUsers));
var onlineNotTaggedPairs = SortEntries(filteredEntries.Where(e => FilterNotTaggedUsers(e) && FilterOnlineOrPausedSelf(e)));
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder((_configService.Current.ShowOfflineUsersSeparately ? TagHandler.CustomOnlineTag : TagHandler.CustomAllTag), onlineNotTaggedPairs, allOnlineNotTaggedPairs));
if (allOnlineNotTaggedPairs.Count > 0)
{
drawFolders.Add(_drawEntityFactory.CreateTagFolder(
_configService.Current.ShowOfflineUsersSeparately ? TagHandler.CustomOnlineTag : TagHandler.CustomAllTag,
onlineNotTaggedPairs,
allOnlineNotTaggedPairs));
}
if (_configService.Current.ShowOfflineUsersSeparately)
{
var allOfflinePairs = ImmutablePairList(allPairs.Where(p => FilterOfflineUsers(p.Key, p.Value)));
var filteredOfflinePairs = BasicSortedDictionary(filteredPairs.Where(p => FilterOfflineUsers(p.Key, p.Value)));
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(TagHandler.CustomOfflineTag, filteredOfflinePairs, allOfflinePairs));
var allOfflinePairs = SortEntries(allEntries.Where(FilterOfflineUsers));
if (allOfflinePairs.Count > 0)
{
var filteredOfflinePairs = SortEntries(filteredEntries.Where(FilterOfflineUsers));
drawFolders.Add(_drawEntityFactory.CreateTagFolder(TagHandler.CustomOfflineTag, filteredOfflinePairs, allOfflinePairs));
}
if (_configService.Current.ShowSyncshellOfflineUsersSeparately)
{
var allOfflineSyncshellUsers = ImmutablePairList(allPairs.Where(p => FilterOfflineSyncshellUsers(p.Key)));
var filteredOfflineSyncshellUsers = BasicSortedDictionary(filteredPairs.Where(p => FilterOfflineSyncshellUsers(p.Key)));
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(TagHandler.CustomOfflineSyncshellTag, filteredOfflineSyncshellUsers, allOfflineSyncshellUsers));
var allOfflineSyncshellUsers = SortEntries(allEntries.Where(FilterOfflineSyncshellUsers));
if (allOfflineSyncshellUsers.Count > 0)
{
var filteredOfflineSyncshellUsers = SortEntries(filteredEntries.Where(FilterOfflineSyncshellUsers));
drawFolders.Add(_drawEntityFactory.CreateTagFolder(TagHandler.CustomOfflineSyncshellTag, filteredOfflineSyncshellUsers, allOfflineSyncshellUsers));
}
}
}
//Unpaired
drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(TagHandler.CustomUnpairedTag,
BasicSortedDictionary(filteredPairs.Where(p => p.Key.IsOneSidedPair)),
ImmutablePairList(allPairs.Where(p => p.Key.IsOneSidedPair))));
//Unpaired
var unpairedAllEntries = SortEntries(allEntries.Where(e => e.IsOneSided));
if (unpairedAllEntries.Count > 0)
{
var unpairedFiltered = SortEntries(filteredEntries.Where(e => e.IsOneSided));
drawFolders.Add(_drawEntityFactory.CreateTagFolder(TagHandler.CustomUnpairedTag, unpairedFiltered, unpairedAllEntries));
}
return drawFolders;
}
}
private static bool PassesFilter(Pair pair, string filter)
private bool PassesFilter(PairUiEntry entry, string filter)
{
if (string.IsNullOrEmpty(filter)) return true;
return pair.UserData.AliasOrUID.Contains(filter, StringComparison.OrdinalIgnoreCase) || (pair.GetNote()?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false) || (pair.PlayerName?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false);
return entry.AliasOrUid.Contains(filter, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(entry.Note) && entry.Note.Contains(filter, StringComparison.OrdinalIgnoreCase))
|| (!string.IsNullOrEmpty(entry.DisplayName) && entry.DisplayName.Contains(filter, StringComparison.OrdinalIgnoreCase));
}
private string AlphabeticalSortKey(Pair pair)
private string AlphabeticalSortKey(PairUiEntry entry)
{
if (_configService.Current.ShowCharacterNameInsteadOfNotesForVisible && !string.IsNullOrEmpty(pair.PlayerName))
if (_configService.Current.ShowCharacterNameInsteadOfNotesForVisible && !string.IsNullOrEmpty(entry.DisplayName))
{
return _configService.Current.PreferNotesOverNamesForVisible ? (pair.GetNote() ?? string.Empty) : pair.PlayerName;
return _configService.Current.PreferNotesOverNamesForVisible ? (entry.Note ?? string.Empty) : entry.DisplayName;
}
return pair.GetNote() ?? pair.UserData.AliasOrUID;
return !string.IsNullOrEmpty(entry.Note) ? entry.Note : entry.AliasOrUid;
}
private bool FilterOnlineOrPausedSelf(Pair pair) => pair.IsOnline || (!pair.IsOnline && !_configService.Current.ShowOfflineUsersSeparately) || pair.UserPair.OwnPermissions.IsPaused();
private bool FilterOnlineOrPausedSelf(PairUiEntry entry) => entry.IsOnline || (!entry.IsOnline && !_configService.Current.ShowOfflineUsersSeparately) || entry.SelfPermissions.IsPaused();
private bool FilterVisibleUsers(Pair pair) => pair.IsVisible && (_configService.Current.ShowSyncshellUsersInVisible || pair.IsDirectlyPaired);
private bool FilterVisibleUsers(PairUiEntry entry) => entry.IsVisible && entry.IsOnline && (_configService.Current.ShowSyncshellUsersInVisible || entry.IsDirectlyPaired);
private bool FilterTagUsers(Pair pair, string tag) => pair.IsDirectlyPaired && !pair.IsOneSidedPair && _tagHandler.HasPairTag(pair.UserData.UID, tag);
private bool FilterTagUsers(PairUiEntry entry, string tag) => entry.IsDirectlyPaired && !entry.IsOneSided && _tagHandler.HasPairTag(entry.DisplayEntry.Ident.UserId, tag);
private static bool FilterGroupUsers(List<GroupFullInfoDto> groups, GroupFullInfoDto group) => groups.Exists(g => string.Equals(g.GID, group.GID, StringComparison.Ordinal));
private bool FilterNotTaggedUsers(Pair pair) => pair.IsDirectlyPaired && !pair.IsOneSidedPair && !_tagHandler.HasAnyPairTag(pair.UserData.UID);
private bool FilterNotTaggedUsers(PairUiEntry entry) => entry.IsDirectlyPaired && !entry.IsOneSided && !_tagHandler.HasAnyPairTag(entry.DisplayEntry.Ident.UserId);
private bool FilterNotTaggedSyncshells(GroupFullInfoDto group) => !_tagHandler.HasAnySyncshellTag(group.GID) || _configService.Current.ShowGroupedSyncshellsInAll;
private bool FilterOfflineUsers(Pair pair, List<GroupFullInfoDto> groups) => ((pair.IsDirectlyPaired && _configService.Current.ShowSyncshellOfflineUsersSeparately) || !_configService.Current.ShowSyncshellOfflineUsersSeparately) && (!pair.IsOneSidedPair || groups.Count != 0) && !pair.IsOnline && !pair.UserPair.OwnPermissions.IsPaused();
private static bool FilterOfflineSyncshellUsers(Pair pair) => !pair.IsDirectlyPaired && !pair.IsOnline && !pair.UserPair.OwnPermissions.IsPaused();
private Dictionary<Pair, List<GroupFullInfoDto>> BasicSortedDictionary(IEnumerable<KeyValuePair<Pair, List<GroupFullInfoDto>>> pairs) => pairs.OrderByDescending(u => u.Key.IsVisible).ThenByDescending(u => u.Key.IsOnline).ThenBy(u => AlphabeticalSortKey(u.Key), StringComparer.OrdinalIgnoreCase).ToDictionary(u => u.Key, u => u.Value);
private static ImmutableList<Pair> ImmutablePairList(IEnumerable<KeyValuePair<Pair, List<GroupFullInfoDto>>> pairs) => [.. pairs.Select(k => k.Key)];
private void GetGroups(Dictionary<Pair, List<GroupFullInfoDto>> allPairs,
Dictionary<Pair, List<GroupFullInfoDto>> filteredPairs,
GroupFullInfoDto group,
out ImmutableList<Pair> allGroupPairs,
out Dictionary<Pair, List<GroupFullInfoDto>> filteredGroupPairs)
private bool FilterOfflineUsers(PairUiEntry entry)
{
allGroupPairs = ImmutablePairList(allPairs
.Where(u => FilterGroupUsers(u.Value, group)));
var groups = entry.DisplayEntry.Groups;
var includeDirect = _configService.Current.ShowSyncshellOfflineUsersSeparately ? entry.IsDirectlyPaired : true;
var includeGroup = !entry.IsOneSided || groups.Count != 0;
return includeDirect && includeGroup && !entry.IsOnline && !entry.SelfPermissions.IsPaused();
}
filteredGroupPairs = filteredPairs
.Where(u => FilterGroupUsers(u.Value, group) && FilterOnlineOrPausedSelf(u.Key))
.OrderByDescending(u => u.Key.IsOnline)
.ThenBy(u =>
{
if (string.Equals(u.Key.UserData.UID, group.OwnerUID, StringComparison.Ordinal)) return 0;
if (group.GroupPairUserInfos.TryGetValue(u.Key.UserData.UID, out var info))
{
if (info.IsModerator()) return 1;
if (info.IsPinned()) return 2;
}
return u.Key.IsVisible ? 3 : 4;
})
.ThenBy(u => AlphabeticalSortKey(u.Key), StringComparer.OrdinalIgnoreCase)
.ToDictionary(k => k.Key, k => k.Value);
private static bool FilterOfflineSyncshellUsers(PairUiEntry entry) => !entry.IsDirectlyPaired && !entry.IsOnline && !entry.SelfPermissions.IsPaused();
private ImmutableList<PairUiEntry> SortEntries(IEnumerable<PairUiEntry> entries)
{
return entries
.OrderByDescending(e => e.IsVisible)
.ThenByDescending(e => e.IsOnline)
.ThenBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase)
.ToImmutableList();
}
private ImmutableList<PairUiEntry> SortVisibleEntries(IEnumerable<PairUiEntry> entries)
{
var entryList = entries.ToList();
return _configService.Current.VisiblePairSortMode switch
{
VisiblePairSortMode.VramUsage => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateVramBytes),
VisiblePairSortMode.EffectiveVramUsage => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateEffectiveVramBytes),
VisiblePairSortMode.TriangleCount => SortVisibleByMetric(entryList, e => e.LastAppliedDataTris),
VisiblePairSortMode.Alphabetical => entryList
.OrderBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase)
.ToImmutableList(),
VisiblePairSortMode.PreferredDirectPairs => SortVisibleByPreferred(entryList),
_ => SortEntries(entryList),
};
}
private ImmutableList<PairUiEntry> SortVisibleByMetric(IEnumerable<PairUiEntry> entries, Func<PairUiEntry, long> selector)
{
return entries
.OrderByDescending(entry => selector(entry) >= 0)
.ThenByDescending(selector)
.ThenByDescending(entry => entry.IsOnline)
.ThenBy(entry => AlphabeticalSortKey(entry), StringComparer.OrdinalIgnoreCase)
.ToImmutableList();
}
private ImmutableList<PairUiEntry> SortVisibleByPreferred(IEnumerable<PairUiEntry> entries)
{
return entries
.OrderByDescending(entry => entry.IsDirectlyPaired && entry.SelfPermissions.IsSticky())
.ThenByDescending(entry => entry.IsDirectlyPaired)
.ThenByDescending(entry => entry.IsOnline)
.ThenBy(entry => AlphabeticalSortKey(entry), StringComparer.OrdinalIgnoreCase)
.ToImmutableList();
}
private ImmutableList<PairUiEntry> SortGroupEntries(IEnumerable<PairUiEntry> entries, GroupFullInfoDto group)
{
return entries
.OrderByDescending(e => e.IsOnline)
.ThenBy(e => GroupSortWeight(e, group))
.ThenBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase)
.ToImmutableList();
}
private int GroupSortWeight(PairUiEntry entry, GroupFullInfoDto group)
{
if (string.Equals(entry.DisplayEntry.Ident.UserId, group.OwnerUID, StringComparison.Ordinal))
{
return 0;
}
if (group.GroupPairUserInfos.TryGetValue(entry.DisplayEntry.Ident.UserId, out var info))
{
if (info.IsModerator()) return 1;
if (info.IsPinned()) return 2;
}
return entry.IsVisible ? 3 : 4;
}
private ImmutableList<PairUiEntry> ResolveGroupEntries(
IReadOnlyDictionary<string, PairUiEntry> entryLookup,
IReadOnlyDictionary<string, Syncshell> syncshells,
GroupFullInfoDto group,
bool applyFilters)
{
if (!syncshells.TryGetValue(group.Group.GID, out var shell))
{
return ImmutableList<PairUiEntry>.Empty;
}
var entries = shell.Users.Keys
.Select(id => entryLookup.TryGetValue(id, out var entry) ? entry : null)
.Where(entry => entry is not null)
.Cast<PairUiEntry>();
if (applyFilters && _configService.Current.ShowOfflineUsersSeparately)
{
entries = entries.Where(entry => !FilterOfflineUsers(entry));
}
if (applyFilters && _configService.Current.ShowSyncshellOfflineUsersSeparately)
{
entries = entries.Where(entry => !FilterOfflineSyncshellUsers(entry));
}
return SortGroupEntries(entries, group);
}
private string GetServerError()

View File

@@ -1,9 +1,11 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility.Raii;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.UI.Handlers;
using LightlessSync.UI.Models;
using System.Collections.Immutable;
using LightlessSync.UI;
using LightlessSync.UI.Style;
namespace LightlessSync.UI.Components;
@@ -11,16 +13,18 @@ public abstract class DrawFolderBase : IDrawFolder
{
public IImmutableList<DrawUserPair> DrawPairs { get; init; }
protected readonly string _id;
protected readonly IImmutableList<Pair> _allPairs;
protected readonly IImmutableList<PairUiEntry> _allPairs;
protected readonly TagHandler _tagHandler;
protected readonly UiSharedService _uiSharedService;
private float _menuWidth = -1;
public int OnlinePairs => DrawPairs.Count(u => u.Pair.IsOnline);
public int OnlinePairs => DrawPairs.Count(u => u.DisplayEntry.Connection.IsOnline);
public int TotalPairs => _allPairs.Count;
private bool _wasHovered = false;
private bool _suppressNextRowToggle;
private bool _rowClickArmed;
protected DrawFolderBase(string id, IImmutableList<DrawUserPair> drawPairs,
IImmutableList<Pair> allPairs, TagHandler tagHandler, UiSharedService uiSharedService)
IImmutableList<PairUiEntry> allPairs, TagHandler tagHandler, UiSharedService uiSharedService)
{
_id = id;
DrawPairs = drawPairs;
@@ -31,11 +35,14 @@ public abstract class DrawFolderBase : IDrawFolder
protected abstract bool RenderIfEmpty { get; }
protected abstract bool RenderMenu { get; }
protected virtual bool EnableRowClick => true;
public void Draw()
{
if (!RenderIfEmpty && !DrawPairs.Any()) return;
_suppressNextRowToggle = false;
using var id = ImRaii.PushId("folder_" + _id);
var color = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetColorU32(ImGuiCol.FrameBgHovered), _wasHovered);
using (ImRaii.Child("folder__" + _id, new System.Numerics.Vector2(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetCursorPosX(), ImGui.GetFrameHeight())))
@@ -48,7 +55,8 @@ public abstract class DrawFolderBase : IDrawFolder
_uiSharedService.IconText(icon);
if (ImGui.IsItemClicked())
{
_tagHandler.SetTagOpen(_id, !_tagHandler.IsTagOpen(_id));
ToggleFolderOpen();
SuppressNextRowToggle();
}
ImGui.SameLine();
@@ -62,10 +70,41 @@ public abstract class DrawFolderBase : IDrawFolder
DrawName(rightSideStart - leftSideEnd);
}
_wasHovered = ImGui.IsItemHovered();
var rowHovered = ImGui.IsItemHovered();
_wasHovered = rowHovered;
if (EnableRowClick)
{
if (rowHovered && ImGui.IsMouseClicked(ImGuiMouseButton.Left) && !_suppressNextRowToggle)
{
_rowClickArmed = true;
}
if (_rowClickArmed && rowHovered && ImGui.IsMouseReleased(ImGuiMouseButton.Left))
{
ToggleFolderOpen();
_rowClickArmed = false;
}
if (!ImGui.IsMouseDown(ImGuiMouseButton.Left))
{
_rowClickArmed = false;
}
}
else
{
_rowClickArmed = false;
}
if (_wasHovered)
{
Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), spanFullWidth: true);
}
color.Dispose();
_suppressNextRowToggle = false;
ImGui.Separator();
// if opened draw content
@@ -110,6 +149,7 @@ public abstract class DrawFolderBase : IDrawFolder
ImGui.SameLine(windowEndX - barButtonSize.X);
if (_uiSharedService.IconButton(FontAwesomeIcon.EllipsisV))
{
SuppressNextRowToggle();
ImGui.OpenPopup("User Flyout Menu");
}
if (ImGui.BeginPopup("User Flyout Menu"))
@@ -123,7 +163,16 @@ public abstract class DrawFolderBase : IDrawFolder
_menuWidth = 0;
}
}
return DrawRightSide(rightSideStart);
}
protected void SuppressNextRowToggle()
{
_suppressNextRowToggle = true;
}
private void ToggleFolderOpen()
{
_tagHandler.SetTagOpen(_id, !_tagHandler.IsTagOpen(_id));
}
}

View File

@@ -5,9 +5,9 @@ using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.UI.Handlers;
using LightlessSync.UI.Models;
using LightlessSync.WebAPI;
using System.Collections.Immutable;
@@ -22,7 +22,7 @@ public class DrawFolderGroup : DrawFolderBase
private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi;
public DrawFolderGroup(string id, GroupFullInfoDto groupFullInfoDto, ApiController apiController,
IImmutableList<DrawUserPair> drawPairs, IImmutableList<Pair> allPairs, TagHandler tagHandler, IdDisplayHandler idDisplayHandler,
IImmutableList<DrawUserPair> drawPairs, IImmutableList<PairUiEntry> allPairs, TagHandler tagHandler, IdDisplayHandler idDisplayHandler,
LightlessMediator lightlessMediator, UiSharedService uiSharedService, SelectTagForSyncshellUi selectTagForSyncshellUi) :
base(id, drawPairs, allPairs, tagHandler, uiSharedService)
{
@@ -35,6 +35,7 @@ public class DrawFolderGroup : DrawFolderBase
protected override bool RenderIfEmpty => true;
protected override bool RenderMenu => true;
protected override bool EnableRowClick => false;
private bool IsModerator => IsOwner || _groupFullInfoDto.GroupUserInfo.IsModerator();
private bool IsOwner => string.Equals(_groupFullInfoDto.OwnerUID, _apiController.UID, StringComparison.Ordinal);
private bool IsPinned => _groupFullInfoDto.GroupUserInfo.IsPinned();
@@ -87,6 +88,13 @@ public class DrawFolderGroup : DrawFolderBase
ImGui.Separator();
ImGui.TextUnformatted("General Syncshell Actions");
if (_uiSharedService.IconTextButton(FontAwesomeIcon.AddressCard, "Open Syncshell Profile", menuWidth, true))
{
ImGui.CloseCurrentPopup();
_lightlessMediator.Publish(new GroupProfileOpenStandaloneMessage(_groupFullInfoDto));
}
UiSharedService.AttachToolTip("Opens the profile for this syncshell in a new window.");
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Copy, "Copy ID", menuWidth, true))
{
ImGui.CloseCurrentPopup();
@@ -160,6 +168,14 @@ public class DrawFolderGroup : DrawFolderBase
{
ImGui.Separator();
ImGui.TextUnformatted("Syncshell Admin Functions");
if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserEdit, "Open Profile Editor", menuWidth, true))
{
ImGui.CloseCurrentPopup();
_lightlessMediator.Publish(new OpenGroupProfileEditorMessage(_groupFullInfoDto));
}
UiSharedService.AttachToolTip("Open the syncshell profile editor.");
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Cog, "Open Admin Panel", menuWidth, true))
{
ImGui.CloseCurrentPopup();
@@ -244,6 +260,7 @@ public class DrawFolderGroup : DrawFolderBase
ImGui.SameLine();
if (_uiSharedService.IconButton(pauseIcon))
{
SuppressNextRowToggle();
var perm = _groupFullInfoDto.GroupUserPermissions;
perm.SetPaused(!perm.IsPaused());
_ = _apiController.GroupChangeIndividualPermissionState(new GroupPairUserPermissionDto(_groupFullInfoDto.Group, new(_apiController.UID), perm));

View File

@@ -1,11 +1,18 @@
using Dalamud.Bindings.ImGui;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility.Raii;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Handlers;
using LightlessSync.UI.Models;
using LightlessSync.WebAPI;
using System.Collections.Immutable;
namespace LightlessSync.UI.Components;
@@ -14,14 +21,30 @@ public class DrawFolderTag : DrawFolderBase
private readonly ApiController _apiController;
private readonly SelectPairForTagUi _selectPairForTagUi;
private readonly RenamePairTagUi _renameTagUi;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly LightlessConfigService _configService;
private readonly LightlessMediator _mediator;
public DrawFolderTag(string id, IImmutableList<DrawUserPair> drawPairs, IImmutableList<Pair> allPairs,
TagHandler tagHandler, ApiController apiController, SelectPairForTagUi selectPairForTagUi, RenamePairTagUi renameTagUi, UiSharedService uiSharedService)
public DrawFolderTag(
string id,
IImmutableList<DrawUserPair> drawPairs,
IImmutableList<PairUiEntry> allPairs,
TagHandler tagHandler,
ApiController apiController,
SelectPairForTagUi selectPairForTagUi,
RenamePairTagUi renameTagUi,
UiSharedService uiSharedService,
ServerConfigurationManager serverConfigurationManager,
LightlessConfigService configService,
LightlessMediator mediator)
: base(id, drawPairs, allPairs, tagHandler, uiSharedService)
{
_apiController = apiController;
_selectPairForTagUi = selectPairForTagUi;
_renameTagUi = renameTagUi;
_serverConfigurationManager = serverConfigurationManager;
_configService = configService;
_mediator = mediator;
}
protected override bool RenderIfEmpty => _id switch
@@ -86,15 +109,18 @@ public class DrawFolderTag : DrawFolderBase
if (RenderCount)
{
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, ImGui.GetStyle().ItemSpacing with { X = ImGui.GetStyle().ItemSpacing.X / 2f }))
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing,
ImGui.GetStyle().ItemSpacing with { X = ImGui.GetStyle().ItemSpacing.X / 2f }))
{
ImGui.SameLine();
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("[" + OnlinePairs.ToString() + "]");
ImGui.TextUnformatted($"[{OnlinePairs}]");
}
UiSharedService.AttachToolTip(OnlinePairs + " online" + Environment.NewLine + TotalPairs + " total");
UiSharedService.AttachToolTip($"{OnlinePairs} online{Environment.NewLine}{TotalPairs} total");
}
ImGui.SameLine();
return ImGui.GetCursorPosX();
}
@@ -102,19 +128,24 @@ public class DrawFolderTag : DrawFolderBase
protected override void DrawMenu(float menuWidth)
{
ImGui.TextUnformatted("Group Menu");
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Users, "Select Pairs", menuWidth, isInPopup: true))
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Users, "Select Pairs", menuWidth, true))
{
_selectPairForTagUi.Open(_id);
}
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Edit, "Rename Pair Group", menuWidth, isInPopup: true))
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Edit, "Rename Pair Group", menuWidth, true))
{
_renameTagUi.Open(_id);
}
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Delete Pair Group", menuWidth, isInPopup: true) && UiSharedService.CtrlPressed())
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Delete Pair Group", menuWidth, true) &&
UiSharedService.CtrlPressed())
{
_tagHandler.RemovePairTag(_id);
}
UiSharedService.AttachToolTip("Hold CTRL to remove this Group permanently." + Environment.NewLine +
UiSharedService.AttachToolTip(
"Hold CTRL to remove this Group permanently." + Environment.NewLine +
"Note: this will not unpair with users in this Group.");
}
@@ -122,7 +153,7 @@ public class DrawFolderTag : DrawFolderBase
{
ImGui.AlignTextToFramePadding();
string name = _id switch
var name = _id switch
{
TagHandler.CustomUnpairedTag => "One-sided Individual Pairs",
TagHandler.CustomOnlineTag => "Online / Paused by you",
@@ -138,16 +169,25 @@ public class DrawFolderTag : DrawFolderBase
protected override float DrawRightSide(float currentRightSideX)
{
if (!RenderPause) return currentRightSideX;
var allArePaused = _allPairs.All(pair => pair.UserPair!.OwnPermissions.IsPaused());
var pauseButton = allArePaused ? FontAwesomeIcon.Play : FontAwesomeIcon.Pause;
var pauseButtonX = _uiSharedService.GetIconButtonSize(pauseButton).X;
var buttonPauseOffset = currentRightSideX - pauseButtonX;
ImGui.SameLine(buttonPauseOffset);
if (_uiSharedService.IconButton(pauseButton))
if (_id == TagHandler.CustomVisibleTag)
{
return DrawVisibleFilter(currentRightSideX);
}
if (!RenderPause)
{
return currentRightSideX;
}
var allArePaused = _allPairs.All(entry => entry.SelfPermissions.IsPaused());
var pauseIcon = allArePaused ? FontAwesomeIcon.Play : FontAwesomeIcon.Pause;
var pauseButtonSize = _uiSharedService.GetIconButtonSize(pauseIcon);
var buttonPauseOffset = currentRightSideX - pauseButtonSize.X;
ImGui.SameLine(buttonPauseOffset);
if (_uiSharedService.IconButton(pauseIcon))
{
SuppressNextRowToggle();
if (allArePaused)
{
ResumeAllPairs(_allPairs);
@@ -157,39 +197,89 @@ public class DrawFolderTag : DrawFolderBase
PauseRemainingPairs(_allPairs);
}
}
if (allArePaused)
{
UiSharedService.AttachToolTip($"Resume pairing with all pairs in {_id}");
}
else
{
UiSharedService.AttachToolTip($"Pause pairing with all pairs in {_id}");
}
UiSharedService.AttachToolTip(allArePaused
? $"Resume pairing with all pairs in {_id}"
: $"Pause pairing with all pairs in {_id}");
return currentRightSideX;
}
private void PauseRemainingPairs(IEnumerable<Pair> availablePairs)
private void PauseRemainingPairs(IEnumerable<PairUiEntry> entries)
{
_ = _apiController.SetBulkPermissions(new(availablePairs
.ToDictionary(g => g.UserData.UID, g =>
{
var perm = g.UserPair.OwnPermissions;
perm.SetPaused(paused: true);
return perm;
}, StringComparer.Ordinal), new(StringComparer.Ordinal)))
_ = _apiController.SetBulkPermissions(new(
entries.ToDictionary(entry => entry.DisplayEntry.User.UID, entry =>
{
var permissions = entry.SelfPermissions;
permissions.SetPaused(true);
return permissions;
}, StringComparer.Ordinal),
new(StringComparer.Ordinal)))
.ConfigureAwait(false);
}
private void ResumeAllPairs(IEnumerable<Pair> availablePairs)
private void ResumeAllPairs(IEnumerable<PairUiEntry> entries)
{
_ = _apiController.SetBulkPermissions(new(availablePairs
.ToDictionary(g => g.UserData.UID, g =>
{
var perm = g.UserPair.OwnPermissions;
perm.SetPaused(paused: false);
return perm;
}, StringComparer.Ordinal), new(StringComparer.Ordinal)))
_ = _apiController.SetBulkPermissions(new(
entries.ToDictionary(entry => entry.DisplayEntry.User.UID, entry =>
{
var permissions = entry.SelfPermissions;
permissions.SetPaused(false);
return permissions;
}, StringComparer.Ordinal),
new(StringComparer.Ordinal)))
.ConfigureAwait(false);
}
private float DrawVisibleFilter(float currentRightSideX)
{
var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Filter);
var spacingX = ImGui.GetStyle().ItemSpacing.X;
var buttonStart = currentRightSideX - buttonSize.X;
ImGui.SameLine(buttonStart);
if (_uiSharedService.IconButton(FontAwesomeIcon.Filter))
{
SuppressNextRowToggle();
ImGui.OpenPopup($"visible-filter-{_id}");
}
UiSharedService.AttachToolTip("Adjust how visible pairs are ordered.");
if (ImGui.BeginPopup($"visible-filter-{_id}"))
{
ImGui.TextUnformatted("Visible Pair Ordering");
ImGui.Separator();
foreach (VisiblePairSortMode mode in Enum.GetValues<VisiblePairSortMode>())
{
var selected = _configService.Current.VisiblePairSortMode == mode;
if (ImGui.MenuItem(GetSortLabel(mode), string.Empty, selected))
{
if (!selected)
{
_configService.Current.VisiblePairSortMode = mode;
_configService.Save();
_mediator.Publish(new RefreshUiMessage());
}
ImGui.CloseCurrentPopup();
}
}
ImGui.EndPopup();
}
return buttonStart - spacingX;
}
private static string GetSortLabel(VisiblePairSortMode mode) => mode switch
{
VisiblePairSortMode.Alphabetical => "Alphabetical",
VisiblePairSortMode.VramUsage => "VRAM usage (descending)",
VisiblePairSortMode.EffectiveVramUsage => "Effective VRAM usage (descending)",
VisiblePairSortMode.TriangleCount => "Triangle count (descending)",
VisiblePairSortMode.PreferredDirectPairs => "Preferred permissions & Direct pairs",
_ => "Default",
};
}

View File

@@ -1,9 +1,11 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility.Raii;
using LightlessSync.UI;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group;
using LightlessSync.UI.Handlers;
using LightlessSync.UI.Style;
using LightlessSync.UI.Models;
using LightlessSync.WebAPI;
using System.Collections.Immutable;
@@ -22,6 +24,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
private readonly RenameSyncshellTagUi _renameSyncshellTagUi;
private bool _wasHovered = false;
private float _menuWidth;
private bool _rowClickArmed;
public IImmutableList<DrawUserPair> DrawPairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).ToImmutableList();
public int OnlinePairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).Where(g => g.Pair.IsOnline).DistinctBy(g => g.Pair.UserData.UID).Count();
@@ -48,7 +51,9 @@ public class DrawGroupedGroupFolder : IDrawFolder
using var id = ImRaii.PushId(_id);
var color = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetColorU32(ImGuiCol.FrameBgHovered), _wasHovered);
using (ImRaii.Child("folder__" + _id, new Vector2(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetCursorPosX(), ImGui.GetFrameHeight())))
var allowRowClick = string.IsNullOrEmpty(_tag);
var suppressRowToggle = false;
using (ImRaii.Child("folder__" + _id, new System.Numerics.Vector2(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetCursorPosX(), ImGui.GetFrameHeight())))
{
ImGui.Dummy(new Vector2(0f, ImGui.GetFrameHeight()));
using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(0f, 0f)))
@@ -61,6 +66,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
if (ImGui.IsItemClicked())
{
_tagHandler.SetTagOpen(_id, !_tagHandler.IsTagOpen(_id));
suppressRowToggle = true;
}
ImGui.SameLine();
@@ -92,7 +98,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
ImGui.SameLine();
DrawPauseButton();
ImGui.SameLine();
DrawMenu();
DrawMenu(ref suppressRowToggle);
} else
{
ImGui.TextUnformatted("All Syncshells");
@@ -102,7 +108,36 @@ public class DrawGroupedGroupFolder : IDrawFolder
}
}
color.Dispose();
_wasHovered = ImGui.IsItemHovered();
var rowHovered = ImGui.IsItemHovered();
_wasHovered = rowHovered;
if (allowRowClick)
{
if (rowHovered && ImGui.IsMouseClicked(ImGuiMouseButton.Left) && !suppressRowToggle)
{
_rowClickArmed = true;
}
if (_rowClickArmed && rowHovered && ImGui.IsMouseReleased(ImGuiMouseButton.Left))
{
_tagHandler.SetTagOpen(_id, !_tagHandler.IsTagOpen(_id));
_rowClickArmed = false;
}
if (!ImGui.IsMouseDown(ImGuiMouseButton.Left))
{
_rowClickArmed = false;
}
}
else
{
_rowClickArmed = false;
}
if (_wasHovered)
{
Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), spanFullWidth: true);
}
ImGui.Separator();
@@ -154,7 +189,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
}
}
protected void DrawMenu()
protected void DrawMenu(ref bool suppressRowToggle)
{
var barButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.EllipsisV);
var windowEndX = ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth();
@@ -162,6 +197,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
ImGui.SameLine(windowEndX - barButtonSize.X);
if (_uiSharedService.IconButton(FontAwesomeIcon.EllipsisV))
{
suppressRowToggle = true;
ImGui.OpenPopup("User Flyout Menu");
}
if (ImGui.BeginPopup("User Flyout Menu"))

View File

@@ -12,11 +12,16 @@ using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Handlers;
using LightlessSync.UI.Models;
using LightlessSync.UI.Style;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using LightlessSync.UI;
namespace LightlessSync.UI.Components;
@@ -27,29 +32,41 @@ public class DrawUserPair
protected readonly LightlessMediator _mediator;
protected readonly List<GroupFullInfoDto> _syncedGroups;
private readonly GroupFullInfoDto? _currentGroup;
protected Pair _pair;
protected Pair? _pair;
private PairUiEntry _uiEntry;
protected PairDisplayEntry _displayEntry;
private readonly string _id;
private readonly SelectTagForPairUi _selectTagForPairUi;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly UiSharedService _uiSharedService;
private readonly PlayerPerformanceConfigService _performanceConfigService;
private readonly CharaDataManager _charaDataManager;
private readonly PairLedger _pairLedger;
private float _menuWidth = -1;
private bool _wasHovered = false;
private TooltipSnapshot _tooltipSnapshot = TooltipSnapshot.Empty;
private string _cachedTooltip = string.Empty;
public DrawUserPair(string id, Pair entry, List<GroupFullInfoDto> syncedGroups,
public DrawUserPair(
string id,
PairUiEntry uiEntry,
Pair? legacyPair,
GroupFullInfoDto? currentGroup,
ApiController apiController, IdDisplayHandler uIDDisplayHandler,
LightlessMediator lightlessMediator, SelectTagForPairUi selectTagForPairUi,
ApiController apiController,
IdDisplayHandler uIDDisplayHandler,
LightlessMediator lightlessMediator,
SelectTagForPairUi selectTagForPairUi,
ServerConfigurationManager serverConfigurationManager,
UiSharedService uiSharedService, PlayerPerformanceConfigService performanceConfigService,
CharaDataManager charaDataManager)
UiSharedService uiSharedService,
PlayerPerformanceConfigService performanceConfigService,
CharaDataManager charaDataManager,
PairLedger pairLedger)
{
_id = id;
_pair = entry;
_syncedGroups = syncedGroups;
_uiEntry = uiEntry;
_displayEntry = uiEntry.DisplayEntry;
_pair = legacyPair ?? throw new ArgumentNullException(nameof(legacyPair));
_syncedGroups = uiEntry.DisplayEntry.Groups.ToList();
_currentGroup = currentGroup;
_apiController = apiController;
_displayHandler = uIDDisplayHandler;
@@ -59,6 +76,18 @@ public class DrawUserPair
_uiSharedService = uiSharedService;
_performanceConfigService = performanceConfigService;
_charaDataManager = charaDataManager;
_pairLedger = pairLedger;
}
public PairDisplayEntry DisplayEntry => _displayEntry;
public PairUiEntry UiEntry => _uiEntry;
public void UpdateDisplayEntry(PairUiEntry entry)
{
_uiEntry = entry;
_displayEntry = entry.DisplayEntry;
_syncedGroups.Clear();
_syncedGroups.AddRange(entry.DisplayEntry.Groups);
}
public Pair Pair => _pair;
@@ -77,6 +106,10 @@ public class DrawUserPair
DrawName(posX, rightSide);
}
_wasHovered = ImGui.IsItemHovered();
if (_wasHovered)
{
Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), spanFullWidth: true);
}
color.Dispose();
}
@@ -103,7 +136,7 @@ public class DrawUserPair
if (_uiSharedService.IconTextButton(FontAwesomeIcon.PlayCircle, "Cycle pause state", _menuWidth, true))
{
_ = _apiController.CyclePauseAsync(_pair.UserData);
_ = _apiController.CyclePauseAsync(_pair);
ImGui.CloseCurrentPopup();
}
ImGui.Separator();
@@ -313,6 +346,7 @@ public class DrawUserPair
_pair.PlayerName ?? string.Empty,
_pair.LastAppliedDataBytes,
_pair.LastAppliedApproximateVRAMBytes,
_pair.LastAppliedApproximateEffectiveVRAMBytes,
_pair.LastAppliedDataTris,
_pair.IsPaired,
groupDisplays is null ? ImmutableArray<string>.Empty : ImmutableArray.CreateRange(groupDisplays));
@@ -381,7 +415,14 @@ public class DrawUserPair
{
builder.Append(Environment.NewLine);
builder.Append("Approx. VRAM Usage: ");
builder.Append(UiSharedService.ByteToString(snapshot.LastAppliedApproximateVRAMBytes, true));
var originalText = UiSharedService.ByteToString(snapshot.LastAppliedApproximateVRAMBytes, true);
builder.Append(originalText);
if (snapshot.LastAppliedApproximateEffectiveVRAMBytes >= 0)
{
builder.Append(" (Effective: ");
builder.Append(UiSharedService.ByteToString(snapshot.LastAppliedApproximateEffectiveVRAMBytes, true));
builder.Append(')');
}
}
if (snapshot.LastAppliedDataTris >= 0)
@@ -420,12 +461,13 @@ public class DrawUserPair
string PlayerName,
long LastAppliedDataBytes,
long LastAppliedApproximateVRAMBytes,
long LastAppliedApproximateEffectiveVRAMBytes,
long LastAppliedDataTris,
bool IsPaired,
ImmutableArray<string> GroupDisplays)
{
public static TooltipSnapshot Empty { get; } =
new(false, false, false, IndividualPairStatus.None, string.Empty, string.Empty, -1, -1, -1, false, ImmutableArray<string>.Empty);
new(false, false, false, IndividualPairStatus.None, string.Empty, string.Empty, -1, -1, -1, -1, false, ImmutableArray<string>.Empty);
}
private void DrawPairedClientMenu()
@@ -647,7 +689,13 @@ public class DrawUserPair
private void DrawSyncshellMenu(GroupFullInfoDto group, bool selfIsOwner, bool selfIsModerator, bool userIsPinned, bool userIsModerator)
{
if (selfIsOwner || ((selfIsModerator) && (!userIsModerator)))
var showModeratorActions = selfIsOwner || (selfIsModerator && !userIsModerator);
var showOwnerActions = selfIsOwner;
if (showModeratorActions || showOwnerActions)
ImGui.Separator();
if (showModeratorActions)
{
ImGui.TextUnformatted("Syncshell Moderator Functions");
var pinText = userIsPinned ? "Unpin user" : "Pin user";
@@ -683,7 +731,7 @@ public class DrawUserPair
ImGui.Separator();
}
if (selfIsOwner)
if (showOwnerActions)
{
ImGui.TextUnformatted("Syncshell Owner Functions");
string modText = userIsModerator ? "Demod user" : "Mod user";

View File

@@ -1,6 +1,7 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using LightlessSync.API.Dto.Group;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.WebAPI;
@@ -12,14 +13,16 @@ public class BanUserPopupHandler : IPopupHandler
{
private readonly ApiController _apiController;
private readonly UiSharedService _uiSharedService;
private readonly PairFactory _pairFactory;
private string _banReason = string.Empty;
private GroupFullInfoDto _group = null!;
private Pair _reportedPair = null!;
public BanUserPopupHandler(ApiController apiController, UiSharedService uiSharedService)
public BanUserPopupHandler(ApiController apiController, UiSharedService uiSharedService, PairFactory pairFactory)
{
_apiController = apiController;
_uiSharedService = uiSharedService;
_pairFactory = pairFactory;
}
public Vector2 PopupSize => new(500, 250);
@@ -43,7 +46,7 @@ public class BanUserPopupHandler : IPopupHandler
public void Open(OpenBanUserPopupMessage message)
{
_reportedPair = message.PairToBan;
_reportedPair = _pairFactory.Create(message.PairToBan.UniqueIdent) ?? message.PairToBan;
_group = message.GroupFullInfoDto;
_banReason = string.Empty;
}

View File

@@ -23,7 +23,7 @@ public class SelectPairForTagUi
_uidDisplayHandler = uidDisplayHandler;
}
public void Draw(List<Pair> pairs)
public void Draw(IReadOnlyList<Pair> pairs)
{
var workHeight = ImGui.GetMainViewport().WorkSize.Y / ImGuiHelpers.GlobalScale;
var minSize = new Vector2(300, workHeight < 400 ? workHeight : 400) * ImGuiHelpers.GlobalScale;

View File

@@ -21,7 +21,7 @@ public class SelectSyncshellForTagUi
_tagHandler = tagHandler;
}
public void Draw(List<GroupFullInfoDto> groups)
public void Draw(IReadOnlyCollection<GroupFullInfoDto> groups)
{
var workHeight = ImGui.GetMainViewport().WorkSize.Y / ImGuiHelpers.GlobalScale;
var minSize = new Vector2(300, workHeight < 400 ? workHeight : 400) * ImGuiHelpers.GlobalScale;

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,21 @@
using LightlessSync.API.Dto.Group;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Components;
using LightlessSync.UI.Handlers;
using LightlessSync.UI.Models;
using LightlessSync.WebAPI;
using Microsoft.Extensions.Logging;
using System.Collections.Immutable;
namespace LightlessSync.UI;
@@ -19,6 +26,7 @@ public class DrawEntityFactory
private readonly LightlessMediator _mediator;
private readonly SelectPairForTagUi _selectPairForTagUi;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly LightlessConfigService _configService;
private readonly UiSharedService _uiSharedService;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly CharaDataManager _charaDataManager;
@@ -29,13 +37,28 @@ public class DrawEntityFactory
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
private readonly TagHandler _tagHandler;
private readonly IdDisplayHandler _uidDisplayHandler;
private readonly PairLedger _pairLedger;
private readonly PairFactory _pairFactory;
public DrawEntityFactory(ILogger<DrawEntityFactory> logger, ApiController apiController, IdDisplayHandler uidDisplayHandler,
SelectTagForPairUi selectTagForPairUi, RenamePairTagUi renamePairTagUi, LightlessMediator mediator,
TagHandler tagHandler, SelectPairForTagUi selectPairForTagUi,
ServerConfigurationManager serverConfigurationManager, UiSharedService uiSharedService,
PlayerPerformanceConfigService playerPerformanceConfigService, CharaDataManager charaDataManager,
SelectTagForSyncshellUi selectTagForSyncshellUi, RenameSyncshellTagUi renameSyncshellTagUi, SelectSyncshellForTagUi selectSyncshellForTagUi)
public DrawEntityFactory(
ILogger<DrawEntityFactory> logger,
ApiController apiController,
IdDisplayHandler uidDisplayHandler,
SelectTagForPairUi selectTagForPairUi,
RenamePairTagUi renamePairTagUi,
LightlessMediator mediator,
TagHandler tagHandler,
SelectPairForTagUi selectPairForTagUi,
ServerConfigurationManager serverConfigurationManager,
LightlessConfigService configService,
UiSharedService uiSharedService,
PlayerPerformanceConfigService playerPerformanceConfigService,
CharaDataManager charaDataManager,
SelectTagForSyncshellUi selectTagForSyncshellUi,
RenameSyncshellTagUi renameSyncshellTagUi,
SelectSyncshellForTagUi selectSyncshellForTagUi,
PairLedger pairLedger,
PairFactory pairFactory)
{
_logger = logger;
_apiController = apiController;
@@ -46,44 +69,151 @@ public class DrawEntityFactory
_tagHandler = tagHandler;
_selectPairForTagUi = selectPairForTagUi;
_serverConfigurationManager = serverConfigurationManager;
_configService = configService;
_uiSharedService = uiSharedService;
_playerPerformanceConfigService = playerPerformanceConfigService;
_charaDataManager = charaDataManager;
_selectTagForSyncshellUi = selectTagForSyncshellUi;
_renameSyncshellTagUi = renameSyncshellTagUi;
_selectSyncshellForTagUi = selectSyncshellForTagUi;
_pairLedger = pairLedger;
_pairFactory = pairFactory;
}
public DrawFolderGroup CreateDrawGroupFolder(GroupFullInfoDto groupFullInfoDto,
Dictionary<Pair, List<GroupFullInfoDto>> filteredPairs,
IImmutableList<Pair> allPairs)
public DrawFolderGroup CreateGroupFolder(
string id,
GroupFullInfoDto groupFullInfo,
IEnumerable<PairUiEntry> drawEntries,
IEnumerable<PairUiEntry> allEntries)
{
return new DrawFolderGroup(groupFullInfoDto.Group.GID, groupFullInfoDto, _apiController,
filteredPairs.Select(p => CreateDrawPair(groupFullInfoDto.Group.GID + p.Key.UserData.UID, p.Key, p.Value, groupFullInfoDto)).ToImmutableList(),
allPairs, _tagHandler, _uidDisplayHandler, _mediator, _uiSharedService, _selectTagForSyncshellUi);
var drawPairs = drawEntries
.Select(entry => CreateDrawPair($"{id}:{entry.DisplayEntry.Ident.UserId}", entry, groupFullInfo))
.Where(draw => draw is not null)
.Cast<DrawUserPair>()
.ToImmutableList();
var allPairs = allEntries.ToImmutableList();
return new DrawFolderGroup(
id,
groupFullInfo,
_apiController,
drawPairs,
allPairs,
_tagHandler,
_uidDisplayHandler,
_mediator,
_uiSharedService,
_selectTagForSyncshellUi);
}
public DrawFolderGroup CreateDrawGroupFolder(string id, GroupFullInfoDto groupFullInfoDto,
Dictionary<Pair, List<GroupFullInfoDto>> filteredPairs,
IImmutableList<Pair> allPairs)
public DrawFolderTag CreateTagFolder(
string tag,
IEnumerable<PairUiEntry> drawEntries,
IEnumerable<PairUiEntry> allEntries)
{
return new DrawFolderGroup(id, groupFullInfoDto, _apiController,
filteredPairs.Select(p => CreateDrawPair(groupFullInfoDto.Group.GID + p.Key.UserData.UID, p.Key, p.Value, groupFullInfoDto)).ToImmutableList(),
allPairs, _tagHandler, _uidDisplayHandler, _mediator, _uiSharedService, _selectTagForSyncshellUi);
var drawPairs = drawEntries
.Select(entry => CreateDrawPair($"{tag}:{entry.DisplayEntry.Ident.UserId}", entry))
.Where(draw => draw is not null)
.Cast<DrawUserPair>()
.ToImmutableList();
var allPairs = allEntries.ToImmutableList();
return new DrawFolderTag(
tag,
drawPairs,
allPairs,
_tagHandler,
_apiController,
_selectPairForTagUi,
_renamePairTagUi,
_uiSharedService,
_serverConfigurationManager,
_configService,
_mediator);
}
public DrawFolderTag CreateDrawTagFolder(string tag,
Dictionary<Pair, List<GroupFullInfoDto>> filteredPairs,
IImmutableList<Pair> allPairs)
public DrawUserPair? CreateDrawPair(
string id,
PairUiEntry entry,
GroupFullInfoDto? currentGroup = null)
{
return new(tag, filteredPairs.Select(u => CreateDrawPair(tag, u.Key, u.Value, currentGroup: null)).ToImmutableList(),
allPairs, _tagHandler, _apiController, _selectPairForTagUi, _renamePairTagUi, _uiSharedService);
var pair = _pairFactory.Create(entry.DisplayEntry);
if (pair is null)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Skipping draw pair for {UserId}: legacy pair not found.", entry.DisplayEntry.Ident.UserId);
}
return null;
}
return new DrawUserPair(
id,
entry,
pair,
currentGroup,
_apiController,
_uidDisplayHandler,
_mediator,
_selectTagForPairUi,
_serverConfigurationManager,
_uiSharedService,
_playerPerformanceConfigService,
_charaDataManager,
_pairLedger);
}
public DrawUserPair CreateDrawPair(string id, Pair user, List<GroupFullInfoDto> groups, GroupFullInfoDto? currentGroup)
public IReadOnlyList<PairUiEntry> GetAllEntries()
{
return new DrawUserPair(id + user.UserData.UID, user, groups, currentGroup, _apiController, _uidDisplayHandler,
_mediator, _selectTagForPairUi, _serverConfigurationManager, _uiSharedService, _playerPerformanceConfigService,
_charaDataManager);
try
{
return _pairLedger.GetAllEntries()
.Select(BuildUiEntry)
.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to build pair display entries.");
return Array.Empty<PairUiEntry>();
}
}
private PairUiEntry BuildUiEntry(PairDisplayEntry entry)
{
var handler = entry.Handler;
var alias = entry.User.AliasOrUID;
if (string.IsNullOrWhiteSpace(alias))
{
alias = entry.Ident.UserId;
}
var displayName = !string.IsNullOrWhiteSpace(handler?.PlayerName)
? handler!.PlayerName!
: alias;
var note = _serverConfigurationManager.GetNoteForUid(entry.Ident.UserId) ?? string.Empty;
var isPaused = entry.SelfPermissions.IsPaused();
return new PairUiEntry(
entry,
alias,
displayName,
note,
entry.IsVisible,
entry.IsOnline,
entry.IsDirectlyPaired,
entry.IsOneSided,
entry.HasAnyConnection,
isPaused,
entry.SelfPermissions,
entry.OtherPermissions,
entry.PairStatus,
handler?.LastAppliedDataBytes ?? -1,
handler?.LastAppliedDataTris ?? -1,
handler?.LastAppliedApproximateVRAMBytes ?? -1,
handler?.LastAppliedApproximateEffectiveVRAMBytes ?? -1,
handler);
}
}

View File

@@ -5,7 +5,6 @@ using Dalamud.Plugin.Services;
using Dalamud.Utility;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Configurations;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
@@ -17,6 +16,8 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using System.Runtime.InteropServices;
using System.Text;
using LightlessSync.UI.Services;
using LightlessSync.PlayerData.Pairs;
using static LightlessSync.Services.PairRequestService;
namespace LightlessSync.UI;
@@ -37,7 +38,7 @@ public sealed class DtrEntry : IDisposable, IHostedService
private readonly BroadcastService _broadcastService;
private readonly BroadcastScannerService _broadcastScannerService;
private readonly LightlessMediator _lightlessMediator;
private readonly PairManager _pairManager;
private readonly PairUiService _pairUiService;
private readonly PairRequestService _pairRequestService;
private readonly DalamudUtilService _dalamudUtilService;
private Task? _runTask;
@@ -57,7 +58,7 @@ public sealed class DtrEntry : IDisposable, IHostedService
IDtrBar dtrBar,
ConfigurationServiceBase<LightlessConfig> configService,
LightlessMediator lightlessMediator,
PairManager pairManager,
PairUiService pairUiService,
PairRequestService pairRequestService,
ApiController apiController,
ServerConfigurationManager serverManager,
@@ -71,7 +72,7 @@ public sealed class DtrEntry : IDisposable, IHostedService
_lightfinderEntry = new(CreateLightfinderEntry);
_configService = configService;
_lightlessMediator = lightlessMediator;
_pairManager = pairManager;
_pairUiService = pairUiService;
_pairRequestService = pairRequestService;
_apiController = apiController;
_serverManager = serverManager;
@@ -165,7 +166,7 @@ public sealed class DtrEntry : IDisposable, IHostedService
entry.OnClick = interactionEvent => OnLightfinderEntryClick(interactionEvent);
return entry;
}
private void OnStatusEntryClick(DtrInteractionEvent interactionEvent)
{
if (interactionEvent.ClickType.Equals(MouseClickType.Left))
@@ -254,16 +255,15 @@ public sealed class DtrEntry : IDisposable, IHostedService
if (_apiController.IsConnected)
{
var pairCount = _pairManager.GetVisibleUserCount();
var snapshot = _pairUiService.GetSnapshot();
var visiblePairsQuery = snapshot.PairsByUid.Values.Where(x => x.IsVisible && !x.IsPaused);
var pairCount = visiblePairsQuery.Count();
text = $"\uE044 {pairCount}";
if (pairCount > 0)
{
var preferNote = config.PreferNoteInDtrTooltip;
var showUid = config.ShowUidInDtrTooltip;
var visiblePairsQuery = _pairManager.GetOnlineUserPairs()
.Where(x => x.IsVisible);
IEnumerable<string> visiblePairs = showUid
? visiblePairsQuery.Select(x => string.Format("{0} ({1})", preferNote ? x.GetNote() ?? x.PlayerName : x.PlayerName, x.UserData.AliasOrUID))
: visiblePairsQuery.Select(x => string.Format("{0}", preferNote ? x.GetNote() ?? x.PlayerName : x.PlayerName));

View File

@@ -0,0 +1,701 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Components;
using Dalamud.Interface.ImGuiFileDialog;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using LightlessSync.API.Data;
using LightlessSync.API.Dto.Group;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.UI.Tags;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.PixelFormats;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Threading.Tasks;
namespace LightlessSync.UI;
public partial class EditProfileUi
{
private void OpenGroupEditor(GroupFullInfoDto groupInfo)
{
_mode = ProfileEditorMode.Group;
_groupInfo = groupInfo;
var profile = _lightlessProfileManager.GetLightlessGroupProfile(groupInfo.Group);
_groupProfileData = profile;
SyncGroupProfileState(profile, resetSelection: true);
var scale = ImGuiHelpers.GlobalScale;
var viewport = ImGui.GetMainViewport();
ProfileEditorLayoutCoordinator.Enable(groupInfo.Group.GID);
ProfileEditorLayoutCoordinator.EnsureAnchor(viewport.WorkPos, scale);
Mediator.Publish(new GroupProfileOpenStandaloneMessage(groupInfo));
IsOpen = true;
_wasOpen = true;
}
private void ResetGroupEditorState()
{
_groupInfo = null;
_groupProfileData = null;
_groupIsNsfw = false;
_groupIsDisabled = false;
_groupServerIsNsfw = false;
_groupServerIsDisabled = false;
_queuedProfileImage = null;
_queuedBannerImage = null;
_profileImage = Array.Empty<byte>();
_bannerImage = Array.Empty<byte>();
_profileDescription = string.Empty;
_descriptionText = string.Empty;
_profileTagIds = Array.Empty<int>();
_tagEditorSelection.Clear();
_pfpTextureWrap?.Dispose();
_pfpTextureWrap = null;
_bannerTextureWrap?.Dispose();
_bannerTextureWrap = null;
_showProfileImageError = false;
_showBannerImageError = false;
}
private void DrawGroupEditor(float scale)
{
if (_groupInfo is null)
{
UiSharedService.TextWrapped("Open the Syncshell admin panel and choose a group to edit its profile.");
return;
}
var viewport = ImGui.GetMainViewport();
var linked = ProfileEditorLayoutCoordinator.IsActive(_groupInfo.Group.GID);
if (linked)
{
ProfileEditorLayoutCoordinator.EnsureAnchor(viewport.WorkPos, scale);
var desiredSize = ProfileEditorLayoutCoordinator.GetEditorSize(scale);
if (!ProfileEditorLayoutCoordinator.NearlyEquals(ImGui.GetWindowSize(), desiredSize))
ImGui.SetWindowSize(desiredSize, ImGuiCond.Always);
var currentPos = ImGui.GetWindowPos();
if (IsWindowBeingDragged())
ProfileEditorLayoutCoordinator.UpdateAnchorFromEditor(currentPos, scale);
var desiredPos = ProfileEditorLayoutCoordinator.GetEditorPosition(scale);
if (!ProfileEditorLayoutCoordinator.NearlyEquals(currentPos, desiredPos))
ImGui.SetWindowPos(desiredPos, ImGuiCond.Always);
}
else
{
var defaultProfilePos = viewport.WorkPos + new Vector2(50f, 70f) * scale;
var defaultEditorPos = defaultProfilePos + ProfileEditorLayoutCoordinator.GetEditorOffset(scale);
ImGui.SetWindowPos(defaultEditorPos, ImGuiCond.FirstUseEver);
}
if (_queuedProfileImage is not null)
ApplyQueuedGroupProfileImage();
if (_queuedBannerImage is not null)
ApplyQueuedGroupBannerImage();
var profile = _lightlessProfileManager.GetLightlessGroupProfile(_groupInfo.Group);
_groupProfileData = profile;
SyncGroupProfileState(profile, resetSelection: false);
var accent = UIColors.Get("LightlessPurple");
var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.015f);
var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.07f);
using var panelBg = ImRaii.PushColor(ImGuiCol.ChildBg, accentBg);
using var panelBorder = ImRaii.PushColor(ImGuiCol.ChildBg, accentBorder);
ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale);
if (ImGui.BeginChild("##GroupProfileEditorCanvas", -Vector2.One, true, ImGuiWindowFlags.NoScrollbar))
{
DrawGroupGuidelinesSection(scale);
ImGui.Dummy(new Vector2(0f, 4f * scale));
DrawGroupProfileContent(profile, scale);
}
ImGui.EndChild();
ImGui.PopStyleVar();
}
private void DrawGroupGuidelinesSection(float scale)
{
DrawSection("Guidelines", scale, () =>
{
ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(1f, 1f));
_uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "All users that are paired and unpaused with you will be able to see your profile pictures, tags and description.");
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "Other users have the possibility to report this profile for breaking the rules.");
_uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), "AVOID: Anything as profile image that can be considered highly illegal or obscene (bestiality, anything that could be considered a sexual act with a minor (that includes Lalafells), etc.)");
_uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), "AVOID: Slurs of any kind in the description that can be considered highly offensive");
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "In case of valid reports from other users this can lead to disabling the profile forever or terminating syncshell owner's Lightless account indefinitely.");
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "Judgement of the profile validity from reports through staff is not up to debate and the decisions to disable the profile or your account permanent.");
_uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessBlue"), "If the profile picture or profile description could be considered NSFW, enable the toggle in visibility settings.");
ImGui.PopStyleVar();
});
}
private void DrawGroupProfileContent(LightlessGroupProfileData profile, float scale)
{
DrawSection("Profile Preview", scale, () => DrawGroupProfileSnapshot(profile, scale));
DrawSection("Profile Image", scale, DrawGroupProfileImageControls);
DrawSection("Profile Banner", scale, DrawGroupProfileBannerControls);
DrawSection("Profile Description", scale, DrawGroupProfileDescriptionEditor);
DrawSection("Profile Tags", scale, () => DrawGroupProfileTagsEditor(scale));
DrawSection("Visibility", scale, DrawGroupProfileVisibilityControls);
}
private void DrawGroupProfileSnapshot(LightlessGroupProfileData profile, float scale)
{
var bannerHeight = 140f * scale;
ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale);
if (ImGui.BeginChild("##GroupProfileBannerPreview", new Vector2(-1f, bannerHeight), true))
{
if (_bannerTextureWrap != null)
{
var childSize = ImGui.GetWindowSize();
var padding = ImGui.GetStyle().WindowPadding;
var contentSize = new Vector2(
MathF.Max(childSize.X - padding.X * 2f, 1f),
MathF.Max(childSize.Y - padding.Y * 2f, 1f));
var imageSize = ImGuiHelpers.ScaledVector2(_bannerTextureWrap.Width, _bannerTextureWrap.Height);
if (imageSize.X > contentSize.X || imageSize.Y > contentSize.Y)
{
var ratio = MathF.Min(contentSize.X / MathF.Max(imageSize.X, 1f), contentSize.Y / MathF.Max(imageSize.Y, 1f));
imageSize *= ratio;
}
var offset = new Vector2(
MathF.Max((contentSize.X - imageSize.X) * 0.5f, 0f),
MathF.Max((contentSize.Y - imageSize.Y) * 0.5f, 0f));
ImGui.SetCursorPos(padding + offset);
ImGui.Image(_bannerTextureWrap.Handle, imageSize);
}
else
{
ImGui.TextColored(UIColors.Get("LightlessPurple"), "No profile banner uploaded.");
}
}
ImGui.EndChild();
ImGui.PopStyleVar();
ImGui.Dummy(new Vector2(0f, 6f * scale));
if (_pfpTextureWrap != null)
{
var size = ImGuiHelpers.ScaledVector2(_pfpTextureWrap.Width, _pfpTextureWrap.Height);
var maxEdge = 160f * scale;
if (size.X > maxEdge || size.Y > maxEdge)
{
var ratio = MathF.Min(maxEdge / MathF.Max(size.X, 1f), maxEdge / MathF.Max(size.Y, 1f));
size *= ratio;
}
ImGui.Image(_pfpTextureWrap.Handle, size);
}
else
{
ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale);
if (ImGui.BeginChild("##GroupProfileImagePlaceholder", new Vector2(160f * scale, 160f * scale), true))
ImGui.TextColored(UIColors.Get("LightlessPurple"), "No profile picture uploaded.");
ImGui.EndChild();
ImGui.PopStyleVar();
}
ImGui.SameLine();
ImGui.BeginGroup();
ImGui.TextColored(UIColors.Get("LightlessBlue"), _groupInfo!.GroupAliasOrGID);
ImGui.TextDisabled($"ID: {_groupInfo.Group.GID}");
ImGui.TextDisabled($"Owner: {_groupInfo.Owner.AliasOrUID}");
ImGui.EndGroup();
ImGui.Dummy(new Vector2(0f, 4f * scale));
ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale);
if (ImGui.BeginChild("##GroupProfileDescriptionPreview", new Vector2(-1f, 120f * scale), true))
{
var hasDescription = !string.IsNullOrWhiteSpace(profile.Description);
ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X);
if (!hasDescription)
{
ImGui.TextDisabled("Syncshell has no description set.");
}
else if (!SeStringUtils.TryRenderSeStringMarkupAtCursor(profile.Description!))
{
UiSharedService.TextWrapped(profile.Description);
}
ImGui.PopTextWrapPos();
}
ImGui.EndChild();
ImGui.PopStyleVar();
ImGui.Dummy(new Vector2(0f, 4f * scale));
ImGui.TextColored(UIColors.Get("LightlessBlue"), "Saved Tags");
var savedTags = _profileTagService.ResolveTags(_profileTagIds);
if (savedTags.Count == 0)
{
ImGui.TextDisabled("-- No tags set --");
}
else
{
bool first = true;
for (int i = 0; i < savedTags.Count; i++)
{
if (!savedTags[i].HasContent)
continue;
if (!first)
ImGui.SameLine(0f, 6f * scale);
first = false;
using (ImRaii.PushId($"group-snapshot-tag-{i}"))
DrawTagPreview(savedTags[i], scale, "##groupSnapshotTagPreview");
}
if (!first)
ImGui.NewLine();
}
}
private void DrawGroupProfileImageControls()
{
_uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "Profile pictures must be 512x512 and under 2 MiB.");
if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile picture"))
{
_fileDialogManager.OpenFileDialog("Select syncshell profile picture", ImageFileDialogFilter, (success, file) =>
{
if (!success || string.IsNullOrEmpty(file))
return;
_showProfileImageError = false;
_ = SubmitGroupProfilePicture(file);
});
}
UiSharedService.AttachToolTip("Select an image up to 512x512 pixels and <= 2 MiB (PNG/JPG/JPEG/WEBP/BMP).");
ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear profile picture"))
{
_ = ClearGroupProfilePicture();
}
UiSharedService.AttachToolTip("Remove the current profile picture from this syncshell.");
if (_showProfileImageError)
{
UiSharedService.ColorTextWrapped("Image must be no larger than 512x512 pixels and under 2 MiB.", ImGuiColors.DalamudRed);
}
}
private void DrawGroupProfileBannerControls()
{
_uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "Profile banners must be 840x260 and under 2 MiB.");
if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile banner"))
{
_fileDialogManager.OpenFileDialog("Select syncshell profile banner", ImageFileDialogFilter, (success, file) =>
{
if (!success || string.IsNullOrEmpty(file))
return;
_showBannerImageError = false;
_ = SubmitGroupProfileBanner(file);
});
}
UiSharedService.AttachToolTip("Select an image up to 840x260 pixels and <= 2 MiB (PNG/JPG/JPEG/WEBP/BMP).");
ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear profile banner"))
{
_ = ClearGroupProfileBanner();
}
UiSharedService.AttachToolTip("Remove the current profile banner.");
if (_showBannerImageError)
{
UiSharedService.ColorTextWrapped("Banner must be no larger than 840x260 pixels and under 2 MiB.", ImGuiColors.DalamudRed);
}
}
private void DrawGroupProfileDescriptionEditor()
{
ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(6f, 4f) * ImGuiHelpers.GlobalScale);
var descriptionBoxSize = new Vector2(-1f, 160f * ImGuiHelpers.GlobalScale);
ImGui.InputTextMultiline("##GroupDescription", ref _descriptionText, 1500, descriptionBoxSize);
ImGui.PopStyleVar();
ImGui.TextDisabled($"{_descriptionText.Length}/1500 characters");
ImGui.SameLine();
ImGuiComponents.HelpMarker(DescriptionMacroTooltip);
bool changed = !string.Equals(_descriptionText, _profileDescription, StringComparison.Ordinal);
if (!changed)
ImGui.BeginDisabled();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Description"))
{
_ = SubmitGroupDescription(_descriptionText);
}
UiSharedService.AttachToolTip("Apply the text above to the syncshell profile description.");
if (!changed)
ImGui.EndDisabled();
ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description"))
{
_ = SubmitGroupDescription(string.Empty);
}
UiSharedService.AttachToolTip("Remove the profile description.");
}
private void DrawGroupProfileTagsEditor(float scale)
{
DrawTagEditor(
scale,
contextPrefix: "group",
saveTooltip: "Apply the selected tags to this syncshell profile.",
submitAction: payload => SubmitGroupTagChanges(payload),
allowReorder: true,
sortPayloadBeforeSubmit: true,
onPayloadPrepared: payload =>
{
_tagEditorSelection.Clear();
if (payload.Length > 0)
_tagEditorSelection.AddRange(payload);
});
}
private void DrawGroupProfileVisibilityControls()
{
bool changedNsfw = DrawCheckboxRow("Profile is NSFW", _groupIsNsfw, out var newNsfw, "Flag this profile as not safe for work.");
if (changedNsfw)
_groupIsNsfw = newNsfw;
bool changedDisabled = DrawCheckboxRow("Disable profile for viewers", _groupIsDisabled, out var newDisabled, "Temporarily hide this profile from members.");
if (changedDisabled)
_groupIsDisabled = newDisabled;
bool visibilityChanged = (_groupIsNsfw != _groupServerIsNsfw) || (_groupIsDisabled != _groupServerIsDisabled);
if (!visibilityChanged)
ImGui.BeginDisabled();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Apply Visibility Changes"))
{
_ = SubmitGroupVisibilityChanges(_groupIsNsfw, _groupIsDisabled);
}
UiSharedService.AttachToolTip("Apply the visibility toggles above.");
if (!visibilityChanged)
ImGui.EndDisabled();
ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.SyncAlt, "Reset"))
{
_groupIsNsfw = _groupServerIsNsfw;
_groupIsDisabled = _groupServerIsDisabled;
}
}
private string? GetCurrentGroupProfileImageBase64()
{
if (_queuedProfileImage is not null && _queuedProfileImage.Length > 0)
return Convert.ToBase64String(_queuedProfileImage);
if (!string.IsNullOrWhiteSpace(_groupProfileData?.Base64ProfilePicture))
return _groupProfileData!.Base64ProfilePicture;
return _profileImage.Length > 0 ? Convert.ToBase64String(_profileImage) : null;
}
private string? GetCurrentGroupBannerBase64()
{
if (_queuedBannerImage is not null && _queuedBannerImage.Length > 0)
return Convert.ToBase64String(_queuedBannerImage);
if (!string.IsNullOrWhiteSpace(_groupProfileData?.Base64BannerPicture))
return _groupProfileData!.Base64BannerPicture;
return _bannerImage.Length > 0 ? Convert.ToBase64String(_bannerImage) : null;
}
private async Task SubmitGroupProfilePicture(string filePath)
{
if (_groupInfo is null)
return;
try
{
var fileContent = await File.ReadAllBytesAsync(filePath).ConfigureAwait(false);
await using var stream = new MemoryStream(fileContent);
var format = await Image.DetectFormatAsync(stream).ConfigureAwait(false);
if (!IsSupportedImageFormat(format))
{
_showProfileImageError = true;
return;
}
using var image = Image.Load<Rgba32>(fileContent);
if (image.Width > 512 || image.Height > 512 || fileContent.Length > 2000 * 1024)
{
_showProfileImageError = true;
return;
}
await _apiController.GroupSetProfile(new GroupProfileDto(
_groupInfo.Group,
Description: null,
Tags: null,
PictureBase64: Convert.ToBase64String(fileContent),
BannerBase64: null,
IsNsfw: null,
IsDisabled: null)).ConfigureAwait(false);
_showProfileImageError = false;
_queuedProfileImage = fileContent;
Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to upload syncshell profile picture.");
}
}
private async Task ClearGroupProfilePicture()
{
if (_groupInfo is null)
return;
try
{
await _apiController.GroupSetProfile(new GroupProfileDto(
_groupInfo.Group,
Description: null,
Tags: null,
PictureBase64: null,
BannerBase64: null,
IsNsfw: null,
IsDisabled: null)).ConfigureAwait(false);
_queuedProfileImage = Array.Empty<byte>();
Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to clear syncshell profile picture.");
}
}
private async Task SubmitGroupProfileBanner(string filePath)
{
if (_groupInfo is null)
return;
try
{
var fileContent = await File.ReadAllBytesAsync(filePath).ConfigureAwait(false);
await using var stream = new MemoryStream(fileContent);
var format = await Image.DetectFormatAsync(stream).ConfigureAwait(false);
if (!IsSupportedImageFormat(format))
{
_showBannerImageError = true;
return;
}
using var image = Image.Load<Rgba32>(fileContent);
if (image.Width > 840 || image.Height > 260 || fileContent.Length > 2000 * 1024)
{
_showBannerImageError = true;
return;
}
await _apiController.GroupSetProfile(new GroupProfileDto(
_groupInfo.Group,
Description: null,
Tags: null,
PictureBase64: null,
BannerBase64: Convert.ToBase64String(fileContent),
IsNsfw: null,
IsDisabled: null)).ConfigureAwait(false);
_showBannerImageError = false;
_queuedBannerImage = fileContent;
Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to upload syncshell profile banner.");
}
}
private async Task ClearGroupProfileBanner()
{
if (_groupInfo is null)
return;
try
{
await _apiController.GroupSetProfile(new GroupProfileDto(
_groupInfo.Group,
Description: null,
Tags: null,
PictureBase64: null,
BannerBase64: null,
IsNsfw: null,
IsDisabled: null)).ConfigureAwait(false);
_queuedBannerImage = Array.Empty<byte>();
Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to clear syncshell profile banner.");
}
}
private async Task SubmitGroupDescription(string description)
{
if (_groupInfo is null)
return;
try
{
await _apiController.GroupSetProfile(new GroupProfileDto(
_groupInfo.Group,
Description: description,
Tags: null,
PictureBase64: GetCurrentGroupProfileImageBase64(),
BannerBase64: GetCurrentGroupBannerBase64(),
IsNsfw: null,
IsDisabled: null)).ConfigureAwait(false);
_profileDescription = description;
Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update syncshell profile description.");
}
}
private async Task SubmitGroupTagChanges(int[] payload)
{
if (_groupInfo is null)
return;
try
{
await _apiController.GroupSetProfile(new GroupProfileDto(
_groupInfo.Group,
Description: null,
Tags: payload,
PictureBase64: GetCurrentGroupProfileImageBase64(),
BannerBase64: GetCurrentGroupBannerBase64(),
IsNsfw: null,
IsDisabled: null)).ConfigureAwait(false);
_profileTagIds = payload.Length == 0 ? Array.Empty<int>() : payload.ToArray();
Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update syncshell profile tags.");
}
}
private async Task SubmitGroupVisibilityChanges(bool isNsfw, bool isDisabled)
{
if (_groupInfo is null)
return;
try
{
await _apiController.GroupSetProfile(new GroupProfileDto(
_groupInfo.Group,
Description: null,
Tags: null,
PictureBase64: GetCurrentGroupProfileImageBase64(),
BannerBase64: GetCurrentGroupBannerBase64(),
IsNsfw: isNsfw,
IsDisabled: isDisabled)).ConfigureAwait(false);
_groupServerIsNsfw = isNsfw;
_groupServerIsDisabled = isDisabled;
Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update syncshell profile visibility.");
}
}
private void ApplyQueuedGroupProfileImage()
{
if (_queuedProfileImage is null)
return;
_profileImage = _queuedProfileImage;
_pfpTextureWrap?.Dispose();
_pfpTextureWrap = _profileImage.Length > 0 ? _uiSharedService.LoadImage(_profileImage) : null;
_queuedProfileImage = null;
}
private void ApplyQueuedGroupBannerImage()
{
if (_queuedBannerImage is null)
return;
_bannerImage = _queuedBannerImage;
_bannerTextureWrap?.Dispose();
_bannerTextureWrap = _bannerImage.Length > 0 ? _uiSharedService.LoadImage(_bannerImage) : null;
_queuedBannerImage = null;
}
private void SyncGroupProfileState(LightlessGroupProfileData profile, bool resetSelection)
{
if (!_profileImage.SequenceEqual(profile.ProfileImageData.Value))
{
_profileImage = profile.ProfileImageData.Value;
_pfpTextureWrap?.Dispose();
_pfpTextureWrap = _profileImage.Length > 0 ? _uiSharedService.LoadImage(_profileImage) : null;
}
if (!_bannerImage.SequenceEqual(profile.BannerImageData.Value))
{
_bannerImage = profile.BannerImageData.Value;
_bannerTextureWrap?.Dispose();
_bannerTextureWrap = _bannerImage.Length > 0 ? _uiSharedService.LoadImage(_bannerImage) : null;
}
if (!string.Equals(_profileDescription, profile.Description, StringComparison.Ordinal))
{
_profileDescription = profile.Description;
_descriptionText = _profileDescription;
}
var tags = profile.Tags ?? Array.Empty<int>();
if (!TagsEqual(tags, _profileTagIds))
{
_profileTagIds = tags.Count == 0 ? Array.Empty<int>() : tags.ToArray();
if (resetSelection)
{
_tagEditorSelection.Clear();
if (_profileTagIds.Length > 0)
_tagEditorSelection.AddRange(_profileTagIds);
}
}
_groupIsNsfw = profile.IsNsfw;
_groupIsDisabled = profile.IsDisabled;
_groupServerIsNsfw = profile.IsNsfw;
_groupServerIsDisabled = profile.IsDisabled;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,13 +10,16 @@ using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Style;
using LightlessSync.Utils;
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Text;
namespace LightlessSync.UI.Handlers;
public class IdDisplayHandler
{
private readonly LightlessConfigService _lightlessConfigService;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly LightlessMediator _mediator;
private readonly ServerConfigurationManager _serverManager;
private readonly Dictionary<string, bool> _showIdForEntry = new(StringComparer.Ordinal);
@@ -30,11 +33,16 @@ public class IdDisplayHandler
private Vector4 _currentBg = new(0.15f, 0.15f, 0.15f, 1f);
private float _highlightBoost;
public IdDisplayHandler(LightlessMediator mediator, ServerConfigurationManager serverManager, LightlessConfigService lightlessConfigService)
public IdDisplayHandler(
LightlessMediator mediator,
ServerConfigurationManager serverManager,
LightlessConfigService lightlessConfigService,
PlayerPerformanceConfigService playerPerformanceConfigService)
{
_mediator = mediator;
_serverManager = serverManager;
_lightlessConfigService = lightlessConfigService;
_playerPerformanceConfigService = playerPerformanceConfigService;
}
public void DrawGroupText(string id, GroupFullInfoDto group, float textPosX, Func<float> editBoxWidth)
@@ -48,6 +56,13 @@ public class IdDisplayHandler
using (ImRaii.PushFont(UiBuilder.MonoFont, textIsUid))
ImGui.TextUnformatted(playerText);
if (ImGui.IsItemHovered())
{
ImGui.SetTooltip("Left click to switch between ID display and alias"
+ Environment.NewLine + "Right click to edit notes for this syncshell"
+ Environment.NewLine + "Middle Mouse Button to open syncshell profile in a separate window");
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Left))
{
var prevState = textIsUid;
@@ -73,6 +88,11 @@ public class IdDisplayHandler
_editEntry = group.GID;
_editIsUid = false;
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Middle))
{
_mediator.Publish(new GroupProfileOpenStandaloneMessage(group));
}
}
else
{
@@ -97,10 +117,14 @@ public class IdDisplayHandler
{
ImGui.SameLine(textPosX);
(bool textIsUid, string playerText) = GetPlayerText(pair);
var compactPerformanceText = BuildCompactPerformanceUsageText(pair);
if (!string.Equals(_editEntry, pair.UserData.UID, StringComparison.Ordinal))
{
ImGui.AlignTextToFramePadding();
var rowStart = ImGui.GetCursorScreenPos();
var rowWidth = MathF.Max(editBoxWidth.Invoke(), 0f);
var rowRightLimit = rowStart.X + rowWidth;
var font = textIsUid ? UiBuilder.MonoFont : ImGui.GetFont();
@@ -125,7 +149,6 @@ public class IdDisplayHandler
? SeStringUtils.BuildFormattedPlayerName(playerText, textColor, glowColor)
: SeStringUtils.BuildPlain(playerText);
var rowStart = ImGui.GetCursorScreenPos();
var drawList = ImGui.GetWindowDrawList();
bool useHighlight = false;
float highlightPadX = 0f;
@@ -200,6 +223,8 @@ public class IdDisplayHandler
drawList.ChannelsMerge();
}
var nameRectMin = ImGui.GetItemRectMin();
var nameRectMax = ImGui.GetItemRectMax();
if (ImGui.IsItemHovered())
{
if (!string.Equals(_lastMouseOverUid, id))
@@ -261,12 +286,43 @@ public class IdDisplayHandler
{
_mediator.Publish(new ProfileOpenStandaloneMessage(pair));
}
if (!string.IsNullOrEmpty(compactPerformanceText))
{
ImGui.SameLine();
const float compactFontScale = 0.85f;
ImGui.SetWindowFontScale(compactFontScale);
var compactHeight = ImGui.GetTextLineHeight();
var nameHeight = nameRectMax.Y - nameRectMin.Y;
var targetPos = ImGui.GetCursorScreenPos();
var availableWidth = MathF.Max(rowRightLimit - targetPos.X, 0f);
var centeredY = nameRectMin.Y + MathF.Max((nameHeight - compactHeight) * 0.5f, 0f);
float verticalOffset = 1f * ImGuiHelpers.GlobalScale;
centeredY += verticalOffset;
ImGui.SetCursorScreenPos(new Vector2(targetPos.X, centeredY));
var performanceText = string.Empty;
var wasTruncated = false;
if (availableWidth > 0f)
{
performanceText = TruncateTextToWidth(compactPerformanceText, availableWidth, out wasTruncated);
}
ImGui.TextDisabled(performanceText);
ImGui.SetWindowFontScale(1f);
if (wasTruncated && ImGui.IsItemHovered())
{
ImGui.SetTooltip(compactPerformanceText);
}
}
}
else
{
ImGui.AlignTextToFramePadding();
ImGui.SetNextItemWidth(editBoxWidth.Invoke());
ImGui.SetNextItemWidth(MathF.Max(editBoxWidth.Invoke(), 0f));
if (ImGui.InputTextWithHint("##" + pair.UserData.UID, "Nick/Notes", ref _editComment, 255, ImGuiInputTextFlags.EnterReturnsTrue))
{
_serverManager.SetNoteForUid(pair.UserData.UID, _editComment);
@@ -346,6 +402,57 @@ public class IdDisplayHandler
return (textIsUid, playerText!);
}
private string? BuildCompactPerformanceUsageText(Pair pair)
{
var config = _playerPerformanceConfigService.Current;
if (!config.ShowPerformanceIndicator || !config.ShowPerformanceUsageNextToName)
{
return null;
}
var vramBytes = pair.LastAppliedApproximateEffectiveVRAMBytes >= 0
? pair.LastAppliedApproximateEffectiveVRAMBytes
: pair.LastAppliedApproximateVRAMBytes;
var triangleCount = pair.LastAppliedDataTris;
if (vramBytes < 0 && triangleCount < 0)
{
return null;
}
var segments = new List<string>(2);
if (vramBytes >= 0)
{
segments.Add(UiSharedService.ByteToString(vramBytes));
}
if (triangleCount >= 0)
{
segments.Add(FormatTriangleCount(triangleCount));
}
return segments.Count == 0 ? null : string.Join(" / ", segments);
}
private static string FormatTriangleCount(long triangleCount)
{
if (triangleCount < 0)
{
return string.Empty;
}
if (triangleCount >= 1_000_000)
{
return FormattableString.Invariant($"{triangleCount / 1_000_000d:0.#}m tris");
}
if (triangleCount >= 1_000)
{
return FormattableString.Invariant($"{triangleCount / 1_000d:0.#}k tris");
}
return $"{triangleCount} tris";
}
internal void Clear()
{
_editEntry = string.Empty;
@@ -370,4 +477,52 @@ public class IdDisplayHandler
return showidInsteadOfName;
}
}
private static string TruncateTextToWidth(string text, float maxWidth, out bool wasTruncated)
{
wasTruncated = false;
if (string.IsNullOrEmpty(text) || maxWidth <= 0f)
{
return string.Empty;
}
var fullWidth = ImGui.CalcTextSize(text).X;
if (fullWidth <= maxWidth)
{
return text;
}
wasTruncated = true;
const string Ellipsis = "...";
var ellipsisWidth = ImGui.CalcTextSize(Ellipsis).X;
if (ellipsisWidth >= maxWidth)
{
return Ellipsis;
}
var builder = new StringBuilder(text.Length);
var remainingWidth = maxWidth - ellipsisWidth;
foreach (var rune in text.EnumerateRunes())
{
var runeText = rune.ToString();
var runeWidth = ImGui.CalcTextSize(runeText).X;
if (runeWidth > remainingWidth)
{
break;
}
builder.Append(runeText);
remainingWidth -= runeWidth;
}
if (builder.Length == 0)
{
return Ellipsis;
}
builder.Append(Ellipsis);
return builder.ToString();
}
}

View File

@@ -0,0 +1,25 @@
using System.Collections.Generic;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto.Group;
using LightlessSync.PlayerData.Pairs;
namespace LightlessSync.UI.Models;
public sealed record PairDisplayEntry(
PairUniqueIdentifier Ident,
PairConnection Connection,
IReadOnlyList<GroupFullInfoDto> Groups,
IPairHandlerAdapter? Handler)
{
public UserData User => Connection.User;
public bool IsOnline => Connection.IsOnline;
public bool IsVisible => Handler?.IsVisible ?? false;
public bool IsDirectlyPaired => Connection.IsDirectlyPaired;
public bool IsOneSided => Connection.IsOneSided;
public bool HasAnyConnection => Connection.HasAnyConnection;
public string? IdentString => Connection.Ident;
public UserPermissions SelfPermissions => Connection.SelfToOtherPermissions;
public UserPermissions OtherPermissions => Connection.OtherToSelfPermissions;
public IndividualPairStatus? PairStatus => Connection.IndividualPairStatus;
}

View File

@@ -0,0 +1,30 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto.Group;
using LightlessSync.PlayerData.Pairs;
namespace LightlessSync.UI.Models;
public sealed record PairUiEntry(
PairDisplayEntry DisplayEntry,
string AliasOrUid,
string DisplayName,
string Note,
bool IsVisible,
bool IsOnline,
bool IsDirectlyPaired,
bool IsOneSided,
bool HasAnyConnection,
bool IsPaused,
UserPermissions SelfPermissions,
UserPermissions OtherPermissions,
IndividualPairStatus? PairStatus,
long LastAppliedDataBytes,
long LastAppliedDataTris,
long LastAppliedApproximateVramBytes,
long LastAppliedApproximateEffectiveVramBytes,
IPairHandlerAdapter? Handler)
{
public PairUniqueIdentifier Ident => DisplayEntry.Ident;
public IReadOnlyList<GroupFullInfoDto> Groups => DisplayEntry.Groups;
}

View File

@@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using LightlessSync.API.Dto.Group;
using LightlessSync.PlayerData.Pairs;
namespace LightlessSync.UI.Models;
public sealed record PairUiSnapshot(
IReadOnlyDictionary<string, Pair> PairsByUid,
IReadOnlyList<Pair> DirectPairs,
IReadOnlyDictionary<GroupFullInfoDto, IReadOnlyList<Pair>> GroupPairs,
IReadOnlyDictionary<Pair, IReadOnlyList<GroupFullInfoDto>> PairsWithGroups,
IReadOnlyDictionary<string, GroupFullInfoDto> GroupsByGid,
IReadOnlyCollection<GroupFullInfoDto> Groups)
{
public static PairUiSnapshot Empty { get; } = new(
new ReadOnlyDictionary<string, Pair>(new Dictionary<string, Pair>()),
Array.Empty<Pair>(),
new ReadOnlyDictionary<GroupFullInfoDto, IReadOnlyList<Pair>>(new Dictionary<GroupFullInfoDto, IReadOnlyList<Pair>>()),
new ReadOnlyDictionary<Pair, IReadOnlyList<GroupFullInfoDto>>(new Dictionary<Pair, IReadOnlyList<GroupFullInfoDto>>()),
new ReadOnlyDictionary<string, GroupFullInfoDto>(new Dictionary<string, GroupFullInfoDto>()),
Array.Empty<GroupFullInfoDto>());
}

View File

@@ -0,0 +1,11 @@
namespace LightlessSync.UI.Models;
public enum VisiblePairSortMode
{
Default = 0,
Alphabetical = 1,
VramUsage = 2,
EffectiveVramUsage = 3,
TriangleCount = 4,
PreferredDirectPairs = 5,
}

View File

@@ -4,10 +4,11 @@ using Dalamud.Interface.Textures.TextureWraps;
using Dalamud.Interface.Utility;
using LightlessSync.API.Data.Extensions;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Services;
using LightlessSync.PlayerData.Pairs;
using Microsoft.Extensions.Logging;
using System.Numerics;
@@ -16,7 +17,7 @@ namespace LightlessSync.UI;
public class PopoutProfileUi : WindowMediatorSubscriberBase
{
private readonly LightlessProfileManager _lightlessProfileManager;
private readonly PairManager _pairManager;
private readonly PairUiService _pairUiService;
private readonly ServerConfigurationManager _serverManager;
private readonly UiSharedService _uiSharedService;
private Vector2 _lastMainPos = Vector2.Zero;
@@ -29,12 +30,12 @@ public class PopoutProfileUi : WindowMediatorSubscriberBase
public PopoutProfileUi(ILogger<PopoutProfileUi> logger, LightlessMediator mediator, UiSharedService uiBuilder,
ServerConfigurationManager serverManager, LightlessConfigService lightlessConfigService,
LightlessProfileManager lightlessProfileManager, PairManager pairManager, PerformanceCollectorService performanceCollectorService) : base(logger, mediator, "###LightlessSyncPopoutProfileUI", performanceCollectorService)
LightlessProfileManager lightlessProfileManager, PairUiService pairUiService, PerformanceCollectorService performanceCollectorService) : base(logger, mediator, "###LightlessSyncPopoutProfileUI", performanceCollectorService)
{
_uiSharedService = uiBuilder;
_serverManager = serverManager;
_lightlessProfileManager = lightlessProfileManager;
_pairManager = pairManager;
_pairUiService = pairUiService;
Flags = ImGuiWindowFlags.NoDecoration;
Mediator.Subscribe<ProfilePopoutToggle>(this, (msg) =>
@@ -143,13 +144,17 @@ public class PopoutProfileUi : WindowMediatorSubscriberBase
UiSharedService.ColorText("They: paused", UIColors.Get("LightlessYellow"));
}
}
var snapshot = _pairUiService.GetSnapshot();
if (_pair.UserPair.Groups.Any())
{
ImGui.TextUnformatted("Paired through Syncshells:");
foreach (var group in _pair.UserPair.Groups)
{
var groupNote = _serverManager.GetNoteForGid(group);
var groupName = _pairManager.GroupPairs.First(f => string.Equals(f.Key.GID, group, StringComparison.Ordinal)).Key.GroupAliasOrGID;
var groupName = snapshot.GroupsByGid.TryGetValue(group, out var groupInfo)
? groupInfo.GroupAliasOrGID
: group;
var groupString = string.IsNullOrEmpty(groupNote) ? groupName : $"{groupNote} ({groupName})";
ImGui.TextUnformatted("- " + groupString);
}

View File

@@ -0,0 +1,84 @@
using System;
using System.Numerics;
using System.Threading;
namespace LightlessSync.UI;
internal static class ProfileEditorLayoutCoordinator
{
private static readonly Lock Gate = new();
private static string? _activeUid;
private static Vector2? _anchor;
private const float ProfileWidth = 840f;
private const float ProfileHeight = 525f;
private const float EditorWidth = 380f;
private const float Spacing = 0f;
private static readonly Vector2 DefaultOffset = new(50f, 70f);
public static void Enable(string uid)
{
using var _ = Gate.EnterScope();
if (!string.Equals(_activeUid, uid, StringComparison.Ordinal))
_anchor = null;
_activeUid = uid;
}
public static void Disable(string uid)
{
using var _ = Gate.EnterScope();
if (string.Equals(_activeUid, uid, StringComparison.Ordinal))
{
_activeUid = null;
_anchor = null;
}
}
public static bool IsActive(string uid)
{
using var _ = Gate.EnterScope();
return string.Equals(_activeUid, uid, StringComparison.Ordinal);
}
public static Vector2 GetProfileSize(float scale) => new(ProfileWidth * scale, ProfileHeight * scale);
public static Vector2 GetEditorSize(float scale) => new(EditorWidth * scale, ProfileHeight * scale);
public static Vector2 GetEditorOffset(float scale) => new((ProfileWidth + Spacing) * scale, 0f);
public static Vector2 EnsureAnchor(Vector2 viewportOrigin, float scale)
{
using var _ = Gate.EnterScope();
if (_anchor is null)
_anchor = viewportOrigin + DefaultOffset * scale;
return _anchor.Value;
}
public static void UpdateAnchorFromProfile(Vector2 profilePosition)
{
using var _ = Gate.EnterScope();
_anchor = profilePosition;
}
public static void UpdateAnchorFromEditor(Vector2 editorPosition, float scale)
{
using var _ = Gate.EnterScope();
_anchor = editorPosition - GetEditorOffset(scale);
}
public static Vector2 GetProfilePosition(float scale)
{
using var _ = Gate.EnterScope();
return _anchor ?? Vector2.Zero;
}
public static Vector2 GetEditorPosition(float scale)
{
using var _ = Gate.EnterScope();
return (_anchor ?? Vector2.Zero) + GetEditorOffset(scale);
}
public static bool NearlyEquals(Vector2 current, Vector2 target, float epsilon = 0.5f)
{
return MathF.Abs(current.X - target.X) <= epsilon && MathF.Abs(current.Y - target.Y) <= epsilon;
}
}

View File

@@ -4,9 +4,30 @@
{
SFW = 0,
NSFW = 1,
RP = 2,
ERP = 3,
Venues = 4,
Gpose = 5
No_RP = 4,
No_ERP = 5,
Venues = 6,
Gpose = 7,
Limsa = 8,
Gridania = 9,
Ul_dah = 10,
WUT = 11,
PVP = 1001,
Ultimate = 1002,
Raids = 1003,
Roulette = 1004,
Crafting = 1005,
Casual = 1006,
Hardcore = 1007,
Glamour = 1008,
Mentor = 1009,
}
}

View File

@@ -0,0 +1,228 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using LightlessSync.API.Dto.Group;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.UI.Models;
using Microsoft.Extensions.Logging;
namespace LightlessSync.UI.Services;
public sealed class PairUiService : DisposableMediatorSubscriberBase
{
private readonly PairLedger _pairLedger;
private readonly PairFactory _pairFactory;
private readonly PairManager _pairManager;
private readonly object _snapshotGate = new();
private PairUiSnapshot _snapshot = PairUiSnapshot.Empty;
private Pair? _lastAddedPair;
private bool _needsRefresh = true;
public PairUiService(
ILogger<PairUiService> logger,
LightlessMediator mediator,
PairLedger pairLedger,
PairFactory pairFactory,
PairManager pairManager) : base(logger, mediator)
{
_pairLedger = pairLedger;
_pairFactory = pairFactory;
_pairManager = pairManager;
Mediator.Subscribe<PairDataChangedMessage>(this, _ => MarkDirty());
Mediator.Subscribe<GroupCollectionChangedMessage>(this, _ => MarkDirty());
Mediator.Subscribe<VisibilityChange>(this, _ => MarkDirty());
EnsureSnapshot();
}
public PairUiSnapshot GetSnapshot()
{
EnsureSnapshot();
lock (_snapshotGate)
{
return _snapshot;
}
}
public Pair? GetLastAddedPair()
{
EnsureSnapshot();
lock (_snapshotGate)
{
return _lastAddedPair;
}
}
public void ClearLastAddedPair()
{
_pairManager.ClearLastAddedUser();
lock (_snapshotGate)
{
_lastAddedPair = null;
}
}
private void MarkDirty()
{
lock (_snapshotGate)
{
_needsRefresh = true;
}
}
private void EnsureSnapshot()
{
bool shouldBuild;
lock (_snapshotGate)
{
shouldBuild = _needsRefresh;
if (shouldBuild)
{
_needsRefresh = false;
}
}
if (!shouldBuild)
{
return;
}
PairUiSnapshot snapshot;
Pair? lastAddedPair;
try
{
(snapshot, lastAddedPair) = BuildSnapshot();
}
catch
{
lock (_snapshotGate)
{
_needsRefresh = true;
}
throw;
}
lock (_snapshotGate)
{
_snapshot = snapshot;
_lastAddedPair = lastAddedPair;
}
Mediator.Publish(new PairUiUpdatedMessage(snapshot));
}
private (PairUiSnapshot Snapshot, Pair? LastAddedPair) BuildSnapshot()
{
var entries = _pairLedger.GetAllEntries();
var pairByUid = new Dictionary<string, Pair>(StringComparer.Ordinal);
var directPairsList = new List<Pair>();
var groupPairsTemp = new Dictionary<GroupFullInfoDto, List<Pair>>();
var pairsWithGroupsTemp = new Dictionary<Pair, List<GroupFullInfoDto>>();
foreach (var entry in entries)
{
var pair = _pairFactory.Create(entry);
if (pair is null)
{
continue;
}
pairByUid[entry.Ident.UserId] = pair;
if (entry.IsDirectlyPaired)
{
directPairsList.Add(pair);
}
var uniqueGroups = new HashSet<string>(StringComparer.Ordinal);
var groupList = new List<GroupFullInfoDto>();
foreach (var group in entry.Groups)
{
if (!uniqueGroups.Add(group.Group.GID))
{
continue;
}
if (!groupPairsTemp.TryGetValue(group, out var members))
{
members = new List<Pair>();
groupPairsTemp[group] = members;
}
members.Add(pair);
groupList.Add(group);
}
pairsWithGroupsTemp[pair] = groupList;
}
var allGroupsList = _pairLedger.GetAllSyncshells()
.Values
.Select(s => s.GroupFullInfo)
.ToList();
foreach (var group in allGroupsList)
{
if (!groupPairsTemp.ContainsKey(group))
{
groupPairsTemp[group] = new List<Pair>();
}
}
var directPairs = new ReadOnlyCollection<Pair>(directPairsList);
var groupPairsFinal = new Dictionary<GroupFullInfoDto, IReadOnlyList<Pair>>();
foreach (var (group, members) in groupPairsTemp)
{
groupPairsFinal[group] = new ReadOnlyCollection<Pair>(members);
}
var pairsWithGroupsFinal = new Dictionary<Pair, IReadOnlyList<GroupFullInfoDto>>();
foreach (var (pair, groups) in pairsWithGroupsTemp)
{
pairsWithGroupsFinal[pair] = new ReadOnlyCollection<GroupFullInfoDto>(groups);
}
var groupsReadOnly = new ReadOnlyCollection<GroupFullInfoDto>(allGroupsList);
var pairsByUidReadOnly = new ReadOnlyDictionary<string, Pair>(pairByUid);
var groupsByGidReadOnly = new ReadOnlyDictionary<string, GroupFullInfoDto>(allGroupsList.ToDictionary(g => g.Group.GID, g => g, StringComparer.Ordinal));
Pair? lastAddedPair = null;
var lastAdded = _pairManager.GetLastAddedUser();
if (lastAdded is not null)
{
if (!pairByUid.TryGetValue(lastAdded.User.UID, out lastAddedPair))
{
var groups = lastAdded.Groups.Keys
.Select(gid =>
{
var result = _pairManager.GetGroup(gid);
return result.Success ? result.Value.GroupFullInfo : null;
})
.Where(g => g is not null)
.Cast<GroupFullInfoDto>()
.ToList();
var entry = new PairDisplayEntry(new PairUniqueIdentifier(lastAdded.User.UID), lastAdded, groups, null);
lastAddedPair = _pairFactory.Create(entry);
}
}
var snapshot = new PairUiSnapshot(
pairsByUidReadOnly,
directPairs,
new ReadOnlyDictionary<GroupFullInfoDto, IReadOnlyList<Pair>>(groupPairsFinal),
new ReadOnlyDictionary<Pair, IReadOnlyList<GroupFullInfoDto>>(pairsWithGroupsFinal),
groupsByGidReadOnly,
groupsReadOnly);
return (snapshot, lastAddedPair);
}
}

View File

@@ -1,5 +1,6 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Game.Text;
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility;
@@ -16,8 +17,10 @@ using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Services;
using LightlessSync.UI.Style;
using LightlessSync.Utils;
using LightlessSync.UtilsEnum.Enum;
@@ -25,10 +28,12 @@ using LightlessSync.WebAPI;
using LightlessSync.WebAPI.Files;
using LightlessSync.WebAPI.Files.Models;
using LightlessSync.WebAPI.SignalR.Utils;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
@@ -37,6 +42,9 @@ using System.Net.Http.Json;
using System.Numerics;
using System.Text;
using System.Text.Json;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using FfxivCharacter = FFXIVClientStructs.FFXIV.Client.Game.Character.Character;
using FfxivCharacterBase = FFXIVClientStructs.FFXIV.Client.Graphics.Scene.CharacterBase;
namespace LightlessSync.UI;
@@ -54,7 +62,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
private readonly FileUploadManager _fileTransferManager;
private readonly FileTransferOrchestrator _fileTransferOrchestrator;
private readonly IpcManager _ipcManager;
private readonly PairManager _pairManager;
private readonly ActorObjectService _actorObjectService;
private readonly PairUiService _pairUiService;
private readonly PerformanceCollectorService _performanceCollector;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly PairProcessingLimiter _pairProcessingLimiter;
@@ -94,7 +103,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
public SettingsUi(ILogger<SettingsUi> logger,
UiSharedService uiShared, LightlessConfigService configService, UiThemeConfigService themeConfigService,
PairManager pairManager,
PairUiService pairUiService,
ServerConfigurationManager serverConfigurationManager,
PlayerPerformanceConfigService playerPerformanceConfigService,
PairProcessingLimiter pairProcessingLimiter,
@@ -106,12 +115,13 @@ public class SettingsUi : WindowMediatorSubscriberBase
IpcManager ipcManager, CacheMonitor cacheMonitor,
DalamudUtilService dalamudUtilService, HttpClient httpClient,
NameplateService nameplateService,
NameplateHandler nameplateHandler) : base(logger, mediator, "Lightless Sync Settings",
NameplateHandler nameplateHandler,
ActorObjectService actorObjectService) : base(logger, mediator, "Lightless Sync Settings",
performanceCollector)
{
_configService = configService;
_themeConfigService = themeConfigService;
_pairManager = pairManager;
_pairUiService = pairUiService;
_serverConfigurationManager = serverConfigurationManager;
_playerPerformanceConfigService = playerPerformanceConfigService;
_pairProcessingLimiter = pairProcessingLimiter;
@@ -128,13 +138,15 @@ public class SettingsUi : WindowMediatorSubscriberBase
_uiShared = uiShared;
_nameplateService = nameplateService;
_nameplateHandler = nameplateHandler;
_actorObjectService = actorObjectService;
AllowClickthrough = false;
AllowPinning = true;
_validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v);
SizeConstraints = new WindowSizeConstraints()
{
MinimumSize = new Vector2(800, 400), MaximumSize = new Vector2(800, 2000),
MinimumSize = new Vector2(850f, 400f),
MaximumSize = new Vector2(850f, 2000f),
};
TitleBarButtons = new()
@@ -449,6 +461,74 @@ public class SettingsUi : WindowMediatorSubscriberBase
}
}
private void DrawTextureDownscaleCounters()
{
HashSet<Pair> trackedPairs = new();
var snapshot = _pairUiService.GetSnapshot();
foreach (var pair in snapshot.DirectPairs)
{
trackedPairs.Add(pair);
}
foreach (var group in snapshot.GroupPairs.Values)
{
foreach (var pair in group)
{
trackedPairs.Add(pair);
}
}
long totalOriginalBytes = 0;
long totalEffectiveBytes = 0;
var hasData = false;
foreach (var pair in trackedPairs)
{
if (!pair.IsVisible)
continue;
var original = pair.LastAppliedApproximateVRAMBytes;
var effective = pair.LastAppliedApproximateEffectiveVRAMBytes;
if (original >= 0)
{
hasData = true;
totalOriginalBytes += original;
}
if (effective >= 0)
{
hasData = true;
totalEffectiveBytes += effective;
}
}
if (!hasData)
{
ImGui.TextDisabled("VRAM usage has not been calculated yet.");
return;
}
var savedBytes = Math.Max(0L, totalOriginalBytes - totalEffectiveBytes);
var originalText = UiSharedService.ByteToString(totalOriginalBytes, addSuffix: true);
var effectiveText = UiSharedService.ByteToString(totalEffectiveBytes, addSuffix: true);
var savedText = UiSharedService.ByteToString(savedBytes, addSuffix: true);
ImGui.TextUnformatted($"Total VRAM usage (original): {originalText}");
ImGui.TextUnformatted($"Total VRAM usage (effective): {effectiveText}");
if (savedBytes > 0)
{
UiSharedService.ColorText($"VRAM saved by downscaling: {savedText}", UIColors.Get("LightlessGreen"));
}
else
{
ImGui.TextUnformatted($"VRAM saved by downscaling: {savedText}");
}
}
private void DrawThemeVectorRow(MainStyle.StyleVector2Option option)
{
ImGui.TableNextRow();
@@ -1383,6 +1463,22 @@ public class SettingsUi : WindowMediatorSubscriberBase
_logger.LogWarning(ex, $"Could not delete file {file} because it is in use.");
}
}
foreach (var directory in Directory.GetDirectories(_configService.Current.CacheFolder))
{
try
{
Directory.Delete(directory, recursive: true);
}
catch (IOException ex)
{
_logger.LogWarning(ex, "Could not delete directory {Directory} because it is in use.", directory);
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning(ex, "Could not delete directory {Directory} due to access restrictions.", directory);
}
}
});
}
@@ -1422,8 +1518,9 @@ public class SettingsUi : WindowMediatorSubscriberBase
{
if (_uiShared.IconTextButton(FontAwesomeIcon.StickyNote, "Export all your user notes to clipboard"))
{
ImGui.SetClipboardText(UiSharedService.GetNotes(_pairManager.DirectPairs
.UnionBy(_pairManager.GroupPairs.SelectMany(p => p.Value), p => p.UserData,
var snapshot = _pairUiService.GetSnapshot();
ImGui.SetClipboardText(UiSharedService.GetNotes(snapshot.DirectPairs
.UnionBy(snapshot.GroupPairs.SelectMany(p => p.Value), p => p.UserData,
UserDataComparer.Instance).ToList()));
}
@@ -2388,6 +2485,22 @@ public class SettingsUi : WindowMediatorSubscriberBase
_uiShared.DrawHelpText(
"Will show a performance indicator when players exceed defined thresholds in Lightless UI." +
Environment.NewLine + "Will use warning thresholds.");
using (ImRaii.Disabled(!showPerformanceIndicator))
{
using var indent = ImRaii.PushIndent();
bool showCompactStats = _playerPerformanceConfigService.Current.ShowPerformanceUsageNextToName;
if (ImGui.Checkbox("Show performance stats next to alias", ref showCompactStats))
{
_playerPerformanceConfigService.Current.ShowPerformanceUsageNextToName = showCompactStats;
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText(
"Adds a text with approx. VRAM usage and triangle count to the right of pairs alias." +
Environment.NewLine + "Requires performance indicator to be enabled.");
}
bool warnOnExceedingThresholds = _playerPerformanceConfigService.Current.WarnOnExceedingThresholds;
if (ImGui.Checkbox("Warn on loading in players exceeding performance thresholds",
ref warnOnExceedingThresholds))
@@ -2552,6 +2665,102 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.TreePop();
}
ImGui.Separator();
if (_uiShared.MediumTreeNode("Texture Optimization", UIColors.Get("LightlessYellow")))
{
_uiShared.MediumText("Warning", UIColors.Get("DimRed"));
_uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("Texture compression and downscaling is potentially a "),
new SeStringUtils.RichTextEntry("destructive", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry(" process and may cause broken or incorrect character appearances."));
_uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("This feature is encouraged to help "),
new SeStringUtils.RichTextEntry("lower-end systems with limited VRAM", UIColors.Get("LightlessYellow"), true),
new SeStringUtils.RichTextEntry(" and for use in "),
new SeStringUtils.RichTextEntry("performance-critical scenarios", UIColors.Get("LightlessYellow"), true),
new SeStringUtils.RichTextEntry("."));
_uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("Runtime downscaling "),
new SeStringUtils.RichTextEntry("MAY", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry(" cause higher load on the system when processing downloads."));
_uiShared.DrawNoteLine("!!! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("When enabled, we cannot provide support for appearance issues caused by this setting!", UIColors.Get("DimRed"), true));
var textureConfig = _playerPerformanceConfigService.Current;
var trimNonIndex = textureConfig.EnableNonIndexTextureMipTrim;
if (ImGui.Checkbox("Trim mip levels for textures", ref trimNonIndex))
{
textureConfig.EnableNonIndexTextureMipTrim = trimNonIndex;
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText("When enabled, Lightless will remove high-resolution mip levels from textures (not index) that exceed the size limit and are not compressed with any kind compression.");
var downscaleIndex = textureConfig.EnableIndexTextureDownscale;
if (ImGui.Checkbox("Downscale index textures above limit", ref downscaleIndex))
{
textureConfig.EnableIndexTextureDownscale = downscaleIndex;
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText("Controls whether Lightless reduces index textures that exceed the size limit.");
var dimensionOptions = new[] { 512, 1024, 2048, 4096 };
var optionLabels = dimensionOptions.Select(static value => value.ToString()).ToArray();
var currentDimension = textureConfig.TextureDownscaleMaxDimension;
var selectedIndex = Array.IndexOf(dimensionOptions, currentDimension);
if (selectedIndex < 0)
{
selectedIndex = Array.IndexOf(dimensionOptions, 2048);
}
ImGui.SetNextItemWidth(140 * ImGuiHelpers.GlobalScale);
if (ImGui.Combo("Maximum texture dimension", ref selectedIndex, optionLabels, optionLabels.Length))
{
textureConfig.TextureDownscaleMaxDimension = dimensionOptions[selectedIndex];
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText($"Textures above this size will be reduced until their largest dimension is at or below the limit. Block-compressed textures are skipped when \"Only downscale uncompressed\" is enabled.{UiSharedService.TooltipSeparator}Default: 2048");
var keepOriginalTextures = textureConfig.KeepOriginalTextureFiles;
if (ImGui.Checkbox("Keep original texture files", ref keepOriginalTextures))
{
textureConfig.KeepOriginalTextureFiles = keepOriginalTextures;
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText("When disabled, Lightless removes the original texture after a downscaled copy is created.");
ImGui.SameLine();
_uiShared.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), new SeStringUtils.RichTextEntry("If disabled, saved + effective VRAM usage information will not work.", UIColors.Get("LightlessYellow")));
if (!textureConfig.EnableNonIndexTextureMipTrim && !textureConfig.EnableIndexTextureDownscale)
{
UiSharedService.ColorTextWrapped("Both trimming and downscale are disabled. Lightless will keep original textures regardless of size.", UIColors.Get("DimRed"));
}
ImGui.Dummy(new Vector2(5));
_uiShared.ColoredSeparator(UIColors.Get("DimRed"), 3f);
var onlyUncompressed = textureConfig.OnlyDownscaleUncompressedTextures;
if (ImGui.Checkbox("Only downscale uncompressed textures", ref onlyUncompressed))
{
textureConfig.OnlyDownscaleUncompressedTextures = onlyUncompressed;
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText("If disabled, compressed textures will be targeted for downscaling too.");
_uiShared.ColoredSeparator(UIColors.Get("DimRed"), 3f);
ImGui.Dummy(new Vector2(5));
DrawTextureDownscaleCounters();
ImGui.Dummy(new Vector2(5));
_uiShared.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.5f);
ImGui.TreePop();
}
ImGui.Separator();
ImGui.Dummy(new Vector2(10));
@@ -3511,7 +3720,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
// Lightless notification locations
var lightlessLocations = GetLightlessNotificationLocations();
var downloadLocations = GetDownloadNotificationLocations();
if (ImGui.BeginTable("##NotificationLocationTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit))
{
ImGui.TableSetupColumn("Notification Type", ImGuiTableColumnFlags.WidthFixed, 200f * ImGuiHelpers.GlobalScale);
@@ -3674,7 +3883,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.EndTable();
}
ImGuiHelpers.ScaledDummy(5);
if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Clear All Notifications"))
{
@@ -3792,7 +4001,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.Spacing();
ImGui.TextUnformatted("Size & Layout");
float notifWidth = _configService.Current.NotificationWidth;
if (ImGui.SliderFloat("Notification Width", ref notifWidth, 250f, 600f, "%.0f"))
{
@@ -3825,7 +4034,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.Spacing();
ImGui.TextUnformatted("Position");
var currentCorner = _configService.Current.NotificationCorner;
if (ImGui.BeginCombo("Notification Position", GetNotificationCornerLabel(currentCorner)))
{
@@ -3843,7 +4052,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.EndCombo();
}
_uiShared.DrawHelpText("Choose which corner of the screen notifications appear in.");
int offsetY = _configService.Current.NotificationOffsetY;
if (ImGui.SliderInt("Vertical Offset", ref offsetY, -2500, 2500))
{
@@ -4136,7 +4345,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.Separator();
// Location descriptions removed - information is now inline with each setting
}
}
@@ -4256,7 +4465,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.TableSetColumnIndex(2);
var availableWidth = ImGui.GetContentRegionAvail().X;
var buttonWidth = (availableWidth - ImGui.GetStyle().ItemSpacing.X * 2) / 3;
// Play button
using var playId = ImRaii.PushId($"Play_{typeIndex}");
using (ImRaii.Disabled(isDisabled))
@@ -4277,7 +4486,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
}
}
UiSharedService.AttachToolTip("Test this sound");
// Disable toggle button
ImGui.SameLine();
using var disableId = ImRaii.PushId($"Disable_{typeIndex}");
@@ -4285,11 +4494,11 @@ public class SettingsUi : WindowMediatorSubscriberBase
{
var icon = isDisabled ? FontAwesomeIcon.VolumeOff : FontAwesomeIcon.VolumeUp;
var color = isDisabled ? UIColors.Get("DimRed") : UIColors.Get("LightlessGreen");
ImGui.PushStyleColor(ImGuiCol.Button, color);
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, color * new Vector4(1.2f, 1.2f, 1.2f, 1f));
ImGui.PushStyleColor(ImGuiCol.ButtonActive, color * new Vector4(0.8f, 0.8f, 0.8f, 1f));
if (ImGui.Button(icon.ToIconString(), new Vector2(buttonWidth, 0)))
{
bool newDisabled = !isDisabled;
@@ -4303,16 +4512,16 @@ public class SettingsUi : WindowMediatorSubscriberBase
}
_configService.Save();
}
ImGui.PopStyleColor(3);
}
UiSharedService.AttachToolTip(isDisabled ? "Sound is disabled - click to enable" : "Sound is enabled - click to disable");
// Reset button
ImGui.SameLine();
using var resetId = ImRaii.PushId($"Reset_{typeIndex}");
bool isDefault = currentSoundId == defaultSoundId;
using (ImRaii.Disabled(isDefault))
{
using (ImRaii.PushFont(UiBuilder.IconFont))
@@ -4337,6 +4546,4 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.EndTable();
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@ internal static class MainStyle
new("color.border", "Border", () => Rgba(65, 65, 65, 255), ImGuiCol.Border),
new("color.borderShadow", "Border Shadow", () => Rgba(0, 0, 0, 150), ImGuiCol.BorderShadow),
new("color.frameBg", "Frame Background", () => Rgba(40, 40, 40, 255), ImGuiCol.FrameBg),
new("color.frameBgHovered", "Frame Background (Hover)", () => Rgba(50, 50, 50, 255), 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.titleBg", "Title Background", () => Rgba(24, 24, 24, 232), ImGuiCol.TitleBg),
new("color.titleBgActive", "Title Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.TitleBgActive),

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@
using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.ImGuiFileDialog;
using Dalamud.Interface.Textures.TextureWraps;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using LightlessSync.API.Data;
@@ -10,14 +9,13 @@ using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.UI.Handlers;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.WebAPI;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using System.Globalization;
using System.Linq;
using System.Numerics;
@@ -30,35 +28,28 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
private readonly bool _isModerator = false;
private readonly bool _isOwner = false;
private readonly List<string> _oneTimeInvites = [];
private readonly PairManager _pairManager;
private readonly PairUiService _pairUiService;
private readonly LightlessProfileManager _lightlessProfileManager;
private readonly FileDialogManager _fileDialogManager;
private readonly UiSharedService _uiSharedService;
private List<BannedGroupUserDto> _bannedUsers = [];
private LightlessGroupProfileData? _profileData = null;
private bool _adjustedForScollBarsLocalProfile = false;
private bool _adjustedForScollBarsOnlineProfile = false;
private string _descriptionText = string.Empty;
private IDalamudTextureWrap? _pfpTextureWrap;
private string _profileDescription = string.Empty;
private byte[] _profileImage = [];
private bool _showFileDialogError = false;
private int _multiInvites;
private string _newPassword;
private bool _pwChangeSuccess;
private Task<int>? _pruneTestTask;
private Task<int>? _pruneTask;
private int _pruneDays = 14;
private List<int> _selectedTags = [];
public SyncshellAdminUI(ILogger<SyncshellAdminUI> logger, LightlessMediator mediator, ApiController apiController,
UiSharedService uiSharedService, PairManager pairManager, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager, FileDialogManager fileDialogManager)
UiSharedService uiSharedService, PairUiService pairUiService, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager, FileDialogManager fileDialogManager)
: base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService)
{
GroupFullInfo = groupFullInfo;
_apiController = apiController;
_uiSharedService = uiSharedService;
_pairManager = pairManager;
_pairUiService = pairUiService;
_lightlessProfileManager = lightlessProfileManager;
_fileDialogManager = fileDialogManager;
@@ -68,14 +59,6 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
_multiInvites = 30;
_pwChangeSuccess = true;
IsOpen = true;
Mediator.Subscribe<ClearProfileGroupDataMessage>(this, (msg) =>
{
if (msg.GroupData == null || string.Equals(msg.GroupData.AliasOrGID, GroupFullInfo.Group.AliasOrGID, StringComparison.Ordinal))
{
_pfpTextureWrap?.Dispose();
_pfpTextureWrap = null;
}
});
SizeConstraints = new WindowSizeConstraints()
{
MinimumSize = new(700, 500),
@@ -90,10 +73,13 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
if (!_isModerator && !_isOwner) return;
_logger.LogTrace("Drawing Syncshell Admin UI for {group}", GroupFullInfo.GroupAliasOrGID);
GroupFullInfo = _pairManager.Groups[GroupFullInfo.Group];
var snapshot = _pairUiService.GetSnapshot();
if (snapshot.GroupsByGid.TryGetValue(GroupFullInfo.Group.GID, out var updatedInfo))
{
GroupFullInfo = updatedInfo;
}
_profileData = _lightlessProfileManager.GetLightlessGroupProfile(GroupFullInfo.Group);
GetTagsFromProfile();
using var id = ImRaii.PushId("syncshell_admin_" + GroupFullInfo.GID);
using (_uiSharedService.UidFont.Push())
@@ -215,179 +201,47 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
private void DrawProfile()
{
var profileTab = ImRaii.TabItem("Profile");
if (!profileTab)
return;
if (profileTab)
if (_profileData != null)
{
if (_uiSharedService.MediumTreeNode("Current Profile", UIColors.Get("LightlessPurple")))
if (!string.Equals(_profileDescription, _profileData.Description, StringComparison.Ordinal))
{
ImGui.Dummy(new Vector2(5));
if (!_profileImage.SequenceEqual(_profileData.ImageData.Value))
{
_profileImage = _profileData.ImageData.Value;
_pfpTextureWrap?.Dispose();
_pfpTextureWrap = _uiSharedService.LoadImage(_profileImage);
}
if (!string.Equals(_profileDescription, _profileData.Description, StringComparison.OrdinalIgnoreCase))
{
_profileDescription = _profileData.Description;
_descriptionText = _profileDescription;
}
if (_pfpTextureWrap != null)
{
ImGui.Image(_pfpTextureWrap.Handle, ImGuiHelpers.ScaledVector2(_pfpTextureWrap.Width, _pfpTextureWrap.Height));
}
var spacing = ImGui.GetStyle().ItemSpacing.X;
ImGuiHelpers.ScaledRelativeSameLine(256, spacing);
using (_uiSharedService.GameFont.Push())
{
var descriptionTextSize = ImGui.CalcTextSize(_profileData.Description, wrapWidth: 256f);
var childFrame = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 256);
if (descriptionTextSize.Y > childFrame.Y)
{
_adjustedForScollBarsOnlineProfile = true;
}
else
{
_adjustedForScollBarsOnlineProfile = false;
}
childFrame = childFrame with
{
X = childFrame.X + (_adjustedForScollBarsOnlineProfile ? ImGui.GetStyle().ScrollbarSize : 0),
};
if (ImGui.BeginChildFrame(101, childFrame))
{
UiSharedService.TextWrapped(_profileData.Description);
}
ImGui.EndChildFrame();
ImGui.TreePop();
}
var nsfw = _profileData.IsNsfw;
ImGui.BeginDisabled();
ImGui.Checkbox("Is NSFW", ref nsfw);
ImGui.EndDisabled();
_profileDescription = _profileData.Description;
}
ImGui.Separator();
UiSharedService.TextWrapped("Preview the Syncshell profile in a standalone window.");
if (_uiSharedService.MediumTreeNode("Profile Settings", UIColors.Get("LightlessPurple")))
if (_uiSharedService.IconTextButton(FontAwesomeIcon.AddressCard, "Open Syncshell Profile"))
{
ImGui.Dummy(new Vector2(5));
ImGui.TextUnformatted($"Profile Picture:");
if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile picture"))
{
_fileDialogManager.OpenFileDialog("Select new Profile picture", ".png", (success, file) =>
{
if (!success) return;
_ = Task.Run(async () =>
{
var fileContent = await File.ReadAllBytesAsync(file).ConfigureAwait(false);
MemoryStream ms = new(fileContent);
await using (ms.ConfigureAwait(false))
{
var format = await Image.DetectFormatAsync(ms).ConfigureAwait(false);
if (!format.FileExtensions.Contains("png", StringComparer.OrdinalIgnoreCase))
{
_showFileDialogError = true;
return;
}
using var image = Image.Load<Rgba32>(fileContent);
if (image.Width > 512 || image.Height > 512 || (fileContent.Length > 2000 * 1024))
{
_showFileDialogError = true;
return;
}
_showFileDialogError = false;
await _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, Convert.ToBase64String(fileContent), BannerBase64: null, IsNsfw: null, IsDisabled: null))
.ConfigureAwait(false);
}
});
});
}
UiSharedService.AttachToolTip("Select and upload a new profile picture");
ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear uploaded profile picture"))
{
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null));
}
UiSharedService.AttachToolTip("Clear your currently uploaded profile picture");
if (_showFileDialogError)
{
UiSharedService.ColorTextWrapped("The profile picture must be a PNG file with a maximum height and width of 256px and 250KiB size", ImGuiColors.DalamudRed);
}
ImGui.Separator();
ImGui.TextUnformatted($"Tags:");
var childFrameLocal = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 200);
var allCategoryIndexes = Enum.GetValues<ProfileTags>()
.Cast<int>()
.ToList();
foreach(int tag in allCategoryIndexes)
{
using (ImRaii.PushId($"tag-{tag}")) DrawTag(tag);
}
ImGui.Separator();
var widthTextBox = 400;
var posX = ImGui.GetCursorPosX();
ImGui.TextUnformatted($"Description {_descriptionText.Length}/1500");
ImGui.SetCursorPosX(posX);
ImGuiHelpers.ScaledRelativeSameLine(widthTextBox, ImGui.GetStyle().ItemSpacing.X);
ImGui.TextUnformatted("Preview (approximate)");
using (_uiSharedService.GameFont.Push())
ImGui.InputTextMultiline("##description", ref _descriptionText, 1500, ImGuiHelpers.ScaledVector2(widthTextBox, 200));
ImGui.SameLine();
using (_uiSharedService.GameFont.Push())
{
var descriptionTextSizeLocal = ImGui.CalcTextSize(_descriptionText, wrapWidth: 256f);
if (descriptionTextSizeLocal.Y > childFrameLocal.Y)
{
_adjustedForScollBarsLocalProfile = true;
}
else
{
_adjustedForScollBarsLocalProfile = false;
}
childFrameLocal = childFrameLocal with
{
X = childFrameLocal.X + (_adjustedForScollBarsLocalProfile ? ImGui.GetStyle().ScrollbarSize : 0),
};
if (ImGui.BeginChildFrame(102, childFrameLocal))
{
UiSharedService.TextWrapped(_descriptionText);
}
ImGui.EndChildFrame();
}
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Description"))
{
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: _descriptionText, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null));
}
UiSharedService.AttachToolTip("Sets your profile description text");
ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description"))
{
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null));
}
UiSharedService.AttachToolTip("Clears your profile description text");
ImGui.Separator();
ImGui.TextUnformatted($"Profile Options:");
var isNsfw = _profileData.IsNsfw;
if (ImGui.Checkbox("Profile is NSFW", ref isNsfw))
{
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: isNsfw, IsDisabled: null));
}
_uiSharedService.DrawHelpText("If your profile description or image can be considered NSFW, toggle this to ON");
ImGui.TreePop();
Mediator.Publish(new GroupProfileOpenStandaloneMessage(GroupFullInfo));
}
UiSharedService.AttachToolTip("Opens the standalone Syncshell profile window for this group.");
ImGuiHelpers.ScaledDummy(2f);
ImGui.TextDisabled("Profile Flags");
ImGui.BulletText(_profileData.IsNsfw ? "Marked as NSFW" : "Marked as SFW");
ImGui.BulletText(_profileData.IsDisabled ? "Profile disabled for viewers" : "Profile active");
ImGuiHelpers.ScaledDummy(2f);
_uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
ImGuiHelpers.ScaledDummy(2f);
UiSharedService.TextWrapped("Open the syncshell profile editor to update images, description, tags, and visibility settings.");
ImGuiHelpers.ScaledDummy(2f);
if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserEdit, "Open Syncshell Profile Editor"))
{
Mediator.Publish(new OpenGroupProfileEditorMessage(GroupFullInfo));
}
UiSharedService.AttachToolTip("Launches the editor window and associated live preview for this syncshell.");
}
else
{
UiSharedService.TextWrapped("Profile information is loading...");
}
profileTab.Dispose();
}
@@ -398,7 +252,8 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
{
if (_uiSharedService.MediumTreeNode("User List & Administration", UIColors.Get("LightlessPurple")))
{
if (!_pairManager.GroupPairs.TryGetValue(GroupFullInfo, out var pairs))
var snapshot = _pairUiService.GetSnapshot();
if (!snapshot.GroupPairs.TryGetValue(GroupFullInfo, out var pairs))
{
UiSharedService.ColorTextWrapped("No users found in this Syncshell", ImGuiColors.DalamudYellow);
}
@@ -734,37 +589,8 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
}
inviteTab.Dispose();
}
private void DrawTag(int tag)
{
var HasTag = _selectedTags.Contains(tag);
var tagName = (ProfileTags)tag;
if (ImGui.Checkbox(tagName.ToString(), ref HasTag))
{
if (HasTag)
{
_selectedTags.Add(tag);
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: _selectedTags.ToArray(), PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null));
}
else
{
_selectedTags.Remove(tag);
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: _selectedTags.ToArray(), PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null));
}
}
}
private void GetTagsFromProfile()
{
if (_profileData != null)
{
_selectedTags = [.. _profileData.Tags];
}
}
public override void OnClose()
{
Mediator.Publish(new RemoveWindowMessage(this));
_pfpTextureWrap?.Dispose();
}
}

View File

@@ -7,13 +7,16 @@ using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto;
using LightlessSync.API.Dto.Group;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Threading.Tasks;
namespace LightlessSync.UI;
@@ -23,7 +26,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
private readonly BroadcastService _broadcastService;
private readonly UiSharedService _uiSharedService;
private readonly BroadcastScannerService _broadcastScannerService;
private readonly PairManager _pairManager;
private readonly PairUiService _pairUiService;
private readonly DalamudUtilService _dalamudUtilService;
private readonly List<GroupJoinDto> _nearbySyncshells = [];
@@ -43,14 +46,14 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
UiSharedService uiShared,
ApiController apiController,
BroadcastScannerService broadcastScannerService,
PairManager pairManager,
PairUiService pairUiService,
DalamudUtilService dalamudUtilService) : base(logger, mediator, "Shellfinder###LightlessSyncshellFinderUI", performanceCollectorService)
{
_broadcastService = broadcastService;
_uiSharedService = uiShared;
_apiController = apiController;
_broadcastScannerService = broadcastScannerService;
_pairManager = pairManager;
_pairUiService = pairUiService;
_dalamudUtilService = dalamudUtilService;
IsOpen = false;
@@ -266,7 +269,8 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
private async Task RefreshSyncshellsAsync()
{
var syncshellBroadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts();
_currentSyncshells = [.. _pairManager.GroupPairs.Select(g => g.Key)];
var snapshot = _pairUiService.GetSnapshot();
_currentSyncshells = snapshot.GroupPairs.Keys.ToList();
_recentlyJoined.RemoveWhere(gid => _currentSyncshells.Any(s => string.Equals(s.GID, gid, StringComparison.Ordinal)));

View File

@@ -0,0 +1,30 @@
using System.Numerics;
namespace LightlessSync.UI.Tags;
public readonly record struct ProfileTagDefinition(
string? Text,
string? SeStringPayload = null,
bool UseTextureSegments = false,
Vector4? BackgroundColor = null,
Vector4? BorderColor = null,
Vector4? TextColor = null)
{
public bool HasContent => !string.IsNullOrWhiteSpace(Text) || !string.IsNullOrWhiteSpace(SeStringPayload);
public bool HasSeString => !string.IsNullOrWhiteSpace(SeStringPayload);
public ProfileTagDefinition WithColors(Vector4? background, Vector4? border, Vector4? textColor = null)
=> this with { BackgroundColor = background, BorderColor = border, TextColor = textColor };
public static ProfileTagDefinition FromText(string text, Vector4? background = null, Vector4? border = null, Vector4? textColor = null)
=> new(text, null, false, background, border, textColor);
public static ProfileTagDefinition FromIcon(uint iconId, Vector4? background = null, Vector4? border = null)
=> new(null, $"<icon({iconId})>", true, background, border, null);
public static ProfileTagDefinition FromIconAndText(uint iconId, string text, Vector4? background = null, Vector4? border = null, Vector4? textColor = null)
=> new(text, $"<icon({iconId})> {text}", true, background, border, textColor);
public static ProfileTagDefinition FromSeString(string payload, Vector4? background = null, Vector4? border = null, Vector4? textColor = null)
=> new(null, payload, true, background, border, textColor);
}

View File

@@ -0,0 +1,226 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.ImGuiSeStringRenderer;
using Dalamud.Interface.Textures.TextureWraps;
using Dalamud.Interface.Utility;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Numerics;
namespace LightlessSync.UI.Tags;
internal static class ProfileTagRenderer
{
public static Vector2 MeasureTag(
ProfileTagDefinition tag,
float scale,
ImGuiStylePtr style,
Vector4 fallbackBackground,
Vector4 fallbackBorder,
uint defaultTextColorU32,
List<SeStringUtils.SeStringSegment> segmentBuffer,
Func<uint, IDalamudTextureWrap?> iconResolver,
ILogger? logger)
=> RenderTagInternal(tag, Vector2.Zero, scale, default, style, fallbackBackground, fallbackBorder, defaultTextColorU32, segmentBuffer, iconResolver, logger, draw: false);
public static Vector2 RenderTag(
ProfileTagDefinition tag,
Vector2 screenMin,
float scale,
ImDrawListPtr drawList,
ImGuiStylePtr style,
Vector4 fallbackBackground,
Vector4 fallbackBorder,
uint defaultTextColorU32,
List<SeStringUtils.SeStringSegment> segmentBuffer,
Func<uint, IDalamudTextureWrap?> iconResolver,
ILogger? logger)
=> RenderTagInternal(tag, screenMin, scale, drawList, style, fallbackBackground, fallbackBorder, defaultTextColorU32, segmentBuffer, iconResolver, logger, draw: true);
private static Vector2 RenderTagInternal(
ProfileTagDefinition tag,
Vector2 screenMin,
float scale,
ImDrawListPtr drawList,
ImGuiStylePtr style,
Vector4 fallbackBackground,
Vector4 fallbackBorder,
uint defaultTextColorU32,
List<SeStringUtils.SeStringSegment> segmentBuffer,
Func<uint, IDalamudTextureWrap?> iconResolver,
ILogger? logger,
bool draw)
{
segmentBuffer.Clear();
var padding = new Vector2(10f * scale, 6f * scale);
var rounding = style.FrameRounding > 0f ? style.FrameRounding : 6f * scale;
var backgroundColor = tag.BackgroundColor ?? fallbackBackground;
var borderColor = tag.BorderColor ?? fallbackBorder;
var textColor = tag.TextColor ?? style.Colors[(int)ImGuiCol.Text];
var textColorU32 = tag.TextColor.HasValue ? ImGui.ColorConvertFloat4ToU32(tag.TextColor.Value) : defaultTextColorU32;
string? textContent = tag.Text;
Vector2 textSize = string.IsNullOrWhiteSpace(textContent) ? Vector2.Zero : ImGui.CalcTextSize(textContent);
var sePayload = tag.SeStringPayload;
bool hasSeString = !string.IsNullOrWhiteSpace(sePayload);
bool useTextureSegments = hasSeString && tag.UseTextureSegments;
bool useSeRenderer = hasSeString && !useTextureSegments;
Vector2 seSize = Vector2.Zero;
List<SeStringUtils.SeStringSegment>? seSegments = null;
if (hasSeString)
{
if (useSeRenderer)
{
try
{
var drawParams = new SeStringDrawParams
{
TargetDrawList = draw ? drawList : default,
ScreenOffset = draw ? screenMin + padding : Vector2.Zero,
WrapWidth = float.MaxValue
};
var measure = ImGuiHelpers.CompileSeStringWrapped(sePayload!, drawParams);
seSize = measure.Size;
if (seSize.Y <= 0f)
seSize.Y = ImGui.GetTextLineHeight();
textContent = null;
textSize = Vector2.Zero;
}
catch (Exception ex)
{
logger?.LogDebug(ex, "Failed to compile SeString payload '{Payload}' for profile tag", sePayload);
useSeRenderer = false;
}
}
if (!useSeRenderer && useTextureSegments)
{
segmentBuffer.Clear();
if (SeStringUtils.TryResolveSegments(sePayload!, scale, iconResolver, segmentBuffer, out seSize) && segmentBuffer.Count > 0)
{
seSegments = segmentBuffer;
textContent = null;
textSize = Vector2.Zero;
}
else
{
segmentBuffer.Clear();
var fallback = SeStringUtils.StripMarkup(sePayload!);
if (!string.IsNullOrWhiteSpace(fallback))
{
textContent = fallback;
textSize = ImGui.CalcTextSize(fallback);
}
}
}
else if (!useSeRenderer && string.IsNullOrWhiteSpace(textContent))
{
var fallback = SeStringUtils.StripMarkup(sePayload!);
if (!string.IsNullOrWhiteSpace(fallback))
{
textContent = fallback;
textSize = ImGui.CalcTextSize(fallback);
}
}
}
bool drewSeString = useSeRenderer || seSegments is { Count: > 0 };
var contentHeight = drewSeString ? seSize.Y : textSize.Y;
if (contentHeight <= 0f)
contentHeight = ImGui.GetTextLineHeight();
var contentWidth = drewSeString ? seSize.X : textSize.X;
if (contentWidth <= 0f)
contentWidth = textSize.X;
if (contentWidth <= 0f)
contentWidth = 40f * scale;
var tagSize = new Vector2(contentWidth + padding.X * 2f, contentHeight + padding.Y * 2f);
if (!draw)
{
if (seSegments is not null)
seSegments.Clear();
return tagSize;
}
var rectMin = screenMin;
var rectMax = rectMin + tagSize;
drawList.AddRectFilled(rectMin, rectMax, ImGui.ColorConvertFloat4ToU32(backgroundColor), rounding);
drawList.AddRect(rectMin, rectMax, ImGui.ColorConvertFloat4ToU32(borderColor), rounding);
var contentStart = rectMin + padding;
var verticalOffset = (tagSize.Y - padding.Y * 2f - contentHeight) * 0.5f;
var basePos = new Vector2(contentStart.X, contentStart.Y + MathF.Max(verticalOffset, 0f));
if (useSeRenderer && sePayload is { Length: > 0 })
{
var drawParams = new SeStringDrawParams
{
TargetDrawList = drawList,
ScreenOffset = basePos,
WrapWidth = float.MaxValue
};
try
{
ImGuiHelpers.CompileSeStringWrapped(sePayload!, drawParams);
}
catch (Exception ex)
{
logger?.LogDebug(ex, "Failed to draw SeString payload '{Payload}' for profile tag", sePayload);
var fallback = !string.IsNullOrWhiteSpace(textContent) ? textContent : SeStringUtils.StripMarkup(sePayload!);
if (!string.IsNullOrWhiteSpace(fallback))
drawList.AddText(basePos, textColorU32, fallback);
}
}
else if (seSegments is { Count: > 0 })
{
var segmentX = basePos.X;
foreach (var segment in seSegments)
{
var segmentPos = new Vector2(segmentX, basePos.Y + (contentHeight - segment.Size.Y) * 0.5f);
switch (segment.Type)
{
case SeStringUtils.SeStringSegmentType.Icon:
if (segment.Texture != null)
{
drawList.AddImage(segment.Texture.Handle, segmentPos, segmentPos + segment.Size);
}
else if (!string.IsNullOrEmpty(segment.Text))
{
drawList.AddText(segmentPos, textColorU32, segment.Text);
}
break;
case SeStringUtils.SeStringSegmentType.Text:
var colorU32 = segment.Color.HasValue
? ImGui.ColorConvertFloat4ToU32(segment.Color.Value)
: textColorU32;
drawList.AddText(segmentPos, colorU32, segment.Text ?? string.Empty);
break;
}
segmentX += segment.Size.X;
}
seSegments.Clear();
}
else if (!string.IsNullOrWhiteSpace(textContent))
{
drawList.AddText(basePos, textColorU32, textContent);
}
else
{
drawList.AddText(basePos, textColorU32, string.Empty);
}
return tagSize;
}
}

View File

@@ -0,0 +1,131 @@
using LightlessSync.UI;
using System;
using System.Collections.Generic;
using System.Numerics;
namespace LightlessSync.UI.Tags;
/// <summary>
/// Library of tags. That's it.
/// </summary>
public sealed class ProfileTagService
{
private static readonly IReadOnlyDictionary<int, ProfileTagDefinition> TagLibrary = CreateTagLibrary();
public IReadOnlyDictionary<int, ProfileTagDefinition> GetTagLibrary()
=> TagLibrary;
public IReadOnlyList<ProfileTagDefinition> ResolveTags(IReadOnlyList<int>? tagIds)
{
if (tagIds is null || tagIds.Count == 0)
return Array.Empty<ProfileTagDefinition>();
var result = new List<ProfileTagDefinition>(tagIds.Count);
foreach (var id in tagIds)
{
if (TagLibrary.TryGetValue(id, out var tag))
result.Add(tag);
}
return result;
}
public bool TryGetDefinition(int tagId, out ProfileTagDefinition definition)
=> TagLibrary.TryGetValue(tagId, out definition);
private static IReadOnlyDictionary<int, ProfileTagDefinition> CreateTagLibrary()
{
var dictionary = new Dictionary<int, ProfileTagDefinition>
{
[(int)ProfileTags.SFW] = ProfileTagDefinition.FromIconAndText(
230419,
"SFW",
background: new Vector4(0.16f, 0.24f, 0.18f, 0.95f),
border: new Vector4(0.32f, 0.52f, 0.34f, 0.85f),
textColor: new Vector4(0.78f, 0.94f, 0.80f, 1f)),
[(int)ProfileTags.NSFW] = ProfileTagDefinition.FromIconAndText(
230419,
"NSFW",
background: new Vector4(0.32f, 0.18f, 0.22f, 0.95f),
border: new Vector4(0.72f, 0.32f, 0.38f, 0.85f),
textColor: new Vector4(1f, 0.82f, 0.86f, 1f)),
[(int)ProfileTags.RP] = ProfileTagDefinition.FromIconAndText(
61545,
"RP",
background: new Vector4(0.20f, 0.20f, 0.30f, 0.95f),
border: new Vector4(0.42f, 0.42f, 0.66f, 0.85f),
textColor: new Vector4(0.80f, 0.84f, 1f, 1f)),
[(int)ProfileTags.ERP] = ProfileTagDefinition.FromIconAndText(
61545,
"ERP",
background: new Vector4(0.20f, 0.20f, 0.30f, 0.95f),
border: new Vector4(0.42f, 0.42f, 0.66f, 0.85f),
textColor: new Vector4(0.80f, 0.84f, 1f, 1f)),
[(int)ProfileTags.No_RP] = ProfileTagDefinition.FromIconAndText(
230420,
"No RP",
background: new Vector4(0.30f, 0.18f, 0.30f, 0.95f),
border: new Vector4(0.69f, 0.40f, 0.65f, 0.85f),
textColor: new Vector4(1f, 0.84f, 1f, 1f)),
[(int)ProfileTags.No_ERP] = ProfileTagDefinition.FromIconAndText(
230420,
"No ERP",
background: new Vector4(0.30f, 0.18f, 0.30f, 0.95f),
border: new Vector4(0.69f, 0.40f, 0.65f, 0.85f),
textColor: new Vector4(1f, 0.84f, 1f, 1f)),
[(int)ProfileTags.Venues] = ProfileTagDefinition.FromIconAndText(
60756,
"Venues",
background: new Vector4(0.18f, 0.24f, 0.28f, 0.95f),
border: new Vector4(0.33f, 0.55f, 0.63f, 0.85f),
textColor: new Vector4(0.78f, 0.90f, 0.97f, 1f)),
[(int)ProfileTags.Gpose] = ProfileTagDefinition.FromIconAndText(
61546,
"GPose",
background: new Vector4(0.18f, 0.18f, 0.26f, 0.95f),
border: new Vector4(0.35f, 0.34f, 0.54f, 0.85f),
textColor: new Vector4(0.80f, 0.82f, 0.96f, 1f)),
[(int)ProfileTags.Limsa] = ProfileTagDefinition.FromIconAndText(
60572,
"Limsa"),
[(int)ProfileTags.Gridania] = ProfileTagDefinition.FromIconAndText(
60573,
"Gridania"),
[(int)ProfileTags.Ul_dah] = ProfileTagDefinition.FromIconAndText(
60574,
"Ul'dah"),
[(int)ProfileTags.WUT] = ProfileTagDefinition.FromIconAndText(
61397,
"WU/T"),
[(int)ProfileTags.PVP] = ProfileTagDefinition.FromIcon(61806),
[(int)ProfileTags.Ultimate] = ProfileTagDefinition.FromIcon(61832),
[(int)ProfileTags.Raids] = ProfileTagDefinition.FromIcon(61802),
[(int)ProfileTags.Roulette] = ProfileTagDefinition.FromIcon(61807),
[(int)ProfileTags.Crafting] = ProfileTagDefinition.FromIcon(61816),
[(int)ProfileTags.Casual] = ProfileTagDefinition.FromIcon(61753),
[(int)ProfileTags.Hardcore] = ProfileTagDefinition.FromIcon(61754),
[(int)ProfileTags.Glamour] = ProfileTagDefinition.FromIcon(61759),
[(int)ProfileTags.Mentor] = ProfileTagDefinition.FromIcon(61760)
};
return dictionary;
}
}

View File

@@ -1,3 +1,4 @@
using System;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.Utility;
@@ -10,8 +11,12 @@ using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
using LightlessSync.UI.Models;
using LightlessSync.UI.Style;
using LightlessSync.WebAPI;
using System.Numerics;
using System.Threading.Tasks;
using System.Linq;
namespace LightlessSync.UI;
@@ -22,7 +27,6 @@ public class TopTabMenu
private readonly LightlessMediator _lightlessMediator;
private readonly PairManager _pairManager;
private readonly PairRequestService _pairRequestService;
private readonly DalamudUtilService _dalamudUtilService;
private readonly HashSet<string> _pendingPairRequestActions = new(StringComparer.Ordinal);
@@ -36,11 +40,12 @@ public class TopTabMenu
private string _pairToAdd = string.Empty;
private SelectedTab _selectedTab = SelectedTab.None;
public TopTabMenu(LightlessMediator lightlessMediator, ApiController apiController, PairManager pairManager, UiSharedService uiSharedService, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService)
private PairUiSnapshot? _currentSnapshot;
public TopTabMenu(LightlessMediator lightlessMediator, ApiController apiController, UiSharedService uiSharedService, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService)
{
_lightlessMediator = lightlessMediator;
_apiController = apiController;
_pairManager = pairManager;
_pairRequestService = pairRequestService;
_dalamudUtilService = dalamudUtilService;
_uiSharedService = uiSharedService;
@@ -77,34 +82,46 @@ public class TopTabMenu
_selectedTab = value;
}
}
public void Draw()
private PairUiSnapshot Snapshot => _currentSnapshot ?? throw new InvalidOperationException("Pair UI snapshot is not available outside of Draw.");
public void Draw(PairUiSnapshot snapshot)
{
var availableWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X;
var spacing = ImGui.GetStyle().ItemSpacing;
var buttonX = (availableWidth - (spacing.X * 4)) / 5f;
var buttonY = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Pause).Y;
var buttonSize = new Vector2(buttonX, buttonY);
var drawList = ImGui.GetWindowDrawList();
var underlineColor = ImGui.GetColorU32(UIColors.Get("LightlessPurpleActive")); // ImGui.GetColorU32(ImGuiCol.Separator);
var btncolor = ImRaii.PushColor(ImGuiCol.Button, ImGui.ColorConvertFloat4ToU32(new(0, 0, 0, 0)));
ImGuiHelpers.ScaledDummy(spacing.Y / 2f);
using (ImRaii.PushFont(UiBuilder.IconFont))
_currentSnapshot = snapshot;
try
{
var x = ImGui.GetCursorScreenPos();
if (ImGui.Button(FontAwesomeIcon.User.ToIconString(), buttonSize))
var availableWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X;
var spacing = ImGui.GetStyle().ItemSpacing;
var buttonX = (availableWidth - (spacing.X * 5)) / 6f;
var buttonY = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Pause).Y;
var buttonSize = new Vector2(buttonX, buttonY);
const float buttonBorderThickness = 12f;
var buttonRounding = ImGui.GetStyle().FrameRounding;
var drawList = ImGui.GetWindowDrawList();
var underlineColor = ImGui.GetColorU32(UIColors.Get("LightlessPurpleActive")); // ImGui.GetColorU32(ImGuiCol.Separator);
var btncolor = ImRaii.PushColor(ImGuiCol.Button, ImGui.ColorConvertFloat4ToU32(new(0, 0, 0, 0)));
ImGuiHelpers.ScaledDummy(spacing.Y / 2f);
using (ImRaii.PushFont(UiBuilder.IconFont))
{
TabSelection = TabSelection == SelectedTab.Individual ? SelectedTab.None : SelectedTab.Individual;
var x = ImGui.GetCursorScreenPos();
if (ImGui.Button(FontAwesomeIcon.User.ToIconString(), buttonSize))
{
TabSelection = TabSelection == SelectedTab.Individual ? SelectedTab.None : SelectedTab.Individual;
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
{
Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding);
}
ImGui.SameLine();
var xAfter = ImGui.GetCursorScreenPos();
if (TabSelection == SelectedTab.Individual)
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);
}
ImGui.SameLine();
var xAfter = ImGui.GetCursorScreenPos();
if (TabSelection == SelectedTab.Individual)
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("Individual Pair Menu");
UiSharedService.AttachToolTip("Individual Pair Menu");
using (ImRaii.PushFont(UiBuilder.IconFont))
{
@@ -113,6 +130,10 @@ public class TopTabMenu
{
TabSelection = TabSelection == SelectedTab.Syncshell ? SelectedTab.None : SelectedTab.Syncshell;
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
{
Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding);
}
ImGui.SameLine();
var xAfter = ImGui.GetCursorScreenPos();
if (TabSelection == SelectedTab.Syncshell)
@@ -122,6 +143,20 @@ public class TopTabMenu
}
UiSharedService.AttachToolTip("Syncshell Menu");
using (ImRaii.PushFont(UiBuilder.IconFont))
{
if (ImGui.Button(FontAwesomeIcon.Comments.ToIconString(), buttonSize))
{
_lightlessMediator.Publish(new UiToggleMessage(typeof(ZoneChatUi)));
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
{
Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding);
}
}
UiSharedService.AttachToolTip("Zone Chat");
ImGui.SameLine();
ImGui.SameLine();
using (ImRaii.PushFont(UiBuilder.IconFont))
{
@@ -130,6 +165,10 @@ public class TopTabMenu
{
TabSelection = TabSelection == SelectedTab.Lightfinder ? SelectedTab.None : SelectedTab.Lightfinder;
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
{
Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding);
}
ImGui.SameLine();
var xAfter = ImGui.GetCursorScreenPos();
@@ -148,6 +187,10 @@ public class TopTabMenu
{
TabSelection = TabSelection == SelectedTab.UserConfig ? SelectedTab.None : SelectedTab.UserConfig;
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
{
Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding);
}
ImGui.SameLine();
var xAfter = ImGui.GetCursorScreenPos();
@@ -166,6 +209,10 @@ public class TopTabMenu
{
_lightlessMediator.Publish(new UiToggleMessage(typeof(SettingsUi)));
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
{
Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding);
}
ImGui.SameLine();
}
UiSharedService.AttachToolTip("Open Lightless Settings");
@@ -196,12 +243,18 @@ public class TopTabMenu
if (TabSelection != SelectedTab.None) ImGuiHelpers.ScaledDummy(3f);
DrawIncomingPairRequests(availableWidth);
ImGui.Separator();
DrawFilter(availableWidth, spacing.X);
}
finally
{
_currentSnapshot = null;
}
}
private void DrawAddPair(float availableXWidth, float spacingX)
{
@@ -209,7 +262,7 @@ public class TopTabMenu
ImGui.SetNextItemWidth(availableXWidth - buttonSize - spacingX);
ImGui.InputTextWithHint("##otheruid", "Other players UID/Alias", ref _pairToAdd, 20);
ImGui.SameLine();
var alreadyExisting = _pairManager.DirectPairs.Exists(p => string.Equals(p.UserData.UID, _pairToAdd, StringComparison.Ordinal) || string.Equals(p.UserData.Alias, _pairToAdd, StringComparison.Ordinal));
var alreadyExisting = Snapshot.DirectPairs.Any(p => string.Equals(p.UserData.UID, _pairToAdd, StringComparison.Ordinal) || string.Equals(p.UserData.Alias, _pairToAdd, StringComparison.Ordinal));
using (ImRaii.Disabled(alreadyExisting || string.IsNullOrEmpty(_pairToAdd)))
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserPlus, "Add"))
@@ -431,12 +484,23 @@ public class TopTabMenu
{
Filter = filter;
}
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive())
{
Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, 10, exactSize: true, clipToElement: true, roundingOverride: ImGui.GetStyle().FrameRounding);
}
ImGui.SameLine();
using var disabled = ImRaii.Disabled(string.IsNullOrEmpty(Filter));
var disableClear = string.IsNullOrEmpty(Filter);
using var disabled = ImRaii.Disabled(disableClear);
var clearHovered = false;
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Ban, "Clear"))
{
Filter = string.Empty;
}
clearHovered = ImGui.IsItemHovered();
if (!disableClear && clearHovered)
{
Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, 10, exactSize: true, clipToElement: true, roundingOverride: ImGui.GetStyle().FrameRounding);
}
}
private void DrawGlobalIndividualButtons(float availableXWidth, float spacingX)
@@ -666,7 +730,7 @@ public class TopTabMenu
if (ImGui.Button(FontAwesomeIcon.Check.ToIconString(), buttonSize))
{
_ = GlobalControlCountdown(10);
var bulkSyncshells = _pairManager.GroupPairs.Keys.OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase)
var bulkSyncshells = Snapshot.GroupPairs.Keys.OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Group.GID, g =>
{
var perm = g.GroupUserPermissions;
@@ -691,7 +755,8 @@ public class TopTabMenu
{
var buttonX = (availableWidth - (spacingX)) / 2f;
using (ImRaii.Disabled(_pairManager.GroupPairs.Select(k => k.Key).Distinct()
using (ImRaii.Disabled(Snapshot.GroupPairs.Keys
.Distinct()
.Count(g => string.Equals(g.OwnerUID, _apiController.UID, StringComparison.Ordinal)) >= _apiController.ServerInfo.MaxGroupsCreatedByUser))
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Plus, "Create new Syncshell", buttonX))
@@ -701,7 +766,7 @@ public class TopTabMenu
ImGui.SameLine();
}
using (ImRaii.Disabled(_pairManager.GroupPairs.Select(k => k.Key).Distinct().Count() >= _apiController.ServerInfo.MaxGroupsJoinedByUser))
using (ImRaii.Disabled(Snapshot.GroupPairs.Keys.Distinct().Count() >= _apiController.ServerInfo.MaxGroupsJoinedByUser))
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Users, "Join existing Syncshell", buttonX))
{
@@ -770,7 +835,7 @@ public class TopTabMenu
if (_uiSharedService.IconTextButton(enableIcon, enableText, null, true))
{
_ = GlobalControlCountdown(10);
var bulkIndividualPairs = _pairManager.PairsWithGroups.Keys
var bulkIndividualPairs = Snapshot.PairsWithGroups.Keys
.Where(g => g.IndividualPairStatus == IndividualPairStatus.Bidirectional)
.ToDictionary(g => g.UserPair.User.UID, g =>
{
@@ -784,7 +849,7 @@ public class TopTabMenu
if (_uiSharedService.IconTextButton(disableIcon, disableText, null, true))
{
_ = GlobalControlCountdown(10);
var bulkIndividualPairs = _pairManager.PairsWithGroups.Keys
var bulkIndividualPairs = Snapshot.PairsWithGroups.Keys
.Where(g => g.IndividualPairStatus == IndividualPairStatus.Bidirectional)
.ToDictionary(g => g.UserPair.User.UID, g =>
{
@@ -808,7 +873,7 @@ public class TopTabMenu
if (_uiSharedService.IconTextButton(enableIcon, enableText, null, true))
{
_ = GlobalControlCountdown(10);
var bulkSyncshells = _pairManager.GroupPairs.Keys
var bulkSyncshells = Snapshot.GroupPairs.Keys
.OrderBy(u => u.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Group.GID, g =>
{
@@ -822,7 +887,7 @@ public class TopTabMenu
if (_uiSharedService.IconTextButton(disableIcon, disableText, null, true))
{
_ = GlobalControlCountdown(10);
var bulkSyncshells = _pairManager.GroupPairs.Keys
var bulkSyncshells = Snapshot.GroupPairs.Keys
.OrderBy(u => u.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Group.GID, g =>
{

View File

@@ -15,7 +15,9 @@ namespace LightlessSync.UI
{ "FullBlack", "#000000" },
{ "LightlessBlue", "#a6c2ff" },
{ "LightlessYellow", "#ffe97a" },
{ "LightlessYellow2", "#cfbd63" },
{ "LightlessGreen", "#7cd68a" },
{ "LightlessGreenDefault", "#468a50" },
{ "LightlessOrange", "#ffb366" },
{ "PairBlue", "#88a2db" },
{ "DimRed", "#d44444" },
@@ -25,6 +27,9 @@ namespace LightlessSync.UI
{ "Lightfinder", "#ad8af5" },
{ "LightfinderEdge", "#000000" },
{ "ProfileBodyGradientTop", "#2f283fff" },
{ "ProfileBodyGradientBottom", "#372d4d00" },
};
private static LightlessConfigService? _configService;

View File

@@ -4,6 +4,7 @@ using Dalamud.Interface.Colors;
using Dalamud.Interface.GameFonts;
using Dalamud.Interface.ImGuiFileDialog;
using Dalamud.Interface.ManagedFontAtlas;
using Dalamud.Interface.Textures;
using Dalamud.Interface.Textures.TextureWraps;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
@@ -400,10 +401,21 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
public static bool ShiftPressed() => (GetKeyState(0xA1) & 0x8000) != 0 || (GetKeyState(0xA0) & 0x8000) != 0;
public static void TextWrapped(string text, float wrapPos = 0)
public static void TextWrapped(string text, float wrapPos = 0, Vector4? color = null)
{
ImGui.PushTextWrapPos(wrapPos);
if (color.HasValue)
{
ImGui.PushStyleColor(ImGuiCol.Text, color.Value);
}
ImGui.TextUnformatted(text);
if (color.HasValue)
{
ImGui.PopStyleColor();
}
ImGui.PopTextWrapPos();
}
@@ -519,8 +531,9 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
bool changed = ImGui.Checkbox(label, ref value);
var boxSize = ImGui.GetFrameHeight();
var min = pos;
var max = ImGui.GetItemRectMax();
var max = new Vector2(pos.X + boxSize, pos.Y + boxSize);
var col = ImGui.GetColorU32(borderColor ?? ImGuiColors.DalamudGrey);
ImGui.GetWindowDrawList().AddRect(min, max, col, rounding, ImDrawFlags.None, borderThickness);
@@ -1220,6 +1233,100 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
return _textureProvider.CreateFromImageAsync(imageData).Result;
}
private static readonly (bool ItemHq, bool HiRes)[] IconLookupOrders =
[
(false, true),
(true, true),
(false, false),
(true, false)
];
public bool TryGetIcon(uint iconId, out IDalamudTextureWrap? wrap)
{
foreach (var (itemHq, hiRes) in IconLookupOrders)
{
if (TryGetIconWithLookup(iconId, itemHq, hiRes, out wrap))
return true;
}
foreach (var (itemHq, hiRes) in IconLookupOrders)
{
if (!_textureProvider.TryGetIconPath(new GameIconLookup(iconId, itemHq, hiRes), out var path) || string.IsNullOrEmpty(path))
continue;
try
{
var reference = _textureProvider.GetFromGame(path);
if (reference.TryGetWrap(out var texture, out _))
{
wrap = texture;
return true;
}
}
catch (Exception ex)
{
Logger.LogTrace(ex, "Failed to load icon {IconId} from path {Path}", iconId, path);
}
}
foreach (var hiRes in new[] { true, false })
{
var manualPath = BuildIconPath(iconId, hiRes);
if (TryLoadTextureFromPath(manualPath, iconId, out wrap))
return true;
}
wrap = null;
return false;
}
private bool TryLoadTextureFromPath(string path, uint iconId, out IDalamudTextureWrap? wrap)
{
try
{
var reference = _textureProvider.GetFromGame(path);
if (reference.TryGetWrap(out var texture, out _))
{
wrap = texture;
return true;
}
}
catch (Exception ex)
{
Logger.LogTrace(ex, "Failed to load icon {IconId} from manual path {Path}", iconId, path);
}
wrap = null;
return false;
}
private static string BuildIconPath(uint iconId, bool hiRes)
{
var folder = iconId - iconId % 1000;
var basePath = $"ui/icon/{folder:000000}/{iconId:000000}";
return hiRes ? $"{basePath}_hr1.tex" : $"{basePath}.tex";
}
private bool TryGetIconWithLookup(uint iconId, bool itemHq, bool hiRes, out IDalamudTextureWrap? wrap)
{
try
{
var icon = _textureProvider.GetFromGameIcon(new GameIconLookup(iconId, itemHq, hiRes));
if (icon.TryGetWrap(out var texture, out _))
{
wrap = texture;
return true;
}
}
catch (Exception ex)
{
Logger.LogTrace(ex, "Failed to load icon {IconId} (HQ:{ItemHq}, HR:{HiRes})", iconId, itemHq, hiRes);
}
wrap = null;
return false;
}
public void LoadLocalization(string languageCode)
{
_localization.SetupWithLangCode(languageCode);
@@ -1285,13 +1392,24 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
num++;
}
ImGui.PushID(text);
string displayText = text;
string idText = text;
int idSeparatorIndex = text.IndexOf("##", StringComparison.Ordinal);
if (idSeparatorIndex >= 0)
{
displayText = text[..idSeparatorIndex];
idText = text[(idSeparatorIndex + 2)..];
if (string.IsNullOrEmpty(idText))
idText = displayText;
}
ImGui.PushID(idText);
Vector2 vector;
using (IconFont.Push())
vector = ImGui.CalcTextSize(icon.ToIconString());
Vector2 vector2 = ImGui.CalcTextSize(text);
Vector2 vector2 = ImGui.CalcTextSize(displayText);
ImDrawListPtr windowDrawList = ImGui.GetWindowDrawList();
Vector2 cursorScreenPos = ImGui.GetCursorScreenPos();
float num2 = 3f * ImGuiHelpers.GlobalScale;
@@ -1316,7 +1434,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
windowDrawList.AddText(pos, ImGui.GetColorU32(ImGuiCol.Text), icon.ToIconString());
Vector2 pos2 = new Vector2(pos.X + vector.X + num2, cursorScreenPos.Y + ImGui.GetStyle().FramePadding.Y);
windowDrawList.AddText(pos2, ImGui.GetColorU32(ImGuiCol.Text), text);
windowDrawList.AddText(pos2, ImGui.GetColorU32(ImGuiCol.Text), displayText);
ImGui.PopID();
if (num > 0)
{

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,7 @@
using System.Security.Cryptography;
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace LightlessSync.Utils;
@@ -9,8 +12,8 @@ public static class Crypto
private const int _bufferSize = 65536;
#pragma warning disable SYSLIB0021 // Type or member is obsolete
private static readonly Dictionary<(string, ushort), string> _hashListPlayersSHA256 = [];
private static readonly Dictionary<string, string> _hashListSHA256 = new(StringComparer.Ordinal);
private static readonly ConcurrentDictionary<(string, ushort), string> _hashListPlayersSHA256 = new();
private static readonly ConcurrentDictionary<string, string> _hashListSHA256 = new(StringComparer.Ordinal);
private static readonly SHA256CryptoServiceProvider _sha256CryptoProvider = new();
public static string GetFileHash(this string filePath)
@@ -42,25 +45,18 @@ public static class Crypto
public static string GetHash256(this (string, ushort) playerToHash)
{
if (_hashListPlayersSHA256.TryGetValue(playerToHash, out var hash))
return hash;
return _hashListPlayersSHA256[playerToHash] =
BitConverter.ToString(_sha256CryptoProvider.ComputeHash(Encoding.UTF8.GetBytes(playerToHash.Item1 + playerToHash.Item2.ToString()))).Replace("-", "", StringComparison.Ordinal);
return _hashListPlayersSHA256.GetOrAdd(playerToHash, key => ComputeHashSHA256(key.Item1 + key.Item2.ToString()));
}
public static string GetHash256(this string stringToHash)
{
return GetOrComputeHashSHA256(stringToHash);
return _hashListSHA256.GetOrAdd(stringToHash, ComputeHashSHA256);
}
private static string GetOrComputeHashSHA256(string stringToCompute)
private static string ComputeHashSHA256(string stringToCompute)
{
if (_hashListSHA256.TryGetValue(stringToCompute, out var hash))
return hash;
return _hashListSHA256[stringToCompute] =
BitConverter.ToString(_sha256CryptoProvider.ComputeHash(Encoding.UTF8.GetBytes(stringToCompute))).Replace("-", "", StringComparison.Ordinal);
using var sha = SHA256.Create();
return BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(stringToCompute))).Replace("-", "", StringComparison.Ordinal);
}
#pragma warning restore SYSLIB0021 // Type or member is obsolete
}

View File

@@ -4,9 +4,17 @@ using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface;
using Dalamud.Interface.ImGuiSeStringRenderer;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Textures.TextureWraps;
using Lumina.Text;
using Lumina.Text.Parse;
using Lumina.Text.ReadOnly;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Numerics;
using System.Reflection;
using System.Text;
using System.Threading;
using DalamudSeString = Dalamud.Game.Text.SeStringHandling.SeString;
using DalamudSeStringBuilder = Dalamud.Game.Text.SeStringHandling.SeStringBuilder;
@@ -19,6 +27,438 @@ public static class SeStringUtils
private static int _seStringHitboxCounter;
private static int _iconHitboxCounter;
public static bool TryRenderSeStringMarkupAtCursor(string payload)
{
if (string.IsNullOrWhiteSpace(payload))
return false;
var wrapWidth = ImGui.GetContentRegionAvail().X;
if (wrapWidth <= 0f || float.IsNaN(wrapWidth) || float.IsInfinity(wrapWidth))
wrapWidth = float.MaxValue;
var normalizedPayload = payload.ReplaceLineEndings("<br>");
try
{
_ = ReadOnlySeString.FromMacroString(normalizedPayload, new MacroStringParseOptions
{
ExceptionMode = MacroStringParseExceptionMode.Throw
});
}
catch (Exception)
{
return false;
}
try
{
var drawParams = new SeStringDrawParams
{
WrapWidth = wrapWidth,
Font = ImGui.GetFont(),
Color = ImGui.GetColorU32(ImGuiCol.Text),
};
var renderId = ImGui.GetID($"SeStringMarkup##{normalizedPayload.GetHashCode()}");
var drawResult = ImGuiHelpers.CompileSeStringWrapped(normalizedPayload, drawParams, renderId);
var height = drawResult.Size.Y;
if (height <= 0f)
height = ImGui.GetTextLineHeight();
ImGui.Dummy(new Vector2(0f, height));
if (drawResult.InteractedPayloadEnvelope.Length > 0 &&
TryExtractLink(drawResult.InteractedPayloadEnvelope, drawResult.InteractedPayload, out var linkUrl, out var tooltipText))
{
var hoverText = !string.IsNullOrEmpty(linkUrl) ? linkUrl : tooltipText;
if (!string.IsNullOrEmpty(hoverText))
{
ImGui.BeginTooltip();
ImGui.TextUnformatted(hoverText);
ImGui.EndTooltip();
}
if (!string.IsNullOrEmpty(linkUrl))
ImGui.SetMouseCursor(ImGuiMouseCursor.Hand);
if (drawResult.Clicked && !string.IsNullOrEmpty(linkUrl))
Dalamud.Utility.Util.OpenLink(linkUrl);
}
return true;
}
catch (Exception ex)
{
ImGui.TextDisabled($"[SeString error] {ex.Message}");
return false;
}
}
public enum SeStringSegmentType
{
Text,
Icon
}
public readonly record struct SeStringSegment(
SeStringSegmentType Type,
string? Text,
Vector4? Color,
uint IconId,
IDalamudTextureWrap? Texture,
Vector2 Size);
public static bool TryResolveSegments(
string payload,
float scale,
Func<uint, IDalamudTextureWrap?> iconResolver,
List<SeStringSegment> resolvedSegments,
out Vector2 totalSize)
{
totalSize = Vector2.Zero;
if (string.IsNullOrWhiteSpace(payload))
return false;
var parsedSegments = new List<ParsedSegment>(Math.Max(1, payload.Length / 4));
if (!ParseSegments(payload, parsedSegments))
return false;
float width = 0f;
float height = 0f;
foreach (var segment in parsedSegments)
{
switch (segment.Type)
{
case ParsedSegmentType.Text:
{
var text = segment.Text ?? string.Empty;
if (text.Length == 0)
break;
var textSize = ImGui.CalcTextSize(text);
resolvedSegments.Add(new SeStringSegment(SeStringSegmentType.Text, text, segment.Color, 0, null, textSize));
width += textSize.X;
height = MathF.Max(height, textSize.Y);
break;
}
case ParsedSegmentType.Icon:
{
var wrap = iconResolver(segment.IconId);
Vector2 iconSize;
string? fallback = null;
if (wrap != null)
{
iconSize = CalculateIconSize(wrap, scale);
}
else
{
fallback = $"[{segment.IconId}]";
iconSize = ImGui.CalcTextSize(fallback);
}
resolvedSegments.Add(new SeStringSegment(SeStringSegmentType.Icon, fallback, segment.Color, segment.IconId, wrap, iconSize));
width += iconSize.X;
height = MathF.Max(height, iconSize.Y);
break;
}
}
}
totalSize = new Vector2(width, height);
parsedSegments.Clear();
return resolvedSegments.Count > 0;
}
private enum ParsedSegmentType
{
Text,
Icon
}
private readonly record struct ParsedSegment(
ParsedSegmentType Type,
string? Text,
uint IconId,
Vector4? Color);
private static bool ParseSegments(string payload, List<ParsedSegment> segments)
{
var builder = new StringBuilder(payload.Length);
Vector4? activeColor = null;
var index = 0;
while (index < payload.Length)
{
if (payload[index] == '<')
{
var end = payload.IndexOf('>', index);
if (end == -1)
break;
var tagContent = payload.Substring(index + 1, end - index - 1);
if (TryHandleIconTag(tagContent, segments, builder, activeColor))
{
index = end + 1;
continue;
}
if (TryHandleColorTag(tagContent, segments, builder, ref activeColor))
{
index = end + 1;
continue;
}
builder.Append('<');
builder.Append(tagContent);
builder.Append('>');
index = end + 1;
}
else
{
builder.Append(payload[index]);
index++;
}
}
if (index < payload.Length)
builder.Append(payload, index, payload.Length - index);
FlushTextBuilder(builder, activeColor, segments);
return segments.Count > 0;
}
private static bool TryHandleIconTag(string tagContent, List<ParsedSegment> segments, StringBuilder textBuilder, Vector4? activeColor)
{
if (!tagContent.StartsWith("icon(", StringComparison.OrdinalIgnoreCase) || !tagContent.EndsWith(')'))
return false;
var inner = tagContent.AsSpan(5, tagContent.Length - 6).Trim();
if (!uint.TryParse(inner, NumberStyles.Integer, CultureInfo.InvariantCulture, out var iconId))
return false;
FlushTextBuilder(textBuilder, activeColor, segments);
segments.Add(new ParsedSegment(ParsedSegmentType.Icon, null, iconId, null));
return true;
}
private static bool TryHandleColorTag(string tagContent, List<ParsedSegment> segments, StringBuilder textBuilder, ref Vector4? activeColor)
{
if (tagContent.StartsWith("color", StringComparison.OrdinalIgnoreCase))
{
var equalsIndex = tagContent.IndexOf('=');
if (equalsIndex == -1)
return false;
var value = tagContent.Substring(equalsIndex + 1).Trim().Trim('"');
if (!TryParseColor(value, out var color))
return false;
FlushTextBuilder(textBuilder, activeColor, segments);
activeColor = color;
return true;
}
if (tagContent.Equals("/color", StringComparison.OrdinalIgnoreCase))
{
FlushTextBuilder(textBuilder, activeColor, segments);
activeColor = null;
return true;
}
return false;
}
private static void FlushTextBuilder(StringBuilder builder, Vector4? color, List<ParsedSegment> segments)
{
if (builder.Length == 0)
return;
segments.Add(new ParsedSegment(ParsedSegmentType.Text, builder.ToString(), 0, color));
builder.Clear();
}
private static bool TryExtractLink(ReadOnlySpan<byte> envelope, Payload? payload, out string url, out string? tooltipText)
{
url = string.Empty;
tooltipText = null;
if (envelope.Length == 0 && payload is null)
return false;
tooltipText = envelope.Length > 0 ? DalamudSeString.Parse(envelope.ToArray()).TextValue : null;
if (payload is not null && TryReadUrlFromPayload(payload, out url))
return true;
if (!string.IsNullOrWhiteSpace(tooltipText))
{
var candidate = FindFirstUrl(tooltipText);
if (!string.IsNullOrEmpty(candidate))
{
url = candidate;
return true;
}
}
url = string.Empty;
return false;
}
private static bool TryReadUrlFromPayload(object payload, out string url)
{
url = string.Empty;
var type = payload.GetType();
static string? ReadStringProperty(Type type, object instance, string propertyName)
=> type.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
?.GetValue(instance) as string;
string? candidate = ReadStringProperty(type, payload, "Uri")
?? ReadStringProperty(type, payload, "Url")
?? ReadStringProperty(type, payload, "Target")
?? ReadStringProperty(type, payload, "Destination");
if (IsHttpUrl(candidate))
{
url = candidate!;
return true;
}
var dataProperty = type.GetProperty("Data", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (dataProperty?.GetValue(payload) is IEnumerable<string> sequence)
{
foreach (var entry in sequence)
{
if (IsHttpUrl(entry))
{
url = entry;
return true;
}
}
}
var extraStringProp = type.GetProperty("ExtraString", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (IsHttpUrl(extraStringProp?.GetValue(payload) as string))
{
url = (extraStringProp!.GetValue(payload) as string)!;
return true;
}
var textProp = type.GetProperty("Text", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (IsHttpUrl(textProp?.GetValue(payload) as string))
{
url = (textProp!.GetValue(payload) as string)!;
return true;
}
return false;
}
private static string? FindFirstUrl(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
var index = text.IndexOf("http", StringComparison.OrdinalIgnoreCase);
while (index >= 0)
{
var end = index;
while (end < text.Length && !char.IsWhiteSpace(text[end]) && !"\"')]>".Contains(text[end]))
end++;
var candidate = text.Substring(index, end - index).TrimEnd('.', ',', ';');
if (IsHttpUrl(candidate))
return candidate;
index = text.IndexOf("http", end, StringComparison.OrdinalIgnoreCase);
}
return null;
}
private static bool IsHttpUrl(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
return Uri.TryCreate(value, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
}
public static string StripMarkup(string value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
var builder = new StringBuilder(value.Length);
int depth = 0;
foreach (var c in value)
{
if (c == '<')
{
depth++;
continue;
}
if (c == '>' && depth > 0)
{
depth--;
continue;
}
if (depth == 0)
builder.Append(c);
}
return builder.ToString().Trim();
}
private static bool TryParseColor(string value, out Vector4 color)
{
color = default;
if (string.IsNullOrEmpty(value) || value[0] != '#')
return false;
var hex = value.AsSpan(1);
if (!uint.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed))
return false;
byte a, r, g, b;
if (hex.Length == 6)
{
a = 0xFF;
r = (byte)(parsed >> 16);
g = (byte)(parsed >> 8);
b = (byte)parsed;
}
else if (hex.Length == 8)
{
a = (byte)(parsed >> 24);
r = (byte)(parsed >> 16);
g = (byte)(parsed >> 8);
b = (byte)parsed;
}
else
{
return false;
}
const float inv = 1.0f / 255f;
color = new Vector4(r * inv, g * inv, b * inv, a * inv);
return true;
}
private static Vector2 CalculateIconSize(IDalamudTextureWrap wrap, float scale)
{
const float IconHeightScale = 1.25f;
var textHeight = ImGui.GetTextLineHeight();
var baselineHeight = MathF.Max(1f, textHeight - 2f * scale);
var targetHeight = MathF.Max(baselineHeight, baselineHeight * IconHeightScale);
var aspect = wrap.Width > 0 ? wrap.Width / (float)wrap.Height : 1f;
return new Vector2(targetHeight * aspect, targetHeight);
}
public static DalamudSeString BuildFormattedPlayerName(string text, Vector4? textColor, Vector4? glowColor)
{
var b = new DalamudSeStringBuilder();

Some files were not shown because too many files have changed in this diff Show More