init 2
This commit is contained in:
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user