Merge remote-tracking branch 'origin/2.0.2' into 2.0.0-crashing-bugfixes
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Dalamud.Game.ClientState.Conditions;
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Hooking;
|
||||
using Dalamud.Plugin.Services;
|
||||
using FFXIVClientStructs.Interop;
|
||||
@@ -10,6 +9,8 @@ using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind;
|
||||
using IPlayerCharacter = Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter;
|
||||
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
||||
using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
||||
|
||||
@@ -41,7 +42,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
private readonly ConcurrentDictionary<string, ActorDescriptor> _actorsByHash = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<nint, ActorDescriptor>> _actorsByName = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<nint, byte> _pendingHashResolutions = new();
|
||||
private readonly OwnedObjectTracker _ownedTracker = new();
|
||||
private ActorSnapshot _snapshot = ActorSnapshot.Empty;
|
||||
private GposeSnapshot _gposeSnapshot = GposeSnapshot.Empty;
|
||||
|
||||
@@ -151,15 +151,16 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
public bool TryGetOwnedObjectByIndex(ushort objectIndex, out LightlessObjectKind ownedKind)
|
||||
{
|
||||
ownedKind = default;
|
||||
var ownedSnapshot = OwnedObjects;
|
||||
foreach (var (address, kind) in ownedSnapshot)
|
||||
var ownedDescriptors = OwnedDescriptors;
|
||||
for (var i = 0; i < ownedDescriptors.Count; i++)
|
||||
{
|
||||
if (!TryGetDescriptor(address, out var descriptor))
|
||||
var descriptor = ownedDescriptors[i];
|
||||
if (descriptor.ObjectIndex != objectIndex)
|
||||
continue;
|
||||
|
||||
if (descriptor.ObjectIndex == objectIndex)
|
||||
if (descriptor.OwnedKind is { } resolvedKind)
|
||||
{
|
||||
ownedKind = kind;
|
||||
ownedKind = resolvedKind;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -316,7 +317,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
_actorsByHash.Clear();
|
||||
_actorsByName.Clear();
|
||||
_pendingHashResolutions.Clear();
|
||||
_ownedTracker.Reset();
|
||||
Volatile.Write(ref _snapshot, ActorSnapshot.Empty);
|
||||
Volatile.Write(ref _gposeSnapshot, GposeSnapshot.Empty);
|
||||
return Task.CompletedTask;
|
||||
@@ -481,50 +481,196 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
return (isLocalPlayer ? LightlessObjectKind.Player : null, entityId);
|
||||
}
|
||||
|
||||
if (isLocalPlayer)
|
||||
{
|
||||
var entityId = ((Character*)gameObject)->EntityId;
|
||||
return (LightlessObjectKind.Player, entityId);
|
||||
}
|
||||
var ownerId = ResolveOwnerId(gameObject);
|
||||
var localPlayerAddress = _objectTable.LocalPlayer?.Address ?? nint.Zero;
|
||||
if (localPlayerAddress == nint.Zero)
|
||||
return (null, ownerId);
|
||||
|
||||
if (_objectTable.LocalPlayer is not { } localPlayer)
|
||||
return (null, 0);
|
||||
var localEntityId = ((Character*)localPlayerAddress)->EntityId;
|
||||
if (localEntityId == 0)
|
||||
return (null, ownerId);
|
||||
|
||||
var ownerId = gameObject->OwnerId;
|
||||
if (ownerId == 0)
|
||||
if (objectKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
|
||||
{
|
||||
var character = (Character*)gameObject;
|
||||
if (character != null)
|
||||
var expectedMinionOrMount = GetMinionOrMountAddress(localPlayerAddress, localEntityId);
|
||||
if (expectedMinionOrMount != nint.Zero && (nint)gameObject == expectedMinionOrMount)
|
||||
{
|
||||
ownerId = character->CompanionOwnerId;
|
||||
if (ownerId == 0)
|
||||
{
|
||||
var parent = character->GetParentCharacter();
|
||||
if (parent != null)
|
||||
{
|
||||
ownerId = parent->EntityId;
|
||||
}
|
||||
}
|
||||
var resolvedOwner = ownerId != 0 ? ownerId : localEntityId;
|
||||
return (LightlessObjectKind.MinionOrMount, resolvedOwner);
|
||||
}
|
||||
}
|
||||
|
||||
if (ownerId == 0 || ownerId != localPlayer.EntityId)
|
||||
if (objectKind != DalamudObjectKind.BattleNpc)
|
||||
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,
|
||||
};
|
||||
if (ownerId != localEntityId)
|
||||
return (null, ownerId);
|
||||
|
||||
return (ownedKind, ownerId);
|
||||
var expectedPet = GetPetAddress(localPlayerAddress, localEntityId);
|
||||
if (expectedPet != nint.Zero && (nint)gameObject == expectedPet)
|
||||
return (LightlessObjectKind.Pet, ownerId);
|
||||
|
||||
var expectedCompanion = GetCompanionAddress(localPlayerAddress, localEntityId);
|
||||
if (expectedCompanion != nint.Zero && (nint)gameObject == expectedCompanion)
|
||||
return (LightlessObjectKind.Companion, ownerId);
|
||||
|
||||
return (null, ownerId);
|
||||
}
|
||||
|
||||
private unsafe nint GetMinionOrMountAddress(nint localPlayerAddress, uint ownerEntityId)
|
||||
{
|
||||
if (localPlayerAddress == nint.Zero)
|
||||
return nint.Zero;
|
||||
|
||||
var playerObject = (GameObject*)localPlayerAddress;
|
||||
var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1);
|
||||
if (candidateAddress != nint.Zero)
|
||||
{
|
||||
var candidate = (GameObject*)candidateAddress;
|
||||
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
|
||||
if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
|
||||
{
|
||||
if (ownerEntityId == 0 || ResolveOwnerId(candidate) == ownerEntityId)
|
||||
return candidateAddress;
|
||||
}
|
||||
}
|
||||
|
||||
if (ownerEntityId == 0)
|
||||
return candidateAddress;
|
||||
|
||||
foreach (var obj in _objectTable)
|
||||
{
|
||||
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
|
||||
continue;
|
||||
|
||||
if (obj.ObjectKind is not (DalamudObjectKind.MountType or DalamudObjectKind.Companion))
|
||||
continue;
|
||||
|
||||
var candidate = (GameObject*)obj.Address;
|
||||
if (ResolveOwnerId(candidate) == ownerEntityId)
|
||||
return obj.Address;
|
||||
}
|
||||
|
||||
return candidateAddress;
|
||||
}
|
||||
|
||||
private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId)
|
||||
{
|
||||
if (localPlayerAddress == nint.Zero || ownerEntityId == 0)
|
||||
return nint.Zero;
|
||||
|
||||
var manager = CharacterManager.Instance();
|
||||
if (manager != null)
|
||||
{
|
||||
var candidate = (nint)manager->LookupPetByOwnerObject((BattleChara*)localPlayerAddress);
|
||||
if (candidate != nint.Zero)
|
||||
{
|
||||
var candidateObj = (GameObject*)candidate;
|
||||
if (IsPetMatch(candidateObj, ownerEntityId))
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var obj in _objectTable)
|
||||
{
|
||||
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
|
||||
continue;
|
||||
|
||||
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
|
||||
continue;
|
||||
|
||||
var candidate = (GameObject*)obj.Address;
|
||||
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
|
||||
continue;
|
||||
|
||||
if (ResolveOwnerId(candidate) == ownerEntityId)
|
||||
return obj.Address;
|
||||
}
|
||||
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
private unsafe nint GetCompanionAddress(nint localPlayerAddress, uint ownerEntityId)
|
||||
{
|
||||
if (localPlayerAddress == nint.Zero || ownerEntityId == 0)
|
||||
return nint.Zero;
|
||||
|
||||
var manager = CharacterManager.Instance();
|
||||
if (manager != null)
|
||||
{
|
||||
var candidate = (nint)manager->LookupBuddyByOwnerObject((BattleChara*)localPlayerAddress);
|
||||
if (candidate != nint.Zero)
|
||||
{
|
||||
var candidateObj = (GameObject*)candidate;
|
||||
if (IsCompanionMatch(candidateObj, ownerEntityId))
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var obj in _objectTable)
|
||||
{
|
||||
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
|
||||
continue;
|
||||
|
||||
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
|
||||
continue;
|
||||
|
||||
var candidate = (GameObject*)obj.Address;
|
||||
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Buddy)
|
||||
continue;
|
||||
|
||||
if (ResolveOwnerId(candidate) == ownerEntityId)
|
||||
return obj.Address;
|
||||
}
|
||||
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
private static unsafe bool IsPetMatch(GameObject* candidate, uint ownerEntityId)
|
||||
{
|
||||
if (candidate == null)
|
||||
return false;
|
||||
|
||||
if ((DalamudObjectKind)candidate->ObjectKind != DalamudObjectKind.BattleNpc)
|
||||
return false;
|
||||
|
||||
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
|
||||
return false;
|
||||
|
||||
return ResolveOwnerId(candidate) == ownerEntityId;
|
||||
}
|
||||
|
||||
private static unsafe bool IsCompanionMatch(GameObject* candidate, uint ownerEntityId)
|
||||
{
|
||||
if (candidate == null)
|
||||
return false;
|
||||
|
||||
if ((DalamudObjectKind)candidate->ObjectKind != DalamudObjectKind.BattleNpc)
|
||||
return false;
|
||||
|
||||
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Buddy)
|
||||
return false;
|
||||
|
||||
return ResolveOwnerId(candidate) == ownerEntityId;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private void UntrackGameObject(nint address)
|
||||
@@ -618,11 +764,8 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
private void ReplaceDescriptor(ActorDescriptor existing, ActorDescriptor updated)
|
||||
{
|
||||
RemoveDescriptorFromIndexes(existing);
|
||||
_ownedTracker.OnDescriptorRemoved(existing);
|
||||
|
||||
_activePlayers[updated.Address] = updated;
|
||||
IndexDescriptor(updated);
|
||||
_ownedTracker.OnDescriptorAdded(updated);
|
||||
UpdatePendingHashResolutions(updated);
|
||||
PublishSnapshot();
|
||||
}
|
||||
@@ -690,7 +833,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
{
|
||||
_activePlayers[descriptor.Address] = descriptor;
|
||||
IndexDescriptor(descriptor);
|
||||
_ownedTracker.OnDescriptorAdded(descriptor);
|
||||
UpdatePendingHashResolutions(descriptor);
|
||||
PublishSnapshot();
|
||||
}
|
||||
@@ -698,7 +840,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
private void RemoveDescriptor(ActorDescriptor descriptor)
|
||||
{
|
||||
RemoveDescriptorFromIndexes(descriptor);
|
||||
_ownedTracker.OnDescriptorRemoved(descriptor);
|
||||
_pendingHashResolutions.TryRemove(descriptor.Address, out _);
|
||||
PublishSnapshot();
|
||||
}
|
||||
@@ -722,17 +863,90 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
|
||||
private void PublishSnapshot()
|
||||
{
|
||||
var playerDescriptors = _activePlayers.Values
|
||||
.Where(descriptor => descriptor.ObjectKind == DalamudObjectKind.Player)
|
||||
.ToArray();
|
||||
var ownedDescriptors = _activePlayers.Values
|
||||
.Where(descriptor => descriptor.OwnedKind is not null)
|
||||
.ToArray();
|
||||
var playerAddresses = new nint[playerDescriptors.Length];
|
||||
for (var i = 0; i < playerDescriptors.Length; i++)
|
||||
playerAddresses[i] = playerDescriptors[i].Address;
|
||||
var descriptors = _activePlayers.Values.ToArray();
|
||||
var playerCount = 0;
|
||||
var ownedCount = 0;
|
||||
var companionCount = 0;
|
||||
|
||||
var ownedSnapshot = _ownedTracker.CreateSnapshot();
|
||||
foreach (var descriptor in descriptors)
|
||||
{
|
||||
if (descriptor.ObjectKind == DalamudObjectKind.Player)
|
||||
playerCount++;
|
||||
|
||||
if (descriptor.OwnedKind is not null)
|
||||
ownedCount++;
|
||||
|
||||
if (descriptor.ObjectKind == DalamudObjectKind.Companion)
|
||||
companionCount++;
|
||||
}
|
||||
|
||||
var playerDescriptors = new ActorDescriptor[playerCount];
|
||||
var ownedDescriptors = new ActorDescriptor[ownedCount];
|
||||
var playerAddresses = new nint[playerCount];
|
||||
var renderedCompanions = new nint[companionCount];
|
||||
var ownedAddresses = new nint[ownedCount];
|
||||
var ownedMap = new Dictionary<nint, LightlessObjectKind>(ownedCount);
|
||||
nint localPlayer = nint.Zero;
|
||||
nint localPet = nint.Zero;
|
||||
nint localMinionOrMount = nint.Zero;
|
||||
nint localCompanion = nint.Zero;
|
||||
|
||||
var playerIndex = 0;
|
||||
var ownedIndex = 0;
|
||||
var companionIndex = 0;
|
||||
|
||||
foreach (var descriptor in descriptors)
|
||||
{
|
||||
if (descriptor.ObjectKind == DalamudObjectKind.Player)
|
||||
{
|
||||
playerDescriptors[playerIndex] = descriptor;
|
||||
playerAddresses[playerIndex] = descriptor.Address;
|
||||
playerIndex++;
|
||||
}
|
||||
|
||||
if (descriptor.ObjectKind == DalamudObjectKind.Companion)
|
||||
{
|
||||
renderedCompanions[companionIndex] = descriptor.Address;
|
||||
companionIndex++;
|
||||
}
|
||||
|
||||
if (descriptor.OwnedKind is not { } ownedKind)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ownedDescriptors[ownedIndex] = descriptor;
|
||||
ownedAddresses[ownedIndex] = descriptor.Address;
|
||||
ownedMap[descriptor.Address] = ownedKind;
|
||||
|
||||
switch (ownedKind)
|
||||
{
|
||||
case LightlessObjectKind.Player:
|
||||
localPlayer = descriptor.Address;
|
||||
break;
|
||||
case LightlessObjectKind.Pet:
|
||||
localPet = descriptor.Address;
|
||||
break;
|
||||
case LightlessObjectKind.MinionOrMount:
|
||||
localMinionOrMount = descriptor.Address;
|
||||
break;
|
||||
case LightlessObjectKind.Companion:
|
||||
localCompanion = descriptor.Address;
|
||||
break;
|
||||
}
|
||||
|
||||
ownedIndex++;
|
||||
}
|
||||
|
||||
var ownedSnapshot = new OwnedObjectSnapshot(
|
||||
playerAddresses,
|
||||
renderedCompanions,
|
||||
ownedAddresses,
|
||||
ownedMap,
|
||||
localPlayer,
|
||||
localPet,
|
||||
localMinionOrMount,
|
||||
localCompanion);
|
||||
var nextGeneration = Snapshot.Generation + 1;
|
||||
var snapshot = new ActorSnapshot(playerDescriptors, ownedDescriptors, playerAddresses, ownedSnapshot, nextGeneration);
|
||||
Volatile.Write(ref _snapshot, snapshot);
|
||||
@@ -955,109 +1169,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
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,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using K4os.Compression.LZ4.Legacy;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.API.Dto.CharaData;
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.PlayerData.Factories;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using LightlessSync.Services.CharaData;
|
||||
using LightlessSync.Services.CharaData.Models;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI.Models;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI.Files;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -24,10 +27,11 @@ public sealed class CharaDataFileHandler : IDisposable
|
||||
private readonly ILogger<CharaDataFileHandler> _logger;
|
||||
private readonly LightlessCharaFileDataFactory _lightlessCharaFileDataFactory;
|
||||
private readonly PlayerDataFactory _playerDataFactory;
|
||||
private readonly NotificationService _notificationService;
|
||||
private int _globalFileCounter = 0;
|
||||
|
||||
public CharaDataFileHandler(ILogger<CharaDataFileHandler> logger, FileDownloadManagerFactory fileDownloadManagerFactory, FileUploadManager fileUploadManager, FileCacheManager fileCacheManager,
|
||||
DalamudUtilService dalamudUtilService, GameObjectHandlerFactory gameObjectHandlerFactory, PlayerDataFactory playerDataFactory)
|
||||
DalamudUtilService dalamudUtilService, GameObjectHandlerFactory gameObjectHandlerFactory, PlayerDataFactory playerDataFactory, NotificationService notificationService)
|
||||
{
|
||||
_fileDownloadManager = fileDownloadManagerFactory.Create();
|
||||
_logger = logger;
|
||||
@@ -36,6 +40,7 @@ public sealed class CharaDataFileHandler : IDisposable
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
_gameObjectHandlerFactory = gameObjectHandlerFactory;
|
||||
_playerDataFactory = playerDataFactory;
|
||||
_notificationService = notificationService;
|
||||
_lightlessCharaFileDataFactory = new(fileCacheManager);
|
||||
}
|
||||
|
||||
@@ -248,54 +253,161 @@ public sealed class CharaDataFileHandler : IDisposable
|
||||
}
|
||||
|
||||
internal async Task SaveCharaFileAsync(string description, string filePath)
|
||||
{
|
||||
var createPlayerDataStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
var data = await CreatePlayerData().ConfigureAwait(false);
|
||||
createPlayerDataStopwatch.Stop();
|
||||
_logger.LogInformation("CreatePlayerData took {elapsed}ms", createPlayerDataStopwatch.ElapsedMilliseconds);
|
||||
|
||||
if (data == null) return;
|
||||
|
||||
await Task.Run(async () => await SaveCharaFileAsyncInternal(description, filePath, data).ConfigureAwait(false)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task SaveCharaFileAsyncInternal(string description, string filePath, CharacterData data)
|
||||
{
|
||||
var tempFilePath = filePath + ".tmp";
|
||||
var overallStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var data = await CreatePlayerData().ConfigureAwait(false);
|
||||
if (data == null) return;
|
||||
|
||||
var lightlessCharaFileData = _lightlessCharaFileDataFactory.Create(description, data);
|
||||
LightlessCharaFileHeader output = new(LightlessCharaFileHeader.CurrentVersion, lightlessCharaFileData);
|
||||
|
||||
using var fs = new FileStream(tempFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
|
||||
using var fs = new FileStream(tempFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, bufferSize: 65536, useAsync: false);
|
||||
using var lz4 = new LZ4Stream(fs, LZ4StreamMode.Compress, LZ4StreamFlags.HighCompression);
|
||||
using var writer = new BinaryWriter(lz4);
|
||||
output.WriteToStream(writer);
|
||||
|
||||
int fileIndex = 0;
|
||||
long totalBytesWritten = 0;
|
||||
long totalBytesToWrite = output.CharaFileData.Files.Sum(f => f.Length);
|
||||
var fileWriteStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
const long updateIntervalMs = 1000;
|
||||
|
||||
foreach (var item in output.CharaFileData.Files)
|
||||
{
|
||||
fileIndex++;
|
||||
var fileStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var file = _fileCacheManager.GetFileCacheByHash(item.Hash)!;
|
||||
_logger.LogDebug("Saving to MCDF: {hash}:{file}", item.Hash, file.ResolvedFilepath);
|
||||
_logger.LogDebug("Saving to MCDF [{fileNum}/{totalFiles}]: {hash}:{file}", fileIndex, output.CharaFileData.Files.Count, item.Hash, file.ResolvedFilepath);
|
||||
_logger.LogDebug("\tAssociated GamePaths:");
|
||||
foreach (var path in item.GamePaths)
|
||||
{
|
||||
_logger.LogDebug("\t{path}", path);
|
||||
}
|
||||
|
||||
var fsRead = File.OpenRead(file.ResolvedFilepath);
|
||||
await using (fsRead.ConfigureAwait(false))
|
||||
using var fsRead = File.OpenRead(file.ResolvedFilepath);
|
||||
using var br = new BinaryReader(fsRead);
|
||||
byte[] buffer = new byte[item.Length];
|
||||
int bytesRead = br.Read(buffer, 0, item.Length);
|
||||
|
||||
if (bytesRead != item.Length)
|
||||
{
|
||||
using var br = new BinaryReader(fsRead);
|
||||
byte[] buffer = new byte[item.Length];
|
||||
br.Read(buffer, 0, item.Length);
|
||||
writer.Write(buffer);
|
||||
_logger.LogWarning("Expected to read {expected} bytes but got {actual} bytes from {file}", item.Length, bytesRead, file.ResolvedFilepath);
|
||||
}
|
||||
|
||||
writer.Write(buffer);
|
||||
totalBytesWritten += bytesRead;
|
||||
|
||||
fileStopwatch.Stop();
|
||||
_logger.LogDebug("Wrote file [{fileNum}/{totalFiles}] in {elapsed}ms ({sizeKb}kb)", fileIndex, output.CharaFileData.Files.Count, fileStopwatch.ElapsedMilliseconds, item.Length / 1024);
|
||||
|
||||
if (fileWriteStopwatch.ElapsedMilliseconds >= updateIntervalMs && totalBytesToWrite > 0)
|
||||
{
|
||||
float progress = (float)totalBytesWritten / totalBytesToWrite;
|
||||
var elapsed = overallStopwatch.Elapsed;
|
||||
var eta = CalculateEta(elapsed, progress);
|
||||
|
||||
var notification = new LightlessNotification
|
||||
{
|
||||
Id = "chara_file_save_progress",
|
||||
Title = "Character Data",
|
||||
Message = $"Compressing and saving character file... {(progress * 100):F0}%\nETA: {FormatTimespan(eta)}",
|
||||
Type = NotificationType.Info,
|
||||
Duration = TimeSpan.FromMinutes(5),
|
||||
ShowProgress = true,
|
||||
Progress = progress
|
||||
};
|
||||
|
||||
_notificationService.Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||
|
||||
fileWriteStopwatch.Restart();
|
||||
}
|
||||
}
|
||||
|
||||
var flushStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
writer.Flush();
|
||||
await lz4.FlushAsync().ConfigureAwait(false);
|
||||
await fs.FlushAsync().ConfigureAwait(false);
|
||||
lz4.Flush();
|
||||
fs.Flush();
|
||||
fs.Close();
|
||||
flushStopwatch.Stop();
|
||||
_logger.LogInformation("Flush operations took {elapsed}ms", flushStopwatch.ElapsedMilliseconds);
|
||||
|
||||
var moveStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
File.Move(tempFilePath, filePath, true);
|
||||
moveStopwatch.Stop();
|
||||
_logger.LogInformation("File move took {elapsed}ms", moveStopwatch.ElapsedMilliseconds);
|
||||
|
||||
overallStopwatch.Stop();
|
||||
_logger.LogInformation("SaveCharaFileAsync completed successfully in {elapsed}ms. Total bytes written: {totalBytes}mb", overallStopwatch.ElapsedMilliseconds, totalBytesWritten / (1024 * 1024));
|
||||
|
||||
_notificationService.ShowNotification(
|
||||
"Character Data",
|
||||
"Character file saved successfully!",
|
||||
NotificationType.Info,
|
||||
duration: TimeSpan.FromSeconds(5));
|
||||
|
||||
_notificationService.Mediator.Publish(new LightlessNotificationDismissMessage("chara_file_save_progress"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failure Saving Lightless Chara File, deleting output");
|
||||
File.Delete(tempFilePath);
|
||||
overallStopwatch.Stop();
|
||||
_logger.LogError(ex, "Failure Saving Lightless Chara File after {elapsed}ms, deleting output", overallStopwatch.ElapsedMilliseconds);
|
||||
try
|
||||
{
|
||||
File.Delete(tempFilePath);
|
||||
}
|
||||
catch (Exception deleteEx)
|
||||
{
|
||||
_logger.LogError(deleteEx, "Failed to delete temporary file {file}", tempFilePath);
|
||||
}
|
||||
|
||||
_notificationService.ShowErrorNotification(
|
||||
"Character Data Save Failed",
|
||||
"Failed to save character file",
|
||||
ex);
|
||||
|
||||
_notificationService.Mediator.Publish(new LightlessNotificationDismissMessage("chara_file_save_progress"));
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeSpan CalculateEta(TimeSpan elapsed, float progress)
|
||||
{
|
||||
if (progress <= 0 || elapsed.TotalSeconds < 0.1)
|
||||
return TimeSpan.Zero;
|
||||
|
||||
double totalSeconds = elapsed.TotalSeconds / progress;
|
||||
double remainingSeconds = totalSeconds - elapsed.TotalSeconds;
|
||||
|
||||
return TimeSpan.FromSeconds(Math.Max(0, remainingSeconds));
|
||||
}
|
||||
|
||||
private static string FormatTimespan(TimeSpan ts)
|
||||
{
|
||||
if (ts.TotalSeconds < 1)
|
||||
return "< 1s";
|
||||
|
||||
if (ts.TotalSeconds < 60)
|
||||
return $"{ts.TotalSeconds:F0}s";
|
||||
|
||||
if (ts.TotalMinutes < 60)
|
||||
return $"{ts.TotalMinutes:F1}m";
|
||||
|
||||
return $"{ts.TotalHours:F1}h";
|
||||
}
|
||||
|
||||
internal async Task<List<string>> UploadFiles(List<string> fileList, ValueProgress<string> uploadProgress, CancellationToken token)
|
||||
{
|
||||
return await _fileUploadManager.UploadFiles(fileList, uploadProgress, token).ConfigureAwait(false);
|
||||
|
||||
275
LightlessSync/Services/Chat/ChatEmoteService.cs
Normal file
275
LightlessSync/Services/Chat/ChatEmoteService.cs
Normal file
@@ -0,0 +1,275 @@
|
||||
using Dalamud.Interface.Textures.TextureWraps;
|
||||
using LightlessSync.UI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LightlessSync.Services.Chat;
|
||||
|
||||
public sealed class ChatEmoteService : IDisposable
|
||||
{
|
||||
private const string GlobalEmoteSetUrl = "https://7tv.io/v3/emote-sets/global";
|
||||
|
||||
private readonly ILogger<ChatEmoteService> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly UiSharedService _uiSharedService;
|
||||
private readonly ConcurrentDictionary<string, EmoteEntry> _emotes = new(StringComparer.Ordinal);
|
||||
private readonly SemaphoreSlim _downloadGate = new(3, 3);
|
||||
|
||||
private readonly object _loadLock = new();
|
||||
private Task? _loadTask;
|
||||
|
||||
public ChatEmoteService(ILogger<ChatEmoteService> logger, HttpClient httpClient, UiSharedService uiSharedService)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClient = httpClient;
|
||||
_uiSharedService = uiSharedService;
|
||||
}
|
||||
|
||||
public void EnsureGlobalEmotesLoaded()
|
||||
{
|
||||
lock (_loadLock)
|
||||
{
|
||||
if (_loadTask is not null && !_loadTask.IsCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_emotes.Count > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_loadTask = Task.Run(LoadGlobalEmotesAsync);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetEmoteNames()
|
||||
{
|
||||
EnsureGlobalEmotesLoaded();
|
||||
var names = _emotes.Keys.ToArray();
|
||||
Array.Sort(names, StringComparer.OrdinalIgnoreCase);
|
||||
return names;
|
||||
}
|
||||
|
||||
public bool TryGetEmote(string code, out IDalamudTextureWrap? texture)
|
||||
{
|
||||
texture = null;
|
||||
EnsureGlobalEmotesLoaded();
|
||||
|
||||
if (!_emotes.TryGetValue(code, out var entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.Texture is not null)
|
||||
{
|
||||
texture = entry.Texture;
|
||||
return true;
|
||||
}
|
||||
|
||||
entry.EnsureLoading(QueueEmoteDownload);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var entry in _emotes.Values)
|
||||
{
|
||||
entry.Texture?.Dispose();
|
||||
}
|
||||
|
||||
_downloadGate.Dispose();
|
||||
}
|
||||
|
||||
private async Task LoadGlobalEmotesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = await _httpClient.GetStreamAsync(GlobalEmoteSetUrl).ConfigureAwait(false);
|
||||
using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
|
||||
|
||||
if (!document.RootElement.TryGetProperty("emotes", out var emotes))
|
||||
{
|
||||
_logger.LogWarning("7TV emote set response missing emotes array");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var emoteElement in emotes.EnumerateArray())
|
||||
{
|
||||
if (!emoteElement.TryGetProperty("name", out var nameElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = nameElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var url = TryBuildEmoteUrl(emoteElement);
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_emotes.TryAdd(name, new EmoteEntry(url));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load 7TV emote set");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryBuildEmoteUrl(JsonElement emoteElement)
|
||||
{
|
||||
if (!emoteElement.TryGetProperty("data", out var dataElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!dataElement.TryGetProperty("host", out var hostElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hostElement.TryGetProperty("url", out var urlElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var baseUrl = urlElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (baseUrl.StartsWith("//", StringComparison.Ordinal))
|
||||
{
|
||||
baseUrl = "https:" + baseUrl;
|
||||
}
|
||||
|
||||
if (!hostElement.TryGetProperty("files", out var filesElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var fileName = PickBestStaticFile(filesElement);
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return baseUrl.TrimEnd('/') + "/" + fileName;
|
||||
}
|
||||
|
||||
private static string? PickBestStaticFile(JsonElement filesElement)
|
||||
{
|
||||
string? png1x = null;
|
||||
string? webp1x = null;
|
||||
string? pngFallback = null;
|
||||
string? webpFallback = null;
|
||||
|
||||
foreach (var file in filesElement.EnumerateArray())
|
||||
{
|
||||
if (file.TryGetProperty("static", out var staticElement) && staticElement.ValueKind == JsonValueKind.False)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!file.TryGetProperty("name", out var nameElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = nameElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name.Equals("1x.png", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
png1x = name;
|
||||
}
|
||||
else if (name.Equals("1x.webp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
webp1x = name;
|
||||
}
|
||||
else if (name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && pngFallback is null)
|
||||
{
|
||||
pngFallback = name;
|
||||
}
|
||||
else if (name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) && webpFallback is null)
|
||||
{
|
||||
webpFallback = name;
|
||||
}
|
||||
}
|
||||
|
||||
return png1x ?? webp1x ?? pngFallback ?? webpFallback;
|
||||
}
|
||||
|
||||
private void QueueEmoteDownload(EmoteEntry entry)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await _downloadGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var data = await _httpClient.GetByteArrayAsync(entry.Url).ConfigureAwait(false);
|
||||
var texture = _uiSharedService.LoadImage(data);
|
||||
entry.SetTexture(texture);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to load 7TV emote {Url}", entry.Url);
|
||||
entry.MarkFailed();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_downloadGate.Release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class EmoteEntry
|
||||
{
|
||||
private int _loadingState;
|
||||
|
||||
public EmoteEntry(string url)
|
||||
{
|
||||
Url = url;
|
||||
}
|
||||
|
||||
public string Url { get; }
|
||||
public IDalamudTextureWrap? Texture { get; private set; }
|
||||
|
||||
public void EnsureLoading(Action<EmoteEntry> queueDownload)
|
||||
{
|
||||
if (Texture is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _loadingState, 1, 0) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
queueDownload(this);
|
||||
}
|
||||
|
||||
public void SetTexture(IDalamudTextureWrap texture)
|
||||
{
|
||||
Texture = texture;
|
||||
Interlocked.Exchange(ref _loadingState, 0);
|
||||
}
|
||||
|
||||
public void MarkFailed()
|
||||
{
|
||||
Interlocked.Exchange(ref _loadingState, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using LightlessSync.API.Dto.Chat;
|
||||
using LightlessSync.API.Data.Extensions;
|
||||
using LightlessSync.Services.ActorTracking;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Services.ServerConfiguration;
|
||||
using LightlessSync.WebAPI;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -25,6 +26,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
private readonly ActorObjectService _actorObjectService;
|
||||
private readonly PairUiService _pairUiService;
|
||||
private readonly ChatConfigService _chatConfigService;
|
||||
private readonly ServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
private readonly Lock _sync = new();
|
||||
|
||||
@@ -37,6 +39,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
private readonly Dictionary<string, bool> _lastPresenceStates = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string> _selfTokens = new(StringComparer.Ordinal);
|
||||
private readonly List<PendingSelfMessage> _pendingSelfMessages = new();
|
||||
private readonly Dictionary<string, List<ChatMessageEntry>> _messageHistoryCache = new(StringComparer.Ordinal);
|
||||
private List<ChatChannelSnapshot>? _cachedChannelSnapshots;
|
||||
private bool _channelsSnapshotDirty = true;
|
||||
|
||||
@@ -54,7 +57,8 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
ApiController apiController,
|
||||
DalamudUtilService dalamudUtilService,
|
||||
ActorObjectService actorObjectService,
|
||||
PairUiService pairUiService)
|
||||
PairUiService pairUiService,
|
||||
ServerConfigurationManager serverConfigurationManager)
|
||||
: base(logger, mediator)
|
||||
{
|
||||
_apiController = apiController;
|
||||
@@ -62,6 +66,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
_actorObjectService = actorObjectService;
|
||||
_pairUiService = pairUiService;
|
||||
_chatConfigService = chatConfigService;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
|
||||
_isLoggedIn = _dalamudUtilService.IsLoggedIn;
|
||||
_isConnected = _apiController.IsConnected;
|
||||
@@ -776,6 +781,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
using (_sync.EnterScope())
|
||||
{
|
||||
var remainingGroups = new HashSet<string>(_groupDefinitions.Keys, StringComparer.OrdinalIgnoreCase);
|
||||
var allowRemoval = _isConnected;
|
||||
|
||||
foreach (var info in infoList)
|
||||
{
|
||||
@@ -791,18 +797,19 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
var key = BuildChannelKey(descriptor);
|
||||
if (!_channels.TryGetValue(key, out var state))
|
||||
{
|
||||
state = new ChatChannelState(key, ChatChannelType.Group, info.DisplayName ?? groupId, descriptor);
|
||||
state.IsConnected = _chatEnabled && _isConnected;
|
||||
state.IsAvailable = _chatEnabled && _isConnected;
|
||||
state.StatusText = !_chatEnabled
|
||||
? "Chat services disabled"
|
||||
: (_isConnected ? null : "Disconnected from chat server");
|
||||
_channels[key] = state;
|
||||
_lastReadCounts[key] = 0;
|
||||
if (_chatEnabled)
|
||||
{
|
||||
descriptorsToJoin.Add(descriptor);
|
||||
}
|
||||
state = new ChatChannelState(key, ChatChannelType.Group, info.DisplayName ?? groupId, descriptor);
|
||||
var restoredCount = RestoreCachedMessagesLocked(state);
|
||||
state.IsConnected = _chatEnabled && _isConnected;
|
||||
state.IsAvailable = _chatEnabled && _isConnected;
|
||||
state.StatusText = !_chatEnabled
|
||||
? "Chat services disabled"
|
||||
: (_isConnected ? null : "Disconnected from chat server");
|
||||
_channels[key] = state;
|
||||
_lastReadCounts[key] = restoredCount > 0 ? state.Messages.Count : 0;
|
||||
if (_chatEnabled)
|
||||
{
|
||||
descriptorsToJoin.Add(descriptor);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -816,26 +823,30 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var removedGroupId in remainingGroups)
|
||||
if (allowRemoval)
|
||||
{
|
||||
if (_groupDefinitions.TryGetValue(removedGroupId, out var definition))
|
||||
foreach (var removedGroupId in remainingGroups)
|
||||
{
|
||||
var key = BuildChannelKey(definition.Descriptor);
|
||||
if (_channels.TryGetValue(key, out var state))
|
||||
if (_groupDefinitions.TryGetValue(removedGroupId, out var definition))
|
||||
{
|
||||
descriptorsToLeave.Add(state.Descriptor);
|
||||
_channels.Remove(key);
|
||||
_lastReadCounts.Remove(key);
|
||||
_lastPresenceStates.Remove(BuildPresenceKey(state.Descriptor));
|
||||
_selfTokens.Remove(key);
|
||||
_pendingSelfMessages.RemoveAll(p => string.Equals(p.ChannelKey, key, StringComparison.Ordinal));
|
||||
if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal))
|
||||
var key = BuildChannelKey(definition.Descriptor);
|
||||
if (_channels.TryGetValue(key, out var state))
|
||||
{
|
||||
_activeChannelKey = null;
|
||||
CacheMessagesLocked(state);
|
||||
descriptorsToLeave.Add(state.Descriptor);
|
||||
_channels.Remove(key);
|
||||
_lastReadCounts.Remove(key);
|
||||
_lastPresenceStates.Remove(BuildPresenceKey(state.Descriptor));
|
||||
_selfTokens.Remove(key);
|
||||
_pendingSelfMessages.RemoveAll(p => string.Equals(p.ChannelKey, key, StringComparison.Ordinal));
|
||||
if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal))
|
||||
{
|
||||
_activeChannelKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_groupDefinitions.Remove(removedGroupId);
|
||||
_groupDefinitions.Remove(removedGroupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1013,13 +1024,14 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
descriptor.Type,
|
||||
displayName,
|
||||
descriptor.Type == ChatChannelType.Zone ? (_lastZoneDescriptor ?? descriptor) : descriptor);
|
||||
var restoredCount = RestoreCachedMessagesLocked(state);
|
||||
|
||||
state.IsConnected = _isConnected;
|
||||
state.IsAvailable = descriptor.Type == ChatChannelType.Group && _isConnected;
|
||||
state.StatusText = descriptor.Type == ChatChannelType.Zone ? ZoneUnavailableMessage : (_isConnected ? null : "Disconnected from chat server");
|
||||
|
||||
_channels[key] = state;
|
||||
_lastReadCounts[key] = 0;
|
||||
_lastReadCounts[key] = restoredCount > 0 ? state.Messages.Count : 0;
|
||||
publishChannelList = true;
|
||||
}
|
||||
|
||||
@@ -1159,6 +1171,15 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
|
||||
if (dto.Sender.Kind == ChatSenderKind.IdentifiedUser && dto.Sender.User is not null)
|
||||
{
|
||||
if (dto.Channel.Type != ChatChannelType.Group || _chatConfigService.Current.ShowNotesInSyncshellChat)
|
||||
{
|
||||
var note = _serverConfigurationManager.GetNoteForUid(dto.Sender.User.UID);
|
||||
if (!string.IsNullOrWhiteSpace(note))
|
||||
{
|
||||
return note;
|
||||
}
|
||||
}
|
||||
|
||||
return dto.Sender.User.AliasOrUID;
|
||||
}
|
||||
|
||||
@@ -1288,11 +1309,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
if (!_channels.TryGetValue(ZoneChannelKey, out var state))
|
||||
{
|
||||
state = new ChatChannelState(ZoneChannelKey, ChatChannelType.Zone, "Zone Chat", new ChatChannelDescriptor { Type = ChatChannelType.Zone });
|
||||
var restoredCount = RestoreCachedMessagesLocked(state);
|
||||
state.IsConnected = _chatEnabled && _isConnected;
|
||||
state.IsAvailable = false;
|
||||
state.StatusText = _chatEnabled ? ZoneUnavailableMessage : "Chat services disabled";
|
||||
_channels[ZoneChannelKey] = state;
|
||||
_lastReadCounts[ZoneChannelKey] = 0;
|
||||
_lastReadCounts[ZoneChannelKey] = restoredCount > 0 ? state.Messages.Count : 0;
|
||||
UpdateChannelOrderLocked();
|
||||
}
|
||||
|
||||
@@ -1301,6 +1323,11 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
|
||||
private void RemoveZoneStateLocked()
|
||||
{
|
||||
if (_channels.TryGetValue(ZoneChannelKey, out var existing))
|
||||
{
|
||||
CacheMessagesLocked(existing);
|
||||
}
|
||||
|
||||
if (_channels.Remove(ZoneChannelKey))
|
||||
{
|
||||
_lastReadCounts.Remove(ZoneChannelKey);
|
||||
@@ -1315,6 +1342,28 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
}
|
||||
}
|
||||
|
||||
private void CacheMessagesLocked(ChatChannelState state)
|
||||
{
|
||||
if (state.Messages.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_messageHistoryCache[state.Key] = new List<ChatMessageEntry>(state.Messages);
|
||||
}
|
||||
|
||||
private int RestoreCachedMessagesLocked(ChatChannelState state)
|
||||
{
|
||||
if (_messageHistoryCache.TryGetValue(state.Key, out var cached) && cached.Count > 0)
|
||||
{
|
||||
state.Messages.AddRange(cached);
|
||||
_messageHistoryCache.Remove(state.Key);
|
||||
return cached.Count;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private sealed class ChatChannelState
|
||||
{
|
||||
public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Dalamud.Game.ClientState.Conditions;
|
||||
using Dalamud.Game.ClientState.Objects;
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Game.ClientState.Objects.Types;
|
||||
using Dalamud.Plugin.Services;
|
||||
@@ -24,6 +23,7 @@ using Microsoft.Extensions.Logging;
|
||||
using System.Numerics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind;
|
||||
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
||||
using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
|
||||
using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags;
|
||||
@@ -265,6 +265,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
public bool IsAnythingDrawing { get; private set; } = false;
|
||||
public bool IsInCutscene { get; private set; } = false;
|
||||
public bool IsInGpose { get; private set; } = false;
|
||||
public bool IsGameUiHidden => _gameGui.GameUiHidden;
|
||||
public bool IsLoggedIn { get; private set; }
|
||||
public bool IsOnFrameworkThread => _framework.IsInFrameworkUpdateThread;
|
||||
public bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51];
|
||||
@@ -433,7 +434,22 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
var mgr = CharacterManager.Instance();
|
||||
playerPointer ??= GetPlayerPtr();
|
||||
if (playerPointer == IntPtr.Zero || (IntPtr)mgr == IntPtr.Zero) return IntPtr.Zero;
|
||||
return (IntPtr)mgr->LookupPetByOwnerObject((BattleChara*)playerPointer);
|
||||
|
||||
var ownerAddress = playerPointer.Value;
|
||||
var ownerEntityId = ((Character*)ownerAddress)->EntityId;
|
||||
if (ownerEntityId == 0) return IntPtr.Zero;
|
||||
|
||||
var candidate = (IntPtr)mgr->LookupPetByOwnerObject((BattleChara*)ownerAddress);
|
||||
if (candidate != IntPtr.Zero)
|
||||
{
|
||||
var candidateObj = (GameObject*)candidate;
|
||||
if (IsPetMatch(candidateObj, ownerEntityId))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return FindOwnedPet(ownerEntityId, ownerAddress);
|
||||
}
|
||||
|
||||
public async Task<IntPtr> GetPetAsync(IntPtr? playerPointer = null)
|
||||
@@ -470,6 +486,60 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
private unsafe nint FindOwnedPet(uint ownerEntityId, nint ownerAddress)
|
||||
{
|
||||
if (ownerEntityId == 0)
|
||||
{
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
foreach (var obj in _objectTable)
|
||||
{
|
||||
if (obj is null || obj.Address == nint.Zero || obj.Address == ownerAddress)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidate = (GameObject*)obj.Address;
|
||||
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ResolveOwnerId(candidate) == ownerEntityId)
|
||||
{
|
||||
return obj.Address;
|
||||
}
|
||||
}
|
||||
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
private static unsafe bool IsPetMatch(GameObject* candidate, uint ownerEntityId)
|
||||
{
|
||||
if (candidate == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((DalamudObjectKind)candidate->ObjectKind != DalamudObjectKind.BattleNpc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ResolveOwnerId(candidate) == ownerEntityId;
|
||||
}
|
||||
|
||||
private static unsafe uint ResolveOwnerId(GameObject* gameObject)
|
||||
{
|
||||
if (gameObject == null)
|
||||
|
||||
@@ -0,0 +1,863 @@
|
||||
using Dalamud.Game.ClientState.Objects.Enums;
|
||||
using Dalamud.Plugin.Services;
|
||||
using FFXIVClientStructs.FFXIV.Client.System.Framework;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI;
|
||||
using LightlessSync.UI.Services;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.UtilsEnum.Enum;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Immutable;
|
||||
using Task = System.Threading.Tasks.Task;
|
||||
|
||||
namespace LightlessSync.Services.LightFinder;
|
||||
|
||||
/// <summary>
|
||||
/// Native nameplate handler that injects LightFinder labels via the signature hook path.
|
||||
/// </summary>
|
||||
public unsafe class LightFinderNativePlateHandler : DisposableMediatorSubscriberBase, IHostedService
|
||||
{
|
||||
private const uint NameplateNodeIdBase = 0x7D99D500;
|
||||
private const string DefaultLabelText = "LightFinder";
|
||||
|
||||
private readonly ILogger<LightFinderNativePlateHandler> _logger;
|
||||
private readonly IClientState _clientState;
|
||||
private readonly IObjectTable _objectTable;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly PairUiService _pairUiService;
|
||||
private readonly NameplateUpdateHookService _nameplateUpdateHookService;
|
||||
|
||||
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];
|
||||
private readonly string?[] _lastLabelByIndex = new string?[AddonNamePlate.NumNamePlateObjects];
|
||||
|
||||
private ImmutableHashSet<string> _activeBroadcastingCids = [];
|
||||
private LightfinderLabelRenderer _lastRenderer;
|
||||
private uint _lastSignatureUpdateFrame;
|
||||
private bool _isUpdating;
|
||||
private string _lastLabelContent = DefaultLabelText;
|
||||
|
||||
public LightFinderNativePlateHandler(
|
||||
ILogger<LightFinderNativePlateHandler> logger,
|
||||
IClientState clientState,
|
||||
LightlessConfigService configService,
|
||||
LightlessMediator mediator,
|
||||
IObjectTable objectTable,
|
||||
PairUiService pairUiService,
|
||||
NameplateUpdateHookService nameplateUpdateHookService) : base(logger, mediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_clientState = clientState;
|
||||
_configService = configService;
|
||||
_objectTable = objectTable;
|
||||
_pairUiService = pairUiService;
|
||||
_nameplateUpdateHookService = nameplateUpdateHookService;
|
||||
_lastRenderer = _configService.Current.LightfinderLabelRenderer;
|
||||
|
||||
Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
|
||||
}
|
||||
|
||||
private bool IsSignatureMode => _configService.Current.LightfinderLabelRenderer == LightfinderLabelRenderer.SignatureHook;
|
||||
|
||||
/// <summary>
|
||||
/// Starts listening for nameplate updates from the hook service.
|
||||
/// </summary>
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_nameplateUpdateHookService.NameplateUpdated += OnNameplateUpdated;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops listening for nameplate updates and tears down any constructed nodes.
|
||||
/// </summary>
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_nameplateUpdateHookService.NameplateUpdated -= OnNameplateUpdated;
|
||||
UnsubscribeAll();
|
||||
TryDestroyNameplateNodes();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggered by the sig hook to refresh native nameplate labels.
|
||||
/// </summary>
|
||||
private void HandleNameplateUpdate(RaptureAtkModule* raptureAtkModule)
|
||||
{
|
||||
if (_isUpdating)
|
||||
return;
|
||||
|
||||
_isUpdating = true;
|
||||
try
|
||||
{
|
||||
RefreshRendererState();
|
||||
if (!IsSignatureMode)
|
||||
return;
|
||||
|
||||
if (raptureAtkModule == null)
|
||||
return;
|
||||
|
||||
var namePlateAddon = GetNamePlateAddon(raptureAtkModule);
|
||||
if (namePlateAddon == null)
|
||||
return;
|
||||
|
||||
if (_clientState.IsGPosing)
|
||||
{
|
||||
HideAllNameplateNodes(namePlateAddon);
|
||||
return;
|
||||
}
|
||||
|
||||
var fw = Framework.Instance();
|
||||
if (fw == null)
|
||||
return;
|
||||
|
||||
var frame = fw->FrameCounter;
|
||||
if (_lastSignatureUpdateFrame == frame)
|
||||
return;
|
||||
|
||||
_lastSignatureUpdateFrame = frame;
|
||||
UpdateNameplateNodes(namePlateAddon);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isUpdating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hook callback from the nameplate update signature.
|
||||
/// </summary>
|
||||
private void OnNameplateUpdated(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo,
|
||||
NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
|
||||
{
|
||||
HandleNameplateUpdate(raptureAtkModule);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the active broadcasting CID set and requests a nameplate redraw.
|
||||
/// </summary>
|
||||
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 (native): {Cids}", string.Join(',', _activeBroadcastingCids));
|
||||
RequestNameplateRedraw();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sync renderer state with config and clear/remove native nodes if needed.
|
||||
/// </summary>
|
||||
private void RefreshRendererState()
|
||||
{
|
||||
var renderer = _configService.Current.LightfinderLabelRenderer;
|
||||
if (renderer == _lastRenderer)
|
||||
return;
|
||||
|
||||
_lastRenderer = renderer;
|
||||
|
||||
if (renderer == LightfinderLabelRenderer.SignatureHook)
|
||||
{
|
||||
ClearNameplateCaches();
|
||||
RequestNameplateRedraw();
|
||||
}
|
||||
else
|
||||
{
|
||||
TryDestroyNameplateNodes();
|
||||
ClearNameplateCaches();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests a full nameplate update through the native addon.
|
||||
/// </summary>
|
||||
private void RequestNameplateRedraw()
|
||||
{
|
||||
if (!IsSignatureMode)
|
||||
return;
|
||||
|
||||
var raptureAtkModule = GetRaptureAtkModule();
|
||||
if (raptureAtkModule == null)
|
||||
return;
|
||||
|
||||
var namePlateAddon = GetNamePlateAddon(raptureAtkModule);
|
||||
if (namePlateAddon == null)
|
||||
return;
|
||||
|
||||
namePlateAddon->DoFullUpdate = 1;
|
||||
}
|
||||
|
||||
private HashSet<ulong> VisibleUserIds
|
||||
=> [.. _pairUiService.GetSnapshot().PairsByUid.Values
|
||||
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
|
||||
.Select(u => (ulong)u.PlayerCharacterId)];
|
||||
|
||||
/// <summary>
|
||||
/// Creates/updates LightFinder label nodes for active broadcasts.
|
||||
/// </summary>
|
||||
private void UpdateNameplateNodes(AddonNamePlate* namePlateAddon)
|
||||
{
|
||||
if (namePlateAddon == null)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
_logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsNameplateAddonVisible(namePlateAddon))
|
||||
return;
|
||||
|
||||
if (!IsSignatureMode)
|
||||
{
|
||||
HideAllNameplateNodes(namePlateAddon);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_activeBroadcastingCids.Count == 0)
|
||||
{
|
||||
HideAllNameplateNodes(namePlateAddon);
|
||||
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)
|
||||
return;
|
||||
|
||||
var config = _configService.Current;
|
||||
var visibleUserIdsSnapshot = VisibleUserIds;
|
||||
var labelColor = UIColors.Get("Lightfinder");
|
||||
var edgeColor = UIColors.Get("LightfinderEdge");
|
||||
var scaleMultiplier = Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f);
|
||||
var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f;
|
||||
var effectiveScale = baseScale * scaleMultiplier;
|
||||
var labelContent = config.LightfinderLabelUseIcon
|
||||
? LightFinderPlateHandler.NormalizeIconGlyph(config.LightfinderLabelIconGlyph)
|
||||
: DefaultLabelText;
|
||||
|
||||
if (!config.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal)))
|
||||
labelContent = DefaultLabelText;
|
||||
|
||||
if (!string.Equals(_lastLabelContent, labelContent, StringComparison.Ordinal))
|
||||
{
|
||||
_lastLabelContent = labelContent;
|
||||
Array.Fill(_lastLabelByIndex, null);
|
||||
}
|
||||
|
||||
var desiredFontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed;
|
||||
var baseFontSize = config.LightfinderLabelUseIcon ? 36f : 24f;
|
||||
var desiredFontSize = (byte)Math.Clamp((int)Math.Round(baseFontSize * scaleMultiplier), 1, 255);
|
||||
var desiredFlags = config.LightfinderLabelUseIcon
|
||||
? TextFlags.Edge | TextFlags.Glare | TextFlags.AutoAdjustNodeSize
|
||||
: TextFlags.Edge | TextFlags.Glare;
|
||||
var desiredLineSpacing = (byte)Math.Clamp((int)Math.Round(24 * scaleMultiplier), 0, byte.MaxValue);
|
||||
var defaultNodeWidth = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
|
||||
var defaultNodeHeight = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale);
|
||||
|
||||
var safeCount = Math.Min(ui3DModule->NamePlateObjectInfoCount, vec.Length);
|
||||
var visibleIndices = new bool[AddonNamePlate.NumNamePlateObjects];
|
||||
|
||||
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;
|
||||
|
||||
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
|
||||
if (cid == null || !_activeBroadcastingCids.Contains(cid))
|
||||
continue;
|
||||
|
||||
var local = _objectTable.LocalPlayer;
|
||||
if (!config.LightfinderLabelShowOwn && local != null &&
|
||||
objectInfo->GameObject->GetGameObjectId() == local.GameObjectId)
|
||||
continue;
|
||||
|
||||
var hidePaired = !config.LightfinderLabelShowPaired;
|
||||
var goId = (ulong)gameObject->GetGameObjectId();
|
||||
if (hidePaired && visibleUserIdsSnapshot.Contains(goId))
|
||||
continue;
|
||||
|
||||
var nameplateObject = namePlateAddon->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;
|
||||
}
|
||||
|
||||
var nodeId = GetNameplateNodeId(nameplateIndex);
|
||||
var pNode = EnsureNameplateTextNode(nameContainer, root, nodeId, out var nodeCreated);
|
||||
if (pNode == null)
|
||||
continue;
|
||||
|
||||
bool isVisible =
|
||||
((marker != null) && marker->AtkResNode.IsVisible()) ||
|
||||
(nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) ||
|
||||
config.LightfinderLabelShowHidden;
|
||||
|
||||
if (!isVisible)
|
||||
continue;
|
||||
|
||||
if (!pNode->AtkResNode.IsVisible())
|
||||
pNode->AtkResNode.ToggleVisibility(enable: true);
|
||||
visibleIndices[nameplateIndex] = true;
|
||||
|
||||
if (nodeCreated)
|
||||
pNode->AtkResNode.SetUseDepthBasedPriority(enable: true);
|
||||
|
||||
var scaleMatches = NearlyEqual(pNode->AtkResNode.ScaleX, effectiveScale) &&
|
||||
NearlyEqual(pNode->AtkResNode.ScaleY, effectiveScale);
|
||||
if (!scaleMatches)
|
||||
pNode->AtkResNode.SetScale(effectiveScale, effectiveScale);
|
||||
|
||||
var fontTypeChanged = pNode->FontType != desiredFontType;
|
||||
if (fontTypeChanged)
|
||||
pNode->FontType = desiredFontType;
|
||||
|
||||
var fontSizeChanged = pNode->FontSize != desiredFontSize;
|
||||
if (fontSizeChanged)
|
||||
pNode->FontSize = desiredFontSize;
|
||||
|
||||
var needsTextUpdate = nodeCreated ||
|
||||
!string.Equals(_lastLabelByIndex[nameplateIndex], labelContent, StringComparison.Ordinal);
|
||||
if (needsTextUpdate)
|
||||
{
|
||||
pNode->SetText(labelContent);
|
||||
_lastLabelByIndex[nameplateIndex] = labelContent;
|
||||
}
|
||||
|
||||
var flagsChanged = pNode->TextFlags != desiredFlags;
|
||||
var nodeWidth = (int)pNode->AtkResNode.GetWidth();
|
||||
if (nodeWidth <= 0)
|
||||
nodeWidth = defaultNodeWidth;
|
||||
var nodeHeight = defaultNodeHeight;
|
||||
AlignmentType alignment;
|
||||
|
||||
var textScaleY = nameText->AtkResNode.ScaleY;
|
||||
if (textScaleY <= 0f)
|
||||
textScaleY = 1f;
|
||||
|
||||
var blockHeight = 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)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)Math.Round(4 * effectiveScale);
|
||||
|
||||
var positionY = blockTop - verticalPadding - nodeHeight;
|
||||
|
||||
var textWidth = 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)Math.Round(nameText->AtkResNode.X);
|
||||
var hasValidOffset = false;
|
||||
|
||||
if (Math.Abs((int)nameplateObject.TextW) > 0 || textOffset != 0)
|
||||
{
|
||||
_cachedNameplateTextOffsets[nameplateIndex] = textOffset;
|
||||
hasValidOffset = true;
|
||||
}
|
||||
else if (_cachedNameplateTextOffsets[nameplateIndex] != int.MinValue)
|
||||
{
|
||||
hasValidOffset = true;
|
||||
}
|
||||
|
||||
int positionX;
|
||||
|
||||
if (!config.LightfinderLabelUseIcon)
|
||||
{
|
||||
var needsWidthRefresh = nodeCreated || needsTextUpdate || !scaleMatches || fontTypeChanged || fontSizeChanged || flagsChanged;
|
||||
if (flagsChanged)
|
||||
pNode->TextFlags = desiredFlags;
|
||||
|
||||
if (needsWidthRefresh)
|
||||
{
|
||||
if (pNode->AtkResNode.Width != 0)
|
||||
pNode->AtkResNode.Width = 0;
|
||||
nodeWidth = (int)pNode->AtkResNode.GetWidth();
|
||||
if (nodeWidth <= 0)
|
||||
nodeWidth = defaultNodeWidth;
|
||||
}
|
||||
|
||||
if (pNode->AtkResNode.Width != (ushort)nodeWidth)
|
||||
pNode->AtkResNode.Width = (ushort)nodeWidth;
|
||||
}
|
||||
else
|
||||
{
|
||||
var needsWidthRefresh = nodeCreated || needsTextUpdate || !scaleMatches || fontTypeChanged || fontSizeChanged || flagsChanged;
|
||||
if (flagsChanged)
|
||||
pNode->TextFlags = desiredFlags;
|
||||
|
||||
if (needsWidthRefresh && pNode->AtkResNode.Width != 0)
|
||||
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)Math.Clamp((int)alignment, 0, 8);
|
||||
if (pNode->AtkResNode.Color.A != 255)
|
||||
pNode->AtkResNode.Color.A = 255;
|
||||
|
||||
var textR = (byte)(labelColor.X * 255);
|
||||
var textG = (byte)(labelColor.Y * 255);
|
||||
var textB = (byte)(labelColor.Z * 255);
|
||||
var textA = (byte)(labelColor.W * 255);
|
||||
|
||||
if (pNode->TextColor.R != textR || pNode->TextColor.G != textG ||
|
||||
pNode->TextColor.B != textB || pNode->TextColor.A != textA)
|
||||
{
|
||||
pNode->TextColor.R = textR;
|
||||
pNode->TextColor.G = textG;
|
||||
pNode->TextColor.B = textB;
|
||||
pNode->TextColor.A = textA;
|
||||
}
|
||||
|
||||
var edgeR = (byte)(edgeColor.X * 255);
|
||||
var edgeG = (byte)(edgeColor.Y * 255);
|
||||
var edgeB = (byte)(edgeColor.Z * 255);
|
||||
var edgeA = (byte)(edgeColor.W * 255);
|
||||
|
||||
if (pNode->EdgeColor.R != edgeR || pNode->EdgeColor.G != edgeG ||
|
||||
pNode->EdgeColor.B != edgeB || pNode->EdgeColor.A != edgeA)
|
||||
{
|
||||
pNode->EdgeColor.R = edgeR;
|
||||
pNode->EdgeColor.G = edgeG;
|
||||
pNode->EdgeColor.B = edgeB;
|
||||
pNode->EdgeColor.A = edgeA;
|
||||
}
|
||||
|
||||
var desiredAlignment = config.LightfinderLabelUseIcon ? alignment : AlignmentType.Bottom;
|
||||
if (pNode->AlignmentType != desiredAlignment)
|
||||
pNode->AlignmentType = desiredAlignment;
|
||||
|
||||
var desiredX = (short)Math.Clamp(positionX, short.MinValue, short.MaxValue);
|
||||
var desiredY = (short)Math.Clamp(positionY, short.MinValue, short.MaxValue);
|
||||
if (!NearlyEqual(pNode->AtkResNode.X, desiredX) || !NearlyEqual(pNode->AtkResNode.Y, desiredY))
|
||||
pNode->AtkResNode.SetPositionShort(desiredX, desiredY);
|
||||
|
||||
if (pNode->LineSpacing != desiredLineSpacing)
|
||||
pNode->LineSpacing = desiredLineSpacing;
|
||||
if (pNode->CharSpacing != 1)
|
||||
pNode->CharSpacing = 1;
|
||||
}
|
||||
|
||||
HideUnmarkedNodes(namePlateAddon, visibleIndices);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the current RaptureAtkModule for native UI access.
|
||||
/// </summary>
|
||||
private static RaptureAtkModule* GetRaptureAtkModule()
|
||||
{
|
||||
var framework = Framework.Instance();
|
||||
if (framework == null)
|
||||
return null;
|
||||
|
||||
var uiModule = framework->GetUIModule();
|
||||
if (uiModule == null)
|
||||
return null;
|
||||
|
||||
return uiModule->GetRaptureAtkModule();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the NamePlate addon from the given RaptureAtkModule.
|
||||
/// </summary>
|
||||
private static AddonNamePlate* GetNamePlateAddon(RaptureAtkModule* raptureAtkModule)
|
||||
{
|
||||
if (raptureAtkModule == null)
|
||||
return null;
|
||||
|
||||
var addon = raptureAtkModule->RaptureAtkUnitManager.GetAddonByName("NamePlate");
|
||||
return addon != null ? (AddonNamePlate*)addon : null;
|
||||
}
|
||||
|
||||
private static uint GetNameplateNodeId(int index)
|
||||
=> NameplateNodeIdBase + (uint)index;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the NamePlate addon is visible and safe to touch.
|
||||
/// </summary>
|
||||
private static bool IsNameplateAddonVisible(AddonNamePlate* namePlateAddon)
|
||||
{
|
||||
if (namePlateAddon == null)
|
||||
return false;
|
||||
|
||||
var root = namePlateAddon->AtkUnitBase.RootNode;
|
||||
return root != null && root->IsVisible();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a LightFinder text node by ID in the name container.
|
||||
/// </summary>
|
||||
private static AtkTextNode* FindNameplateTextNode(AtkResNode* nameContainer, uint nodeId)
|
||||
{
|
||||
if (nameContainer == null)
|
||||
return null;
|
||||
|
||||
var child = nameContainer->ChildNode;
|
||||
while (child != null)
|
||||
{
|
||||
if (child->NodeId == nodeId &&
|
||||
child->Type == NodeType.Text &&
|
||||
child->ParentNode == nameContainer)
|
||||
return (AtkTextNode*)child;
|
||||
|
||||
child = child->PrevSiblingNode;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures a LightFinder text node exists for the given nameplate index.
|
||||
/// </summary>
|
||||
private static AtkTextNode* EnsureNameplateTextNode(AtkResNode* nameContainer, AtkComponentNode* root, uint nodeId, out bool created)
|
||||
{
|
||||
created = false;
|
||||
if (nameContainer == null || root == null || root->Component == null)
|
||||
return null;
|
||||
|
||||
var existing = FindNameplateTextNode(nameContainer, nodeId);
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
if (nameContainer->ChildNode == null)
|
||||
return null;
|
||||
|
||||
var newNode = AtkNodeHelpers.CreateOrphanTextNode(nodeId, TextFlags.Edge | TextFlags.Glare);
|
||||
if (newNode == null)
|
||||
return null;
|
||||
|
||||
var lastChild = nameContainer->ChildNode;
|
||||
while (lastChild->PrevSiblingNode != null)
|
||||
lastChild = lastChild->PrevSiblingNode;
|
||||
|
||||
newNode->AtkResNode.NextSiblingNode = lastChild;
|
||||
newNode->AtkResNode.ParentNode = nameContainer;
|
||||
lastChild->PrevSiblingNode = (AtkResNode*)newNode;
|
||||
root->Component->UldManager.UpdateDrawNodeList();
|
||||
newNode->AtkResNode.SetUseDepthBasedPriority(true);
|
||||
|
||||
created = true;
|
||||
return newNode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hides all native LightFinder nodes on the nameplate addon.
|
||||
/// </summary>
|
||||
private static void HideAllNameplateNodes(AddonNamePlate* namePlateAddon)
|
||||
{
|
||||
if (namePlateAddon == null)
|
||||
return;
|
||||
|
||||
if (!IsNameplateAddonVisible(namePlateAddon))
|
||||
return;
|
||||
|
||||
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
|
||||
{
|
||||
HideNameplateTextNode(namePlateAddon->NamePlateObjectArray[i], GetNameplateNodeId(i));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hides all LightFinder nodes not marked as visible this frame.
|
||||
/// </summary>
|
||||
private static void HideUnmarkedNodes(AddonNamePlate* namePlateAddon, bool[] visibleIndices)
|
||||
{
|
||||
if (namePlateAddon == null)
|
||||
return;
|
||||
|
||||
if (!IsNameplateAddonVisible(namePlateAddon))
|
||||
return;
|
||||
|
||||
var visibleLength = visibleIndices.Length;
|
||||
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
|
||||
{
|
||||
if (i < visibleLength && visibleIndices[i])
|
||||
continue;
|
||||
|
||||
HideNameplateTextNode(namePlateAddon->NamePlateObjectArray[i], GetNameplateNodeId(i));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hides the LightFinder text node for a single nameplate object.
|
||||
/// </summary>
|
||||
private static void HideNameplateTextNode(AddonNamePlate.NamePlateObject nameplateObject, uint nodeId)
|
||||
{
|
||||
var nameContainer = nameplateObject.NameContainer;
|
||||
if (nameContainer == null)
|
||||
return;
|
||||
|
||||
var node = FindNameplateTextNode(nameContainer, nodeId);
|
||||
if (!IsValidNameplateTextNode(node, nameContainer))
|
||||
return;
|
||||
|
||||
node->AtkResNode.ToggleVisibility(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to destroy all constructed LightFinder nodes safely.
|
||||
/// </summary>
|
||||
private void TryDestroyNameplateNodes()
|
||||
{
|
||||
var raptureAtkModule = GetRaptureAtkModule();
|
||||
if (raptureAtkModule == null)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
_logger.LogDebug("Unable to destroy nameplate nodes because the RaptureAtkModule is not available.");
|
||||
return;
|
||||
}
|
||||
|
||||
var namePlateAddon = GetNamePlateAddon(raptureAtkModule);
|
||||
if (namePlateAddon == null)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
_logger.LogDebug("Unable to destroy nameplate nodes because the NamePlate addon is not available.");
|
||||
return;
|
||||
}
|
||||
|
||||
DestroyNameplateNodes(namePlateAddon);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all constructed LightFinder nodes from the given nameplate addon.
|
||||
/// </summary>
|
||||
private void DestroyNameplateNodes(AddonNamePlate* namePlateAddon)
|
||||
{
|
||||
if (namePlateAddon == null)
|
||||
return;
|
||||
|
||||
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
|
||||
{
|
||||
var nameplateObject = namePlateAddon->NamePlateObjectArray[i];
|
||||
var root = nameplateObject.RootComponentNode;
|
||||
var nameContainer = nameplateObject.NameContainer;
|
||||
if (root == null || root->Component == null || nameContainer == null)
|
||||
continue;
|
||||
|
||||
var nodeId = GetNameplateNodeId(i);
|
||||
var textNode = FindNameplateTextNode(nameContainer, nodeId);
|
||||
if (!IsValidNameplateTextNode(textNode, nameContainer))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
var resNode = &textNode->AtkResNode;
|
||||
|
||||
if (resNode->PrevSiblingNode != null)
|
||||
resNode->PrevSiblingNode->NextSiblingNode = resNode->NextSiblingNode;
|
||||
if (resNode->NextSiblingNode != null)
|
||||
resNode->NextSiblingNode->PrevSiblingNode = resNode->PrevSiblingNode;
|
||||
|
||||
root->Component->UldManager.UpdateDrawNodeList();
|
||||
resNode->Destroy(true);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Unknown error while removing text node 0x{Node:X} for nameplate {Index} on component node 0x{Component:X}", (IntPtr)textNode, i, (IntPtr)root);
|
||||
}
|
||||
}
|
||||
|
||||
ClearNameplateCaches();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a node is a LightFinder text node owned by the container.
|
||||
/// </summary>
|
||||
private static bool IsValidNameplateTextNode(AtkTextNode* node, AtkResNode* nameContainer)
|
||||
{
|
||||
if (node == null || nameContainer == null)
|
||||
return false;
|
||||
|
||||
var resNode = &node->AtkResNode;
|
||||
return resNode->Type == NodeType.Text && resNode->ParentNode == nameContainer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Float comparison helper for UI values.
|
||||
/// </summary>
|
||||
private static bool NearlyEqual(float a, float b, float epsilon = 0.001f)
|
||||
=> Math.Abs(a - b) <= epsilon;
|
||||
|
||||
private static 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)Math.Round(rawHeight * scale);
|
||||
return Math.Max(1, computed);
|
||||
}
|
||||
|
||||
private static 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)Math.Round(rawWidth * scale);
|
||||
return Math.Max(1, computed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears cached text sizing and label state for nameplates.
|
||||
/// </summary>
|
||||
public void ClearNameplateCaches()
|
||||
{
|
||||
Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
|
||||
Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
|
||||
Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
|
||||
Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
|
||||
Array.Fill(_lastLabelByIndex, null);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -51,6 +51,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
private readonly Lock _labelLock = new();
|
||||
private readonly NameplateBuffers _buffers = new();
|
||||
private int _labelRenderCount;
|
||||
private LightfinderLabelRenderer _lastRenderer;
|
||||
|
||||
private const string _defaultLabelText = "LightFinder";
|
||||
private const SeIconChar _defaultIcon = SeIconChar.Hyadelyn;
|
||||
@@ -70,6 +71,8 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
private readonly List<RectF> _uiRects = new(128);
|
||||
private ImmutableHashSet<string> _activeBroadcastingCids = [];
|
||||
|
||||
private bool IsPictomancyRenderer => _configService.Current.LightfinderLabelRenderer == LightfinderLabelRenderer.Pictomancy;
|
||||
|
||||
public LightFinderPlateHandler(
|
||||
ILogger<LightFinderPlateHandler> logger,
|
||||
IAddonLifecycle addonLifecycle,
|
||||
@@ -92,9 +95,29 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
_pairUiService = pairUiService;
|
||||
_uiBuilder = pluginInterface.UiBuilder ?? throw new ArgumentNullException(nameof(pluginInterface));
|
||||
_ = pictomancyService ?? throw new ArgumentNullException(nameof(pictomancyService));
|
||||
_lastRenderer = _configService.Current.LightfinderLabelRenderer;
|
||||
|
||||
}
|
||||
|
||||
private void RefreshRendererState()
|
||||
{
|
||||
var renderer = _configService.Current.LightfinderLabelRenderer;
|
||||
if (renderer == _lastRenderer)
|
||||
return;
|
||||
|
||||
_lastRenderer = renderer;
|
||||
|
||||
if (renderer == LightfinderLabelRenderer.Pictomancy)
|
||||
{
|
||||
FlagRefresh();
|
||||
}
|
||||
else
|
||||
{
|
||||
ClearNameplateCaches();
|
||||
_lastNamePlateDrawFrame = 0;
|
||||
}
|
||||
}
|
||||
|
||||
internal void Init()
|
||||
{
|
||||
if (!_drawSubscribed)
|
||||
@@ -168,6 +191,14 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
/// <param name="args"></param>
|
||||
private void NameplateDrawDetour(AddonEvent type, AddonArgs args)
|
||||
{
|
||||
RefreshRendererState();
|
||||
if (!IsPictomancyRenderer)
|
||||
{
|
||||
ClearLabelBuffer();
|
||||
_lastNamePlateDrawFrame = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_clientState.IsGPosing)
|
||||
{
|
||||
ClearLabelBuffer();
|
||||
@@ -503,6 +534,10 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
/// </summary>
|
||||
private void OnUiBuilderDraw()
|
||||
{
|
||||
RefreshRendererState();
|
||||
if (!IsPictomancyRenderer)
|
||||
return;
|
||||
|
||||
if (!_mEnabled)
|
||||
return;
|
||||
|
||||
@@ -1015,6 +1050,12 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
|
||||
public void OnTick(PriorityFrameworkUpdateMessage _)
|
||||
{
|
||||
if (!IsPictomancyRenderer)
|
||||
{
|
||||
_needsLabelRefresh = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_needsLabelRefresh)
|
||||
{
|
||||
UpdateNameplateNodes();
|
||||
|
||||
@@ -15,11 +15,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
|
||||
private readonly LightFinderService _broadcastService;
|
||||
private readonly LightFinderPlateHandler _lightFinderPlateHandler;
|
||||
private readonly LightFinderNativePlateHandler _lightFinderNativePlateHandler;
|
||||
|
||||
private readonly ConcurrentDictionary<string, BroadcastEntry> _broadcastCache = new(StringComparer.Ordinal);
|
||||
private readonly Queue<string> _lookupQueue = new();
|
||||
private readonly HashSet<string> _lookupQueuedCids = [];
|
||||
private readonly HashSet<string> _syncshellCids = [];
|
||||
private volatile bool _pendingLocalBroadcast;
|
||||
private TimeSpan? _pendingLocalTtl;
|
||||
|
||||
private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4);
|
||||
private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1);
|
||||
@@ -43,12 +46,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
LightFinderService broadcastService,
|
||||
LightlessMediator mediator,
|
||||
LightFinderPlateHandler lightFinderPlateHandler,
|
||||
LightFinderNativePlateHandler lightFinderNativePlateHandler,
|
||||
ActorObjectService actorTracker) : base(logger, mediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_actorTracker = actorTracker;
|
||||
_broadcastService = broadcastService;
|
||||
_lightFinderPlateHandler = lightFinderPlateHandler;
|
||||
_lightFinderNativePlateHandler = lightFinderNativePlateHandler;
|
||||
|
||||
_logger = logger;
|
||||
_framework = framework;
|
||||
@@ -73,6 +78,8 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
if (!_broadcastService.IsBroadcasting)
|
||||
return;
|
||||
|
||||
TryPrimeLocalBroadcastCache();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var address in _actorTracker.PlayerAddresses)
|
||||
@@ -143,6 +150,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
.ToList();
|
||||
|
||||
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
|
||||
_lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids);
|
||||
UpdateSyncshellBroadcasts();
|
||||
}
|
||||
|
||||
@@ -157,9 +165,45 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
_lookupQueue.Clear();
|
||||
_lookupQueuedCids.Clear();
|
||||
_syncshellCids.Clear();
|
||||
_pendingLocalBroadcast = false;
|
||||
_pendingLocalTtl = null;
|
||||
|
||||
_lightFinderPlateHandler.UpdateBroadcastingCids([]);
|
||||
_lightFinderNativePlateHandler.UpdateBroadcastingCids([]);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingLocalBroadcast = true;
|
||||
_pendingLocalTtl = msg.Ttl;
|
||||
TryPrimeLocalBroadcastCache();
|
||||
}
|
||||
|
||||
private void TryPrimeLocalBroadcastCache()
|
||||
{
|
||||
if (!_pendingLocalBroadcast)
|
||||
return;
|
||||
|
||||
if (!TryGetLocalHashedCid(out var localCid))
|
||||
return;
|
||||
|
||||
var ttl = _pendingLocalTtl ?? _maxAllowedTtl;
|
||||
var expiry = DateTime.UtcNow + ttl;
|
||||
|
||||
_broadcastCache.AddOrUpdate(localCid,
|
||||
new BroadcastEntry(true, expiry, null),
|
||||
(_, old) => new BroadcastEntry(true, expiry, old.GID));
|
||||
|
||||
_pendingLocalBroadcast = false;
|
||||
_pendingLocalTtl = null;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var activeCids = _broadcastCache
|
||||
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now)
|
||||
.Select(e => e.Key)
|
||||
.ToList();
|
||||
|
||||
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
|
||||
_lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids);
|
||||
}
|
||||
|
||||
private void UpdateSyncshellBroadcasts()
|
||||
|
||||
@@ -2,10 +2,8 @@ using Dalamud.Game.ClientState.Objects.Enums;
|
||||
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;
|
||||
@@ -24,27 +22,22 @@ namespace LightlessSync.Services;
|
||||
/// </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 IGameGui _gameGui;
|
||||
private readonly IObjectTable _objectTable;
|
||||
private readonly PairUiService _pairUiService;
|
||||
private readonly NameplateUpdateHookService _nameplateUpdateHookService;
|
||||
|
||||
public NameplateService(ILogger<NameplateService> logger,
|
||||
LightlessConfigService configService,
|
||||
IClientState clientState,
|
||||
IGameGui gameGui,
|
||||
IObjectTable objectTable,
|
||||
IGameInteropProvider interop,
|
||||
LightlessMediator lightlessMediator,
|
||||
PairUiService pairUiService) : base(logger, lightlessMediator)
|
||||
PairUiService pairUiService,
|
||||
NameplateUpdateHookService nameplateUpdateHookService) : base(logger, lightlessMediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
@@ -52,21 +45,18 @@ public unsafe class NameplateService : DisposableMediatorSubscriberBase
|
||||
_gameGui = gameGui;
|
||||
_objectTable = objectTable;
|
||||
_pairUiService = pairUiService;
|
||||
_nameplateUpdateHookService = nameplateUpdateHookService;
|
||||
|
||||
interop.InitializeFromAttributes(this);
|
||||
_nameplateHook?.Enable();
|
||||
_nameplateUpdateHookService.NameplateUpdated += OnNameplateUpdated;
|
||||
Refresh();
|
||||
|
||||
Mediator.Subscribe<VisibilityChange>(this, (_) => Refresh());
|
||||
}
|
||||
|
||||
/// <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"/>,
|
||||
/// Nameplate update handler, triggered by the signature hook service.
|
||||
/// </summary>
|
||||
private nint UpdateNameplateDetour(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
|
||||
private void OnNameplateUpdated(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -74,10 +64,8 @@ public unsafe class NameplateService : DisposableMediatorSubscriberBase
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Error in NameplateService UpdateNameplateDetour");
|
||||
_logger.LogError(e, "Error in NameplateService OnNameplateUpdated");
|
||||
}
|
||||
|
||||
return _nameplateHook!.Original(raptureAtkModule, namePlateInfo, numArray, stringArray, battleChara, numArrayIndex, stringArrayIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -246,7 +234,7 @@ public unsafe class NameplateService : DisposableMediatorSubscriberBase
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_nameplateHook?.Dispose();
|
||||
_nameplateUpdateHookService.NameplateUpdated -= OnNameplateUpdated;
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
|
||||
57
LightlessSync/Services/NameplateUpdateHookService.cs
Normal file
57
LightlessSync/Services/NameplateUpdateHookService.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using Dalamud.Hooking;
|
||||
using Dalamud.Plugin.Services;
|
||||
using Dalamud.Utility.Signatures;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public unsafe sealed class NameplateUpdateHookService : IDisposable
|
||||
{
|
||||
private delegate nint UpdateNameplateDelegate(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo,
|
||||
NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex);
|
||||
public delegate void NameplateUpdatedHandler(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<NameplateUpdateHookService> _logger;
|
||||
|
||||
public NameplateUpdateHookService(ILogger<NameplateUpdateHookService> logger, IGameInteropProvider interop)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
interop.InitializeFromAttributes(this);
|
||||
_nameplateHook?.Enable();
|
||||
}
|
||||
|
||||
public event NameplateUpdatedHandler? NameplateUpdated;
|
||||
|
||||
/// <summary>
|
||||
/// Detour for the game's internal nameplate update function.
|
||||
/// This will be called whenever the client updates any nameplate.
|
||||
/// </summary>
|
||||
private nint UpdateNameplateDetour(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo,
|
||||
NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
|
||||
{
|
||||
try
|
||||
{
|
||||
NameplateUpdated?.Invoke(raptureAtkModule, namePlateInfo, numArray, stringArray, battleChara, numArrayIndex, stringArrayIndex);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Error in NameplateUpdateHookService UpdateNameplateDetour");
|
||||
}
|
||||
|
||||
return _nameplateHook!.Original(raptureAtkModule, namePlateInfo, numArray, stringArray, battleChara, numArrayIndex, stringArrayIndex);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_nameplateHook?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -394,6 +394,21 @@ public sealed class TextureMetadataHelper
|
||||
if (string.IsNullOrEmpty(fileNameWithExtension) && string.IsNullOrEmpty(fileNameWithoutExtension))
|
||||
return TextureMapKind.Unknown;
|
||||
|
||||
if (normalized.Contains("/eye/eyelids_shadow.tex", StringComparison.Ordinal))
|
||||
return TextureMapKind.Normal;
|
||||
|
||||
if (normalized.Contains("/ui/map/", StringComparison.Ordinal) && !string.IsNullOrEmpty(fileNameWithoutExtension))
|
||||
{
|
||||
if (fileNameWithoutExtension.EndsWith("m_m", StringComparison.Ordinal)
|
||||
|| fileNameWithoutExtension.EndsWith("m_s", StringComparison.Ordinal))
|
||||
return TextureMapKind.Mask;
|
||||
|
||||
if (fileNameWithoutExtension.EndsWith("_m", StringComparison.Ordinal)
|
||||
|| fileNameWithoutExtension.EndsWith("_s", StringComparison.Ordinal)
|
||||
|| fileNameWithoutExtension.EndsWith("d", StringComparison.Ordinal))
|
||||
return TextureMapKind.Diffuse;
|
||||
}
|
||||
|
||||
foreach (var (kind, token) in MapTokens)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(fileNameWithExtension) &&
|
||||
@@ -563,7 +578,16 @@ public sealed class TextureMetadataHelper
|
||||
|
||||
var normalized = format.ToUpperInvariant();
|
||||
return normalized.Contains("A8", StringComparison.Ordinal)
|
||||
|| normalized.Contains("A1", StringComparison.Ordinal)
|
||||
|| normalized.Contains("A4", StringComparison.Ordinal)
|
||||
|| normalized.Contains("A16", StringComparison.Ordinal)
|
||||
|| normalized.Contains("A32", StringComparison.Ordinal)
|
||||
|| normalized.Contains("ARGB", StringComparison.Ordinal)
|
||||
|| normalized.Contains("RGBA", StringComparison.Ordinal)
|
||||
|| normalized.Contains("BGRA", StringComparison.Ordinal)
|
||||
|| normalized.Contains("DXT3", StringComparison.Ordinal)
|
||||
|| normalized.Contains("DXT5", StringComparison.Ordinal)
|
||||
|| normalized.Contains("BC2", StringComparison.Ordinal)
|
||||
|| normalized.Contains("BC3", StringComparison.Ordinal)
|
||||
|| normalized.Contains("BC7", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user