2.0.0 (#92)
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m27s
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:
938
LightlessSync/Services/ActorTracking/ActorObjectService.cs
Normal file
938
LightlessSync/Services/ActorTracking/ActorObjectService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
19
LightlessSync/Services/CharaData/CharacterAnalysisSummary.cs
Normal file
19
LightlessSync/Services/CharaData/CharacterAnalysisSummary.cs
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
34
LightlessSync/Services/Chat/ChatModels.cs
Normal file
34
LightlessSync/Services/Chat/ChatModels.cs
Normal 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);
|
||||
1315
LightlessSync/Services/Chat/ZoneChatService.cs
Normal file
1315
LightlessSync/Services/Chat/ZoneChatService.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
|
||||
692
LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs
Normal file
692
LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
17
LightlessSync/Services/Profiles/LightlessGroupProfileData.cs
Normal file
17
LightlessSync/Services/Profiles/LightlessGroupProfileData.cs
Normal 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);
|
||||
}
|
||||
20
LightlessSync/Services/Profiles/LightlessProfileData.cs
Normal file
20
LightlessSync/Services/Profiles/LightlessProfileData.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public record LightlessProfileData(
|
||||
bool IsFlagged,
|
||||
bool IsNSFW,
|
||||
string Base64ProfilePicture,
|
||||
string Base64SupporterPicture,
|
||||
string Base64BannerPicture,
|
||||
string Description,
|
||||
IReadOnlyList<int> Tags)
|
||||
{
|
||||
public Lazy<byte[]> ImageData { get; } = new(() => ConvertSafe(Base64ProfilePicture));
|
||||
public Lazy<byte[]> SupporterImageData { get; } = new(() => ConvertSafe(Base64SupporterPicture));
|
||||
public Lazy<byte[]> BannerImageData { get; } = new(() => ConvertSafe(Base64BannerPicture));
|
||||
|
||||
private static byte[] ConvertSafe(string value) => string.IsNullOrEmpty(value) ? Array.Empty<byte>() : Convert.FromBase64String(value);
|
||||
}
|
||||
348
LightlessSync/Services/Profiles/LightlessProfileManager.cs
Normal file
348
LightlessSync/Services/Profiles/LightlessProfileManager.cs
Normal file
File diff suppressed because one or more lines are too long
17
LightlessSync/Services/Profiles/LightlessUserProfileData.cs
Normal file
17
LightlessSync/Services/Profiles/LightlessUserProfileData.cs
Normal 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);
|
||||
}
|
||||
82
LightlessSync/Services/Rendering/PctDrawListExtensions.cs
Normal file
82
LightlessSync/Services/Rendering/PctDrawListExtensions.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
47
LightlessSync/Services/Rendering/PictomancyService.cs
Normal file
47
LightlessSync/Services/Rendering/PictomancyService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
312
LightlessSync/Services/TextureCompression/IndexDownscaler.cs
Normal file
312
LightlessSync/Services/TextureCompression/IndexDownscaler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
280
LightlessSync/Services/TextureCompression/TexFileHelper.cs
Normal file
280
LightlessSync/Services/TextureCompression/TexFileHelper.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
namespace LightlessSync.Services.TextureCompression;
|
||||
|
||||
public sealed record TextureCompressionRequest(
|
||||
string PrimaryFilePath,
|
||||
IReadOnlyList<string> DuplicateFilePaths,
|
||||
TextureCompressionTarget Target);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace LightlessSync.Services.TextureCompression;
|
||||
|
||||
public enum TextureCompressionTarget
|
||||
{
|
||||
BC1,
|
||||
BC3,
|
||||
BC4,
|
||||
BC5,
|
||||
BC7
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
11
LightlessSync/Services/TextureCompression/TextureMapKind.cs
Normal file
11
LightlessSync/Services/TextureCompression/TextureMapKind.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace LightlessSync.Services.TextureCompression;
|
||||
|
||||
public enum TextureMapKind
|
||||
{
|
||||
Diffuse,
|
||||
Normal,
|
||||
Specular,
|
||||
Mask,
|
||||
Index,
|
||||
Unknown
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace LightlessSync.Services.TextureCompression;
|
||||
|
||||
public enum TextureUsageCategory
|
||||
{
|
||||
Gear,
|
||||
Weapon,
|
||||
Accessory,
|
||||
Customization,
|
||||
MountOrMinion,
|
||||
Companion,
|
||||
Monster,
|
||||
Housing,
|
||||
UI,
|
||||
VisualEffect,
|
||||
Unknown
|
||||
}
|
||||
@@ -1,10 +1,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user