2.0.0 (#92)
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m27s

2.0.0 Changes:

- Reworked shell finder UI with compact or list view with profile tags showing with the listing, allowing moderators to broadcast the syncshell as well to have it be used more.
- Reworked user list in syncshell admin screen to have filter visible and moved away from table to its own thing, allowing to copy uid/note/alias when clicking on the name.
- Reworked download bars and download box to make it look more modern, removed the jitter around, so it shouldn't vibrate around much.
- Chat has been added to the top menu, working in Zone or in Syncshells to be used there.
- Paired system has been revamped to make pausing and unpausing faster, and loading people should be faster as well.
- Moved to the internal object table to have faster load times for users; people should load in faster
- Compactor is running on a multi-threaded level instead of single-threaded; this should increase the speed of compacting files
- Nameplate Service has been reworked so it wouldn't use the nameplate handler anymore.
- Files can be resized when downloading to reduce load on users if they aren't compressed. (can be toggled to resize all).
- Penumbra Collections are now only made when people are visible, reducing the load on boot-up when having many syncshells in your list.
- Lightfinder plates have been moved away from using Nameplates, but will use an overlay.
- Main UI has been changed a bit with a gradient, and on hover will glow up now.
- Reworked Profile UI for Syncshell and Users to be more user-facing with more customizable items.
- Reworked Settings UI to look more modern.
- Performance should be better due to new systems that would dispose of the collections and better caching of items.

Co-authored-by: defnotken <itsdefnotken@gmail.com>
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: choco <choco@patat.nl>
Co-authored-by: cake <admin@cakeandbanana.nl>
Co-authored-by: Minmoose <KennethBohr@outlook.com>
Reviewed-on: #92
This commit was merged in pull request #92.
This commit is contained in:
2025-12-21 17:19:34 +00:00
parent 906f401940
commit 835a0a637d
191 changed files with 32636 additions and 8841 deletions

View File

@@ -0,0 +1,938 @@
using System.Collections.Concurrent;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Hooking;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.Interop;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.Services.ActorTracking;
public sealed 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 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 readonly OwnedObjectTracker _ownedTracker = new();
private ActorSnapshot _snapshot = ActorSnapshot.Empty;
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;
_mediator = mediator;
}
private ActorSnapshot Snapshot => Volatile.Read(ref _snapshot);
public IReadOnlyList<nint> PlayerAddresses => Snapshot.PlayerAddresses;
public IEnumerable<ActorDescriptor> PlayerDescriptors => _activePlayers.Values;
public IReadOnlyList<ActorDescriptor> PlayerCharacterDescriptors => Snapshot.PlayerDescriptors;
public bool TryGetActorByHash(string hash, out ActorDescriptor descriptor) => _actorsByHash.TryGetValue(hash, out descriptor);
public bool TryGetValidatedActorByHash(string hash, out ActorDescriptor descriptor)
{
descriptor = default;
if (!_actorsByHash.TryGetValue(hash, out var candidate))
return false;
if (!ValidateDescriptorThreadSafe(candidate))
return false;
descriptor = candidate;
return true;
}
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 (!ValidateDescriptorThreadSafe(candidate))
continue;
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 => Snapshot.OwnedObjects.RenderedPlayers;
public IReadOnlyList<nint> RenderedCompanionAddresses => Snapshot.OwnedObjects.RenderedCompanions;
public IReadOnlyList<nint> OwnedObjectAddresses => Snapshot.OwnedObjects.OwnedAddresses;
public IReadOnlyDictionary<nint, LightlessObjectKind> OwnedObjects => Snapshot.OwnedObjects.Map;
public nint LocalPlayerAddress => Snapshot.OwnedObjects.LocalPlayer;
public nint LocalPetAddress => Snapshot.OwnedObjects.LocalPet;
public nint LocalMinionOrMountAddress => Snapshot.OwnedObjects.LocalMinionOrMount;
public nint LocalCompanionAddress => Snapshot.OwnedObjects.LocalCompanion;
public bool TryGetOwnedKind(nint address, out LightlessObjectKind kind)
=> OwnedObjects.TryGetValue(address, out kind);
public bool TryGetOwnedActor(LightlessObjectKind kind, out ActorDescriptor descriptor)
{
descriptor = default;
if (!TryGetOwnedObject(kind, out var address))
return false;
return TryGetDescriptor(address, out descriptor);
}
public bool TryGetOwnedObjectByIndex(ushort objectIndex, out LightlessObjectKind ownedKind)
{
ownedKind = default;
var ownedSnapshot = OwnedObjects;
foreach (var (address, kind) in ownedSnapshot)
{
if (!TryGetDescriptor(address, out var descriptor))
continue;
if (descriptor.ObjectIndex == objectIndex)
{
ownedKind = kind;
return true;
}
}
return false;
}
public bool TryGetOwnedObject(LightlessObjectKind kind, out nint address)
{
var ownedSnapshot = Snapshot.OwnedObjects;
address = kind switch
{
LightlessObjectKind.Player => ownedSnapshot.LocalPlayer,
LightlessObjectKind.Pet => ownedSnapshot.LocalPet,
LightlessObjectKind.MinionOrMount => ownedSnapshot.LocalMinionOrMount,
LightlessObjectKind.Companion => ownedSnapshot.LocalCompanion,
_ => 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 (TryGetValidatedActorByHash(hash, out var descriptor) && descriptor.Address != nint.Zero)
{
address = descriptor.Address;
return true;
}
address = nint.Zero;
return false;
}
public async Task WaitForFullyLoadedAsync(nint address, CancellationToken cancellationToken = default)
{
if (address == nint.Zero)
throw new ArgumentException("Address cannot be zero.", nameof(address));
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var isLoaded = await _framework.RunOnFrameworkThread(() => IsObjectFullyLoaded(address)).ConfigureAwait(false);
if (isLoaded)
return;
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
}
private bool ValidateDescriptorThreadSafe(ActorDescriptor descriptor)
{
if (_framework.IsInFrameworkUpdateThread)
return ValidateDescriptorInternal(descriptor);
return _framework.RunOnFrameworkThread(() => ValidateDescriptorInternal(descriptor)).GetAwaiter().GetResult();
}
private bool ValidateDescriptorInternal(ActorDescriptor descriptor)
{
if (descriptor.Address == nint.Zero)
return false;
if (descriptor.ObjectKind == DalamudObjectKind.Player &&
!string.IsNullOrEmpty(descriptor.HashedContentId))
{
if (!TryGetLivePlayerHash(descriptor, out var liveHash))
{
UntrackGameObject(descriptor.Address);
return false;
}
if (!string.Equals(liveHash, descriptor.HashedContentId, StringComparison.Ordinal))
{
UntrackGameObject(descriptor.Address);
return false;
}
}
return true;
}
private bool TryGetLivePlayerHash(ActorDescriptor descriptor, out string liveHash)
{
liveHash = string.Empty;
if (_objectTable.CreateObjectReference(descriptor.Address) is not IPlayerCharacter playerCharacter)
return false;
return DalamudUtilService.TryGetHashedCID(playerCharacter, out liveHash);
}
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();
_ownedTracker.Reset();
Volatile.Write(ref _snapshot, ActorSnapshot.Empty);
return Task.CompletedTask;
}
private unsafe 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 unsafe void OnCharacterInitialized(Character* chara)
{
try
{
_onInitializeHook!.Original(chara);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking original character initialize.");
}
QueueFrameworkUpdate(() => TrackGameObject((GameObject*)chara));
}
private unsafe 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 unsafe 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 unsafe 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))
{
RemoveDescriptor(existing);
}
AddDescriptor(descriptor);
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 unsafe ActorDescriptor? BuildDescriptor(GameObject* gameObject, DalamudObjectKind objectKind)
{
if (gameObject == null)
return null;
var address = (nint)gameObject;
string name = string.Empty;
ushort objectIndex = gameObject->ObjectIndex;
bool isInGpose = objectIndex >= 200;
bool isLocal = _objectTable.LocalPlayer?.Address == address;
string hashedCid = string.Empty;
IPlayerCharacter? resolvedPlayer = null;
if (_objectTable.CreateObjectReference(address) is IPlayerCharacter playerCharacter)
{
resolvedPlayer = playerCharacter;
name = playerCharacter.Name.TextValue ?? string.Empty;
objectIndex = playerCharacter.ObjectIndex;
isInGpose = objectIndex >= 200;
isLocal = playerCharacter.Address == _objectTable.LocalPlayer?.Address;
}
else
{
name = gameObject->NameString ?? string.Empty;
}
if (objectKind == DalamudObjectKind.Player)
{
if (resolvedPlayer == null || !DalamudUtilService.TryGetHashedCID(resolvedPlayer, out hashedCid))
{
hashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address);
}
}
var (ownedKind, ownerEntityId) = DetermineOwnedKind(gameObject, objectKind, isLocal);
return new ActorDescriptor(name, hashedCid, address, objectIndex, isLocal, isInGpose, objectKind, ownedKind, ownerEntityId);
}
private unsafe (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 (_objectTable.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))
{
RemoveDescriptor(descriptor);
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 unsafe 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 bool TryGetDescriptor(nint address, out ActorDescriptor descriptor)
=> _activePlayers.TryGetValue(address, out descriptor);
private unsafe void OnCompanionInitialized(Companion* companion)
{
try
{
_onCompanionInitializeHook!.Original(companion);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking original companion initialize.");
}
QueueFrameworkUpdate(() => TrackGameObject((GameObject*)companion));
}
private unsafe 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)
&& _actorsByName.TryGetValue(descriptor.Name, out var bucket))
{
bucket.TryRemove(descriptor.Address, out _);
if (bucket.IsEmpty)
{
_actorsByName.TryRemove(descriptor.Name, out _);
}
}
}
private void AddDescriptor(ActorDescriptor descriptor)
{
_activePlayers[descriptor.Address] = descriptor;
IndexDescriptor(descriptor);
_ownedTracker.OnDescriptorAdded(descriptor);
PublishSnapshot();
}
private void RemoveDescriptor(ActorDescriptor descriptor)
{
RemoveDescriptorFromIndexes(descriptor);
_ownedTracker.OnDescriptorRemoved(descriptor);
PublishSnapshot();
}
private void PublishSnapshot()
{
var playerDescriptors = _activePlayers.Values
.Where(descriptor => descriptor.ObjectKind == DalamudObjectKind.Player)
.ToArray();
var playerAddresses = new nint[playerDescriptors.Length];
for (var i = 0; i < playerDescriptors.Length; i++)
playerAddresses[i] = playerDescriptors[i].Address;
var ownedSnapshot = _ownedTracker.CreateSnapshot();
var nextGeneration = Snapshot.Generation + 1;
var snapshot = new ActorSnapshot(playerDescriptors, playerAddresses, ownedSnapshot, nextGeneration);
Volatile.Write(ref _snapshot, snapshot);
}
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 unsafe 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;
}
private static unsafe bool IsObjectFullyLoaded(nint address)
{
if (address == nint.Zero)
return false;
var gameObject = (GameObject*)address;
if (gameObject == null)
return false;
var drawObject = gameObject->DrawObject;
if (drawObject == null)
return false;
if ((gameObject->RenderFlags & VisibilityFlags.Nameplate) != VisibilityFlags.None)
return false;
var characterBase = (CharacterBase*)drawObject;
if (characterBase == null)
return false;
if (characterBase->HasModelInSlotLoaded != 0)
return false;
if (characterBase->HasModelFilesInSlotLoaded != 0)
return false;
return true;
}
private sealed class OwnedObjectTracker
{
private readonly HashSet<nint> _renderedPlayers = new();
private readonly HashSet<nint> _renderedCompanions = new();
private readonly Dictionary<nint, LightlessObjectKind> _ownedObjects = new();
private nint _localPlayerAddress = nint.Zero;
private nint _localPetAddress = nint.Zero;
private nint _localMinionMountAddress = nint.Zero;
private nint _localCompanionAddress = nint.Zero;
public void OnDescriptorAdded(ActorDescriptor descriptor)
{
if (descriptor.ObjectKind == DalamudObjectKind.Player)
{
_renderedPlayers.Add(descriptor.Address);
if (descriptor.IsLocalPlayer)
_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:
_localPlayerAddress = descriptor.Address;
break;
case LightlessObjectKind.Pet:
_localPetAddress = descriptor.Address;
break;
case LightlessObjectKind.MinionOrMount:
_localMinionMountAddress = descriptor.Address;
break;
case LightlessObjectKind.Companion:
_localCompanionAddress = descriptor.Address;
break;
}
}
}
public void OnDescriptorRemoved(ActorDescriptor descriptor)
{
if (descriptor.ObjectKind == DalamudObjectKind.Player)
{
_renderedPlayers.Remove(descriptor.Address);
if (descriptor.IsLocalPlayer && _localPlayerAddress == descriptor.Address)
_localPlayerAddress = nint.Zero;
}
else if (descriptor.ObjectKind == DalamudObjectKind.Companion)
{
_renderedCompanions.Remove(descriptor.Address);
if (_localCompanionAddress == descriptor.Address)
_localCompanionAddress = nint.Zero;
}
if (descriptor.OwnedKind is { } ownedKind)
{
_ownedObjects.Remove(descriptor.Address);
switch (ownedKind)
{
case LightlessObjectKind.Player when _localPlayerAddress == descriptor.Address:
_localPlayerAddress = nint.Zero;
break;
case LightlessObjectKind.Pet when _localPetAddress == descriptor.Address:
_localPetAddress = nint.Zero;
break;
case LightlessObjectKind.MinionOrMount when _localMinionMountAddress == descriptor.Address:
_localMinionMountAddress = nint.Zero;
break;
case LightlessObjectKind.Companion when _localCompanionAddress == descriptor.Address:
_localCompanionAddress = nint.Zero;
break;
}
}
}
public OwnedObjectSnapshot CreateSnapshot()
=> new(
_renderedPlayers.ToArray(),
_renderedCompanions.ToArray(),
_ownedObjects.Keys.ToArray(),
new Dictionary<nint, LightlessObjectKind>(_ownedObjects),
_localPlayerAddress,
_localPetAddress,
_localMinionMountAddress,
_localCompanionAddress);
public void Reset()
{
_renderedPlayers.Clear();
_renderedCompanions.Clear();
_ownedObjects.Clear();
_localPlayerAddress = nint.Zero;
_localPetAddress = nint.Zero;
_localMinionMountAddress = nint.Zero;
_localCompanionAddress = nint.Zero;
}
}
private sealed record OwnedObjectSnapshot(
IReadOnlyList<nint> RenderedPlayers,
IReadOnlyList<nint> RenderedCompanions,
IReadOnlyList<nint> OwnedAddresses,
IReadOnlyDictionary<nint, LightlessObjectKind> Map,
nint LocalPlayer,
nint LocalPet,
nint LocalMinionOrMount,
nint LocalCompanion)
{
public static OwnedObjectSnapshot Empty { get; } = new(
Array.Empty<nint>(),
Array.Empty<nint>(),
Array.Empty<nint>(),
new Dictionary<nint, LightlessObjectKind>(),
nint.Zero,
nint.Zero,
nint.Zero,
nint.Zero);
}
private sealed record ActorSnapshot(
IReadOnlyList<ActorDescriptor> PlayerDescriptors,
IReadOnlyList<nint> PlayerAddresses,
OwnedObjectSnapshot OwnedObjects,
int Generation)
{
public static ActorSnapshot Empty { get; } = new(
Array.Empty<ActorDescriptor>(),
Array.Empty<nint>(),
OwnedObjectSnapshot.Empty,
0);
}
}

View File

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

View File

@@ -0,0 +1,19 @@
using LightlessSync.API.Data.Enum;
using LightlessSync.Services.CharaData.Models;
using System.Collections.Immutable;
namespace LightlessSync.Services.CharaData;
public sealed class CharacterAnalysisSummary
{
public static CharacterAnalysisSummary Empty { get; } =
new(ImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary>.Empty);
internal CharacterAnalysisSummary(IImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary> objects)
{
Objects = objects;
}
public IImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary> Objects { get; }
public bool HasData => Objects.Any(kvp => kvp.Value.HasEntries);
}

View File

@@ -0,0 +1,8 @@
using System.Runtime.InteropServices;
namespace LightlessSync.Services.CharaData.Models;
[StructLayout(LayoutKind.Auto)]
public readonly record struct CharacterAnalysisObjectSummary(int EntryCount, long TotalTriangles, long TexOriginalBytes, long TexCompressedBytes)
{
public bool HasEntries => EntryCount > 0;
}

View File

@@ -1,16 +1,14 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.FileCache;
using LightlessSync.Services.CharaData;
using LightlessSync.Services.CharaData.Models;
using LightlessSync.Services.Mediator;
using LightlessSync.UI;
using LightlessSync.Utils;
using Lumina.Data.Files;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace LightlessSync.Services;
public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
@@ -40,73 +38,97 @@ 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))
var remaining = allFiles.Where(c => !c.IsComputed || recalculate).ToList();
if (remaining.Count == 0)
return;
TotalFiles = remaining.Count;
CurrentFile = 0;
Logger.LogDebug("=== Computing {amount} remaining files ===", remaining.Count);
Mediator.Publish(new HaltScanMessage(nameof(CharacterAnalyzer)));
try
{
var remaining = allFiles.Where(c => !c.IsComputed || recalculate).ToList();
TotalFiles = remaining.Count;
CurrentFile = 1;
Logger.LogDebug("=== Computing {amount} remaining files ===", remaining.Count);
Mediator.Publish(new HaltScanMessage(nameof(CharacterAnalyzer)));
try
foreach (var file in remaining)
{
foreach (var file in remaining)
{
Logger.LogDebug("Computing file {file}", file.FilePaths[0]);
await file.ComputeSizes(_fileCacheManager, cancelToken).ConfigureAwait(false);
CurrentFile++;
}
cancelToken.ThrowIfCancellationRequested();
_fileCacheManager.WriteOutFullCsv();
var path = file.FilePaths.FirstOrDefault() ?? "<unknown>";
Logger.LogDebug("Computing file {file}", path);
await file.ComputeSizes(_fileCacheManager, cancelToken).ConfigureAwait(false);
CurrentFile++;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to analyze files");
}
finally
{
Mediator.Publish(new ResumeScanMessage(nameof(CharacterAnalyzer)));
}
await _fileCacheManager.WriteOutFullCsvAsync(cancelToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
Logger.LogInformation("File analysis cancelled");
throw;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to analyze files");
}
finally
{
Mediator.Publish(new ResumeScanMessage(nameof(CharacterAnalyzer)));
}
RecalculateSummary();
Mediator.Publish(new CharacterDataAnalyzedMessage());
_analysisCts.CancelDispose();
_analysisCts = null;
if (print) PrintAnalysis();
}
public void Dispose()
{
_analysisCts.CancelDispose();
_baseAnalysisCts.Dispose();
}
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.Exists(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);
@@ -114,9 +136,8 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
{
token.ThrowIfCancellationRequested();
var fileCacheEntries = _fileCacheManager.GetAllFileCachesByHash(fileEntry.Hash, ignoreCacheEntries: true, validate: false).ToList();
var fileCacheEntries = (await _fileCacheManager.GetAllFileCachesByHashAsync(fileEntry.Hash, ignoreCacheEntries: true, validate: false, token).ConfigureAwait(false)).ToList();
if (fileCacheEntries.Count == 0) continue;
var filePath = fileCacheEntries[0].ResolvedFilepath;
FileInfo fi = new(filePath);
string ext = "unk?";
@@ -128,30 +149,24 @@ 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,
[.. fileEntry.GamePaths],
fileCacheEntries.Select(c => c.ResolvedFilepath).Distinct().ToList(),
[.. fileCacheEntries.Select(c => c.ResolvedFilepath).Distinct(StringComparer.Ordinal)],
entry.Size > 0 ? entry.Size.Value : 0,
entry.CompressedSize > 0 ? entry.CompressedSize.Value : 0,
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 +192,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
_latestSummary = new CharacterAnalysisSummary(builder.ToImmutable());
}
private void PrintAnalysis()
{
if (LastAnalysis.Count == 0) return;
@@ -186,7 +200,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 +228,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 +235,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;
@@ -231,7 +242,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
{
var compressedsize = await fileCacheManager.GetCompressedFileData(Hash, token).ConfigureAwait(false);
var normalSize = new FileInfo(FilePaths[0]).Length;
var entries = fileCacheManager.GetAllFileCachesByHash(Hash, ignoreCacheEntries: true, validate: false);
var entries = await fileCacheManager.GetAllFileCachesByHashAsync(Hash, ignoreCacheEntries: true, validate: false, token).ConfigureAwait(false);
foreach (var entry in entries)
{
entry.Size = normalSize;
@@ -239,53 +250,40 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
}
OriginalSize = normalSize;
CompressedSize = compressedsize.Item2.LongLength;
RefreshFormat();
}
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 => _format ??= CreateFormatValue();
public Lazy<string> Format = new(() =>
private Lazy<string>? _format;
public void RefreshFormat()
{
switch (FileType)
_format = CreateFormatValue();
}
private Lazy<string> CreateFormatValue()
=> new(() =>
{
case "tex":
{
try
{
using var stream = new FileStream(FilePaths[0], FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new BinaryReader(stream);
reader.BaseStream.Position = 4;
var format = (TexFile.TextureFormat)reader.ReadInt32();
return format.ToString();
}
catch
{
return "Unknown";
}
}
default:
if (!string.Equals(FileType, "tex", StringComparison.Ordinal))
{
return string.Empty;
}
});
}
try
{
using var stream = new FileStream(FilePaths[0], FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new BinaryReader(stream);
reader.BaseStream.Position = 4;
var format = (TexFile.TextureFormat)reader.ReadInt32();
return format.ToString();
}
catch
{
return "Unknown";
}
});
}
}
public readonly record struct CharacterAnalysisObjectSummary(int EntryCount, long TotalTriangles, long TexOriginalBytes, long TexCompressedBytes)
{
public bool HasEntries => EntryCount > 0;
}
public sealed class CharacterAnalysisSummary
{
public static CharacterAnalysisSummary Empty { get; } =
new(ImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary>.Empty);
internal CharacterAnalysisSummary(IImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary> objects)
{
Objects = objects;
}
public IImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary> Objects { get; }
public bool HasData => Objects.Any(kvp => kvp.Value.HasEntries);
}

View File

@@ -0,0 +1,34 @@
using LightlessSync.API.Dto.Chat;
namespace LightlessSync.Services.Chat;
public sealed record ChatMessageEntry(
ChatMessageDto? Payload,
string DisplayName,
bool FromSelf,
DateTime ReceivedAtUtc,
ChatSystemEntry? SystemMessage = null)
{
public bool IsSystem => SystemMessage is not null;
}
public enum ChatSystemEntryType
{
ZoneSeparator
}
public sealed record ChatSystemEntry(ChatSystemEntryType Type, string? ZoneName);
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);
public readonly record struct ChatReportResult(bool Success, string? ErrorMessage);

File diff suppressed because it is too large Load Diff

View File

@@ -48,7 +48,8 @@ public sealed class CommandManagerService : IDisposable
"\t /light gpose - Opens the Lightless Character Data Hub window" + Environment.NewLine +
"\t /light analyze - Opens the Lightless Character Data Analysis window" + Environment.NewLine +
"\t /light settings - Opens the Lightless Settings window" + Environment.NewLine +
"\t /light finder - Opens the Lightfinder window"
"\t /light finder - Opens the Lightfinder window" + Environment.NewLine +
"\t /light chat - Opens the Lightless Chat window"
});
}
@@ -131,7 +132,11 @@ public sealed class CommandManagerService : IDisposable
}
else if (string.Equals(splitArgs[0], "finder", StringComparison.OrdinalIgnoreCase))
{
_mediator.Publish(new UiToggleMessage(typeof(BroadcastUI)));
_mediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
}
else if (string.Equals(splitArgs[0], "chat", StringComparison.OrdinalIgnoreCase))
{
_mediator.Publish(new UiToggleMessage(typeof(ZoneChatUi)));
}
}
}

View File

@@ -92,13 +92,13 @@ namespace LightlessSync.Services.Compactor
}
if ((flushAt - DateTime.UtcNow) <= TimeSpan.Zero) break;
try
{
await Task.Delay(TimeSpan.FromMilliseconds(5), _cts.Token).ConfigureAwait(false);
try
{
await Task.Delay(TimeSpan.FromMilliseconds(5), _cts.Token).ConfigureAwait(false);
}
catch
{
break;
catch
{
break;
}
}
@@ -124,8 +124,8 @@ namespace LightlessSync.Services.Compactor
}
}
}
catch (OperationCanceledException)
{
catch (OperationCanceledException)
{
//Shutting down worker, exception called
}
}
@@ -145,17 +145,13 @@ namespace LightlessSync.Services.Compactor
if (_useShell)
{
var inner = "filefrag -v " + string.Join(' ', list.Select(QuoteSingle));
var inner = "filefrag -v -- " + string.Join(' ', list.Select(QuoteSingle));
res = _runShell(inner, timeoutMs: 15000, workingDir: "/");
}
else
{
var args = new List<string> { "-v" };
foreach (var path in list)
{
args.Add(' ' + path);
}
var args = new List<string> { "-v", "--" };
args.AddRange(list);
res = _runDirect("filefrag", args, workingDir: "/", timeoutMs: 15000);
}
@@ -200,7 +196,7 @@ namespace LightlessSync.Services.Compactor
/// Regex of the File Size return on the Linux/Wine systems, giving back the amount
/// </summary>
/// <returns>Regex of the File Size</returns>
[GeneratedRegex(@"^File size of (/.+?) is ", RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant,matchTimeoutMilliseconds: 500)]
[GeneratedRegex(@"^File size of (/.+?) is ", RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 500)]
private static partial Regex SizeRegex();
/// <summary>

View File

@@ -1,14 +1,18 @@
using Dalamud.Game.ClientState.Objects.SubKinds;
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;
using LightlessSync.UI;
using LightlessSync.Services.LightFinder;
namespace LightlessSync.Services;
@@ -20,11 +24,17 @@ 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 LightFinderScannerService _broadcastScannerService;
private readonly LightFinderService _broadcastService;
private readonly LightlessProfileManager _lightlessProfileManager;
private readonly LightlessMediator _mediator;
private const int _lightlessPrefixColor = 708;
public ContextMenuService(
IContextMenu contextMenu,
@@ -33,11 +43,15 @@ internal class ContextMenuService : IHostedService
ILogger<ContextMenuService> logger,
DalamudUtilService dalamudUtil,
ApiController apiController,
IObjectTable objectTable,
IObjectTable objectTable,
LightlessConfigService configService,
PairRequestService pairRequestService,
PairManager pairManager,
IClientState clientState)
PairUiService pairUiService,
IClientState clientState,
LightFinderScannerService broadcastScannerService,
LightFinderService broadcastService,
LightlessProfileManager lightlessProfileManager,
LightlessMediator mediator)
{
_contextMenu = contextMenu;
_pluginInterface = pluginInterface;
@@ -47,9 +61,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,52 +96,109 @@ internal class ContextMenuService : IHostedService
private void OnMenuOpened(IMenuOpenedArgs args)
{
if (!_pluginInterface.UiBuilder.ShouldModifyUi)
return;
if (args.AddonName != null)
{
var addonName = args.AddonName;
_logger.LogTrace("Context menu addon name: {AddonName}", addonName);
return;
//Check if target is not menutargetdefault.
}
if (args.Target is not MenuTargetDefault target)
{
_logger.LogTrace("Context menu target is not MenuTargetDefault.");
return;
}
_logger.LogTrace("Context menu opened for target: {Target}", target.TargetName ?? "null");
//Check if name or target id isnt null/zero
if (string.IsNullOrEmpty(target.TargetName) || target.TargetObjectId == 0 || target.TargetHomeWorld.RowId == 0)
{
_logger.LogTrace("Context menu target has invalid data: Name='{TargetName}', ObjectId={TargetObjectId}, HomeWorldId={TargetHomeWorldId}", target.TargetName, target.TargetObjectId, target.TargetHomeWorld.RowId);
return;
}
//Check if it is a real target.
IPlayerCharacter? targetData = GetPlayerFromObjectTable(target);
if (targetData == null || targetData.Address == nint.Zero)
if (targetData == null || targetData.Address == nint.Zero || _objectTable.LocalPlayer == null)
{
_logger.LogTrace("Target player {TargetName}@{World} not found in object table.", target.TargetName, target.TargetHomeWorld.RowId);
return;
}
var snapshot = _pairUiService.GetSnapshot();
var pair = snapshot.PairsByUid.Values.FirstOrDefault(p =>
p.IsVisible &&
p.PlayerCharacterId != uint.MaxValue &&
p.PlayerCharacterId == target.TargetObjectId);
if (pair is not null)
{
_logger.LogTrace("Target player {TargetName}@{World} is already paired, adding existing pair context menu.", target.TargetName, target.TargetHomeWorld.RowId);
pair.AddContextMenu(args);
if (!pair.IsDirectlyPaired)
{
_logger.LogTrace("Target player {TargetName}@{World} is not directly paired, add direct pair menu item", target.TargetName, target.TargetHomeWorld.RowId);
AddDirectPairMenuItem(args);
}
return;
}
_logger.LogTrace("Target player {TargetName}@{World} is not paired, adding direct pair request context menu.", target.TargetName, target.TargetHomeWorld.RowId);
//Check if user is directly paired or is own.
if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _clientState.LocalPlayer.GameObjectId == target.TargetObjectId)
if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _objectTable.LocalPlayer?.GameObjectId == target.TargetObjectId || !_configService.Current.EnableRightClickMenus)
{
_logger.LogTrace("Target player {TargetName}@{World} is already paired or is self, or right-click menus are disabled.", target.TargetName, target.TargetHomeWorld.RowId);
return;
}
//Check if in PVP or GPose
if (_clientState.IsPvPExcludingDen || _clientState.IsGPosing)
{
_logger.LogTrace("Cannot send pair request to {TargetName}@{World} while in PvP or GPose.", target.TargetName, target.TargetHomeWorld.RowId);
return;
}
//Check for valid world.
var world = GetWorld(target.TargetHomeWorld.RowId);
if (!IsWorldValid(world))
return;
if (!_configService.Current.EnableRightClickMenus)
return;
args.AddMenuItem(new MenuItem
{
Name = "Send Direct Pair Request",
PrefixChar = 'L',
UseDefaultPrefix = false,
PrefixColor = 708,
OnClicked = async _ => await HandleSelection(args).ConfigureAwait(false)
});
_logger.LogTrace("Target player {TargetName}@{World} is on an invalid world.", target.TargetName, target.TargetHomeWorld.RowId);
return;
}
string? targetHashedCid = null;
if (_broadcastService.IsBroadcasting)
{
targetHashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address);
}
if (!string.IsNullOrEmpty(targetHashedCid) && CanOpenLightfinderProfile(targetHashedCid))
{
var hashedCid = targetHashedCid;
UiSharedService.AddContextMenuItem(args, name: "Open Lightless Profile", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => HandleLightfinderProfileSelection(hashedCid));
}
AddDirectPairMenuItem(args);
}
private void AddDirectPairMenuItem(IMenuOpenedArgs args)
{
UiSharedService.AddContextMenuItem(
args,
name: "Send Direct Pair Request",
prefixChar: 'L',
colorMenuItem: _lightlessPrefixColor,
onClick: () => HandleSelection(args));
}
private HashSet<ulong> VisibleUserIds =>
[.. _pairUiService.GetSnapshot().PairsByUid.Values
.Where(p => p.IsVisible && p.PlayerCharacterId != uint.MaxValue)
.Select(p => (ulong)p.PlayerCharacterId)];
private async Task HandleSelection(IMenuArgs args)
{
if (args.Target is not MenuTargetDefault target)
@@ -159,9 +234,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)
{
@@ -200,8 +314,6 @@ internal class ContextMenuService : IHostedService
private static bool IsChineseJapaneseKoreanCharacter(char c) => c >= 0x4E00 && c <= 0x9FFF;
public bool IsWorldValid(uint worldId) => IsWorldValid(GetWorld(worldId));
public static bool IsWorldValid(World world)
{
var name = world.Name.ToString();

View File

@@ -12,7 +12,10 @@ 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;
@@ -21,7 +24,9 @@ using Microsoft.Extensions.Logging;
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;
using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags;
namespace LightlessSync.Services;
@@ -37,23 +42,27 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private readonly IGameGui _gameGui;
private readonly ILogger<DalamudUtilService> _logger;
private readonly IObjectTable _objectTable;
private readonly ActorObjectService _actorObjectService;
private readonly ITargetManager _targetManager;
private readonly PerformanceCollectorService _performanceCollector;
private readonly LightlessConfigService _configService;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly Lazy<PairFactory> _pairFactory;
private PairUniqueIdentifier? _FocusPairIdent;
private IGameObject? _FocusOriginalTarget;
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 +72,14 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_condition = condition;
_gameData = gameData;
_gameConfig = gameConfig;
_actorObjectService = actorObjectService;
_targetManager = targetManager;
_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,17 +131,24 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
mediator.Subscribe<TargetPairMessage>(this, (msg) =>
{
if (clientState.IsPvP) return;
var name = msg.Pair.PlayerName;
if (string.IsNullOrEmpty(name)) return;
var addr = _playerCharas.FirstOrDefault(f => string.Equals(f.Value.Name, name, StringComparison.Ordinal)).Value.Address;
if (addr == nint.Zero) return;
if (!ResolvePairAddress(msg.Pair, out var pair, out var addr)) return;
var useFocusTarget = _configService.Current.UseFocusTarget;
_ = RunOnFrameworkThread(() =>
{
var gameObject = CreateGameObject(addr);
if (gameObject is null) return;
if (useFocusTarget)
targetManager.FocusTarget = CreateGameObject(addr);
{
_targetManager.FocusTarget = gameObject;
if (_FocusPairIdent.HasValue && _FocusPairIdent.Value.Equals(pair.UniqueIdent))
{
_FocusOriginalTarget = _targetManager.FocusTarget;
}
}
else
targetManager.Target = CreateGameObject(addr);
{
_targetManager.Target = gameObject;
}
}).ConfigureAwait(false);
});
IsWine = Util.IsWine();
@@ -139,6 +158,61 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private Lazy<ulong> RebuildCID() => new(GetCID);
public bool IsWine { get; init; }
private bool ResolvePairAddress(Pair pair, out Pair resolvedPair, out nint address)
{
resolvedPair = _pairFactory.Value.Create(pair.UniqueIdent) ?? pair;
address = nint.Zero;
var name = resolvedPair.PlayerName;
if (string.IsNullOrEmpty(name)) return false;
if (!_actorObjectService.TryGetPlayerByName(name, out var descriptor))
return false;
address = descriptor.Address;
return address != nint.Zero;
}
public void FocusVisiblePair(Pair pair)
{
if (_clientState.IsPvP) return;
if (!ResolvePairAddress(pair, out var resolvedPair, out var address)) return;
_ = RunOnFrameworkThread(() => FocusPairUnsafe(address, resolvedPair.UniqueIdent));
}
public void ReleaseVisiblePairFocus()
{
_ = RunOnFrameworkThread(ReleaseFocusUnsafe);
}
private void FocusPairUnsafe(nint address, PairUniqueIdentifier pairIdent)
{
var target = CreateGameObject(address);
if (target is null) return;
if (!_FocusPairIdent.HasValue)
{
_FocusOriginalTarget = _targetManager.FocusTarget;
}
_targetManager.FocusTarget = target;
_FocusPairIdent = pairIdent;
}
private void ReleaseFocusUnsafe()
{
if (!_FocusPairIdent.HasValue)
{
return;
}
var previous = _FocusOriginalTarget;
if (previous != null && !IsObjectPresent(previous))
{
previous = null;
}
_targetManager.FocusTarget = previous;
_FocusPairIdent = null;
_FocusOriginalTarget = null;
}
public unsafe GameObject* GposeTarget
{
@@ -165,6 +239,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public bool IsInCombat { get; private set; } = false;
public bool IsPerforming { get; private set; } = false;
public bool IsInInstance { get; private set; } = false;
public bool IsInDuty => _condition[ConditionFlag.BoundByDuty];
public bool HasModifiedGameFiles => _gameData.HasModifiedGameDataFiles;
public uint ClassJobId => _classJobId!.Value;
public Lazy<Dictionary<uint, string>> JobData { get; private set; }
@@ -174,6 +249,32 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public bool IsLodEnabled { get; private set; }
public LightlessMediator Mediator { get; }
public bool IsInFieldOperation
{
get
{
if (!IsInDuty)
{
return false;
}
var territoryId = _clientState.TerritoryType;
if (territoryId == 0)
{
return false;
}
if (!TerritoryData.Value.TryGetValue(territoryId, out var name) || string.IsNullOrWhiteSpace(name))
{
return false;
}
return name.Contains("Eureka", StringComparison.OrdinalIgnoreCase)
|| name.Contains("Bozja", StringComparison.OrdinalIgnoreCase)
|| name.Contains("Zadnor", StringComparison.OrdinalIgnoreCase);
}
}
public IGameObject? CreateGameObject(IntPtr reference)
{
EnsureIsOnFramework();
@@ -194,7 +295,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,13 +327,19 @@ 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()
{
EnsureIsOnFramework();
return _clientState.LocalPlayer != null && _clientState.LocalPlayer.IsValid();
return _objectTable.LocalPlayer != null && _objectTable.LocalPlayer.IsValid();
}
public async Task<bool> GetIsPlayerPresentAsync()
@@ -245,7 +352,28 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
EnsureIsOnFramework();
playerPointer ??= GetPlayerPtr();
if (playerPointer == IntPtr.Zero) return IntPtr.Zero;
return _objectTable.GetObjectAddress(((GameObject*)playerPointer)->ObjectIndex + 1);
var playerAddress = playerPointer.Value;
var ownerEntityId = ((Character*)playerAddress)->EntityId;
if (ownerEntityId == 0) return IntPtr.Zero;
if (playerAddress == _actorObjectService.LocalPlayerAddress)
{
var localOwned = _actorObjectService.LocalMinionOrMountAddress;
if (localOwned != nint.Zero)
{
return localOwned;
}
}
var ownedObject = FindOwnedObject(ownerEntityId, playerAddress, static kind =>
kind == DalamudObjectKind.MountType || kind == DalamudObjectKind.Companion);
if (ownedObject != nint.Zero)
{
return ownedObject;
}
return _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1);
}
public async Task<IntPtr> GetMinionOrMountAsync(IntPtr? playerPointer = null)
@@ -268,6 +396,62 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return await RunOnFrameworkThread(() => GetPetPtr(playerPointer)).ConfigureAwait(false);
}
private unsafe nint FindOwnedObject(uint ownerEntityId, nint ownerAddress, Func<DalamudObjectKind, bool> matchesKind)
{
if (ownerEntityId == 0)
{
return nint.Zero;
}
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == ownerAddress)
{
continue;
}
if (!matchesKind(obj.ObjectKind))
{
continue;
}
var candidate = (GameObject*)obj.Address;
if (ResolveOwnerId(candidate) == ownerEntityId)
{
return obj.Address;
}
}
return nint.Zero;
}
private static unsafe uint ResolveOwnerId(GameObject* gameObject)
{
if (gameObject == null)
{
return 0;
}
if (gameObject->OwnerId != 0)
{
return gameObject->OwnerId;
}
var character = (Character*)gameObject;
if (character == null)
{
return 0;
}
if (character->CompanionOwnerId != 0)
{
return character->CompanionOwnerId;
}
var parent = character->GetParentCharacter();
return parent != null ? parent->EntityId : 0;
}
public async Task<IPlayerCharacter> GetPlayerCharacterAsync()
{
return await RunOnFrameworkThread(GetPlayerCharacter).ConfigureAwait(false);
@@ -276,19 +460,20 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public IPlayerCharacter GetPlayerCharacter()
{
EnsureIsOnFramework();
return _clientState.LocalPlayer!;
return _objectTable.LocalPlayer!;
}
public IntPtr GetPlayerCharacterFromCachedTableByIdent(string characterName)
{
if (_playerCharas.TryGetValue(characterName, out var pchar)) return pchar.Address;
if (_actorObjectService.TryGetValidatedActorByHash(characterName, out var actor))
return actor.Address;
return IntPtr.Zero;
}
public string GetPlayerName()
{
EnsureIsOnFramework();
return _clientState.LocalPlayer?.Name.ToString() ?? "--";
return _objectTable.LocalPlayer?.Name.ToString() ?? "--";
}
public async Task<string> GetPlayerNameAsync()
@@ -313,6 +498,24 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return await RunOnFrameworkThread(() => _cid.Value.ToString().GetHash256()).ConfigureAwait(false);
}
public static unsafe bool TryGetHashedCID(IPlayerCharacter? playerCharacter, out string hashedCid)
{
hashedCid = string.Empty;
if (playerCharacter == null)
return false;
var address = playerCharacter.Address;
if (address == nint.Zero)
return false;
var cid = ((BattleChara*)address)->Character.ContentId;
if (cid == 0)
return false;
hashedCid = cid.ToString().GetHash256();
return true;
}
public unsafe static string GetHashedCIDFromPlayerPointer(nint ptr)
{
return ((BattleChara*)ptr)->Character.ContentId.ToString().GetHash256();
@@ -321,7 +524,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public IntPtr GetPlayerPtr()
{
EnsureIsOnFramework();
return _clientState.LocalPlayer?.Address ?? IntPtr.Zero;
return _objectTable.LocalPlayer?.Address ?? IntPtr.Zero;
}
public async Task<IntPtr> GetPlayerPointerAsync()
@@ -332,13 +535,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public uint GetHomeWorldId()
{
EnsureIsOnFramework();
return _clientState.LocalPlayer?.HomeWorld.RowId ?? 0;
return _objectTable.LocalPlayer?.HomeWorld.RowId ?? 0;
}
public uint GetWorldId()
{
EnsureIsOnFramework();
return _clientState.LocalPlayer!.CurrentWorld.RowId;
return _objectTable.LocalPlayer!.CurrentWorld.RowId;
}
public unsafe LocationInfo GetMapData()
@@ -347,8 +550,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
var agentMap = AgentMap.Instance();
var houseMan = HousingManager.Instance();
uint serverId = 0;
if (_clientState.LocalPlayer == null) serverId = 0;
else serverId = _clientState.LocalPlayer.CurrentWorld.RowId;
if (_objectTable.LocalPlayer == null) serverId = 0;
else serverId = _objectTable.LocalPlayer.CurrentWorld.RowId;
uint mapId = agentMap == null ? 0 : agentMap->CurrentMapId;
uint territoryId = agentMap == null ? 0 : agentMap->CurrentTerritoryId;
uint divisionId = houseMan == null ? 0 : (uint)(houseMan->GetCurrentDivision());
@@ -436,17 +639,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
var fileName = Path.GetFileNameWithoutExtension(callerFilePath);
await _performanceCollector.LogPerformance(this, $"RunOnFramework:Act/{fileName}>{callerMember}:{callerLineNumber}", async () =>
{
if (!_framework.IsInFrameworkUpdateThread)
if (_framework.IsInFrameworkUpdateThread)
{
await _framework.RunOnFrameworkThread(act).ContinueWith((_) => Task.CompletedTask).ConfigureAwait(false);
while (_framework.IsInFrameworkUpdateThread) // yield the thread again, should technically never be triggered
{
_logger.LogTrace("Still on framework");
await Task.Delay(1).ConfigureAwait(false);
}
}
else
act();
return;
}
await _framework.RunOnFrameworkThread(act).ConfigureAwait(false);
}).ConfigureAwait(false);
}
@@ -455,18 +654,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
var fileName = Path.GetFileNameWithoutExtension(callerFilePath);
return await _performanceCollector.LogPerformance(this, $"RunOnFramework:Func<{typeof(T)}>/{fileName}>{callerMember}:{callerLineNumber}", async () =>
{
if (!_framework.IsInFrameworkUpdateThread)
if (_framework.IsInFrameworkUpdateThread)
{
var result = await _framework.RunOnFrameworkThread(func).ContinueWith((task) => task.Result).ConfigureAwait(false);
while (_framework.IsInFrameworkUpdateThread) // yield the thread again, should technically never be triggered
{
_logger.LogTrace("Still on framework");
await Task.Delay(1).ConfigureAwait(false);
}
return result;
return func.Invoke();
}
return func.Invoke();
return await _framework.RunOnFrameworkThread(func).ConfigureAwait(false);
}).ConfigureAwait(false);
}
@@ -476,7 +669,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_framework.Update += FrameworkOnUpdate;
if (IsLoggedIn)
{
_classJobId = _clientState.LocalPlayer!.ClassJob.RowId;
_classJobId = _objectTable.LocalPlayer!.ClassJob.RowId;
}
_logger.LogInformation("Started DalamudUtilService");
@@ -489,6 +682,17 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
Mediator.UnsubscribeAll(this);
_framework.Update -= FrameworkOnUpdate;
if (_FocusPairIdent.HasValue)
{
if (_framework.IsInFrameworkUpdateThread)
{
ReleaseFocusUnsafe();
}
else
{
_ = RunOnFrameworkThread(ReleaseFocusUnsafe);
}
}
return Task.CompletedTask;
}
@@ -513,15 +717,11 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
{
logger.LogTrace("[{redrawId}] Waiting for {handler} to finish drawing", redrawId, handler);
curWaitTime += tick;
await Task.Delay(tick).ConfigureAwait(true);
await Task.Delay(tick, ct.Value).ConfigureAwait(true);
}
logger.LogTrace("[{redrawId}] Finished drawing after {curWaitTime}ms", redrawId, curWaitTime);
}
catch (NullReferenceException ex)
{
logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler);
}
catch (AccessViolationException ex)
{
logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler);
@@ -535,7 +735,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
const int tick = 250;
int curWaitTime = 0;
_logger.LogTrace("RenderFlags: {flags}", obj->RenderFlags.ToString("X"));
while (obj->RenderFlags != 0x00 && curWaitTime < timeOut)
while (obj->RenderFlags != VisibilityFlags.None && curWaitTime < timeOut)
{
_logger.LogTrace($"Waiting for gpose actor to finish drawing");
curWaitTime += tick;
@@ -552,8 +752,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
internal (string Name, nint Address) FindPlayerByNameHash(string ident)
{
_playerCharas.TryGetValue(ident, out var result);
return result;
if (_actorObjectService.TryGetValidatedActorByHash(ident, out var descriptor))
{
return (descriptor.Name, descriptor.Address);
}
return default;
}
public string? GetWorldNameFromPlayerAddress(nint address)
@@ -576,7 +780,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
bool isDrawingChanged = false;
if ((nint)drawObj != IntPtr.Zero)
{
isDrawing = gameObj->RenderFlags == 0b100000000000;
isDrawing = (gameObj->RenderFlags & VisibilityFlags.Nameplate) != VisibilityFlags.None;
if (!isDrawing)
{
isDrawing = ((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0;
@@ -629,7 +833,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private unsafe void FrameworkOnUpdateInternal()
{
if ((_clientState.LocalPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty])
if ((_objectTable.LocalPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty])
{
return;
}
@@ -639,37 +843,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))
@@ -679,76 +889,75 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_lastGlobalBlockReason = string.Empty;
}
if (_clientState.IsGPosing && !IsInGpose)
{
_logger.LogDebug("Gpose start");
IsInGpose = true;
Mediator.Publish(new GposeStartMessage());
}
else if (!_clientState.IsGPosing && IsInGpose)
{
_logger.LogDebug("Gpose end");
IsInGpose = false;
Mediator.Publish(new GposeEndMessage());
}
// Checks on conditions
var shouldBeInGpose = _clientState.IsGPosing;
var shouldBeInCombat = _condition[ConditionFlag.InCombat] && !IsInInstance && _playerPerformanceConfigService.Current.PauseInCombat;
var shouldBePerforming = _condition[ConditionFlag.Performing] && _playerPerformanceConfigService.Current.PauseWhilePerforming;
var shouldBeInInstance = _condition[ConditionFlag.BoundByDuty] && _playerPerformanceConfigService.Current.PauseInInstanceDuty;
var shouldBeInCutscene = _condition[ConditionFlag.WatchingCutscene];
if ((_condition[ConditionFlag.InCombat]) && !IsInCombat && !IsInInstance && _playerPerformanceConfigService.Current.PauseInCombat)
{
_logger.LogDebug("Combat start");
IsInCombat = true;
Mediator.Publish(new CombatStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsInCombat)));
}
else if ((!_condition[ConditionFlag.InCombat]) && IsInCombat && !IsInInstance && _playerPerformanceConfigService.Current.PauseInCombat)
{
_logger.LogDebug("Combat end");
IsInCombat = false;
Mediator.Publish(new CombatEndMessage());
Mediator.Publish(new ResumeScanMessage(nameof(IsInCombat)));
}
if (_condition[ConditionFlag.Performing] && !IsPerforming && _playerPerformanceConfigService.Current.PauseWhilePerforming)
{
_logger.LogDebug("Performance start");
IsInCombat = true;
Mediator.Publish(new PerformanceStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsPerforming)));
}
else if (!_condition[ConditionFlag.Performing] && IsPerforming && _playerPerformanceConfigService.Current.PauseWhilePerforming)
{
_logger.LogDebug("Performance end");
IsInCombat = false;
Mediator.Publish(new PerformanceEndMessage());
Mediator.Publish(new ResumeScanMessage(nameof(IsPerforming)));
}
if ((_condition[ConditionFlag.BoundByDuty]) && !IsInInstance && _playerPerformanceConfigService.Current.PauseInInstanceDuty)
{
_logger.LogDebug("Instance start");
IsInInstance = true;
Mediator.Publish(new InstanceOrDutyStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsInInstance)));
}
else if (((!_condition[ConditionFlag.BoundByDuty]) && IsInInstance && _playerPerformanceConfigService.Current.PauseInInstanceDuty) || ((_condition[ConditionFlag.BoundByDuty]) && IsInInstance && !_playerPerformanceConfigService.Current.PauseInInstanceDuty))
{
_logger.LogDebug("Instance end");
IsInInstance = false;
Mediator.Publish(new InstanceOrDutyEndMessage());
Mediator.Publish(new ResumeScanMessage(nameof(IsInInstance)));
}
// Gpose
HandleStateTransition(() => IsInGpose, v => IsInGpose = v, shouldBeInGpose, "Gpose",
onEnter: () =>
{
Mediator.Publish(new GposeStartMessage());
},
onExit: () =>
{
Mediator.Publish(new GposeEndMessage());
});
if (_condition[ConditionFlag.WatchingCutscene] && !IsInCutscene)
{
_logger.LogDebug("Cutscene start");
IsInCutscene = true;
Mediator.Publish(new CutsceneStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsInCutscene)));
}
else if (!_condition[ConditionFlag.WatchingCutscene] && IsInCutscene)
{
_logger.LogDebug("Cutscene end");
IsInCutscene = false;
Mediator.Publish(new CutsceneEndMessage());
Mediator.Publish(new ResumeScanMessage(nameof(IsInCutscene)));
}
// Combat
HandleStateTransition(() => IsInCombat, v => IsInCombat = v, shouldBeInCombat, "Combat",
onEnter: () =>
{
Mediator.Publish(new CombatStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsInCombat)));
},
onExit: () =>
{
Mediator.Publish(new CombatEndMessage());
Mediator.Publish(new ResumeScanMessage(nameof(IsInCombat)));
});
// Performance
HandleStateTransition(() => IsPerforming, v => IsPerforming = v, shouldBePerforming, "Performance",
onEnter: () =>
{
Mediator.Publish(new PerformanceStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsPerforming)));
},
onExit: () =>
{
Mediator.Publish(new PerformanceEndMessage());
Mediator.Publish(new ResumeScanMessage(nameof(IsPerforming)));
});
// Instance / Duty
HandleStateTransition(() => IsInInstance, v => IsInInstance = v, shouldBeInInstance, "Instance",
onEnter: () =>
{
Mediator.Publish(new InstanceOrDutyStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsInInstance)));
},
onExit: () =>
{
Mediator.Publish(new InstanceOrDutyEndMessage());
Mediator.Publish(new ResumeScanMessage(nameof(IsInInstance)));
});
// Cutscene
HandleStateTransition(() => IsInCutscene,v => IsInCutscene = v, shouldBeInCutscene, "Cutscene",
onEnter: () =>
{
Mediator.Publish(new CutsceneStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsInCutscene)));
},
onExit: () =>
{
Mediator.Publish(new CutsceneEndMessage());
Mediator.Publish(new ResumeScanMessage(nameof(IsInCutscene)));
});
if (IsInCutscene)
{
@@ -782,10 +991,22 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
Mediator.Publish(new ResumeScanMessage(nameof(ConditionFlag.BetweenAreas)));
}
var localPlayer = _clientState.LocalPlayer;
var localPlayer = _objectTable.LocalPlayer;
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 +1022,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 +1030,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
{
_logger.LogDebug("Logged out");
IsLoggedIn = false;
_lastWorldId = 0;
Mediator.Publish(new DalamudLogoutMessage());
}
@@ -825,4 +1048,31 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_delayedFrameworkUpdateCheck = DateTime.UtcNow;
});
}
}
/// <summary>
/// Handler for the transition of different states of game
/// </summary>
/// <param name="getState">Get state of condition</param>
/// <param name="setState">Set state of condition</param>
/// <param name="shouldBeActive">Correction of the state of the condition</param>
/// <param name="stateName">Condition name</param>
/// <param name="onEnter">Function for on entering the state</param>
/// <param name="onExit">Function for on leaving the state</param>
private void HandleStateTransition(Func<bool> getState, Action<bool> setState, bool shouldBeActive, string stateName, System.Action onEnter, System.Action onExit)
{
var isActive = getState();
if (shouldBeActive && !isActive)
{
_logger.LogDebug("{stateName} start", stateName);
setState(true);
onEnter();
}
else if (!shouldBeActive && isActive)
{
_logger.LogDebug("{stateName} end", stateName);
setState(false);
onExit();
}
}
}

View File

@@ -6,6 +6,8 @@ public record Event
{
public DateTime EventTime { get; }
public string UID { get; }
public string AliasOrUid { get; }
public string UserId { get; }
public string Character { get; }
public string EventSource { get; }
public EventSeverity EventSeverity { get; }
@@ -14,7 +16,9 @@ public record Event
public Event(string? Character, UserData UserData, string EventSource, EventSeverity EventSeverity, string Message)
{
EventTime = DateTime.Now;
this.UID = UserData.AliasOrUID;
this.UserId = UserData.UID;
this.AliasOrUid = UserData.AliasOrUID;
this.UID = UserData.UID;
this.Character = Character ?? string.Empty;
this.EventSource = EventSource;
this.EventSeverity = EventSeverity;
@@ -37,7 +41,7 @@ public record Event
else
{
if (string.IsNullOrEmpty(Character))
return $"{EventTime:HH:mm:ss.fff}\t[{EventSource}]{{{(int)EventSeverity}}}\t<{UID}> {Message}";
return $"{EventTime:HH:mm:ss.fff}\t[{EventSource}]{{{(int)EventSeverity}}}\t<{AliasOrUid}> {Message}";
else
return $"{EventTime:HH:mm:ss.fff}\t[{EventSource}]{{{(int)EventSeverity}}}\t<{UID}\\{Character}> {Message}";
}

View File

@@ -0,0 +1,692 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.Text;
using Dalamud.Interface;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.System.Framework;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.Rendering;
using LightlessSync.UI;
using LightlessSync.UI.Services;
using LightlessSync.Utils;
using LightlessSync.UtilsEnum.Enum;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using Pictomancy;
using System.Collections.Immutable;
using System.Globalization;
using System.Numerics;
using Task = System.Threading.Tasks.Task;
namespace LightlessSync.Services.LightFinder;
public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscriber
{
private readonly ILogger<LightFinderPlateHandler> _logger;
private readonly IAddonLifecycle _addonLifecycle;
private readonly IGameGui _gameGui;
private readonly IObjectTable _objectTable;
private readonly LightlessConfigService _configService;
private readonly PairUiService _pairUiService;
private readonly LightlessMediator _mediator;
public LightlessMediator Mediator => _mediator;
private readonly IUiBuilder _uiBuilder;
private bool _mEnabled;
private bool _needsLabelRefresh;
private bool _drawSubscribed;
private AddonNamePlate* _mpNameplateAddon;
private readonly object _labelLock = new();
private readonly NameplateBuffers _buffers = new();
private int _labelRenderCount;
private const string DefaultLabelText = "LightFinder";
private const SeIconChar DefaultIcon = SeIconChar.Hyadelyn;
private static readonly string DefaultIconGlyph = SeIconCharExtensions.ToIconString(DefaultIcon);
private static readonly Vector2 DefaultPivot = new(0.5f, 1f);
private ImmutableHashSet<string> _activeBroadcastingCids = [];
public LightFinderPlateHandler(
ILogger<LightFinderPlateHandler> logger,
IAddonLifecycle addonLifecycle,
IGameGui gameGui,
LightlessConfigService configService,
LightlessMediator mediator,
IObjectTable objectTable,
PairUiService pairUiService,
IDalamudPluginInterface pluginInterface,
PictomancyService pictomancyService)
{
_logger = logger;
_addonLifecycle = addonLifecycle;
_gameGui = gameGui;
_configService = configService;
_mediator = mediator;
_objectTable = objectTable;
_pairUiService = pairUiService;
_uiBuilder = pluginInterface.UiBuilder ?? throw new ArgumentNullException(nameof(pluginInterface));
_ = pictomancyService ?? throw new ArgumentNullException(nameof(pictomancyService));
}
internal void Init()
{
if (!_drawSubscribed)
{
_uiBuilder.Draw += OnUiBuilderDraw;
_drawSubscribed = true;
}
EnableNameplate();
_mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, OnTick);
}
internal void Uninit()
{
DisableNameplate();
if (_drawSubscribed)
{
_uiBuilder.Draw -= OnUiBuilderDraw;
_drawSubscribed = false;
}
ClearLabelBuffer();
_mediator.Unsubscribe<PriorityFrameworkUpdateMessage>(this);
_mpNameplateAddon = null;
}
internal void EnableNameplate()
{
if (!_mEnabled)
{
try
{
_addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour);
_mEnabled = true;
}
catch (Exception e)
{
_logger.LogError(e, "Unknown error while trying to enable nameplate.");
DisableNameplate();
}
}
}
internal void DisableNameplate()
{
if (_mEnabled)
{
try
{
_addonLifecycle.UnregisterListener(NameplateDrawDetour);
}
catch (Exception e)
{
_logger.LogError(e, "Unknown error while unregistering nameplate listener.");
}
_mEnabled = false;
ClearNameplateCaches();
}
}
private void NameplateDrawDetour(AddonEvent type, AddonArgs args)
{
if (args.Addon.Address == nint.Zero)
{
if (_logger.IsEnabled(LogLevel.Warning))
_logger.LogWarning("Nameplate draw detour received a null addon address, skipping update.");
return;
}
var pNameplateAddon = (AddonNamePlate*)args.Addon.Address;
if (_mpNameplateAddon != pNameplateAddon)
{
ClearNameplateCaches();
_mpNameplateAddon = pNameplateAddon;
}
UpdateNameplateNodes();
}
private void UpdateNameplateNodes()
{
var currentHandle = _gameGui.GetAddonByName("NamePlate");
if (currentHandle.Address == nint.Zero)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh.");
ClearLabelBuffer();
return;
}
var currentAddon = (AddonNamePlate*)currentHandle.Address;
if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon)
{
if (_mpNameplateAddon != null && _logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Cached NamePlate addon pointer differs from current: waiting for new hook (cached {Cached}, current {Current}).", (IntPtr)_mpNameplateAddon, (IntPtr)currentAddon);
return;
}
var framework = Framework.Instance();
if (framework == null)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Framework instance unavailable during nameplate update, skipping.");
return;
}
var uiModule = framework->GetUIModule();
if (uiModule == null)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("UI module unavailable during nameplate update, skipping.");
return;
}
var ui3DModule = uiModule->GetUI3DModule();
if (ui3DModule == null)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("UI3D module unavailable during nameplate update, skipping.");
return;
}
var vec = ui3DModule->NamePlateObjectInfoPointers;
if (vec.IsEmpty)
{
ClearLabelBuffer();
return;
}
var visibleUserIdsSnapshot = VisibleUserIds;
var safeCount = System.Math.Min(ui3DModule->NamePlateObjectInfoCount, vec.Length);
var currentConfig = _configService.Current;
var labelColor = UIColors.Get("Lightfinder");
var edgeColor = UIColors.Get("LightfinderEdge");
var scratchCount = 0;
for (int i = 0; i < safeCount; ++i)
{
var objectInfoPtr = vec[i];
if (objectInfoPtr == null)
continue;
var objectInfo = objectInfoPtr.Value;
if (objectInfo == null || objectInfo->GameObject == null)
continue;
var nameplateIndex = objectInfo->NamePlateIndex;
if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects)
continue;
var gameObject = objectInfo->GameObject;
if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player)
continue;
// CID gating
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
if (cid == null || !_activeBroadcastingCids.Contains(cid))
continue;
var local = _objectTable.LocalPlayer;
if (!currentConfig.LightfinderLabelShowOwn && local != null &&
objectInfo->GameObject->GetGameObjectId() == local.GameObjectId)
continue;
var hidePaired = !currentConfig.LightfinderLabelShowPaired;
var goId = gameObject->GetGameObjectId();
if (hidePaired && visibleUserIdsSnapshot.Contains(goId))
continue;
var nameplateObject = _mpNameplateAddon->NamePlateObjectArray[nameplateIndex];
var root = nameplateObject.RootComponentNode;
var nameContainer = nameplateObject.NameContainer;
var nameText = nameplateObject.NameText;
var marker = nameplateObject.MarkerIcon;
if (root == null || root->Component == null || nameContainer == null || nameText == null)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Nameplate {Index} missing required nodes during update, skipping.", nameplateIndex);
continue;
}
root->Component->UldManager.UpdateDrawNodeList();
bool isVisible =
(marker != null && marker->AtkResNode.IsVisible()) ||
(nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) ||
currentConfig.LightfinderLabelShowHidden;
if (!isVisible)
continue;
var scaleMultiplier = System.Math.Clamp(currentConfig.LightfinderLabelScale, 0.5f, 2.0f);
var baseScale = currentConfig.LightfinderLabelUseIcon ? 1.0f : 0.5f;
var effectiveScale = baseScale * scaleMultiplier;
var baseFontSize = currentConfig.LightfinderLabelUseIcon ? 36f : 24f;
var targetFontSize = (int)System.Math.Round(baseFontSize * scaleMultiplier);
var labelContent = currentConfig.LightfinderLabelUseIcon
? NormalizeIconGlyph(currentConfig.LightfinderLabelIconGlyph)
: DefaultLabelText;
if (!currentConfig.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal)))
labelContent = DefaultLabelText;
var nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
var nodeHeight = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale);
AlignmentType alignment;
var textScaleY = nameText->AtkResNode.ScaleY;
if (textScaleY <= 0f)
textScaleY = 1f;
var blockHeight = ResolveCache(
_buffers.TextHeights,
nameplateIndex,
System.Math.Abs((int)nameplateObject.TextH),
() => GetScaledTextHeight(nameText),
nodeHeight);
var containerHeight = ResolveCache(
_buffers.ContainerHeights,
nameplateIndex,
(int)nameContainer->Height,
() =>
{
var computed = blockHeight + (int)System.Math.Round(8 * textScaleY);
return computed <= blockHeight ? blockHeight + 1 : computed;
},
blockHeight + 1);
var blockTop = containerHeight - blockHeight;
if (blockTop < 0)
blockTop = 0;
var verticalPadding = (int)System.Math.Round(4 * effectiveScale);
var positionY = blockTop - verticalPadding;
var rawTextWidth = (int)nameplateObject.TextW;
var textWidth = ResolveCache(
_buffers.TextWidths,
nameplateIndex,
System.Math.Abs(rawTextWidth),
() => GetScaledTextWidth(nameText),
nodeWidth);
var textOffset = (int)System.Math.Round(nameText->AtkResNode.X);
var hasValidOffset = TryCacheTextOffset(nameplateIndex, rawTextWidth, textOffset);
if (nameContainer == null)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Nameplate {Index} container became unavailable during update, skipping.", nameplateIndex);
continue;
}
float finalX;
if (currentConfig.LightfinderAutoAlign)
{
var measuredWidth = System.Math.Max(1, textWidth > 0 ? textWidth : nodeWidth);
var measuredWidthF = (float)measuredWidth;
var alignmentType = currentConfig.LabelAlignment;
var containerScale = nameContainer->ScaleX;
if (containerScale <= 0f)
containerScale = 1f;
var containerWidthRaw = (float)nameContainer->Width;
if (containerWidthRaw <= 0f)
containerWidthRaw = measuredWidthF;
var containerWidth = containerWidthRaw * containerScale;
if (containerWidth <= 0f)
containerWidth = measuredWidthF;
var containerLeft = nameContainer->ScreenX;
var containerRight = containerLeft + containerWidth;
var containerCenter = containerLeft + (containerWidth * 0.5f);
var iconMargin = currentConfig.LightfinderLabelUseIcon
? System.Math.Min(containerWidth * 0.1f, 14f * containerScale)
: 0f;
switch (alignmentType)
{
case LabelAlignment.Left:
finalX = containerLeft + iconMargin;
alignment = AlignmentType.BottomLeft;
break;
case LabelAlignment.Right:
finalX = containerRight - iconMargin;
alignment = AlignmentType.BottomRight;
break;
default:
finalX = containerCenter;
alignment = AlignmentType.Bottom;
break;
}
finalX += currentConfig.LightfinderLabelOffsetX;
}
else
{
var cachedTextOffset = _buffers.TextOffsets[nameplateIndex];
var hasCachedOffset = cachedTextOffset != int.MinValue;
var baseOffsetX = (!currentConfig.LightfinderLabelUseIcon && hasValidOffset && hasCachedOffset) ? cachedTextOffset : 0;
finalX = nameContainer->ScreenX + baseOffsetX + 58 + currentConfig.LightfinderLabelOffsetX;
alignment = AlignmentType.Bottom;
}
positionY += currentConfig.LightfinderLabelOffsetY;
alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8);
var finalPosition = new Vector2(finalX, nameContainer->ScreenY + positionY);
var pivot = (currentConfig.LightfinderAutoAlign || currentConfig.LightfinderLabelUseIcon)
? AlignmentToPivot(alignment)
: DefaultPivot;
var textColorPacked = PackColor(labelColor);
var edgeColorPacked = PackColor(edgeColor);
_buffers.LabelScratch[scratchCount++] = new NameplateLabelInfo(
finalPosition,
labelContent,
textColorPacked,
edgeColorPacked,
targetFontSize,
pivot,
currentConfig.LightfinderLabelUseIcon);
}
lock (_labelLock)
{
if (scratchCount == 0)
{
_labelRenderCount = 0;
}
else
{
Array.Copy(_buffers.LabelScratch, _buffers.LabelRender, scratchCount);
_labelRenderCount = scratchCount;
}
}
}
private void OnUiBuilderDraw()
{
if (!_mEnabled)
return;
int copyCount;
lock (_labelLock)
{
copyCount = _labelRenderCount;
if (copyCount == 0)
return;
Array.Copy(_buffers.LabelRender, _buffers.LabelCopy, copyCount);
}
using var drawList = PictoService.Draw();
if (drawList == null)
return;
for (int i = 0; i < copyCount; ++i)
{
ref var info = ref _buffers.LabelCopy[i];
var font = default(ImFontPtr);
if (info.UseIcon)
{
var ioFonts = ImGui.GetIO().Fonts;
font = ioFonts.Fonts.Size > 1 ? new ImFontPtr(ioFonts.Fonts[1]) : ImGui.GetFont();
}
drawList.AddScreenText(info.ScreenPosition, info.Text, info.TextColor, info.FontSize, info.Pivot, info.EdgeColor, font);
}
}
private static Vector2 AlignmentToPivot(AlignmentType alignment) => alignment switch
{
AlignmentType.BottomLeft => new Vector2(0f, 1f),
AlignmentType.BottomRight => new Vector2(1f, 1f),
AlignmentType.TopLeft => new Vector2(0f, 0f),
AlignmentType.TopRight => new Vector2(1f, 0f),
AlignmentType.Top => new Vector2(0.5f, 0f),
AlignmentType.Left => new Vector2(0f, 0.5f),
AlignmentType.Right => new Vector2(1f, 0.5f),
_ => DefaultPivot
};
private static uint PackColor(Vector4 color)
{
var r = (byte)System.Math.Clamp(color.X * 255f, 0f, 255f);
var g = (byte)System.Math.Clamp(color.Y * 255f, 0f, 255f);
var b = (byte)System.Math.Clamp(color.Z * 255f, 0f, 255f);
var a = (byte)System.Math.Clamp(color.W * 255f, 0f, 255f);
return (uint)((a << 24) | (b << 16) | (g << 8) | r);
}
private void ClearLabelBuffer()
{
lock (_labelLock)
{
_labelRenderCount = 0;
}
}
private static unsafe int GetScaledTextHeight(AtkTextNode* node)
{
if (node == null)
return 0;
var resNode = &node->AtkResNode;
var rawHeight = (int)resNode->GetHeight();
if (rawHeight <= 0 && node->LineSpacing > 0)
rawHeight = node->LineSpacing;
if (rawHeight <= 0)
rawHeight = AtkNodeHelpers.DefaultTextNodeHeight;
var scale = resNode->ScaleY;
if (scale <= 0f)
scale = 1f;
var computed = (int)System.Math.Round(rawHeight * scale);
return System.Math.Max(1, computed);
}
private static unsafe int GetScaledTextWidth(AtkTextNode* node)
{
if (node == null)
return 0;
var resNode = &node->AtkResNode;
var rawWidth = (int)resNode->GetWidth();
if (rawWidth <= 0)
rawWidth = AtkNodeHelpers.DefaultTextNodeWidth;
var scale = resNode->ScaleX;
if (scale <= 0f)
scale = 1f;
var computed = (int)System.Math.Round(rawWidth * scale);
return System.Math.Max(1, computed);
}
private static int ResolveCache(
int[] cache,
int index,
int rawValue,
Func<int> fallback,
int fallbackWhenZero)
{
if (rawValue > 0)
{
cache[index] = rawValue;
return rawValue;
}
var cachedValue = cache[index];
if (cachedValue > 0)
return cachedValue;
var computed = fallback();
if (computed <= 0)
computed = fallbackWhenZero;
cache[index] = computed;
return computed;
}
private bool TryCacheTextOffset(int nameplateIndex, int measuredTextWidth, int textOffset)
{
if (System.Math.Abs(measuredTextWidth) > 0 || textOffset != 0)
{
_buffers.TextOffsets[nameplateIndex] = textOffset;
return true;
}
return false;
}
internal static string NormalizeIconGlyph(string? rawInput)
{
if (string.IsNullOrWhiteSpace(rawInput))
return DefaultIconGlyph;
var trimmed = rawInput.Trim();
if (Enum.TryParse<SeIconChar>(trimmed, true, out var iconEnum))
return SeIconCharExtensions.ToIconString(iconEnum);
var hexCandidate = trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? trimmed[2..]
: trimmed;
if (ushort.TryParse(hexCandidate, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hexValue))
return char.ConvertFromUtf32(hexValue);
var enumerator = trimmed.EnumerateRunes();
if (enumerator.MoveNext())
return enumerator.Current.ToString();
return DefaultIconGlyph;
}
internal static string ToIconEditorString(string? rawInput)
{
var normalized = NormalizeIconGlyph(rawInput);
var runeEnumerator = normalized.EnumerateRunes();
return runeEnumerator.MoveNext()
? runeEnumerator.Current.Value.ToString("X4", CultureInfo.InvariantCulture)
: DefaultIconGlyph;
}
private readonly struct NameplateLabelInfo
{
public NameplateLabelInfo(
Vector2 screenPosition,
string text,
uint textColor,
uint edgeColor,
float fontSize,
Vector2 pivot,
bool useIcon)
{
ScreenPosition = screenPosition;
Text = text;
TextColor = textColor;
EdgeColor = edgeColor;
FontSize = fontSize;
Pivot = pivot;
UseIcon = useIcon;
}
public Vector2 ScreenPosition { get; }
public string Text { get; }
public uint TextColor { get; }
public uint EdgeColor { get; }
public float FontSize { get; }
public Vector2 Pivot { get; }
public bool UseIcon { get; }
}
private HashSet<ulong> VisibleUserIds
=> [.. _pairUiService.GetSnapshot().PairsByUid.Values
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)];
public void FlagRefresh()
{
_needsLabelRefresh = true;
}
public void OnTick(PriorityFrameworkUpdateMessage _)
{
if (_needsLabelRefresh)
{
UpdateNameplateNodes();
_needsLabelRefresh = false;
}
}
public void UpdateBroadcastingCids(IEnumerable<string> cids)
{
var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal);
if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet))
return;
_activeBroadcastingCids = newSet;
if (_logger.IsEnabled(LogLevel.Trace))
_logger.LogTrace("Active broadcast IDs: {Cids}", string.Join(',', _activeBroadcastingCids));
FlagRefresh();
}
public void ClearNameplateCaches()
{
_buffers.Clear();
ClearLabelBuffer();
}
private sealed class NameplateBuffers
{
public NameplateBuffers()
{
TextOffsets = new int[AddonNamePlate.NumNamePlateObjects];
System.Array.Fill(TextOffsets, int.MinValue);
}
public int[] TextWidths { get; } = new int[AddonNamePlate.NumNamePlateObjects];
public int[] TextHeights { get; } = new int[AddonNamePlate.NumNamePlateObjects];
public int[] ContainerHeights { get; } = new int[AddonNamePlate.NumNamePlateObjects];
public int[] TextOffsets { get; }
public NameplateLabelInfo[] LabelScratch { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects];
public NameplateLabelInfo[] LabelRender { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects];
public NameplateLabelInfo[] LabelCopy { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects];
public void Clear()
{
System.Array.Clear(TextWidths, 0, TextWidths.Length);
System.Array.Clear(TextHeights, 0, TextHeights.Length);
System.Array.Clear(ContainerHeights, 0, ContainerHeights.Length);
System.Array.Fill(TextOffsets, int.MinValue);
}
}
public Task StartAsync(CancellationToken cancellationToken)
{
Init();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
Uninit();
return Task.CompletedTask;
}
}

View File

@@ -1,67 +1,62 @@
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;
namespace LightlessSync.Services;
namespace LightlessSync.Services.LightFinder;
public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDisposable
public class LightFinderScannerService : DisposableMediatorSubscriberBase
{
private readonly ILogger<BroadcastScannerService> _logger;
private readonly IObjectTable _objectTable;
private readonly ILogger<LightFinderScannerService> _logger;
private readonly ActorObjectService _actorTracker;
private readonly IFramework _framework;
private readonly BroadcastService _broadcastService;
private readonly NameplateHandler _nameplateHandler;
private readonly LightFinderService _broadcastService;
private readonly LightFinderPlateHandler _lightFinderPlateHandler;
private readonly ConcurrentDictionary<string, BroadcastEntry> _broadcastCache = new();
private readonly ConcurrentDictionary<string, BroadcastEntry> _broadcastCache = new(StringComparer.Ordinal);
private readonly Queue<string> _lookupQueue = new();
private readonly HashSet<string> _lookupQueuedCids = new();
private readonly HashSet<string> _syncshellCids = new();
private readonly HashSet<string> _lookupQueuedCids = [];
private readonly HashSet<string> _syncshellCids = [];
private static readonly TimeSpan MaxAllowedTtl = TimeSpan.FromMinutes(4);
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4);
private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1);
private readonly CancellationTokenSource _cleanupCts = new();
private Task? _cleanupTask;
private readonly Task? _cleanupTask;
private readonly int _checkEveryFrames = 20;
private int _frameCounter = 0;
private int _lookupsThisFrame = 0;
private const int MaxLookupsPerFrame = 30;
private const int MaxQueueSize = 100;
private const int _maxLookupsPerFrame = 30;
private const int _maxQueueSize = 100;
private volatile bool _batchRunning = false;
public IReadOnlyDictionary<string, BroadcastEntry> BroadcastCache => _broadcastCache;
public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID);
public BroadcastScannerService(ILogger<BroadcastScannerService> logger,
IClientState clientState,
IObjectTable objectTable,
public LightFinderScannerService(ILogger<LightFinderScannerService> logger,
IFramework framework,
BroadcastService broadcastService,
LightFinderService broadcastService,
LightlessMediator mediator,
NameplateHandler nameplateHandler,
DalamudUtilService dalamudUtil,
LightlessConfigService configService) : base(logger, mediator)
LightFinderPlateHandler lightFinderPlateHandler,
ActorObjectService actorTracker) : base(logger, mediator)
{
_logger = logger;
_objectTable = objectTable;
_actorTracker = actorTracker;
_broadcastService = broadcastService;
_nameplateHandler = nameplateHandler;
_lightFinderPlateHandler = lightFinderPlateHandler;
_logger = logger;
_framework = framework;
_framework.Update += OnFrameworkUpdate;
Mediator.Subscribe<BroadcastStatusChangedMessage>(this, OnBroadcastStatusChanged);
_cleanupTask = Task.Run(ExpiredBroadcastCleanupLoop);
_cleanupTask = Task.Run(ExpiredBroadcastCleanupLoop, _cleanupCts.Token);
_nameplateHandler.Init();
_actorTracker = actorTracker;
}
private void OnFrameworkUpdate(IFramework framework) => Update();
@@ -69,34 +64,34 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
public void Update()
{
_frameCounter++;
_lookupsThisFrame = 0;
var lookupsThisFrame = 0;
if (!_broadcastService.IsBroadcasting)
return;
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)
if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < _maxQueueSize)
_lookupQueue.Enqueue(cid);
}
if (_frameCounter % _checkEveryFrames == 0 && _lookupQueue.Count > 0)
{
var cidsToLookup = new List<string>();
while (_lookupQueue.Count > 0 && _lookupsThisFrame < MaxLookupsPerFrame)
while (_lookupQueue.Count > 0 && lookupsThisFrame < _maxLookupsPerFrame)
{
var cid = _lookupQueue.Dequeue();
_lookupQueuedCids.Remove(cid);
cidsToLookup.Add(cid);
_lookupsThisFrame++;
lookupsThisFrame++;
}
if (cidsToLookup.Count > 0 && !_batchRunning)
@@ -118,8 +113,8 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
continue;
var ttl = info.IsBroadcasting && info.TTL.HasValue
? TimeSpan.FromTicks(Math.Min(info.TTL.Value.Ticks, MaxAllowedTtl.Ticks))
: RetryDelay;
? TimeSpan.FromTicks(Math.Min(info.TTL.Value.Ticks, _maxAllowedTtl.Ticks))
: _retryDelay;
var expiry = now + ttl;
@@ -133,7 +128,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
.Select(e => e.Key)
.ToList();
_nameplateHandler.UpdateBroadcastingCids(activeCids);
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
UpdateSyncshellBroadcasts();
}
@@ -146,7 +141,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
_lookupQueuedCids.Clear();
_syncshellCids.Clear();
_nameplateHandler.UpdateBroadcastingCids(Enumerable.Empty<string>());
_lightFinderPlateHandler.UpdateBroadcastingCids([]);
}
}
@@ -156,7 +151,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
var newSet = _broadcastCache
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID))
.Select(e => e.Key)
.ToHashSet();
.ToHashSet(StringComparer.Ordinal);
if (!_syncshellCids.SetEquals(newSet))
{
@@ -172,7 +167,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
{
var now = DateTime.UtcNow;
return _broadcastCache
return [.. _broadcastCache
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID))
.Select(e => new BroadcastStatusInfoDto
{
@@ -180,8 +175,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
IsBroadcasting = true,
TTL = e.Value.ExpiryTime - now,
GID = e.Value.GID
})
.ToList();
})];
}
private async Task ExpiredBroadcastCleanupLoop()
@@ -192,7 +186,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
{
while (!token.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(10), token);
await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(false);
var now = DateTime.UtcNow;
foreach (var (cid, entry) in _broadcastCache.ToArray())
@@ -202,7 +196,10 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
}
}
}
catch (OperationCanceledException) { }
catch (OperationCanceledException)
{
// No action needed when cancelled
}
catch (Exception ex)
{
_logger.LogError(ex, "Broadcast cleanup loop crashed");
@@ -235,8 +232,15 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
{
base.Dispose(disposing);
_framework.Update -= OnFrameworkUpdate;
if (_cleanupTask != null)
{
_cleanupTask?.Wait(100, _cleanupCts.Token);
}
_cleanupCts.Cancel();
_cleanupCts.Dispose();
_cleanupTask?.Wait(100);
_nameplateHandler.Uninit();
_cleanupCts.Dispose();
}
}

View File

@@ -1,22 +1,21 @@
using Dalamud.Interface;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.UI;
using LightlessSync.UI.Models;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.Services.Mediator;
using LightlessSync.UI;
using LightlessSync.UI.Models;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Threading;
namespace LightlessSync.Services;
public class BroadcastService : IHostedService, IMediatorSubscriber
namespace LightlessSync.Services.LightFinder;
public class LightFinderService : IHostedService, IMediatorSubscriber
{
private readonly ILogger<BroadcastService> _logger;
private readonly ILogger<LightFinderService> _logger;
private readonly ApiController _apiController;
private readonly LightlessMediator _mediator;
private readonly LightlessConfigService _config;
@@ -45,7 +44,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
}
}
public BroadcastService(ILogger<BroadcastService> logger, LightlessMediator mediator, LightlessConfigService config, DalamudUtilService dalamudUtil, ApiController apiController)
public LightFinderService(ILogger<LightFinderService> logger, LightlessMediator mediator, LightlessConfigService config, DalamudUtilService dalamudUtil, ApiController apiController)
{
_logger = logger;
_mediator = mediator;
@@ -58,7 +57,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
{
if (!_apiController.IsConnected)
{
_logger.LogDebug(context + " skipped, not connected");
_logger.LogDebug("{context} skipped, not connected", context);
return;
}
await action().ConfigureAwait(false);
@@ -282,7 +281,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
if (!msg.Enabled)
{
ApplyBroadcastDisabled(forcePublish: true);
Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Disabled Lightfinder for Player: {msg.HashedCid}")));
Mediator.Publish(new EventMessage(new Events.Event(nameof(LightFinderService), Services.Events.EventSeverity.Informational, $"Disabled Lightfinder for Player: {msg.HashedCid}")));
return;
}
@@ -295,7 +294,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
if (TryApplyBroadcastEnabled(ttl, "client request"))
{
_logger.LogDebug("Fetched TTL from server: {TTL}", ttl);
Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Enabled Lightfinder for Player: {msg.HashedCid}")));
Mediator.Publish(new EventMessage(new Events.Event(nameof(LightFinderService), Services.Events.EventSeverity.Informational, $"Enabled Lightfinder for Player: {msg.HashedCid}")));
}
else
{
@@ -372,7 +371,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
public async Task<Dictionary<string, BroadcastStatusInfoDto?>> AreUsersBroadcastingAsync(List<string> hashedCids)
{
Dictionary<string, BroadcastStatusInfoDto?> result = new();
Dictionary<string, BroadcastStatusInfoDto?> result = new(StringComparer.Ordinal);
await RequireConnectionAsync(nameof(AreUsersBroadcastingAsync), async () =>
{
@@ -397,8 +396,6 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
return result;
}
public async void ToggleBroadcast()
{

View File

@@ -1,6 +0,0 @@
namespace LightlessSync.Services;
public record LightlessGroupProfileData(string Base64ProfilePicture, string Description, int[] Tags, bool IsNsfw, bool IsDisabled)
{
public Lazy<byte[]> ImageData { get; } = new Lazy<byte[]>(Convert.FromBase64String(Base64ProfilePicture));
}

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +0,0 @@
namespace LightlessSync.Services;
public record LightlessUserProfileData(bool IsFlagged, bool IsNSFW, string Base64ProfilePicture, string Base64SupporterPicture, string Description)
{
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));
}

View File

@@ -6,9 +6,12 @@ using LightlessSync.API.Dto.Group;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Chat;
using LightlessSync.Services.Events;
using LightlessSync.WebAPI.Files.Models;
using System.Numerics;
using LightlessSync.UI.Models;
namespace LightlessSync.Services.Mediator;
@@ -20,12 +23,15 @@ public record OpenSettingsUiMessage : MessageBase;
public record OpenLightfinderSettingsMessage : MessageBase;
public record DalamudLoginMessage : MessageBase;
public record DalamudLogoutMessage : MessageBase;
public record ActorTrackedMessage(ActorObjectService.ActorDescriptor Descriptor) : SameThreadMessage;
public record ActorUntrackedMessage(ActorObjectService.ActorDescriptor Descriptor) : SameThreadMessage;
public record PriorityFrameworkUpdateMessage : SameThreadMessage;
public record FrameworkUpdateMessage : SameThreadMessage;
public record ClassJobChangedMessage(GameObjectHandler GameObjectHandler) : MessageBase;
public record DelayedFrameworkUpdateMessage : SameThreadMessage;
public record ZoneSwitchStartMessage : MessageBase;
public record ZoneSwitchEndMessage : MessageBase;
public record WorldChangedMessage(ushort PreviousWorldId, ushort CurrentWorldId) : MessageBase;
public record CutsceneStartMessage : MessageBase;
public record GposeStartMessage : SameThreadMessage;
public record GposeEndMessage : MessageBase;
@@ -65,6 +71,7 @@ public record HubReconnectingMessage(Exception? Exception) : SameThreadMessage;
public record HubReconnectedMessage(string? Arg) : SameThreadMessage;
public record HubClosedMessage(Exception? Exception) : SameThreadMessage;
public record ResumeScanMessage(string Source) : MessageBase;
public record FileCacheInitializedMessage : MessageBase;
public record DownloadReadyMessage(Guid RequestId) : MessageBase;
public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase;
public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase;
@@ -72,11 +79,18 @@ public record UiToggleMessage(Type UiType) : MessageBase;
public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : MessageBase;
public record ClearProfileUserDataMessage(UserData? UserData = null) : MessageBase;
public record ClearProfileGroupDataMessage(GroupData? GroupData = null) : MessageBase;
public record CyclePauseMessage(UserData UserData) : MessageBase;
public record CyclePauseMessage(Pair Pair) : MessageBase;
public record PauseMessage(UserData UserData) : MessageBase;
public record ProfilePopoutToggle(Pair? Pair) : MessageBase;
public record CompactUiChange(Vector2 Size, Vector2 Position) : MessageBase;
public record ProfileOpenStandaloneMessage(Pair Pair) : MessageBase;
public record GroupProfileOpenStandaloneMessage(GroupData 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,8 +99,11 @@ 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 PairFocusCharacterMessage(Pair Pair) : SameThreadMessage;
public record CombatStartMessage : MessageBase;
public record CombatEndMessage : MessageBase;
public record PerformanceStartMessage : MessageBase;
@@ -107,10 +124,16 @@ public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData)
public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase;
public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase;
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : MessageBase;
public record UserLeftSyncshell(string gid) : MessageBase;
public record UserJoinedSyncshell(string gid) : MessageBase;
public record SyncshellBroadcastsUpdatedMessage : MessageBase;
public record PairRequestReceivedMessage(string HashedCid, string Message) : MessageBase;
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 GroupCollectionChangedMessage : MessageBase;
public record OpenUserProfileMessage(UserData User) : MessageBase;
#pragma warning restore S2094
#pragma warning restore MA0048 // File name must match type name

View File

@@ -1,694 +0,0 @@
using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.Text;
using Dalamud.Plugin.Services;
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.Utils;
using LightlessSync.UtilsEnum.Enum;
// Created using https://github.com/PunishedPineapple/Distance as a reference, thank you!
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
namespace LightlessSync.Services;
public unsafe class NameplateHandler : IMediatorSubscriber
{
private readonly ILogger<NameplateHandler> _logger;
private readonly IAddonLifecycle _addonLifecycle;
private readonly IGameGui _gameGui;
private readonly IClientState _clientState;
private readonly DalamudUtilService _dalamudUtil;
private readonly LightlessConfigService _configService;
private readonly PairManager _pairManager;
private readonly LightlessMediator _mediator;
public LightlessMediator Mediator => _mediator;
private bool _mEnabled = false;
private bool _needsLabelRefresh = false;
private AddonNamePlate* _mpNameplateAddon = null;
private readonly AtkTextNode*[] _mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateTextWidths = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateTextHeights = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateContainerHeights = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateTextOffsets = new int[AddonNamePlate.NumNamePlateObjects];
internal const uint mNameplateNodeIDBase = 0x7D99D500;
private const string DefaultLabelText = "LightFinder";
private const SeIconChar DefaultIcon = SeIconChar.Hyadelyn;
private const int _containerOffsetX = 50;
private static readonly string DefaultIconGlyph = SeIconCharExtensions.ToIconString(DefaultIcon);
private ImmutableHashSet<string> _activeBroadcastingCids = [];
public NameplateHandler(ILogger<NameplateHandler> logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessConfigService configService, LightlessMediator mediator, IClientState clientState, PairManager pairManager)
{
_logger = logger;
_addonLifecycle = addonLifecycle;
_gameGui = gameGui;
_dalamudUtil = dalamudUtil;
_configService = configService;
_mediator = mediator;
_clientState = clientState;
_pairManager = pairManager;
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
}
internal void Init()
{
EnableNameplate();
_mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, OnTick);
}
internal void Uninit()
{
DisableNameplate();
DestroyNameplateNodes();
_mediator.Unsubscribe<PriorityFrameworkUpdateMessage>(this);
_mpNameplateAddon = null;
}
internal void EnableNameplate()
{
if (!_mEnabled)
{
try
{
_addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour);
_mEnabled = true;
}
catch (Exception e)
{
_logger.LogError($"Unknown error while trying to enable nameplate distances:\n{e}");
DisableNameplate();
}
}
}
internal void DisableNameplate()
{
if (_mEnabled)
{
try
{
_addonLifecycle.UnregisterListener(NameplateDrawDetour);
}
catch (Exception e)
{
_logger.LogError($"Unknown error while unregistering nameplate listener:\n{e}");
}
_mEnabled = false;
HideAllNameplateNodes();
}
}
private void NameplateDrawDetour(AddonEvent type, AddonArgs args)
{
if (args.Addon.Address == nint.Zero)
{
_logger.LogWarning("Nameplate draw detour received a null addon address, skipping update.");
return;
}
var pNameplateAddon = (AddonNamePlate*)args.Addon.Address;
if (_mpNameplateAddon != pNameplateAddon)
{
for (int i = 0; i < _mTextNodes.Length; ++i) _mTextNodes[i] = null;
System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
_mpNameplateAddon = pNameplateAddon;
if (_mpNameplateAddon != null) CreateNameplateNodes();
}
UpdateNameplateNodes();
}
private void CreateNameplateNodes()
{
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
{
var nameplateObject = GetNameplateObject(i);
if (nameplateObject == null)
continue;
var rootNode = nameplateObject.Value.RootComponentNode;
if (rootNode == null || rootNode->Component == null)
continue;
var pNameplateResNode = nameplateObject.Value.NameContainer;
if (pNameplateResNode == null)
continue;
if (pNameplateResNode->ChildNode == null)
continue;
var pNewNode = AtkNodeHelpers.CreateOrphanTextNode(mNameplateNodeIDBase + (uint)i, TextFlags.Edge | TextFlags.Glare);
if (pNewNode != null)
{
var pLastChild = pNameplateResNode->ChildNode;
while (pLastChild->PrevSiblingNode != null) pLastChild = pLastChild->PrevSiblingNode;
pNewNode->AtkResNode.NextSiblingNode = pLastChild;
pNewNode->AtkResNode.ParentNode = pNameplateResNode;
pLastChild->PrevSiblingNode = (AtkResNode*)pNewNode;
rootNode->Component->UldManager.UpdateDrawNodeList();
pNewNode->AtkResNode.SetUseDepthBasedPriority(true);
_mTextNodes[i] = pNewNode;
}
}
}
private void DestroyNameplateNodes()
{
var currentHandle = _gameGui.GetAddonByName("NamePlate", 1);
if (currentHandle.Address == nint.Zero)
{
_logger.LogWarning("Unable to destroy nameplate nodes because the NamePlate addon is not available.");
return;
}
var pCurrentNameplateAddon = (AddonNamePlate*)currentHandle.Address;
if (_mpNameplateAddon == null)
return;
if (_mpNameplateAddon != pCurrentNameplateAddon)
{
_logger.LogWarning("Skipping nameplate node destroy due to addon address mismatch (cached {Cached:X}, current {Current:X}).", (IntPtr)_mpNameplateAddon, (IntPtr)pCurrentNameplateAddon);
return;
}
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
{
var pTextNode = _mTextNodes[i];
var pNameplateNode = GetNameplateComponentNode(i);
if (pTextNode != null && (pNameplateNode == null || pNameplateNode->Component == null))
{
_logger.LogDebug("Skipping destroy for nameplate {Index} because its component node is unavailable.", i);
continue;
}
if (pTextNode != null && pNameplateNode != null && pNameplateNode->Component != null)
{
try
{
if (pTextNode->AtkResNode.PrevSiblingNode != null)
pTextNode->AtkResNode.PrevSiblingNode->NextSiblingNode = pTextNode->AtkResNode.NextSiblingNode;
if (pTextNode->AtkResNode.NextSiblingNode != null)
pTextNode->AtkResNode.NextSiblingNode->PrevSiblingNode = pTextNode->AtkResNode.PrevSiblingNode;
pNameplateNode->Component->UldManager.UpdateDrawNodeList();
pTextNode->AtkResNode.Destroy(true);
_mTextNodes[i] = null;
}
catch (Exception e)
{
_logger.LogError($"Unknown error while removing text node 0x{(IntPtr)pTextNode:X} for nameplate {i} on component node 0x{(IntPtr)pNameplateNode:X}:\n{e}");
}
}
}
System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
}
private void HideAllNameplateNodes()
{
for (int i = 0; i < _mTextNodes.Length; ++i)
{
HideNameplateTextNode(i);
}
}
private void UpdateNameplateNodes()
{
var currentHandle = _gameGui.GetAddonByName("NamePlate");
if (currentHandle.Address == nint.Zero)
{
_logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh.");
return;
}
var currentAddon = (AddonNamePlate*)currentHandle.Address;
if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon)
{
if (_mpNameplateAddon != null)
_logger.LogDebug("Cached NamePlate addon pointer differs from current: waiting for new hook (cached {Cached:X}, current {Current:X}).", (IntPtr)_mpNameplateAddon, (IntPtr)currentAddon);
return;
}
var framework = Framework.Instance();
if (framework == null)
{
_logger.LogDebug("Framework instance unavailable during nameplate update, skipping.");
return;
}
var uiModule = framework->GetUIModule();
if (uiModule == null)
{
_logger.LogDebug("UI module unavailable during nameplate update, skipping.");
return;
}
var ui3DModule = uiModule->GetUI3DModule();
if (ui3DModule == null)
{
_logger.LogDebug("UI3D module unavailable during nameplate update, skipping.");
return;
}
var vec = ui3DModule->NamePlateObjectInfoPointers;
if (vec.IsEmpty)
return;
var visibleUserIdsSnapshot = VisibleUserIds;
var safeCount = System.Math.Min(
ui3DModule->NamePlateObjectInfoCount,
vec.Length
);
for (int i = 0; i < safeCount; ++i)
{
var config = _configService.Current;
var objectInfoPtr = vec[i];
if (objectInfoPtr == null)
continue;
var objectInfo = objectInfoPtr.Value;
if (objectInfo == null || objectInfo->GameObject == null)
continue;
var nameplateIndex = objectInfo->NamePlateIndex;
if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects)
continue;
var pNode = _mTextNodes[nameplateIndex];
if (pNode == null)
continue;
var gameObject = objectInfo->GameObject;
if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player)
{
pNode->AtkResNode.ToggleVisibility(enable: false);
continue;
}
// CID gating
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
if (cid == null || !_activeBroadcastingCids.Contains(cid))
{
pNode->AtkResNode.ToggleVisibility(enable: false);
continue;
}
var local = _clientState.LocalPlayer;
if (!config.LightfinderLabelShowOwn && local != null &&
objectInfo->GameObject->GetGameObjectId() == local.GameObjectId)
{
pNode->AtkResNode.ToggleVisibility(enable: false);
continue;
}
var hidePaired = !config.LightfinderLabelShowPaired;
var goId = (ulong)gameObject->GetGameObjectId();
if (hidePaired && visibleUserIdsSnapshot.Contains(goId))
{
pNode->AtkResNode.ToggleVisibility(enable: false);
continue;
}
var nameplateObject = _mpNameplateAddon->NamePlateObjectArray[nameplateIndex];
var root = nameplateObject.RootComponentNode;
var nameContainer = nameplateObject.NameContainer;
var nameText = nameplateObject.NameText;
var marker = nameplateObject.MarkerIcon;
if (root == null || root->Component == null || nameContainer == null || nameText == null)
{
_logger.LogDebug("Nameplate {Index} missing required nodes during update, skipping.", nameplateIndex);
pNode->AtkResNode.ToggleVisibility(enable: false);
continue;
}
root->Component->UldManager.UpdateDrawNodeList();
bool isVisible =
((marker != null) && marker->AtkResNode.IsVisible()) ||
(nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) ||
config.LightfinderLabelShowHidden;
pNode->AtkResNode.ToggleVisibility(isVisible);
if (!isVisible)
continue;
var labelColor = UIColors.Get("Lightfinder");
var edgeColor = UIColors.Get("LightfinderEdge");
var scaleMultiplier = System.Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f);
var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f;
var effectiveScale = baseScale * scaleMultiplier;
var labelContent = config.LightfinderLabelUseIcon
? NormalizeIconGlyph(config.LightfinderLabelIconGlyph)
: DefaultLabelText;
pNode->FontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed;
pNode->AtkResNode.SetScale(effectiveScale, effectiveScale);
var nodeWidth = (int)pNode->AtkResNode.GetWidth();
if (nodeWidth <= 0)
nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
var nodeHeight = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale);
var baseFontSize = config.LightfinderLabelUseIcon ? 36f : 24f;
var computedFontSize = (int)System.Math.Round(baseFontSize * scaleMultiplier);
pNode->FontSize = (byte)System.Math.Clamp(computedFontSize, 1, 255);
AlignmentType alignment;
var textScaleY = nameText->AtkResNode.ScaleY;
if (textScaleY <= 0f)
textScaleY = 1f;
var blockHeight = System.Math.Abs((int)nameplateObject.TextH);
if (blockHeight > 0)
{
_cachedNameplateTextHeights[nameplateIndex] = blockHeight;
}
else
{
blockHeight = _cachedNameplateTextHeights[nameplateIndex];
}
if (blockHeight <= 0)
{
blockHeight = GetScaledTextHeight(nameText);
if (blockHeight <= 0)
blockHeight = nodeHeight;
_cachedNameplateTextHeights[nameplateIndex] = blockHeight;
}
var containerHeight = (int)nameContainer->Height;
if (containerHeight > 0)
{
_cachedNameplateContainerHeights[nameplateIndex] = containerHeight;
}
else
{
containerHeight = _cachedNameplateContainerHeights[nameplateIndex];
}
if (containerHeight <= 0)
{
containerHeight = blockHeight + (int)System.Math.Round(8 * textScaleY);
if (containerHeight <= blockHeight)
containerHeight = blockHeight + 1;
_cachedNameplateContainerHeights[nameplateIndex] = containerHeight;
}
var blockTop = containerHeight - blockHeight;
if (blockTop < 0)
blockTop = 0;
var verticalPadding = (int)System.Math.Round(4 * effectiveScale);
var positionY = blockTop - verticalPadding - nodeHeight;
var textWidth = System.Math.Abs((int)nameplateObject.TextW);
if (textWidth <= 0)
{
textWidth = GetScaledTextWidth(nameText);
if (textWidth <= 0)
textWidth = nodeWidth;
}
if (textWidth > 0)
{
_cachedNameplateTextWidths[nameplateIndex] = textWidth;
}
var textOffset = (int)System.Math.Round(nameText->AtkResNode.X);
var hasValidOffset = true;
if (System.Math.Abs((int)nameplateObject.TextW) > 0 || textOffset != 0)
{
_cachedNameplateTextOffsets[nameplateIndex] = textOffset;
}
else if (_cachedNameplateTextOffsets[nameplateIndex] != int.MinValue)
{
textOffset = _cachedNameplateTextOffsets[nameplateIndex];
}
else
{
hasValidOffset = false;
}
int positionX;
if (!config.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal)))
labelContent = DefaultLabelText;
pNode->FontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed;
pNode->SetText(labelContent);
if (!config.LightfinderLabelUseIcon)
{
pNode->TextFlags &= ~TextFlags.AutoAdjustNodeSize;
pNode->AtkResNode.Width = 0;
nodeWidth = (int)pNode->AtkResNode.GetWidth();
if (nodeWidth <= 0)
nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
pNode->AtkResNode.Width = (ushort)nodeWidth;
}
else
{
pNode->TextFlags |= TextFlags.AutoAdjustNodeSize;
pNode->AtkResNode.Width = 0;
nodeWidth = pNode->AtkResNode.GetWidth();
}
if (config.LightfinderAutoAlign && nameContainer != null && hasValidOffset)
{
var nameplateWidth = (int)nameContainer->Width;
int leftPos = nameplateWidth / 8;
int rightPos = nameplateWidth - nodeWidth - (nameplateWidth / 8);
int centrePos = (nameplateWidth - nodeWidth) / 2;
int staticMargin = 24;
int calcMargin = (int)(nameplateWidth * 0.08f);
switch (config.LabelAlignment)
{
case LabelAlignment.Left:
positionX = config.LightfinderLabelUseIcon ? leftPos + staticMargin : leftPos;
alignment = AlignmentType.BottomLeft;
break;
case LabelAlignment.Right:
positionX = config.LightfinderLabelUseIcon ? rightPos - staticMargin : nameplateWidth - nodeWidth + calcMargin;
alignment = AlignmentType.BottomRight;
break;
default:
positionX = config.LightfinderLabelUseIcon ? centrePos : centrePos + calcMargin;
alignment = AlignmentType.Bottom;
break;
}
}
else
{
positionX = 58 + config.LightfinderLabelOffsetX;
alignment = AlignmentType.Bottom;
}
positionY += config.LightfinderLabelOffsetY;
alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8);
pNode->AtkResNode.SetUseDepthBasedPriority(enable: true);
pNode->AtkResNode.Color.A = 255;
pNode->TextColor.R = (byte)(labelColor.X * 255);
pNode->TextColor.G = (byte)(labelColor.Y * 255);
pNode->TextColor.B = (byte)(labelColor.Z * 255);
pNode->TextColor.A = (byte)(labelColor.W * 255);
pNode->EdgeColor.R = (byte)(edgeColor.X * 255);
pNode->EdgeColor.G = (byte)(edgeColor.Y * 255);
pNode->EdgeColor.B = (byte)(edgeColor.Z * 255);
pNode->EdgeColor.A = (byte)(edgeColor.W * 255);
if(!config.LightfinderLabelUseIcon)
{
pNode->AlignmentType = AlignmentType.Bottom;
}
else
{
pNode->AlignmentType = alignment;
}
pNode->AtkResNode.SetPositionShort(
(short)System.Math.Clamp(positionX, short.MinValue, short.MaxValue),
(short)System.Math.Clamp(positionY, short.MinValue, short.MaxValue)
);
var computedLineSpacing = (int)System.Math.Round(24 * scaleMultiplier);
pNode->LineSpacing = (byte)System.Math.Clamp(computedLineSpacing, 0, byte.MaxValue);
pNode->CharSpacing = 1;
pNode->TextFlags = config.LightfinderLabelUseIcon
? TextFlags.Edge | TextFlags.Glare | TextFlags.AutoAdjustNodeSize
: TextFlags.Edge | TextFlags.Glare;
}
}
private static unsafe int GetScaledTextHeight(AtkTextNode* node)
{
if (node == null)
return 0;
var resNode = &node->AtkResNode;
var rawHeight = (int)resNode->GetHeight();
if (rawHeight <= 0 && node->LineSpacing > 0)
rawHeight = node->LineSpacing;
if (rawHeight <= 0)
rawHeight = AtkNodeHelpers.DefaultTextNodeHeight;
var scale = resNode->ScaleY;
if (scale <= 0f)
scale = 1f;
var computed = (int)System.Math.Round(rawHeight * scale);
return System.Math.Max(1, computed);
}
private static unsafe int GetScaledTextWidth(AtkTextNode* node)
{
if (node == null)
return 0;
var resNode = &node->AtkResNode;
var rawWidth = (int)resNode->GetWidth();
if (rawWidth <= 0)
rawWidth = AtkNodeHelpers.DefaultTextNodeWidth;
var scale = resNode->ScaleX;
if (scale <= 0f)
scale = 1f;
var computed = (int)System.Math.Round(rawWidth * scale);
return System.Math.Max(1, computed);
}
internal static string NormalizeIconGlyph(string? rawInput)
{
if (string.IsNullOrWhiteSpace(rawInput))
return DefaultIconGlyph;
var trimmed = rawInput.Trim();
if (Enum.TryParse<SeIconChar>(trimmed, true, out var iconEnum))
return SeIconCharExtensions.ToIconString(iconEnum);
var hexCandidate = trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? trimmed[2..]
: trimmed;
if (ushort.TryParse(hexCandidate, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hexValue))
return char.ConvertFromUtf32(hexValue);
var enumerator = trimmed.EnumerateRunes();
if (enumerator.MoveNext())
return enumerator.Current.ToString();
return DefaultIconGlyph;
}
internal static string ToIconEditorString(string? rawInput)
{
var normalized = NormalizeIconGlyph(rawInput);
var runeEnumerator = normalized.EnumerateRunes();
return runeEnumerator.MoveNext()
? runeEnumerator.Current.Value.ToString("X4", CultureInfo.InvariantCulture)
: DefaultIconGlyph;
}
private void HideNameplateTextNode(int i)
{
var pNode = _mTextNodes[i];
if (pNode != null)
{
pNode->AtkResNode.ToggleVisibility(false);
}
}
private AddonNamePlate.NamePlateObject? GetNameplateObject(int i)
{
if (i < AddonNamePlate.NumNamePlateObjects &&
_mpNameplateAddon != null &&
_mpNameplateAddon->NamePlateObjectArray[i].RootComponentNode != null)
{
return _mpNameplateAddon->NamePlateObjectArray[i];
}
else
{
return null;
}
}
private AtkComponentNode* GetNameplateComponentNode(int i)
{
var nameplateObject = GetNameplateObject(i);
return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null;
}
private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs()
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)];
public void FlagRefresh()
{
_needsLabelRefresh = true;
}
public void OnTick(PriorityFrameworkUpdateMessage _)
{
if (_needsLabelRefresh)
{
UpdateNameplateNodes();
_needsLabelRefresh = false;
}
}
public void UpdateBroadcastingCids(IEnumerable<string> cids)
{
var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal);
if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet))
return;
_activeBroadcastingCids = newSet;
_logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids));
FlagRefresh();
}
public void ClearNameplateCaches()
{
System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
}
}

View File

@@ -1,114 +1,254 @@
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.Gui.NamePlate;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.NativeWrapper;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Hooking;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using Dalamud.Utility.Signatures;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
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 Microsoft.Extensions.Logging;
using System.Numerics;
using static LightlessSync.UI.DtrEntry;
using LSeStringBuilder = Lumina.Text.SeStringBuilder;
namespace LightlessSync.Services;
public class NameplateService : DisposableMediatorSubscriberBase
/// <summary>
/// NameplateService is used for coloring our nameplates based on the settings of the user.
/// </summary>
public unsafe class NameplateService : DisposableMediatorSubscriberBase
{
private delegate nint UpdateNameplateDelegate(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex);
// Glyceri, Thanks :bow:
[Signature("40 53 55 57 41 56 48 81 EC ?? ?? ?? ?? 48 8B 84 24", DetourName = nameof(UpdateNameplateDetour))]
private readonly Hook<UpdateNameplateDelegate>? _nameplateHook = null;
private readonly ILogger<NameplateService> _logger;
private readonly LightlessConfigService _configService;
private readonly IClientState _clientState;
private readonly INamePlateGui _namePlateGui;
private readonly PairManager _pairManager;
private readonly IGameGui _gameGui;
private readonly IObjectTable _objectTable;
private readonly PairUiService _pairUiService;
public NameplateService(ILogger<NameplateService> logger,
LightlessConfigService configService,
INamePlateGui namePlateGui,
IClientState clientState,
PairManager pairManager,
LightlessMediator lightlessMediator) : base(logger, lightlessMediator)
IGameGui gameGui,
IObjectTable objectTable,
IGameInteropProvider interop,
LightlessMediator lightlessMediator,
PairUiService pairUiService) : base(logger, lightlessMediator)
{
_logger = logger;
_configService = configService;
_namePlateGui = namePlateGui;
_clientState = clientState;
_pairManager = pairManager;
_gameGui = gameGui;
_objectTable = objectTable;
_pairUiService = pairUiService;
_namePlateGui.OnNamePlateUpdate += OnNamePlateUpdate;
_namePlateGui.RequestRedraw();
Mediator.Subscribe<VisibilityChange>(this, (_) => _namePlateGui.RequestRedraw());
interop.InitializeFromAttributes(this);
_nameplateHook?.Enable();
Refresh();
Mediator.Subscribe<VisibilityChange>(this, (_) => Refresh());
}
private void OnNamePlateUpdate(INamePlateUpdateContext context, IReadOnlyList<INamePlateUpdateHandler> handlers)
/// <summary>
/// Detour for the game's internal nameplate update function.
/// This will be called whenever the client updates any nameplate.
///
/// We hook into it to apply our own nameplate coloring logic via <see cref="SetNameplate"/>,
/// </summary>
private nint UpdateNameplateDetour(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
{
if (!_configService.Current.IsNameplateColorsEnabled || (_configService.Current.IsNameplateColorsEnabled && _clientState.IsPvPExcludingDen))
try
{
SetNameplate(namePlateInfo, battleChara);
}
catch (Exception e)
{
_logger.LogError(e, "Error in NameplateService UpdateNameplateDetour");
}
return _nameplateHook!.Original(raptureAtkModule, namePlateInfo, numArray, stringArray, battleChara, numArrayIndex, stringArrayIndex);
}
/// <summary>
/// Determine if the player should be colored based on conditions (isFriend, IsInParty)
/// </summary>
/// <param name="playerCharacter">Player character that will be checked</param>
/// <param name="visibleUserIds">All visible users in the current object table</param>
/// <returns>PLayer should or shouldnt be colored based on the result. True means colored</returns>
private bool ShouldColorPlayer(IPlayerCharacter playerCharacter, HashSet<ulong> visibleUserIds)
{
if (!visibleUserIds.Contains(playerCharacter.GameObjectId))
return false;
var isInParty = playerCharacter.StatusFlags.HasFlag(StatusFlags.PartyMember);
var isFriend = playerCharacter.StatusFlags.HasFlag(StatusFlags.Friend);
bool partyColorAllowed = _configService.Current.overridePartyColor && isInParty;
bool friendColorAllowed = _configService.Current.overrideFriendColor && isFriend;
if ((isInParty && !partyColorAllowed) || (isFriend && !friendColorAllowed))
return false;
return true;
}
/// <summary>
/// Setting up the nameplate of the user to be colored
/// </summary>
/// <param name="namePlateInfo">Information given from the Signature to be updated</param>
/// <param name="battleChara">Character from FF</param>
private void SetNameplate(RaptureAtkModule.NamePlateInfo* namePlateInfo, BattleChara* battleChara)
{
if (!_configService.Current.IsNameplateColorsEnabled || _clientState.IsPvPExcludingDen)
return;
if (namePlateInfo == null || battleChara == null)
return;
var visibleUsersIds = _pairManager.GetOnlineUserPairs()
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)
.ToHashSet();
var obj = _objectTable.FirstOrDefault(o => o.Address == (nint)battleChara);
if (obj is not IPlayerCharacter player)
return;
var now = DateTime.UtcNow;
var colors = _configService.Current.NameplateColors;
var snapshot = _pairUiService.GetSnapshot();
var visibleUsersIds = snapshot.PairsByUid.Values
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)
.ToHashSet();
foreach (var handler in handlers)
{
var playerCharacter = handler.PlayerCharacter;
if (playerCharacter == null)
continue;
//Check if player should be colored
if (!ShouldColorPlayer(player, visibleUsersIds))
return;
var isInParty = playerCharacter.StatusFlags.HasFlag(StatusFlags.PartyMember);
var isFriend = playerCharacter.StatusFlags.HasFlag(StatusFlags.Friend);
bool partyColorAllowed = (_configService.Current.overridePartyColor && isInParty);
bool friendColorAllowed = (_configService.Current.overrideFriendColor && isFriend);
var originalName = player.Name.ToString();
if (visibleUsersIds.Contains(handler.GameObjectId) &&
!(
(isInParty && !partyColorAllowed) ||
(isFriend && !friendColorAllowed)
))
{
handler.NameParts.TextWrap = CreateTextWrap(colors);
//Check if not null of the name
if (string.IsNullOrEmpty(originalName))
return;
if (_configService.Current.overrideFcTagColor)
{
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);
handler.FreeCompanyTagParts.TextWrap = CreateTextWrap(colors);
}
}
}
}
//Check if any characters/symbols are forbidden
if (HasForbiddenSeStringChars(originalName))
return;
//Swap color channels as we store them in BGR format as FF loves that
var cfgColors = SwapColorChannels(_configService.Current.NameplateColors);
var coloredName = WrapStringInColor(originalName, cfgColors.Glow, cfgColors.Foreground);
//Replace string of nameplate with our colored one
namePlateInfo->Name.SetString(coloredName.EncodeWithNullTerminator());
}
/// <summary>
/// Converts Uint code to Vector4 as we store Colors in Uint in our config, needed for lumina
/// </summary>
/// <param name="rgb">Color code</param>
/// <returns>Vector4 Color</returns>
private static Vector4 RgbUintToVector4(uint rgb)
{
float r = ((rgb >> 16) & 0xFF) / 255f;
float g = ((rgb >> 8) & 0xFF) / 255f;
float b = (rgb & 0xFF) / 255f;
return new Vector4(r, g, b, 1f);
}
/// <summary>
/// Checks if the string has any forbidden characters/symbols as the string builder wouldnt append.
/// </summary>
/// <param name="s">String that has to be checked</param>
/// <returns>Contains forbidden characters/symbols or not</returns>
private static bool HasForbiddenSeStringChars(string s)
{
if (string.IsNullOrEmpty(s))
return false;
foreach (var ch in s)
{
if (ch == '\0' || ch == '\u0002')
return true;
}
return false;
}
/// <summary>
/// Wraps the given string with the given edge and text color.
/// </summary>
/// <param name="text">String that has to be wrapped</param>
/// <param name="edgeColor">Edge(border) color</param>
/// <param name="textColor">Text color</param>
/// <returns>Color wrapped SeString</returns>
public static SeString WrapStringInColor(string text, uint? edgeColor = null, uint? textColor = null)
{
if (string.IsNullOrEmpty(text))
return SeString.Empty;
var builder = new LSeStringBuilder();
if (textColor is uint tc)
builder.PushColorRgba(RgbUintToVector4(tc));
if (edgeColor is uint ec)
builder.PushEdgeColorRgba(RgbUintToVector4(ec));
builder.Append(text);
if (edgeColor != null)
builder.PopEdgeColor();
if (textColor != null)
builder.PopColor();
return builder.ToReadOnlySeString().ToDalamudString();
}
/// <summary>
/// Request redraw of nameplates
/// </summary>
public void RequestRedraw()
{
_namePlateGui.RequestRedraw();
Refresh();
}
private static (SeString, SeString) CreateTextWrap(DtrEntry.Colors color)
/// <summary>
/// Toggles the refresh of the Nameplate addon
/// </summary>
protected void Refresh()
{
var left = new Lumina.Text.SeStringBuilder();
var right = new Lumina.Text.SeStringBuilder();
AtkUnitBasePtr namePlateAddon = _gameGui.GetAddonByName("NamePlate");
left.PushColorRgba(color.Foreground);
right.PopColor();
if (namePlateAddon.IsNull)
{
_logger.LogInformation("NamePlate addon is null, cannot refresh nameplates.");
return;
}
left.PushEdgeColorRgba(color.Glow);
right.PopEdgeColor();
var addonNamePlate = (AddonNamePlate*)namePlateAddon.Address;
return (left.ToReadOnlySeString().ToDalamudString(), right.ToReadOnlySeString().ToDalamudString());
if (addonNamePlate == null)
{
_logger.LogInformation("addonNamePlate addon is null, cannot refresh nameplates.");
return;
}
addonNamePlate->DoFullUpdate = 1;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
_nameplateHook?.Dispose();
}
_namePlateGui.OnNamePlateUpdate -= OnNamePlateUpdate;
_namePlateGui.RequestRedraw();
base.Dispose(disposing);
}
}

View File

@@ -4,9 +4,14 @@ using Dalamud.Interface.ImGuiNotification;
using Dalamud.Plugin.Services;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.UI;
using LightlessSync.UI.Models;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using FFXIVClientStructs.FFXIV.Client.UI;
@@ -23,7 +28,9 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
private readonly INotificationManager _notificationManager;
private readonly IChatGui _chatGui;
private readonly PairRequestService _pairRequestService;
private readonly HashSet<string> _shownPairRequestNotifications = new();
private readonly HashSet<string> _shownPairRequestNotifications = [];
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)
@@ -59,7 +70,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
{
var notification = CreateNotification(title, message, type, duration, actions, soundEffectId);
if (_configService.Current.AutoDismissOnAction && notification.Actions.Any())
if (_configService.Current.AutoDismissOnAction && notification.Actions.Count != 0)
{
WrapActionsWithAutoDismiss(notification);
}
@@ -104,7 +115,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
}
}
private void DismissNotification(LightlessNotification notification)
private static void DismissNotification(LightlessNotification notification)
{
notification.IsDismissed = true;
notification.IsAnimatingOut = true;
@@ -208,10 +219,12 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
Mediator.Publish(new LightlessNotificationMessage(notification));
}
private string FormatDownloadCompleteMessage(string fileName, int fileCount) =>
fileCount > 1
private static string FormatDownloadCompleteMessage(string fileName, int fileCount)
{
return fileCount > 1
? $"Downloaded {fileCount} files successfully."
: $"Downloaded {fileName} successfully.";
}
private List<LightlessNotificationAction> CreateDownloadCompleteActions(Action? onOpenFolder)
{
@@ -257,8 +270,10 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
Mediator.Publish(new LightlessNotificationMessage(notification));
}
private string FormatErrorMessage(string message, Exception? exception) =>
exception != null ? $"{message}\n\nError: {exception.Message}" : message;
private static string FormatErrorMessage(string message, Exception? exception)
{
return exception != null ? $"{message}\n\nError: {exception.Message}" : message;
}
private List<LightlessNotificationAction> CreateErrorActions(Action? onRetry, Action? onViewLog)
{
@@ -332,8 +347,9 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
return string.Join("\n", activeDownloads.Select(x => $"• {x.PlayerName}: {FormatDownloadStatus(x)}"));
}
private string FormatDownloadStatus((string PlayerName, float Progress, string Status) download) =>
download.Status switch
private static string FormatDownloadStatus((string PlayerName, float Progress, string Status) download)
{
return download.Status switch
{
"downloading" => $"{download.Progress:P0}",
"decompressing" => "decompressing",
@@ -341,6 +357,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
"waiting" => "waiting for slot",
_ => download.Status
};
}
private TimeSpan GetDefaultDurationForType(NotificationType type) => type switch
{
@@ -391,6 +408,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)
{
@@ -478,13 +506,16 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
});
}
private Dalamud.Interface.ImGuiNotification.NotificationType
ConvertToDalamudNotificationType(NotificationType type) => type switch
private static Dalamud.Interface.ImGuiNotification.NotificationType
ConvertToDalamudNotificationType(NotificationType type)
{
NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error,
NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
_ => Dalamud.Interface.ImGuiNotification.NotificationType.Info
};
return type switch
{
NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error,
NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
_ => Dalamud.Interface.ImGuiNotification.NotificationType.Info
};
}
private void ShowChat(NotificationMessage msg)
{
@@ -568,7 +599,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
private void HandlePairRequestsUpdated(PairRequestsUpdatedMessage _)
{
var activeRequests = _pairRequestService.GetActiveRequests();
var activeRequestIds = activeRequests.Select(r => r.HashedCid).ToHashSet();
var activeRequestIds = activeRequests.Select(r => r.HashedCid).ToHashSet(StringComparer.Ordinal);
// Dismiss notifications for requests that are no longer active (expired)
var notificationsToRemove = _shownPairRequestNotifications
@@ -585,7 +616,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
private void HandlePairDownloadStatus(PairDownloadStatusMessage msg)
{
var userDownloads = msg.DownloadStatus.Where(x => x.PlayerName != "Pair Queue").ToList();
var userDownloads = msg.DownloadStatus.Where(x => !string.Equals(x.PlayerName, "Pair Queue", StringComparison.Ordinal)).ToList();
var totalProgress = userDownloads.Count > 0 ? userDownloads.Average(x => x.Progress) : 0f;
var message = BuildPairDownloadMessage(userDownloads, msg.QueueWaiting);
@@ -659,7 +690,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);
@@ -734,7 +772,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
return actions;
}
private string GetUserDisplayName(UserData userData, string playerName)
private static string GetUserDisplayName(UserData userData, string playerName)
{
if (!string.IsNullOrEmpty(userData.Alias) && !string.Equals(userData.Alias, userData.UID, StringComparison.Ordinal))
{

View File

@@ -1,15 +1,12 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Services;
namespace LightlessSync.Services.PairProcessing;
public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
{
private const int HardLimit = 32;
private const int _hardLimit = 32;
private readonly LightlessConfigService _configService;
private readonly object _limitLock = new();
private readonly SemaphoreSlim _semaphore;
@@ -24,8 +21,8 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
{
_configService = configService;
_currentLimit = CalculateLimit();
var initialCount = _configService.Current.EnablePairProcessingLimiter ? _currentLimit : HardLimit;
_semaphore = new SemaphoreSlim(initialCount, HardLimit);
var initialCount = _configService.Current.EnablePairProcessingLimiter ? _currentLimit : _hardLimit;
_semaphore = new SemaphoreSlim(initialCount, _hardLimit);
Mediator.Subscribe<PairProcessingLimitChangedMessage>(this, _ => UpdateSemaphoreLimit());
}
@@ -88,7 +85,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
if (!enabled)
{
var releaseAmount = HardLimit - _semaphore.CurrentCount;
var releaseAmount = _hardLimit - _semaphore.CurrentCount;
if (releaseAmount > 0)
{
TryReleaseSemaphore(releaseAmount);
@@ -110,7 +107,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
var increment = desiredLimit - _currentLimit;
_pendingIncrements += increment;
var available = HardLimit - _semaphore.CurrentCount;
var available = _hardLimit - _semaphore.CurrentCount;
var toRelease = Math.Min(_pendingIncrements, available);
if (toRelease > 0 && TryReleaseSemaphore(toRelease))
{
@@ -148,7 +145,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
private int CalculateLimit()
{
var configured = _configService.Current.MaxConcurrentPairApplications;
return Math.Clamp(configured, 1, HardLimit);
return Math.Clamp(configured, 1, _hardLimit);
}
private bool TryReleaseSemaphore(int count = 1)
@@ -248,8 +245,3 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
}
}
}
public readonly record struct PairProcessingLimiterSnapshot(bool IsEnabled, int Limit, int InFlight, int Waiting)
{
public int Remaining => Math.Max(0, Limit - InFlight);
}

View File

@@ -0,0 +1,9 @@
using System.Runtime.InteropServices;
namespace LightlessSync.Services.PairProcessing;
[StructLayout(LayoutKind.Auto)]
public readonly record struct PairProcessingLimiterSnapshot(bool IsEnabled, int Limit, int InFlight, int Waiting)
{
public int Remaining => Math.Max(0, Limit - InFlight);
}

View File

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

View File

@@ -26,12 +26,12 @@ public sealed class PerformanceCollectorService : IHostedService
{
if (!_lightlessConfigService.Current.LogPerformance) return func.Invoke();
string cn = sender.GetType().Name + _counterSplit + counterName.BuildMessage();
var owner = sender.GetType().Name;
var counter = counterName.BuildMessage();
var cn = string.Concat(owner, _counterSplit, counter);
if (!PerformanceCounters.TryGetValue(cn, out var list))
{
list = PerformanceCounters[cn] = new(maxEntries);
}
var dt = DateTime.UtcNow.Ticks;
try
@@ -53,12 +53,12 @@ public sealed class PerformanceCollectorService : IHostedService
{
if (!_lightlessConfigService.Current.LogPerformance) { act.Invoke(); return; }
var cn = sender.GetType().Name + _counterSplit + counterName.BuildMessage();
var owner = sender.GetType().Name;
var counter = counterName.BuildMessage();
var cn = string.Concat(owner, _counterSplit, counter);
if (!PerformanceCounters.TryGetValue(cn, out var list))
{
list = PerformanceCounters[cn] = new(maxEntries);
}
var dt = DateTime.UtcNow.Ticks;
try
@@ -72,7 +72,7 @@ public sealed class PerformanceCollectorService : IHostedService
if (TimeSpan.FromTicks(elapsed) > TimeSpan.FromMilliseconds(10))
_logger.LogWarning(">10ms spike on {counterName}: {time}", cn, TimeSpan.FromTicks(elapsed));
#endif
list.Add(new(TimeOnly.FromDateTime(DateTime.Now), elapsed));
list.Add((TimeOnly.FromDateTime(DateTime.Now), elapsed));
}
}
@@ -121,11 +121,11 @@ public sealed class PerformanceCollectorService : IHostedService
sb.Append('|');
sb.Append("-Counter Name".PadRight(longestCounterName, '-'));
sb.AppendLine();
var orderedData = data.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase).ToList();
var previousCaller = orderedData[0].Key.Split(_counterSplit, StringSplitOptions.RemoveEmptyEntries)[0];
var orderedData = data.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase).ToList();
var previousCaller = SplitCounterKey(orderedData[0].Key).Owner;
foreach (var entry in orderedData)
{
var newCaller = entry.Key.Split(_counterSplit, StringSplitOptions.RemoveEmptyEntries)[0];
var newCaller = SplitCounterKey(entry.Key).Owner;
if (!string.Equals(previousCaller, newCaller, StringComparison.Ordinal))
{
DrawSeparator(sb, longestCounterName);
@@ -135,13 +135,13 @@ public sealed class PerformanceCollectorService : IHostedService
if (pastEntries.Any())
{
sb.Append((" " + TimeSpan.FromTicks(pastEntries.LastOrDefault() == default ? 0 : pastEntries.Last().Item2).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15));
sb.Append((" " + TimeSpan.FromTicks(pastEntries.LastOrDefault() == default ? 0 : pastEntries[^1].Item2).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15));
sb.Append('|');
sb.Append((" " + TimeSpan.FromTicks(pastEntries.Max(m => m.Item2)).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15));
sb.Append('|');
sb.Append((" " + TimeSpan.FromTicks((long)pastEntries.Average(m => m.Item2)).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15));
sb.Append('|');
sb.Append((" " + (pastEntries.LastOrDefault() == default ? "-" : pastEntries.Last().Item1.ToString("HH:mm:ss.ffff", CultureInfo.InvariantCulture))).PadRight(15, ' '));
sb.Append((" " + (pastEntries.LastOrDefault() == default ? "-" : pastEntries[^1].Item1.ToString("HH:mm:ss.ffff", CultureInfo.InvariantCulture))).PadRight(15, ' '));
sb.Append('|');
sb.Append((" " + pastEntries.Count).PadRight(10));
sb.Append('|');
@@ -157,6 +157,12 @@ public sealed class PerformanceCollectorService : IHostedService
_logger.LogInformation("{perf}", sb.ToString());
}
private static (string Owner, string Counter) SplitCounterKey(string cn)
{
var parts = cn.Split(_counterSplit, 2, StringSplitOptions.None);
return (parts[0], parts.Length > 1 ? parts[1] : string.Empty);
}
private static void DrawSeparator(StringBuilder sb, int longestCounterName)
{
sb.Append("".PadRight(15, '-'));
@@ -183,7 +189,7 @@ public sealed class PerformanceCollectorService : IHostedService
{
try
{
var last = entries.Value.ToList().Last();
var last = entries.Value.ToList()[^1];
if (last.Item1.AddMinutes(10) < TimeOnly.FromDateTime(DateTime.Now) && !PerformanceCounters.TryRemove(entries.Key, out _))
{
_logger.LogDebug("Could not remove performance counter {counter}", entries.Key);

View File

@@ -1,9 +1,10 @@
using LightlessSync.API.Data;
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 +18,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 +42,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 +81,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 +128,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 +164,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 +187,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 +207,63 @@ public class PlayerPerformanceService
}
fileSize = fileEntry.Size.Value;
effectiveSize = fileSize;
if (!skipDownscale)
{
var preferredPath = _textureDownscaleService.GetPreferredPath(hash, fileEntry.ResolvedFilepath);
if (!string.IsNullOrEmpty(preferredPath) && File.Exists(preferredPath))
{
try
{
effectiveSize = new FileInfo(preferredPath).Length;
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to read size for preferred texture path {Path}", preferredPath);
effectiveSize = fileSize;
}
}
else
{
effectiveSize = fileSize;
}
}
}
vramUsage += fileSize;
effectiveVramUsage += effectiveSize;
}
pair.LastAppliedApproximateVRAMBytes = vramUsage;
pairHandler.LastAppliedApproximateVRAMBytes = vramUsage;
pairHandler.LastAppliedApproximateEffectiveVRAMBytes = effectiveVramUsage;
_logger.LogDebug("Calculated VRAM usage for {p}", pairHandler);
// no warning of any kind on ignored pairs
if (config.UIDsToIgnore
.Exists(uid => string.Equals(uid, pair.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pair.UserData.UID, StringComparison.Ordinal)))
.Exists(uid => string.Equals(uid, pairHandler.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pairHandler.UserData.UID, StringComparison.Ordinal)))
return true;
bool isPrefPerm = pair.UserPair.OwnPermissions.HasFlag(API.Data.Enum.UserPermissions.Sticky);
bool isPrefPerm = pairHandler.HasStickyPermissions;
// now check auto pause
if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.VRAMSizeAutoPauseThresholdMiB * 1024 * 1024,
if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.VRAMSizeAutoPauseThresholdMiB * 1024L * 1024L,
vramUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm))
{
var message = $"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured VRAM auto pause threshold and has been automatically paused\n" +
var message = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeded your configured VRAM auto pause threshold and has been automatically paused\n" +
$"{UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB}MiB";
_mediator.Publish(new PerformanceNotificationMessage(
$"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
$"{pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) automatically paused",
message,
pair.UserData,
pairHandler.UserData,
true,
pair.PlayerName));
pairHandler.PlayerName));
_mediator.Publish(new PauseMessage(pair.UserData));
_mediator.Publish(new PauseMessage(pairHandler.UserData));
_mediator.Publish(new EventMessage(new Event(pair.PlayerName, pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
_mediator.Publish(new EventMessage(new Event(pairHandler.PlayerName, pairHandler.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
$"Exceeds VRAM threshold: automatically paused ({UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB} MiB)")));
return false;

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,17 @@
namespace LightlessSync.Services.Profiles;
public record LightlessUserProfileData(
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);
}

View File

@@ -0,0 +1,82 @@
using System.Numerics;
using System.Reflection;
using Dalamud.Bindings.ImGui;
using Pictomancy;
namespace LightlessSync.Services.Rendering;
internal static class PctDrawListExtensions
{
private static readonly FieldInfo? DrawListField = typeof(PctDrawList).GetField("_drawList", BindingFlags.Instance | BindingFlags.NonPublic);
private static bool TryGetImDrawList(PctDrawList drawList, out ImDrawListPtr ptr)
{
ptr = default;
if (DrawListField == null)
return false;
if (DrawListField.GetValue(drawList) is ImDrawListPtr list)
{
ptr = list;
return true;
}
return false;
}
public static void AddScreenText(this PctDrawList drawList, Vector2 screenPosition, string text, uint color, float fontSize, Vector2? pivot = null, uint? outlineColor = null, ImFontPtr fontOverride = default)
{
if (drawList == null || string.IsNullOrEmpty(text))
return;
if (!TryGetImDrawList(drawList, out var imDrawList))
return;
var font = fontOverride.IsNull ? ImGui.GetFont() : fontOverride;
if (font.IsNull)
return;
var size = MathF.Max(1f, fontSize);
var pivotValue = pivot ?? new Vector2(0.5f, 0.5f);
Vector2 measured;
float calcFontSize;
if (!fontOverride.IsNull)
{
ImGui.PushFont(font);
measured = ImGui.CalcTextSize(text);
calcFontSize = ImGui.GetFontSize();
ImGui.PopFont();
}
else
{
measured = ImGui.CalcTextSize(text);
calcFontSize = ImGui.GetFontSize();
}
if (calcFontSize > 0f && MathF.Abs(size - calcFontSize) > 0.001f)
{
measured *= size / calcFontSize;
}
var drawPos = screenPosition - measured * pivotValue;
if (outlineColor.HasValue)
{
var thickness = MathF.Max(1f, size / 24f);
Span<Vector2> offsets = stackalloc Vector2[4]
{
new Vector2(1f, 0f),
new Vector2(-1f, 0f),
new Vector2(0f, 1f),
new Vector2(0f, -1f)
};
foreach (var offset in offsets)
{
imDrawList.AddText(font, size, drawPos + offset * thickness, outlineColor.Value, text);
}
}
imDrawList.AddText(font, size, drawPos, color, text);
}
}

View File

@@ -0,0 +1,47 @@
using Dalamud.Plugin;
using Microsoft.Extensions.Logging;
using Pictomancy;
namespace LightlessSync.Services.Rendering;
public sealed class PictomancyService : IDisposable
{
private readonly ILogger<PictomancyService> _logger;
private bool _initialized;
public PictomancyService(ILogger<PictomancyService> logger, IDalamudPluginInterface pluginInterface)
{
_logger = logger;
try
{
PictoService.Initialize(pluginInterface);
_initialized = true;
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Pictomancy initialized");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize Pictomancy");
}
}
public void Dispose()
{
if (!_initialized)
return;
try
{
PictoService.Dispose();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to dispose Pictomancy");
}
finally
{
_initialized = false;
}
}
}

View File

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

View File

@@ -0,0 +1,312 @@
using System.Numerics;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
/*
* Index upscaler code (converted/reversed for downscaling purposes) provided by Ny
* thank you!!
*/
namespace LightlessSync.Services.TextureCompression;
internal static class IndexDownscaler
{
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),
};
public static Image<Rgba32> Downscale(Image<Rgba32> source, int targetWidth, int targetHeight, int blockMultiple)
{
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 (var y = 0; y < nextHeight; y++)
{
var srcY = Math.Min(current.Height - 1, y * 2);
for (var 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 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 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,714 @@
using System.Collections.Concurrent;
using System.Buffers;
using System.Buffers.Binary;
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
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;
/*
* 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 readonly SemaphoreSlim _downscaleSemaphore = new(4);
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(async () =>
{
await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false);
}, 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;
}
public Task WaitForPendingJobsAsync(IEnumerable<string>? hashes, CancellationToken token)
{
if (hashes is null)
{
return Task.CompletedTask;
}
var pending = new List<Task>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var hash in hashes)
{
if (string.IsNullOrEmpty(hash) || !seen.Add(hash))
{
continue;
}
if (_activeJobs.TryGetValue(hash, out var job))
{
pending.Add(job);
}
}
if (pending.Count == 0)
{
return Task.CompletedTask;
}
return Task.WhenAll(pending).WaitAsync(token);
}
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;
await _downscaleSemaphore.WaitAsync().ConfigureAwait(false);
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 (headerInfo is { } headerValue &&
headerValue.Width <= targetMaxDimension &&
headerValue.Height <= targetMaxDimension)
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; header dimensions {Width}x{Height} within target.", hash, headerValue.Width, headerValue.Height);
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.Slice(0, requiredLength);
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)
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; already within bounds.", hash);
return;
}
using var resized = IndexDownscaler.Downscale(originalImage, targetSize.width, targetSize.height, BlockMultiple);
using var resizedScratch = CreateScratchImage(resized, targetSize.width, targetSize.height);
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
{
_downscaleSemaphore.Release();
_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 ScratchImage CreateScratchImage(Image<Rgba32> image, int width, int height)
{
const int BytesPerPixel = 4;
var requiredLength = width * height * BytesPerPixel;
static ScratchImage Create(ReadOnlySpan<byte> pixels, int width, int height)
{
var scratchResult = ScratchImage.FromRGBA(pixels, width, height, out var creationInfo);
return scratchResult.ThrowIfError(creationInfo);
}
if (image.DangerousTryGetSinglePixelMemory(out var pixelMemory))
{
var byteSpan = MemoryMarshal.AsBytes(pixelMemory.Span);
if (byteSpan.Length < requiredLength)
{
throw new InvalidOperationException($"Image buffer shorter than expected ({byteSpan.Length} < {requiredLength}).");
}
return Create(byteSpan.Slice(0, requiredLength), width, height);
}
var rented = ArrayPool<byte>.Shared.Rent(requiredLength);
try
{
var rentedSpan = rented.AsSpan(0, requiredLength);
image.CopyPixelDataTo(rentedSpan);
return Create(rentedSpan, width, height);
}
finally
{
ArrayPool<byte>.Shared.Return(rented);
}
}
private static bool IsIndexMap(TextureMapKind kind)
=> kind is TextureMapKind.Mask
or TextureMapKind.Index;
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 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 && depth <= targetMaxDimension)
{
return false;
}
return true;
}
private int ResolveTargetMaxDimension()
{
var configured = _playerPerformanceConfigService.Current.TextureDownscaleMaxDimension;
if (configured <= 0)
{
return DefaultTargetMaxDimension;
}
return Math.Clamp(configured, BlockMultiple, MaxSupportedTargetDimension);
}
private readonly struct TexHeaderInfo
{
public TexHeaderInfo(ushort width, ushort height, ushort depth, ushort mipLevels, TexFile.TextureFormat format)
{
Width = width;
Height = height;
Depth = depth;
MipLevels = mipLevels;
Format = format;
}
public ushort Width { get; }
public ushort Height { get; }
public ushort Depth { get; }
public ushort MipLevels { get; }
public TexFile.TextureFormat Format { get; }
}
private static bool TryReadTexHeader(string path, out TexHeaderInfo header)
{
header = default;
try
{
Span<byte> buffer = stackalloc byte[16];
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
var read = stream.Read(buffer);
if (read < buffer.Length)
{
return false;
}
var formatValue = BinaryPrimitives.ReadInt32LittleEndian(buffer[4..8]);
var format = (TexFile.TextureFormat)formatValue;
var width = BinaryPrimitives.ReadUInt16LittleEndian(buffer[8..10]);
var height = BinaryPrimitives.ReadUInt16LittleEndian(buffer[10..12]);
var depth = BinaryPrimitives.ReadUInt16LittleEndian(buffer[12..14]);
var mipLevels = BinaryPrimitives.ReadUInt16LittleEndian(buffer[14..16]);
header = new TexHeaderInfo(width, height, depth, mipLevels, format);
return true;
}
catch
{
return false;
}
}
private static bool IsBlockCompressedFormat(TexFile.TextureFormat format)
=> TryGetCompressionTarget(format, out _);
private static bool TryGetCompressionTarget(TexFile.TextureFormat format, out TextureCompressionTarget target)
{
if (BlockCompressedFormatMap.TryGetValue(unchecked((int)format), out var mapped))
{
target = mapped;
return true;
}
target = default;
return false;
}
private void RegisterDownscaledTexture(string hash, string sourcePath, string destination)
{
_downscaledPaths[hash] = destination;
_logger.LogDebug("Downscaled texture {Hash} -> {Path}", hash, destination);
var performanceConfig = _playerPerformanceConfigService.Current;
if (performanceConfig.KeepOriginalTextureFiles)
{
return;
}
if (string.Equals(sourcePath, destination, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (!TryReplaceCacheEntryWithDownscaled(hash, sourcePath, destination))
{
return;
}
TryDelete(sourcePath);
}
private bool TryReplaceCacheEntryWithDownscaled(string hash, string sourcePath, string destination)
{
try
{
var cacheEntry = _fileCacheManager.GetFileCacheByHash(hash);
if (cacheEntry is null || !cacheEntry.IsCacheEntry)
{
return File.Exists(sourcePath) ? false : true;
}
var cacheFolder = _configService.Current.CacheFolder;
if (string.IsNullOrEmpty(cacheFolder))
{
return false;
}
if (!destination.StartsWith(cacheFolder, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var info = new FileInfo(destination);
if (!info.Exists)
{
return false;
}
var relative = Path.GetRelativePath(cacheFolder, destination)
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
var sanitizedRelative = relative.TrimStart(Path.DirectorySeparatorChar);
var prefixed = Path.Combine(FileCacheManager.CachePrefix, sanitizedRelative);
var replacement = new FileCacheEntity(
hash,
prefixed,
info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture),
info.Length,
cacheEntry.CompressedSize);
replacement.SetResolvedFilePath(destination);
if (!string.Equals(cacheEntry.PrefixedFilePath, prefixed, StringComparison.OrdinalIgnoreCase))
{
_fileCacheManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath);
}
_fileCacheManager.UpdateHashedFile(replacement, computeProperties: false);
_fileCacheManager.WriteOutFullCsv();
_logger.LogTrace("Replaced cache entry for texture {Hash} to downscaled path {Path}", hash, destination);
return true;
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to replace cache entry for texture {Hash}", hash);
return false;
}
}
private string? GetExistingDownscaledPath(string hash)
{
var candidate = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex");
return File.Exists(candidate) ? candidate : null;
}
private string GetDownscaledDirectory()
{
var directory = Path.Combine(_configService.Current.CacheFolder, "downscaled");
if (!Directory.Exists(directory))
{
try
{
Directory.CreateDirectory(directory);
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to create downscaled directory {Directory}", directory);
}
}
return directory;
}
private static void TryDelete(string? path)
{
if (string.IsNullOrEmpty(path)) return;
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// ignored
}
}
}

View File

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

View File

@@ -0,0 +1,590 @@
using Dalamud.Plugin.Services;
using Microsoft.Extensions.Logging;
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, "_n_"),
(TextureMapKind.Normal, "_normal"),
(TextureMapKind.Normal, "normal_"),
(TextureMapKind.Normal, "_norm"),
(TextureMapKind.Normal, "norm_"),
(TextureMapKind.Mask, "_m."),
(TextureMapKind.Mask, "_m_"),
(TextureMapKind.Mask, "_mask"),
(TextureMapKind.Mask, "mask_"),
(TextureMapKind.Mask, "_msk"),
(TextureMapKind.Specular, "_s."),
(TextureMapKind.Specular, "_s_"),
(TextureMapKind.Specular, "_spec"),
(TextureMapKind.Specular, "_specular"),
(TextureMapKind.Specular, "specular_"),
(TextureMapKind.Index, "_id."),
(TextureMapKind.Index, "_id_"),
(TextureMapKind.Index, "_idx"),
(TextureMapKind.Index, "_index"),
(TextureMapKind.Index, "index_"),
(TextureMapKind.Index, "_multi"),
(TextureMapKind.Diffuse, "_d."),
(TextureMapKind.Diffuse, "_d_"),
(TextureMapKind.Diffuse, "_diff"),
(TextureMapKind.Diffuse, "_b."),
(TextureMapKind.Diffuse, "_b_"),
(TextureMapKind.Diffuse, "_base"),
(TextureMapKind.Diffuse, "base_")
};
private const string TextureSegment = "/texture/";
private const string MaterialSegment = "/material/";
private const uint NormalSamplerId = ShpkFile.NormalSamplerId;
private const uint IndexSamplerId = ShpkFile.IndexSamplerId;
private const uint SpecularSamplerId = ShpkFile.SpecularSamplerId;
private const uint DiffuseSamplerId = ShpkFile.DiffuseSamplerId;
private const uint MaskSamplerId = ShpkFile.MaskSamplerId;
public TextureMetadataHelper(ILogger<TextureMetadataHelper> logger, IDataManager dataManager)
{
_logger = logger;
_dataManager = dataManager;
}
public static bool TryGetRecommendationInfo(TextureCompressionTarget target, out (string Title, string Description) info)
=> RecommendationCatalog.TryGetValue(target, out info);
public static 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 static 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 static 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 static 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 fileNameWithExtension = Path.GetFileName(normalized);
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(normalized);
if (string.IsNullOrEmpty(fileNameWithExtension) && string.IsNullOrEmpty(fileNameWithoutExtension))
return TextureMapKind.Unknown;
foreach (var (kind, token) in MapTokens)
{
if (!string.IsNullOrEmpty(fileNameWithExtension) &&
fileNameWithExtension.Contains(token, StringComparison.OrdinalIgnoreCase))
return kind;
if (!string.IsNullOrEmpty(fileNameWithoutExtension) &&
fileNameWithoutExtension.Contains(token, StringComparison.OrdinalIgnoreCase))
return kind;
}
return TextureMapKind.Unknown;
}
private static readonly (string Token, TextureCompressionTarget Target)[] FormatTargetTokens =
{
("BC1", TextureCompressionTarget.BC1),
("DXT1", TextureCompressionTarget.BC1),
("BC3", TextureCompressionTarget.BC3),
("DXT3", TextureCompressionTarget.BC3),
("DXT5", TextureCompressionTarget.BC3),
("BC4", TextureCompressionTarget.BC4),
("ATI1", TextureCompressionTarget.BC4),
("BC5", TextureCompressionTarget.BC5),
("ATI2", TextureCompressionTarget.BC5),
("3DC", TextureCompressionTarget.BC5),
("BC7", TextureCompressionTarget.BC7),
("BPTC", TextureCompressionTarget.BC7)
}; // idk man
public static bool TryMapFormatToTarget(string? format, out TextureCompressionTarget target)
{
var normalized = (format ?? string.Empty).ToUpperInvariant();
foreach (var (token, mappedTarget) in FormatTargetTokens)
{
if (normalized.Contains(token, StringComparison.Ordinal))
{
target = mappedTarget;
return true;
}
}
target = TextureCompressionTarget.BC7;
return false;
}
public static (TextureCompressionTarget Target, string Reason)? GetSuggestedTarget(
string? format,
TextureMapKind mapKind,
string? texturePath = null)
{
TextureCompressionTarget? current = null;
if (TryMapFormatToTarget(format, out var mapped))
current = mapped;
var prefersBc4 = IsFacePaintOrMarkTexture(texturePath);
var suggestion = mapKind switch
{
TextureMapKind.Normal => TextureCompressionTarget.BC7,
TextureMapKind.Mask => TextureCompressionTarget.BC7,
TextureMapKind.Index => TextureCompressionTarget.BC5,
TextureMapKind.Specular => TextureCompressionTarget.BC3,
TextureMapKind.Diffuse => TextureCompressionTarget.BC7,
_ => TextureCompressionTarget.BC7
};
if (prefersBc4)
{
suggestion = TextureCompressionTarget.BC4;
}
else if (mapKind == TextureMapKind.Diffuse && current is null && !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 (IsFacePaintPath(normalized))
return "Face Paint";
if (IsLegacyMarkPath(normalized))
return "Legacy Mark";
if (normalized.Contains("decal_equip", StringComparison.OrdinalIgnoreCase))
return "Equipment Decal";
return "Customization";
}
private static bool IsFacePaintOrMarkTexture(string? texturePath)
{
var normalized = Normalize(texturePath);
return IsFacePaintPath(normalized) || IsLegacyMarkPath(normalized);
}
private static bool IsFacePaintPath(string? normalizedPath)
{
if (string.IsNullOrEmpty(normalizedPath))
return false;
return normalizedPath.Contains("decal_face", StringComparison.Ordinal)
|| normalizedPath.Contains("facepaint", StringComparison.Ordinal)
|| normalizedPath.Contains("_decal_", StringComparison.Ordinal);
}
private static bool IsLegacyMarkPath(string? normalizedPath)
{
if (string.IsNullOrEmpty(normalizedPath))
return false;
return normalizedPath.Contains("transparent", StringComparison.Ordinal)
|| normalizedPath.Contains("transparent.tex", StringComparison.Ordinal);
}
private static bool HasAlphaHint(string? format)
{
if (string.IsNullOrEmpty(format))
return false;
var normalized = format.ToUpperInvariant();
return normalized.Contains("A8", StringComparison.Ordinal)
|| normalized.Contains("ARGB", StringComparison.Ordinal)
|| normalized.Contains("BC3", StringComparison.Ordinal)
|| normalized.Contains("BC7", StringComparison.Ordinal);
}
private static string Normalize(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return string.Empty;
return path.Replace('\\', '/').ToLowerInvariant();
}
private readonly record struct MaterialCandidate(string Path, MaterialSource Source)
{
public static MaterialCandidate Game(string path) => new(path, MaterialSource.Game);
public static MaterialCandidate Local(string path) => new(path, MaterialSource.Local);
}
private enum MaterialSource
{
Game,
Local
}
}

View File

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

View File

@@ -1,10 +1,12 @@
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 +17,94 @@ 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;
private readonly DalamudUtilService _dalamudUtilService;
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,
ProfileTagService profileTagService,
DalamudUtilService dalamudUtilService)
{
_loggerFactory = loggerFactory;
_lightlessMediator = lightlessMediator;
_apiController = apiController;
_uiSharedService = uiSharedService;
_pairManager = pairManager;
_pairUiService = pairUiService;
_serverConfigManager = serverConfigManager;
_lightlessProfileManager = lightlessProfileManager;
_performanceCollectorService = performanceCollectorService;
_fileDialogManager = fileDialogManager;
_profileTagService = profileTagService;
_dalamudUtilService = dalamudUtilService;
}
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);
}
public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair)
{
return new StandaloneProfileUi(_loggerFactory.CreateLogger<StandaloneProfileUi>(), _lightlessMediator,
_uiSharedService, _serverConfigManager, _lightlessProfileManager, _pairManager, pair, _performanceCollectorService);
}
=> CreateStandaloneProfileUiInternal(pair, pair.UserData, null, false, null);
public StandaloneProfileUi CreateStandaloneProfileUi(UserData userData)
=> CreateStandaloneProfileUiInternal(null, userData, null, false, null);
public StandaloneProfileUi CreateLightfinderProfileUi(UserData userData, string hashedCid)
=> CreateStandaloneProfileUiInternal(null, userData, null, true, hashedCid);
public StandaloneProfileUi CreateStandaloneGroupProfileUi(GroupData groupInfo)
=> CreateStandaloneProfileUiInternal(null, null, groupInfo, false, null);
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);
}
private StandaloneProfileUi CreateStandaloneProfileUiInternal(
Pair? pair,
UserData? userData,
GroupData? groupData,
bool isLightfinderContext,
string? lightfinderCid)
{
return new StandaloneProfileUi(
_loggerFactory.CreateLogger<StandaloneProfileUi>(),
_lightlessMediator,
_uiSharedService,
_serverConfigManager,
_profileTagService,
dalamudUtilService: _dalamudUtilService,
lightlessProfileManager: _lightlessProfileManager,
pairUiService: _pairUiService,
pair: pair,
userData: userData,
groupData: groupData,
isLightfinderContext: isLightfinderContext,
lightfinderCid: lightfinderCid,
performanceCollector: _performanceCollectorService);
}
}

View File

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

View File

@@ -46,7 +46,7 @@ public sealed class XivDataAnalyzer
if (handle->FileName.Length > 1024) continue;
var skeletonName = handle->FileName.ToString();
if (string.IsNullOrEmpty(skeletonName)) continue;
outputIndices[skeletonName] = new();
outputIndices[skeletonName] = [];
for (ushort boneIdx = 0; boneIdx < curBones; boneIdx++)
{
var boneName = handle->HavokSkeleton->Bones[boneIdx].Name.String;
@@ -70,7 +70,7 @@ public sealed class XivDataAnalyzer
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
if (cacheEntity == null) return null;
using BinaryReader reader = new BinaryReader(File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read));
using BinaryReader reader = new(File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read));
// most of this shit is from vfxeditor, surely nothing will change in the pap format :copium:
reader.ReadInt32(); // ignore
@@ -177,17 +177,18 @@ public sealed class XivDataAnalyzer
}
long tris = 0;
for (int i = 0; i < file.LodCount; i++)
foreach (var lod in file.Lods)
{
try
{
var meshIdx = file.Lods[i].MeshIndex;
var meshCnt = file.Lods[i].MeshCount;
var meshIdx = lod.MeshIndex;
var meshCnt = lod.MeshCount;
tris = file.Meshes.Skip(meshIdx).Take(meshCnt).Sum(p => p.IndexCount) / 3;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not load lod mesh {mesh} from path {path}", i, filePath);
_logger.LogDebug(ex, "Could not load lod mesh {mesh} from path {path}", lod.MeshIndex, filePath);
continue;
}