init 2
This commit is contained in:
15
.gitmodules
vendored
15
.gitmodules
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -823,6 +823,8 @@ public sealed class FileCacheManager : IHostedService
|
||||
|
||||
_logger.LogInformation("Started FileCacheManager");
|
||||
|
||||
_lightlessMediator.Publish(new FileCacheInitializedMessage());
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
21
LightlessSync/Interop/Ipc/TextureConversionJob.cs
Normal file
21
LightlessSync/Interop/Ipc/TextureConversionJob.cs
Normal 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);
|
||||
14
LightlessSync/LightlessConfiguration/ChatConfigService.cs
Normal file
14
LightlessSync/LightlessConfiguration/ChatConfigService.cs
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
LightlessSync/PlayerData/Pairs/IPairPerformanceSubject.cs
Normal file
16
LightlessSync/PlayerData/Pairs/IPairPerformanceSubject.cs
Normal 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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
553
LightlessSync/PlayerData/Pairs/PairCoordinator.cs
Normal file
553
LightlessSync/PlayerData/Pairs/PairCoordinator.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
1835
LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs
Normal file
1835
LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs
Normal file
File diff suppressed because it is too large
Load Diff
493
LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs
Normal file
493
LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
293
LightlessSync/PlayerData/Pairs/PairLedger.cs
Normal file
293
LightlessSync/PlayerData/Pairs/PairLedger.cs
Normal 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
149
LightlessSync/PlayerData/Pairs/PairState.cs
Normal file
149
LightlessSync/PlayerData/Pairs/PairState.cs
Normal 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);
|
||||
118
LightlessSync/PlayerData/Pairs/PairStateCache.cs
Normal file
118
LightlessSync/PlayerData/Pairs/PairStateCache.cs
Normal 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 _);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>());
|
||||
|
||||
754
LightlessSync/Services/ActorTracking/ActorObjectService.cs
Normal file
754
LightlessSync/Services/ActorTracking/ActorObjectService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
23
LightlessSync/Services/Chat/ChatModels.cs
Normal file
23
LightlessSync/Services/Chat/ChatModels.cs
Normal 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);
|
||||
1131
LightlessSync/Services/Chat/ZoneChatService.cs
Normal file
1131
LightlessSync/Services/Chat/ZoneChatService.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
20
LightlessSync/Services/LightlessProfileData.cs
Normal file
20
LightlessSync/Services/LightlessProfileData.cs
Normal 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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
282
LightlessSync/Services/TextureCompression/TexFileHelper.cs
Normal file
282
LightlessSync/Services/TextureCompression/TexFileHelper.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LightlessSync.Services.TextureCompression;
|
||||
|
||||
public sealed record TextureCompressionRequest(
|
||||
string PrimaryFilePath,
|
||||
IReadOnlyList<string> DuplicateFilePaths,
|
||||
TextureCompressionTarget Target);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace LightlessSync.Services.TextureCompression;
|
||||
|
||||
public enum TextureCompressionTarget
|
||||
{
|
||||
BC1,
|
||||
BC3,
|
||||
BC4,
|
||||
BC5,
|
||||
BC7
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
13
LightlessSync/Services/TextureCompression/TextureMapKind.cs
Normal file
13
LightlessSync/Services/TextureCompression/TextureMapKind.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace LightlessSync.Services.TextureCompression;
|
||||
|
||||
public enum TextureMapKind
|
||||
{
|
||||
Diffuse,
|
||||
Normal,
|
||||
Specular,
|
||||
Mask,
|
||||
Index,
|
||||
Emissive,
|
||||
Ui,
|
||||
Unknown
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace LightlessSync.Services.TextureCompression;
|
||||
|
||||
public enum TextureUsageCategory
|
||||
{
|
||||
Gear,
|
||||
Weapon,
|
||||
Accessory,
|
||||
Customization,
|
||||
MountOrMinion,
|
||||
Companion,
|
||||
Monster,
|
||||
Housing,
|
||||
Ui,
|
||||
VisualEffect,
|
||||
Unknown
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
701
LightlessSync/UI/EditProfileUi.Group.cs
Normal file
701
LightlessSync/UI/EditProfileUi.Group.cs
Normal 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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
25
LightlessSync/UI/Models/PairDisplayEntry.cs
Normal file
25
LightlessSync/UI/Models/PairDisplayEntry.cs
Normal 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;
|
||||
}
|
||||
30
LightlessSync/UI/Models/PairUiEntry.cs
Normal file
30
LightlessSync/UI/Models/PairUiEntry.cs
Normal 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;
|
||||
}
|
||||
24
LightlessSync/UI/Models/PairUiSnapshot.cs
Normal file
24
LightlessSync/UI/Models/PairUiSnapshot.cs
Normal 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>());
|
||||
}
|
||||
11
LightlessSync/UI/Models/VisiblePairSortMode.cs
Normal file
11
LightlessSync/UI/Models/VisiblePairSortMode.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace LightlessSync.UI.Models;
|
||||
|
||||
public enum VisiblePairSortMode
|
||||
{
|
||||
Default = 0,
|
||||
Alphabetical = 1,
|
||||
VramUsage = 2,
|
||||
EffectiveVramUsage = 3,
|
||||
TriangleCount = 4,
|
||||
PreferredDirectPairs = 5,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
84
LightlessSync/UI/ProfileEditorLayoutCoordinator.cs
Normal file
84
LightlessSync/UI/ProfileEditorLayoutCoordinator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
}
|
||||
}
|
||||
228
LightlessSync/UI/Services/PairUiService.cs
Normal file
228
LightlessSync/UI/Services/PairUiService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
|
||||
1006
LightlessSync/UI/Style/Selune.cs
Normal file
1006
LightlessSync/UI/Style/Selune.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
|
||||
|
||||
30
LightlessSync/UI/Tags/ProfileTagDefinition.cs
Normal file
30
LightlessSync/UI/Tags/ProfileTagDefinition.cs
Normal 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);
|
||||
}
|
||||
226
LightlessSync/UI/Tags/ProfileTagRenderer.cs
Normal file
226
LightlessSync/UI/Tags/ProfileTagRenderer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
131
LightlessSync/UI/Tags/ProfileTagService.cs
Normal file
131
LightlessSync/UI/Tags/ProfileTagService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
1101
LightlessSync/UI/ZoneChatUi.cs
Normal file
1101
LightlessSync/UI/ZoneChatUi.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user