Merge remote-tracking branch 'origin/2.0.2' into 2.0.0-crashing-bugfixes

This commit is contained in:
choco
2025-12-27 23:08:03 +01:00
28 changed files with 3169 additions and 499 deletions

View File

@@ -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,

View File

@@ -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);

View 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);
}
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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()

View File

@@ -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);

View 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();
}
}

View File

@@ -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);
}