diff --git a/LightlessSync/FileCache/FileCacheEntity.cs b/LightlessSync/FileCache/FileCacheEntity.cs index 418e3d4..9d0515d 100644 --- a/LightlessSync/FileCache/FileCacheEntity.cs +++ b/LightlessSync/FileCache/FileCacheEntity.cs @@ -1,15 +1,23 @@ #nullable disable +using System.Text.Json.Serialization; + namespace LightlessSync.FileCache; public class FileCacheEntity { - public FileCacheEntity(string hash, string path, string lastModifiedDateTicks, long? size = null, long? compressedSize = null) + [JsonConstructor] + public FileCacheEntity( + string hash, + string prefixedFilePath, + string lastModifiedDateTicks, + long? size = null, + long? compressedSize = null) { Size = size; CompressedSize = compressedSize; Hash = hash; - PrefixedFilePath = path; + PrefixedFilePath = prefixedFilePath; LastModifiedDateTicks = lastModifiedDateTicks; } @@ -23,7 +31,5 @@ public class FileCacheEntity public long? Size { get; set; } public void SetResolvedFilePath(string filePath) - { - ResolvedFilepath = filePath.ToLowerInvariant().Replace("\\\\", "\\", StringComparison.Ordinal); - } + => ResolvedFilepath = filePath.ToLowerInvariant().Replace("\\\\", "\\", StringComparison.Ordinal); } \ No newline at end of file diff --git a/LightlessSync/FileCache/FileCacheManager.cs b/LightlessSync/FileCache/FileCacheManager.cs index e2cdc72..b0becf3 100644 --- a/LightlessSync/FileCache/FileCacheManager.cs +++ b/LightlessSync/FileCache/FileCacheManager.cs @@ -7,6 +7,8 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; using System.Text; namespace LightlessSync.FileCache; @@ -31,6 +33,14 @@ public sealed class FileCacheManager : IHostedService private bool _csvHeaderEnsured; public string CacheFolder => _configService.Current.CacheFolder; + private const string _compressedCacheExtension = ".llz4"; + private readonly ConcurrentDictionary _compressLocks = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _sizeCache = + new(StringComparer.OrdinalIgnoreCase); + + [StructLayout(LayoutKind.Auto)] + public readonly record struct SizeInfo(long Original, long Compressed); + public FileCacheManager(ILogger logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator) { _logger = logger; @@ -45,6 +55,18 @@ public sealed class FileCacheManager : IHostedService private static string NormalizeSeparators(string path) => path.Replace("/", "\\", StringComparison.Ordinal) .Replace("\\\\", "\\", StringComparison.Ordinal); + private SemaphoreSlim GetCompressLock(string hash) + => _compressLocks.GetOrAdd(hash, _ => new SemaphoreSlim(1, 1)); + + public void SetSizeInfo(string hash, long original, long compressed) + => _sizeCache[hash] = new SizeInfo(original, compressed); + + public bool TryGetSizeInfo(string hash, out SizeInfo info) + => _sizeCache.TryGetValue(hash, out info); + + private string GetCompressedCachePath(string hash) + => Path.Combine(CacheFolder, hash + _compressedCacheExtension); + private static string NormalizePrefixedPathKey(string prefixedPath) { if (string.IsNullOrEmpty(prefixedPath)) @@ -111,6 +133,114 @@ public sealed class FileCacheManager : IHostedService return int.TryParse(versionSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out version); } + public void UpdateSizeInfo(string hash, long? original = null, long? compressed = null) + { + _sizeCache.AddOrUpdate( + hash, + _ => new SizeInfo(original ?? 0, compressed ?? 0), + (_, old) => new SizeInfo(original ?? old.Original, compressed ?? old.Compressed)); + } + + private void UpdateEntitiesSizes(string hash, long original, long compressed) + { + if (_fileCaches.TryGetValue(hash, out var dict)) + { + foreach (var e in dict.Values) + { + e.Size = original; + e.CompressedSize = compressed; + } + } + } + + public static void ApplySizesToEntries(IEnumerable entries, long original, long compressed) + { + foreach (var e in entries) + { + if (e == null) continue; + e.Size = original; + e.CompressedSize = compressed > 0 ? compressed : null; + } + } + + public async Task GetCompressedSizeAsync(string hash, CancellationToken token) + { + if (_sizeCache.TryGetValue(hash, out var info) && info.Compressed > 0) + return info.Compressed; + + if (_fileCaches.TryGetValue(hash, out var dict)) + { + var any = dict.Values.FirstOrDefault(); + if (any != null && any.CompressedSize > 0) + { + UpdateSizeInfo(hash, original: any.Size > 0 ? any.Size : null, compressed: any.CompressedSize); + return (long)any.CompressedSize; + } + } + + if (!string.IsNullOrWhiteSpace(CacheFolder)) + { + var path = GetCompressedCachePath(hash); + if (File.Exists(path)) + { + var len = new FileInfo(path).Length; + UpdateSizeInfo(hash, compressed: len); + return len; + } + + var bytes = await EnsureCompressedCacheBytesAsync(hash, token).ConfigureAwait(false); + return bytes.LongLength; + } + + var fallback = await GetCompressedFileData(hash, token).ConfigureAwait(false); + return fallback.Item2.LongLength; + } + + private async Task EnsureCompressedCacheBytesAsync(string hash, CancellationToken token) + { + if (string.IsNullOrWhiteSpace(CacheFolder)) + throw new InvalidOperationException("CacheFolder is not set; cannot persist compressed cache."); + + Directory.CreateDirectory(CacheFolder); + + var compressedPath = GetCompressedCachePath(hash); + + if (File.Exists(compressedPath)) + return await File.ReadAllBytesAsync(compressedPath, token).ConfigureAwait(false); + + var sem = GetCompressLock(hash); + await sem.WaitAsync(token).ConfigureAwait(false); + try + { + if (File.Exists(compressedPath)) + return await File.ReadAllBytesAsync(compressedPath, token).ConfigureAwait(false); + + var entity = GetFileCacheByHash(hash); + if (entity == null || string.IsNullOrWhiteSpace(entity.ResolvedFilepath)) + throw new InvalidOperationException($"No local file cache found for hash {hash}."); + + var sourcePath = entity.ResolvedFilepath; + var originalSize = new FileInfo(sourcePath).Length; + + var raw = await File.ReadAllBytesAsync(sourcePath, token).ConfigureAwait(false); + var compressed = LZ4Wrapper.WrapHC(raw, 0, raw.Length); + + var tmpPath = compressedPath + ".tmp"; + await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false); + File.Move(tmpPath, compressedPath, overwrite: true); + + var compressedSize = compressed.LongLength; + SetSizeInfo(hash, originalSize, compressedSize); + UpdateEntitiesSizes(hash, originalSize, compressedSize); + + return compressed; + } + finally + { + sem.Release(); + } + } + private string NormalizeToPrefixedPath(string path) { if (string.IsNullOrEmpty(path)) return string.Empty; @@ -318,9 +448,18 @@ public sealed class FileCacheManager : IHostedService public async Task<(string, byte[])> GetCompressedFileData(string fileHash, CancellationToken uploadToken) { + if (!string.IsNullOrWhiteSpace(CacheFolder)) + { + var bytes = await EnsureCompressedCacheBytesAsync(fileHash, uploadToken).ConfigureAwait(false); + UpdateSizeInfo(fileHash, compressed: bytes.LongLength); + return (fileHash, bytes); + } + var fileCache = GetFileCacheByHash(fileHash)!.ResolvedFilepath; - return (fileHash, LZ4Wrapper.WrapHC(await File.ReadAllBytesAsync(fileCache, uploadToken).ConfigureAwait(false), 0, - (int)new FileInfo(fileCache).Length)); + var raw = await File.ReadAllBytesAsync(fileCache, uploadToken).ConfigureAwait(false); + var compressed = LZ4Wrapper.WrapHC(raw, 0, raw.Length); + UpdateSizeInfo(fileHash, original: raw.LongLength, compressed: compressed.LongLength); + return (fileHash, compressed); } public FileCacheEntity? GetFileCacheByHash(string hash) @@ -891,6 +1030,14 @@ public sealed class FileCacheManager : IHostedService compressed = resultCompressed; } } + + if (size > 0 || compressed > 0) + { + UpdateSizeInfo(hash, + original: size > 0 ? size : null, + compressed: compressed > 0 ? compressed : null); + } + AddHashedFile(ReplacePathPrefixes(new FileCacheEntity(hash, path, time, size, compressed))); } catch (Exception ex) diff --git a/LightlessSync/FileCache/TransientResourceManager.cs b/LightlessSync/FileCache/TransientResourceManager.cs index ed2cca6..a8b467e 100644 --- a/LightlessSync/FileCache/TransientResourceManager.cs +++ b/LightlessSync/FileCache/TransientResourceManager.cs @@ -10,9 +10,6 @@ using LightlessSync.Services.Mediator; using LightlessSync.Utils; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading.Tasks; -using System.Linq; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; namespace LightlessSync.FileCache; @@ -28,7 +25,10 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase private readonly object _ownedHandlerLock = new(); private readonly string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk", "kdb"]; private readonly string[] _handledRecordingFileTypes = ["tex", "mdl", "mtrl"]; + private readonly string[] _handledFileTypesWithRecording; private readonly HashSet _playerRelatedPointers = []; + private readonly object _playerRelatedLock = new(); + private readonly ConcurrentDictionary _playerRelatedByAddress = new(); private readonly Dictionary _ownedHandlers = new(); private ConcurrentDictionary _cachedFrameAddresses = new(); private ConcurrentDictionary>? _semiTransientResources = null; @@ -42,6 +42,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase _dalamudUtil = dalamudUtil; _actorObjectService = actorObjectService; _gameObjectHandlerFactory = gameObjectHandlerFactory; + _handledFileTypesWithRecording = _handledRecordingFileTypes.Concat(_handledFileTypes).ToArray(); Mediator.Subscribe(this, Manager_PenumbraResourceLoadEvent); Mediator.Subscribe(this, msg => HandleActorTracked(msg.Descriptor)); @@ -51,12 +52,18 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase Mediator.Subscribe(this, (msg) => { if (!msg.OwnedObject) return; - _playerRelatedPointers.Add(msg.GameObjectHandler); + lock (_playerRelatedLock) + { + _playerRelatedPointers.Add(msg.GameObjectHandler); + } }); Mediator.Subscribe(this, (msg) => { if (!msg.OwnedObject) return; - _playerRelatedPointers.Remove(msg.GameObjectHandler); + lock (_playerRelatedLock) + { + _playerRelatedPointers.Remove(msg.GameObjectHandler); + } }); foreach (var descriptor in _actorObjectService.ObjectDescriptors) @@ -87,9 +94,12 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase { _semiTransientResources = new(); PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData); - _semiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.Ordinal); + _semiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []) + .ToHashSet(StringComparer.OrdinalIgnoreCase); PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData); - _semiTransientResources[ObjectKind.Pet] = [.. petSpecificData ?? []]; + _semiTransientResources[ObjectKind.Pet] = new HashSet( + petSpecificData ?? [], + StringComparer.OrdinalIgnoreCase); } return _semiTransientResources; @@ -127,14 +137,14 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase { SemiTransientResources.TryGetValue(objectKind, out var result); - return result ?? new HashSet(StringComparer.Ordinal); + return result ?? new HashSet(StringComparer.OrdinalIgnoreCase); } public void PersistTransientResources(ObjectKind objectKind) { if (!SemiTransientResources.TryGetValue(objectKind, out HashSet? semiTransientResources)) { - SemiTransientResources[objectKind] = semiTransientResources = new(StringComparer.Ordinal); + SemiTransientResources[objectKind] = semiTransientResources = new(StringComparer.OrdinalIgnoreCase); } if (!TransientResources.TryGetValue(objectKind, out var resources)) @@ -152,7 +162,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase List newlyAddedGamePaths; lock (semiTransientResources) { - newlyAddedGamePaths = transientResources.Except(semiTransientResources, StringComparer.Ordinal).ToList(); + newlyAddedGamePaths = transientResources.Except(semiTransientResources, StringComparer.OrdinalIgnoreCase).ToList(); foreach (var gamePath in transientResources) { semiTransientResources.Add(gamePath); @@ -197,12 +207,13 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase public void RemoveTransientResource(ObjectKind objectKind, string path) { + var normalizedPath = NormalizeGamePath(path); if (SemiTransientResources.TryGetValue(objectKind, out var resources)) { - resources.RemoveWhere(f => string.Equals(path, f, StringComparison.Ordinal)); + resources.Remove(normalizedPath); if (objectKind == ObjectKind.Player) { - PlayerConfig.RemovePath(path, objectKind); + PlayerConfig.RemovePath(normalizedPath, objectKind); Logger.LogTrace("Saving transient.json from {method}", nameof(RemoveTransientResource)); _configurationService.Save(); } @@ -211,16 +222,17 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase internal bool AddTransientResource(ObjectKind objectKind, string item) { - if (SemiTransientResources.TryGetValue(objectKind, out var semiTransient) && semiTransient != null && semiTransient.Contains(item)) + var normalizedItem = NormalizeGamePath(item); + if (SemiTransientResources.TryGetValue(objectKind, out var semiTransient) && semiTransient != null && semiTransient.Contains(normalizedItem)) return false; if (!TransientResources.TryGetValue(objectKind, out HashSet? transientResource)) { - transientResource = new HashSet(StringComparer.Ordinal); + transientResource = new HashSet(StringComparer.OrdinalIgnoreCase); TransientResources[objectKind] = transientResource; } - return transientResource.Add(item.ToLowerInvariant()); + return transientResource.Add(normalizedItem); } internal void ClearTransientPaths(ObjectKind objectKind, List list) @@ -285,33 +297,13 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase private void DalamudUtil_FrameworkUpdate() { + RefreshPlayerRelatedAddressMap(); + lock (_cacheAdditionLock) { _cachedHandledPaths.Clear(); } - var activeDescriptors = new Dictionary(); - foreach (var descriptor in _actorObjectService.ObjectDescriptors) - { - if (TryResolveObjectKind(descriptor, out var resolvedKind)) - { - activeDescriptors[descriptor.Address] = resolvedKind; - } - } - - foreach (var address in _cachedFrameAddresses.Keys.ToList()) - { - if (!activeDescriptors.ContainsKey(address)) - { - _cachedFrameAddresses.TryRemove(address, out _); - } - } - - foreach (var descriptor in activeDescriptors) - { - _cachedFrameAddresses[descriptor.Key] = descriptor.Value; - } - if (_lastClassJobId != _dalamudUtil.ClassJobId) { _lastClassJobId = _dalamudUtil.ClassJobId; @@ -323,7 +315,9 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData); SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase); PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData); - SemiTransientResources[ObjectKind.Pet] = [.. petSpecificData ?? []]; + SemiTransientResources[ObjectKind.Pet] = new HashSet( + petSpecificData ?? [], + StringComparer.OrdinalIgnoreCase); } foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast()) @@ -340,9 +334,12 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase _ = Task.Run(() => { Logger.LogDebug("Penumbra Mod Settings changed, verifying SemiTransientResources"); - foreach (var item in _playerRelatedPointers) + lock (_playerRelatedLock) { - Mediator.Publish(new TransientResourceChangedMessage(item.Address)); + foreach (var item in _playerRelatedPointers) + { + Mediator.Publish(new TransientResourceChangedMessage(item.Address)); + } } }); } @@ -352,22 +349,24 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase _semiTransientResources = null; } - private static bool TryResolveObjectKind(ActorObjectService.ActorDescriptor descriptor, out ObjectKind resolvedKind) + private void RefreshPlayerRelatedAddressMap() { - if (descriptor.OwnedKind is ObjectKind ownedKind) + _playerRelatedByAddress.Clear(); + var updatedFrameAddresses = new ConcurrentDictionary(); + lock (_playerRelatedLock) { - resolvedKind = ownedKind; - return true; + foreach (var handler in _playerRelatedPointers) + { + var address = (nint)handler.Address; + if (address != nint.Zero) + { + _playerRelatedByAddress[address] = handler; + updatedFrameAddresses[address] = handler.ObjectKind; + } + } } - if (descriptor.ObjectKind == DalamudObjectKind.Player) - { - resolvedKind = ObjectKind.Player; - return true; - } - - resolvedKind = default; - return false; + _cachedFrameAddresses = updatedFrameAddresses; } private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor) @@ -375,18 +374,15 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase if (descriptor.IsInGpose) return; - if (!TryResolveObjectKind(descriptor, out var resolvedKind)) + if (descriptor.OwnedKind is not ObjectKind ownedKind) return; if (Logger.IsEnabled(LogLevel.Debug)) { - Logger.LogDebug("ActorObject tracked: {kind} addr={address:X} name={name}", resolvedKind, descriptor.Address, descriptor.Name); + Logger.LogDebug("ActorObject tracked: {kind} addr={address:X} name={name}", ownedKind, descriptor.Address, descriptor.Name); } - _cachedFrameAddresses[descriptor.Address] = resolvedKind; - - if (descriptor.OwnedKind is not ObjectKind ownedKind) - return; + _cachedFrameAddresses[descriptor.Address] = ownedKind; lock (_ownedHandlerLock) { @@ -465,53 +461,84 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase } } + private static string NormalizeGamePath(string path) + { + if (string.IsNullOrEmpty(path)) + return string.Empty; + + return path.Replace("\\", "/", StringComparison.Ordinal).ToLowerInvariant(); + } + + private static string NormalizeFilePath(string path) + { + if (string.IsNullOrEmpty(path)) + return string.Empty; + + if (path.StartsWith("|", StringComparison.Ordinal)) + { + var lastPipe = path.LastIndexOf('|'); + if (lastPipe >= 0 && lastPipe + 1 < path.Length) + { + path = path[(lastPipe + 1)..]; + } + } + + return NormalizeGamePath(path); + } + + private static bool HasHandledFileType(string gamePath, string[] handledTypes) + { + for (var i = 0; i < handledTypes.Length; i++) + { + if (gamePath.EndsWith(handledTypes[i], StringComparison.Ordinal)) + return true; + } + + return false; + } + private void Manager_PenumbraResourceLoadEvent(PenumbraResourceLoadMessage msg) { - var gamePath = msg.GamePath.ToLowerInvariant(); var gameObjectAddress = msg.GameObject; - var filePath = msg.FilePath; - - // ignore files already processed this frame - if (_cachedHandledPaths.Contains(gamePath)) return; - - lock (_cacheAdditionLock) + if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind)) { - _cachedHandledPaths.Add(gamePath); + if (_actorObjectService.TryGetOwnedKind(gameObjectAddress, out var ownedKind)) + { + objectKind = ownedKind; + } + else + { + return; + } } - // replace individual mtrl stuff - if (filePath.StartsWith("|", StringComparison.OrdinalIgnoreCase)) - { - filePath = filePath.Split("|")[2]; - } - // replace filepath - filePath = filePath.ToLowerInvariant().Replace("\\", "/", StringComparison.OrdinalIgnoreCase); - - // ignore files that are the same - var replacedGamePath = gamePath.ToLowerInvariant().Replace("\\", "/", StringComparison.OrdinalIgnoreCase); - if (string.Equals(filePath, replacedGamePath, StringComparison.OrdinalIgnoreCase)) + var gamePath = NormalizeGamePath(msg.GamePath); + if (string.IsNullOrEmpty(gamePath)) { return; } + // ignore files already processed this frame + lock (_cacheAdditionLock) + { + if (!_cachedHandledPaths.Add(gamePath)) + { + return; + } + } + // ignore files to not handle - var handledTypes = IsTransientRecording ? _handledRecordingFileTypes.Concat(_handledFileTypes) : _handledFileTypes; - if (!handledTypes.Any(type => gamePath.EndsWith(type, StringComparison.OrdinalIgnoreCase))) + var handledTypes = IsTransientRecording ? _handledFileTypesWithRecording : _handledFileTypes; + if (!HasHandledFileType(gamePath, handledTypes)) { - lock (_cacheAdditionLock) - { - _cachedHandledPaths.Add(gamePath); - } return; } - // ignore files not belonging to anything player related - if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind)) + var filePath = NormalizeFilePath(msg.FilePath); + + // ignore files that are the same + if (string.Equals(filePath, gamePath, StringComparison.Ordinal)) { - lock (_cacheAdditionLock) - { - _cachedHandledPaths.Add(gamePath); - } return; } @@ -523,15 +550,15 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase TransientResources[objectKind] = transientResources; } - var owner = _playerRelatedPointers.FirstOrDefault(f => f.Address == gameObjectAddress); + _playerRelatedByAddress.TryGetValue(gameObjectAddress, out var owner); bool alreadyTransient = false; - bool transientContains = transientResources.Contains(replacedGamePath); - bool semiTransientContains = SemiTransientResources.SelectMany(k => k.Value).Any(f => string.Equals(f, gamePath, StringComparison.OrdinalIgnoreCase)); + bool transientContains = transientResources.Contains(gamePath); + bool semiTransientContains = SemiTransientResources.Values.Any(value => value.Contains(gamePath)); if (transientContains || semiTransientContains) { if (!IsTransientRecording) - Logger.LogTrace("Not adding {replacedPath} => {filePath}, Reason: Transient: {contains}, SemiTransient: {contains2}", replacedGamePath, filePath, + Logger.LogTrace("Not adding {replacedPath} => {filePath}, Reason: Transient: {contains}, SemiTransient: {contains2}", gamePath, filePath, transientContains, semiTransientContains); alreadyTransient = true; } @@ -539,10 +566,10 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase { if (!IsTransientRecording) { - bool isAdded = transientResources.Add(replacedGamePath); + bool isAdded = transientResources.Add(gamePath); if (isAdded) { - Logger.LogDebug("Adding {replacedGamePath} for {gameObject} ({filePath})", replacedGamePath, owner?.ToString() ?? gameObjectAddress.ToString("X"), filePath); + Logger.LogDebug("Adding {replacedGamePath} for {gameObject} ({filePath})", gamePath, owner?.ToString() ?? gameObjectAddress.ToString("X"), filePath); SendTransients(gameObjectAddress, objectKind); } } @@ -550,7 +577,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase if (owner != null && IsTransientRecording) { - _recordedTransients.Add(new TransientRecord(owner, replacedGamePath, filePath, alreadyTransient) { AddTransient = !alreadyTransient }); + _recordedTransients.Add(new TransientRecord(owner, gamePath, filePath, alreadyTransient) { AddTransient = !alreadyTransient }); } } @@ -622,7 +649,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase if (!item.AddTransient || item.AlreadyTransient) continue; if (!TransientResources.TryGetValue(item.Owner.ObjectKind, out var transient)) { - TransientResources[item.Owner.ObjectKind] = transient = []; + TransientResources[item.Owner.ObjectKind] = transient = new HashSet(StringComparer.OrdinalIgnoreCase); } Logger.LogTrace("Adding recorded: {gamePath} => {filePath}", item.GamePath, item.FilePath); diff --git a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs index c167654..e077eab 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs @@ -95,6 +95,12 @@ public sealed class IpcCallerPenumbra : IpcServiceBase public Task<(string[] forward, string[][] reverse)> ResolvePathsAsync(string[] forward, string[] reverse) => _resources.ResolvePathsAsync(forward, reverse); + public string ResolveGameObjectPath(string gamePath, int objectIndex) + => _resources.ResolveGameObjectPath(gamePath, objectIndex); + + public string[] ReverseResolveGameObjectPath(string moddedPath, int objectIndex) + => _resources.ReverseResolveGameObjectPath(moddedPath, objectIndex); + public Task RedrawAsync(ILogger logger, GameObjectHandler handler, Guid applicationId, CancellationToken token) => _redraw.RedrawAsync(logger, handler, applicationId, token); @@ -171,11 +177,6 @@ public sealed class IpcCallerPenumbra : IpcServiceBase }); Mediator.Subscribe(this, _ => _shownPenumbraUnavailable = false); - - Mediator.Subscribe(this, msg => _resources.TrackActor(msg.Descriptor.Address)); - Mediator.Subscribe(this, msg => _resources.UntrackActor(msg.Descriptor.Address)); - Mediator.Subscribe(this, msg => _resources.TrackActor(msg.GameObjectHandler.Address)); - Mediator.Subscribe(this, msg => _resources.UntrackActor(msg.GameObjectHandler.Address)); } private void HandlePenumbraInitialized() diff --git a/LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs b/LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs index e5c28e2..c095471 100644 --- a/LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs +++ b/LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs @@ -92,7 +92,7 @@ public sealed class PenumbraCollections : PenumbraBase _activeTemporaryCollections.TryRemove(collectionId, out _); } - public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, IReadOnlyDictionary modPaths) + public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary modPaths) { if (!IsAvailable || collectionId == Guid.Empty) { @@ -109,7 +109,7 @@ public sealed class PenumbraCollections : PenumbraBase var removeResult = _removeTemporaryMod.Invoke("LightlessChara_Files", collectionId, 0); logger.LogTrace("[{ApplicationId}] Removing temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, removeResult); - var addResult = _addTemporaryMod.Invoke("LightlessChara_Files", collectionId, new Dictionary(modPaths), string.Empty, 0); + var addResult = _addTemporaryMod.Invoke("LightlessChara_Files", collectionId, modPaths, string.Empty, 0); logger.LogTrace("[{ApplicationId}] Setting temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, addResult); }).ConfigureAwait(false); } diff --git a/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs b/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs index 75d1d86..73da7cc 100644 --- a/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs +++ b/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using Dalamud.Plugin; using LightlessSync.Interop.Ipc.Framework; using LightlessSync.PlayerData.Handlers; @@ -15,10 +14,11 @@ public sealed class PenumbraResource : PenumbraBase { private readonly ActorObjectService _actorObjectService; private readonly GetGameObjectResourcePaths _gameObjectResourcePaths; + private readonly ResolveGameObjectPath _resolveGameObjectPath; + private readonly ReverseResolveGameObjectPath _reverseResolveGameObjectPath; private readonly ResolvePlayerPathsAsync _resolvePlayerPaths; private readonly GetPlayerMetaManipulations _getPlayerMetaManipulations; private readonly EventSubscriber _gameObjectResourcePathResolved; - private readonly ConcurrentDictionary _trackedActors = new(); public PenumbraResource( ILogger logger, @@ -29,14 +29,11 @@ public sealed class PenumbraResource : PenumbraBase { _actorObjectService = actorObjectService; _gameObjectResourcePaths = new GetGameObjectResourcePaths(pluginInterface); + _resolveGameObjectPath = new ResolveGameObjectPath(pluginInterface); + _reverseResolveGameObjectPath = new ReverseResolveGameObjectPath(pluginInterface); _resolvePlayerPaths = new ResolvePlayerPathsAsync(pluginInterface); _getPlayerMetaManipulations = new GetPlayerMetaManipulations(pluginInterface); _gameObjectResourcePathResolved = GameObjectResourcePathResolved.Subscriber(pluginInterface, HandleResourceLoaded); - - foreach (var descriptor in _actorObjectService.PlayerDescriptors) - { - TrackActor(descriptor.Address); - } } public override string Name => "Penumbra.Resources"; @@ -74,63 +71,34 @@ public sealed class PenumbraResource : PenumbraBase return await _resolvePlayerPaths.Invoke(forwardPaths, reversePaths).ConfigureAwait(false); } - public void TrackActor(nint address) - { - if (address != nint.Zero) - { - _trackedActors[(IntPtr)address] = 0; - } - } + public string ResolveGameObjectPath(string gamePath, int gameObjectIndex) + => IsAvailable ? _resolveGameObjectPath.Invoke(gamePath, gameObjectIndex) : gamePath; - public void UntrackActor(nint address) - { - if (address != nint.Zero) - { - _trackedActors.TryRemove((IntPtr)address, out _); - } - } + public string[] ReverseResolveGameObjectPath(string moddedPath, int gameObjectIndex) + => IsAvailable ? _reverseResolveGameObjectPath.Invoke(moddedPath, gameObjectIndex) : Array.Empty(); - private void HandleResourceLoaded(nint ptr, string resolvedPath, string gamePath) + private void HandleResourceLoaded(nint ptr, string gamePath, string resolvedPath) { if (ptr == nint.Zero) { return; } - if (!_trackedActors.ContainsKey(ptr)) - { - var descriptor = _actorObjectService.PlayerDescriptors.FirstOrDefault(d => d.Address == ptr); - if (descriptor.Address != nint.Zero) - { - _trackedActors[ptr] = 0; - } - else - { - return; - } - } - - if (string.Compare(resolvedPath, gamePath, StringComparison.OrdinalIgnoreCase) == 0) + if (!_actorObjectService.TryGetOwnedKind(ptr, out _)) { return; } - Mediator.Publish(new PenumbraResourceLoadMessage(ptr, resolvedPath, gamePath)); + if (string.Compare(gamePath, resolvedPath, StringComparison.OrdinalIgnoreCase) == 0) + { + return; + } + + Mediator.Publish(new PenumbraResourceLoadMessage(ptr, gamePath, resolvedPath)); } protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current) { - if (current != IpcConnectionState.Available) - { - _trackedActors.Clear(); - } - else - { - foreach (var descriptor in _actorObjectService.PlayerDescriptors) - { - TrackActor(descriptor.Address); - } - } } public override void Dispose() diff --git a/LightlessSync/LightlessConfiguration/ConfigurationMigrator.cs b/LightlessSync/LightlessConfiguration/ConfigurationMigrator.cs index 4a8563f..ce2ac17 100644 --- a/LightlessSync/LightlessConfiguration/ConfigurationMigrator.cs +++ b/LightlessSync/LightlessConfiguration/ConfigurationMigrator.cs @@ -19,6 +19,27 @@ public class ConfigurationMigrator(ILogger logger, Transi transientConfigService.Save(); } + if (transientConfigService.Current.Version == 1) + { + _logger.LogInformation("Migrating Transient Config V1 => V2"); + var totalRemoved = 0; + var configCount = 0; + var changedCount = 0; + foreach (var config in transientConfigService.Current.TransientConfigs.Values) + { + if (config.NormalizePaths(out var removed)) + changedCount++; + + totalRemoved += removed; + + configCount++; + } + + _logger.LogInformation("Transient config normalization: processed {count} entries, updated {updated}, removed {removed} paths", configCount, changedCount, totalRemoved); + transientConfigService.Current.Version = 2; + transientConfigService.Save(); + } + if (serverConfigService.Current.Version == 1) { _logger.LogInformation("Migrating Server Config V1 => V2"); diff --git a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs index f438c45..43090a2 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs @@ -10,6 +10,7 @@ public sealed class ChatConfig : ILightlessConfiguration public bool AutoEnableChatOnLogin { get; set; } = false; public bool ShowRulesOverlayOnOpen { get; set; } = true; public bool ShowMessageTimestamps { get; set; } = true; + public bool ShowNotesInSyncshellChat { get; set; } = true; public float ChatWindowOpacity { get; set; } = .97f; public bool FadeWhenUnfocused { get; set; } = false; public float UnfocusedWindowOpacity { get; set; } = 0.6f; diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 829bca5..737f9ee 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -140,6 +140,7 @@ public class LightlessConfig : ILightlessConfiguration public bool useColoredUIDs { get; set; } = true; public bool BroadcastEnabled { get; set; } = false; public bool LightfinderAutoEnableOnConnect { get; set; } = false; + public LightfinderLabelRenderer LightfinderLabelRenderer { get; set; } = LightfinderLabelRenderer.Pictomancy; public short LightfinderLabelOffsetX { get; set; } = 0; public short LightfinderLabelOffsetY { get; set; } = 0; public bool LightfinderLabelUseIcon { get; set; } = false; diff --git a/LightlessSync/LightlessConfiguration/Configurations/TransientConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/TransientConfig.cs index 0bcb5ad..c1054cc 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/TransientConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/TransientConfig.cs @@ -5,7 +5,7 @@ namespace LightlessSync.LightlessConfiguration.Configurations; public class TransientConfig : ILightlessConfiguration { public Dictionary TransientConfigs { get; set; } = []; - public int Version { get; set; } = 1; + public int Version { get; set; } = 2; public class TransientPlayerConfig { @@ -88,5 +88,70 @@ public class TransientConfig : ILightlessConfiguration } } } + + public bool NormalizePaths(out int removedEntries) + { + bool changed = false; + removedEntries = 0; + + GlobalPersistentCache = NormalizeList(GlobalPersistentCache, ref changed, ref removedEntries); + + foreach (var jobId in JobSpecificCache.Keys.ToList()) + { + JobSpecificCache[jobId] = NormalizeList(JobSpecificCache[jobId], ref changed, ref removedEntries); + } + + foreach (var jobId in JobSpecificPetCache.Keys.ToList()) + { + JobSpecificPetCache[jobId] = NormalizeList(JobSpecificPetCache[jobId], ref changed, ref removedEntries); + } + + return changed; + } + + private static List NormalizeList(List entries, ref bool changed, ref int removedEntries) + { + if (entries.Count == 0) + return entries; + + var result = new List(entries.Count); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in entries) + { + var normalized = NormalizePath(entry); + if (string.IsNullOrEmpty(normalized)) + { + changed = true; + continue; + } + + if (!string.Equals(entry, normalized, StringComparison.Ordinal)) + { + changed = true; + } + + if (seen.Add(normalized)) + { + result.Add(normalized); + } + else + { + changed = true; + } + } + + removedEntries += entries.Count - result.Count; + + return result; + } + + private static string NormalizePath(string path) + { + if (string.IsNullOrEmpty(path)) + return string.Empty; + + return path.Replace("\\", "/", StringComparison.Ordinal).ToLowerInvariant(); + } } } diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 39aa6c8..9ecfcc3 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -194,7 +194,7 @@ public class PlayerDataFactory // get all remaining paths and resolve them var transientPaths = ManageSemiTransientData(objectKind); - var resolvedTransientPaths = await GetFileReplacementsFromPaths(transientPaths, new HashSet(StringComparer.Ordinal)).ConfigureAwait(false); + var resolvedTransientPaths = await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet(StringComparer.Ordinal)).ConfigureAwait(false); if (logDebug) { @@ -373,11 +373,73 @@ public class PlayerDataFactory } } - private async Task> GetFileReplacementsFromPaths(HashSet forwardResolve, HashSet reverseResolve) + private async Task> GetFileReplacementsFromPaths(GameObjectHandler handler, HashSet forwardResolve, HashSet reverseResolve) { var forwardPaths = forwardResolve.ToArray(); var reversePaths = reverseResolve.ToArray(); Dictionary> resolvedPaths = new(StringComparer.Ordinal); + if (handler.ObjectKind != ObjectKind.Player) + { + var (objectIndex, forwardResolved, reverseResolved) = await _dalamudUtil.RunOnFrameworkThread(() => + { + var idx = handler.GetGameObject()?.ObjectIndex; + if (!idx.HasValue) + { + return ((int?)null, Array.Empty(), Array.Empty()); + } + + var resolvedForward = new string[forwardPaths.Length]; + for (int i = 0; i < forwardPaths.Length; i++) + { + resolvedForward[i] = _ipcManager.Penumbra.ResolveGameObjectPath(forwardPaths[i], idx.Value); + } + + var resolvedReverse = new string[reversePaths.Length][]; + for (int i = 0; i < reversePaths.Length; i++) + { + resolvedReverse[i] = _ipcManager.Penumbra.ReverseResolveGameObjectPath(reversePaths[i], idx.Value); + } + + return (idx, resolvedForward, resolvedReverse); + }).ConfigureAwait(false); + + if (objectIndex.HasValue) + { + for (int i = 0; i < forwardPaths.Length; i++) + { + var filePath = forwardResolved[i]?.ToLowerInvariant(); + if (string.IsNullOrEmpty(filePath)) + { + continue; + } + + if (resolvedPaths.TryGetValue(filePath, out var list)) + { + list.Add(forwardPaths[i].ToLowerInvariant()); + } + else + { + resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()]; + } + } + + for (int i = 0; i < reversePaths.Length; i++) + { + var filePath = reversePaths[i].ToLowerInvariant(); + if (resolvedPaths.TryGetValue(filePath, out var list)) + { + list.AddRange(reverseResolved[i].Select(c => c.ToLowerInvariant())); + } + else + { + resolvedPaths[filePath] = new List(reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList()); + } + } + + return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly(); + } + } + var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false); for (int i = 0; i < forwardPaths.Length; i++) { diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index 178daa8..82c4a94 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -65,6 +65,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private int _lastMissingCriticalMods; private int _lastMissingNonCriticalMods; private int _lastMissingForbiddenMods; + private bool _lastMissingCachedFiles; private bool _isVisible; private Guid _penumbraCollection; private readonly object _collectionGate = new(); @@ -557,7 +558,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return; } - var shouldForce = forced || HasMissingCachedFiles(LastReceivedCharacterData); + var hasMissingCachedFiles = HasMissingCachedFiles(LastReceivedCharacterData); + var missingResolved = _lastMissingCachedFiles && !hasMissingCachedFiles; + _lastMissingCachedFiles = hasMissingCachedFiles; + var shouldForce = forced || missingResolved; if (IsPaused()) { @@ -700,7 +704,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { if (!string.IsNullOrEmpty(replacement.FileSwapPath)) { - if (!File.Exists(replacement.FileSwapPath)) + if (Path.IsPathRooted(replacement.FileSwapPath) && !File.Exists(replacement.FileSwapPath)) { Logger.LogTrace("Missing file swap path {Path} detected for {Handler}", replacement.FileSwapPath, GetLogIdentifier()); return true; diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 4131e40..d070831 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -106,6 +106,7 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton(); services.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", string.Empty, useEmbedded: true)); services.AddSingleton(gameGui); + services.AddSingleton(gameInteropProvider); services.AddSingleton(addonLifecycle); services.AddSingleton(pluginInterface.UiBuilder); @@ -116,6 +117,7 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -134,6 +136,7 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -280,12 +283,22 @@ public sealed class Plugin : IDalamudPlugin pluginInterface, sp.GetRequiredService())); + services.AddSingleton(sp => new LightFinderNativePlateHandler( + sp.GetRequiredService>(), + clientState, + sp.GetRequiredService(), + sp.GetRequiredService(), + objectTable, + sp.GetRequiredService(), + sp.GetRequiredService())); + services.AddSingleton(sp => new LightFinderScannerService( sp.GetRequiredService>(), framework, sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService())); services.AddSingleton(sp => new ContextMenuService( @@ -467,7 +480,8 @@ public sealed class Plugin : IDalamudPlugin sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); services.AddScoped(sp => new SyncshellFinderUI( sp.GetRequiredService>(), @@ -536,9 +550,9 @@ public sealed class Plugin : IDalamudPlugin clientState, gameGui, objectTable, - gameInteropProvider, sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); // Hosted services services.AddHostedService(sp => sp.GetRequiredService()); @@ -557,6 +571,7 @@ public sealed class Plugin : IDalamudPlugin services.AddHostedService(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); }).Build(); _ = _host.StartAsync(); diff --git a/LightlessSync/Services/ActorTracking/ActorObjectService.cs b/LightlessSync/Services/ActorTracking/ActorObjectService.cs index 759417f..28c5533 100644 --- a/LightlessSync/Services/ActorTracking/ActorObjectService.cs +++ b/LightlessSync/Services/ActorTracking/ActorObjectService.cs @@ -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 _actorsByHash = new(StringComparer.Ordinal); private readonly ConcurrentDictionary> _actorsByName = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _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(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 _renderedPlayers = new(); - private readonly HashSet _renderedCompanions = new(); - private readonly Dictionary _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(_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 RenderedPlayers, IReadOnlyList RenderedCompanions, diff --git a/LightlessSync/Services/CharacterAnalyzer.cs b/LightlessSync/Services/CharacterAnalyzer.cs index 3eebced..959ece3 100644 --- a/LightlessSync/Services/CharacterAnalyzer.cs +++ b/LightlessSync/Services/CharacterAnalyzer.cs @@ -1,3 +1,4 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Render; using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.FileCache; @@ -98,11 +99,13 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable _analysisCts = null; if (print) PrintAnalysis(); } + public void Dispose() { _analysisCts.CancelDispose(); _baseAnalysisCts.Dispose(); } + public async Task UpdateFileEntriesAsync(IEnumerable filePaths, CancellationToken token) { var normalized = new HashSet( @@ -125,6 +128,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable } } } + private async Task BaseAnalysis(CharacterData charaData, CancellationToken token) { if (string.Equals(charaData.DataHash.Value, _lastDataHash, StringComparison.Ordinal)) return; @@ -136,29 +140,47 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable { token.ThrowIfCancellationRequested(); - var fileCacheEntries = (await _fileCacheManager.GetAllFileCachesByHashAsync(fileEntry.Hash, ignoreCacheEntries: true, validate: false, token).ConfigureAwait(false)).ToList(); - if (fileCacheEntries.Count == 0) continue; - var filePath = fileCacheEntries[0].ResolvedFilepath; - FileInfo fi = new(filePath); - string ext = "unk?"; - try - { - ext = fi.Extension[1..]; - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Could not identify extension for {path}", filePath); - } + var fileCacheEntries = (await _fileCacheManager + .GetAllFileCachesByHashAsync(fileEntry.Hash, ignoreCacheEntries: true, validate: false, token) + .ConfigureAwait(false)) + .ToList(); + + if (fileCacheEntries.Count == 0) + continue; + + var resolved = fileCacheEntries[0].ResolvedFilepath; + + var extWithDot = Path.GetExtension(resolved); + var ext = string.IsNullOrEmpty(extWithDot) ? "unk?" : extWithDot.TrimStart('.'); + var tris = await _xivDataAnalyzer.GetTrianglesByHash(fileEntry.Hash).ConfigureAwait(false); - foreach (var entry in fileCacheEntries) + + var distinctFilePaths = fileCacheEntries + .Select(c => c.ResolvedFilepath) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + long orig = 0, comp = 0; + var first = fileCacheEntries[0]; + if (first.Size > 0) orig = first.Size.Value; + if (first.CompressedSize > 0) comp = first.CompressedSize.Value; + + if (_fileCacheManager.TryGetSizeInfo(fileEntry.Hash, out var cached)) { - data[fileEntry.Hash] = new FileDataEntry(fileEntry.Hash, ext, - [.. fileEntry.GamePaths], - [.. fileCacheEntries.Select(c => c.ResolvedFilepath).Distinct(StringComparer.Ordinal)], - entry.Size > 0 ? entry.Size.Value : 0, - entry.CompressedSize > 0 ? entry.CompressedSize.Value : 0, - tris); + if (orig <= 0 && cached.Original > 0) orig = cached.Original; + if (comp <= 0 && cached.Compressed > 0) comp = cached.Compressed; } + + data[fileEntry.Hash] = new FileDataEntry( + fileEntry.Hash, + ext, + [.. fileEntry.GamePaths], + distinctFilePaths, + orig, + comp, + tris, + fileCacheEntries); } LastAnalysis[obj.Key] = data; } @@ -167,6 +189,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable Mediator.Publish(new CharacterDataAnalyzedMessage()); _lastDataHash = charaData.DataHash.Value; } + private void RecalculateSummary() { var builder = ImmutableDictionary.CreateBuilder(); @@ -192,6 +215,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable _latestSummary = new CharacterAnalysisSummary(builder.ToImmutable()); } + private void PrintAnalysis() { if (LastAnalysis.Count == 0) return; @@ -235,42 +259,79 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable UiSharedService.ByteToString(LastAnalysis.Values.Sum(c => c.Values.Sum(v => v.CompressedSize)))); Logger.LogInformation("IMPORTANT NOTES:\n\r- For Lightless up- and downloads only the compressed size is relevant.\n\r- An unusually high total files count beyond 200 and up will also increase your download time to others significantly."); } - internal sealed record FileDataEntry(string Hash, string FileType, List GamePaths, List FilePaths, long OriginalSize, long CompressedSize, long Triangles) - { - public bool IsComputed => OriginalSize > 0 && CompressedSize > 0; - public async Task ComputeSizes(FileCacheManager fileCacheManager, CancellationToken token) - { - var compressedsize = await fileCacheManager.GetCompressedFileData(Hash, token).ConfigureAwait(false); - var normalSize = new FileInfo(FilePaths[0]).Length; - var entries = await fileCacheManager.GetAllFileCachesByHashAsync(Hash, ignoreCacheEntries: true, validate: false, token).ConfigureAwait(false); - foreach (var entry in entries) - { - entry.Size = normalSize; - entry.CompressedSize = compressedsize.Item2.LongLength; - } - OriginalSize = normalSize; - CompressedSize = compressedsize.Item2.LongLength; - RefreshFormat(); - } - public long OriginalSize { get; private set; } = OriginalSize; - public long CompressedSize { get; private set; } = CompressedSize; - public long Triangles { get; private set; } = Triangles; - public Lazy Format => _format ??= CreateFormatValue(); + internal sealed class FileDataEntry + { + public string Hash { get; } + public string FileType { get; } + public List GamePaths { get; } + public List FilePaths { get; } + + public long OriginalSize { get; private set; } + public long CompressedSize { get; private set; } + public long Triangles { get; private set; } + + public IReadOnlyList CacheEntries { get; } + + public bool IsComputed => OriginalSize > 0 && CompressedSize > 0; + + public FileDataEntry( + string hash, + string fileType, + List gamePaths, + List filePaths, + long originalSize, + long compressedSize, + long triangles, + IReadOnlyList cacheEntries) + { + Hash = hash; + FileType = fileType; + GamePaths = gamePaths; + FilePaths = filePaths; + OriginalSize = originalSize; + CompressedSize = compressedSize; + Triangles = triangles; + CacheEntries = cacheEntries; + } + + public async Task ComputeSizes(FileCacheManager fileCacheManager, CancellationToken token, bool force = false) + { + if (!force && IsComputed) + return; + + if (FilePaths.Count == 0 || string.IsNullOrWhiteSpace(FilePaths[0])) + return; + + var path = FilePaths[0]; + + if (!File.Exists(path)) + return; + + var original = new FileInfo(path).Length; + + var compressedLen = await fileCacheManager.GetCompressedSizeAsync(Hash, token).ConfigureAwait(false); + + fileCacheManager.SetSizeInfo(Hash, original, compressedLen); + FileCacheManager.ApplySizesToEntries(CacheEntries, original, compressedLen); + + OriginalSize = original; + CompressedSize = compressedLen; + + if (string.Equals(FileType, "tex", StringComparison.OrdinalIgnoreCase)) + RefreshFormat(); + } + + public Lazy Format => _format ??= CreateFormatValue(); private Lazy? _format; - public void RefreshFormat() - { - _format = CreateFormatValue(); - } + public void RefreshFormat() => _format = CreateFormatValue(); private Lazy CreateFormatValue() => new(() => { - if (!string.Equals(FileType, "tex", StringComparison.Ordinal)) - { + if (!string.Equals(FileType, "tex", StringComparison.OrdinalIgnoreCase)) return string.Empty; - } try { diff --git a/LightlessSync/Services/Chat/ChatEmoteService.cs b/LightlessSync/Services/Chat/ChatEmoteService.cs new file mode 100644 index 0000000..b733f2e --- /dev/null +++ b/LightlessSync/Services/Chat/ChatEmoteService.cs @@ -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 _logger; + private readonly HttpClient _httpClient; + private readonly UiSharedService _uiSharedService; + private readonly ConcurrentDictionary _emotes = new(StringComparer.Ordinal); + private readonly SemaphoreSlim _downloadGate = new(3, 3); + + private readonly object _loadLock = new(); + private Task? _loadTask; + + public ChatEmoteService(ILogger 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 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 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); + } + } +} diff --git a/LightlessSync/Services/Chat/ZoneChatService.cs b/LightlessSync/Services/Chat/ZoneChatService.cs index 5d37f2b..54dd2d9 100644 --- a/LightlessSync/Services/Chat/ZoneChatService.cs +++ b/LightlessSync/Services/Chat/ZoneChatService.cs @@ -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 _lastPresenceStates = new(StringComparer.Ordinal); private readonly Dictionary _selfTokens = new(StringComparer.Ordinal); private readonly List _pendingSelfMessages = new(); + private readonly Dictionary> _messageHistoryCache = new(StringComparer.Ordinal); private List? _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(_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(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) diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 36ce98b..6c78731 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -25,6 +25,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 Map = Lumina.Excel.Sheets.Map; @@ -84,18 +85,18 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber _configService = configService; _playerPerformanceConfigService = playerPerformanceConfigService; _pairFactory = pairFactory; + var clientLanguage = _clientState.ClientLanguage; WorldData = new(() => { - return gameData.GetExcelSheet(Dalamud.Game.ClientLanguage.English)! + return gameData.GetExcelSheet(clientLanguage)! .Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0]))) .ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString()); }); JobData = new(() => { - return gameData.GetExcelSheet(Dalamud.Game.ClientLanguage.English)! - .ToDictionary(k => k.RowId, k => k.NameEnglish.ToString()); + return gameData.GetExcelSheet(clientLanguage)! + .ToDictionary(k => k.RowId, k => k.Name.ToString()); }); - var clientLanguage = _clientState.ClientLanguage; TerritoryData = new(() => BuildTerritoryData(clientLanguage)); TerritoryDataEnglish = new(() => BuildTerritoryData(Dalamud.Game.ClientLanguage.English)); MapData = new(() => BuildMapData(clientLanguage)); @@ -275,6 +276,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]; @@ -444,7 +446,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 GetPetAsync(IntPtr? playerPointer = null) @@ -481,6 +498,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) diff --git a/LightlessSync/Services/LightFinder/LightFinderNativePlateHandler.cs b/LightlessSync/Services/LightFinder/LightFinderNativePlateHandler.cs new file mode 100644 index 0000000..6598057 --- /dev/null +++ b/LightlessSync/Services/LightFinder/LightFinderNativePlateHandler.cs @@ -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; + +/// +/// Native nameplate handler that injects LightFinder labels via the signature hook path. +/// +public unsafe class LightFinderNativePlateHandler : DisposableMediatorSubscriberBase, IHostedService +{ + private const uint NameplateNodeIdBase = 0x7D99D500; + private const string DefaultLabelText = "LightFinder"; + + private readonly ILogger _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 _activeBroadcastingCids = []; + private LightfinderLabelRenderer _lastRenderer; + private uint _lastSignatureUpdateFrame; + private bool _isUpdating; + private string _lastLabelContent = DefaultLabelText; + + public LightFinderNativePlateHandler( + ILogger 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; + + /// + /// Starts listening for nameplate updates from the hook service. + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _nameplateUpdateHookService.NameplateUpdated += OnNameplateUpdated; + return Task.CompletedTask; + } + + /// + /// Stops listening for nameplate updates and tears down any constructed nodes. + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _nameplateUpdateHookService.NameplateUpdated -= OnNameplateUpdated; + UnsubscribeAll(); + TryDestroyNameplateNodes(); + return Task.CompletedTask; + } + + /// + /// Triggered by the sig hook to refresh native nameplate labels. + /// + 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; + } + } + + /// + /// Hook callback from the nameplate update signature. + /// + private void OnNameplateUpdated(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, + NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex) + { + HandleNameplateUpdate(raptureAtkModule); + } + + /// + /// Updates the active broadcasting CID set and requests a nameplate redraw. + /// + public void UpdateBroadcastingCids(IEnumerable 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(); + } + + /// + /// Sync renderer state with config and clear/remove native nodes if needed. + /// + private void RefreshRendererState() + { + var renderer = _configService.Current.LightfinderLabelRenderer; + if (renderer == _lastRenderer) + return; + + _lastRenderer = renderer; + + if (renderer == LightfinderLabelRenderer.SignatureHook) + { + ClearNameplateCaches(); + RequestNameplateRedraw(); + } + else + { + TryDestroyNameplateNodes(); + ClearNameplateCaches(); + } + } + + /// + /// Requests a full nameplate update through the native addon. + /// + 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 VisibleUserIds + => [.. _pairUiService.GetSnapshot().PairsByUid.Values + .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) + .Select(u => (ulong)u.PlayerCharacterId)]; + + /// + /// Creates/updates LightFinder label nodes for active broadcasts. + /// + 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); + } + + /// + /// Resolve the current RaptureAtkModule for native UI access. + /// + 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(); + } + + /// + /// Resolve the NamePlate addon from the given RaptureAtkModule. + /// + 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; + + /// + /// Checks if the NamePlate addon is visible and safe to touch. + /// + private static bool IsNameplateAddonVisible(AddonNamePlate* namePlateAddon) + { + if (namePlateAddon == null) + return false; + + var root = namePlateAddon->AtkUnitBase.RootNode; + return root != null && root->IsVisible(); + } + + /// + /// Finds a LightFinder text node by ID in the name container. + /// + 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; + } + + /// + /// Ensures a LightFinder text node exists for the given nameplate index. + /// + 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; + } + + /// + /// Hides all native LightFinder nodes on the nameplate addon. + /// + 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)); + } + } + + /// + /// Hides all LightFinder nodes not marked as visible this frame. + /// + 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)); + } + } + + /// + /// Hides the LightFinder text node for a single nameplate object. + /// + 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); + } + + /// + /// Attempts to destroy all constructed LightFinder nodes safely. + /// + 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); + } + + /// + /// Removes all constructed LightFinder nodes from the given nameplate addon. + /// + 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(); + } + + /// + /// Validates that a node is a LightFinder text node owned by the container. + /// + 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; + } + + /// + /// Float comparison helper for UI values. + /// + 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); + } + + /// + /// Clears cached text sizing and label state for nameplates. + /// + 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); + } + +} diff --git a/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs b/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs index 5048ab7..97217c8 100644 --- a/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs +++ b/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs @@ -23,6 +23,7 @@ using Pictomancy; using System.Collections.Immutable; using System.Globalization; using System.Numerics; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Task = System.Threading.Tasks.Task; @@ -41,6 +42,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe private readonly LightlessConfigService _configService; private readonly PairUiService _pairUiService; private readonly LightlessMediator _mediator; + public LightlessMediator Mediator => _mediator; private readonly IUiBuilder _uiBuilder; @@ -51,6 +53,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; @@ -60,16 +63,24 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe // / Overlay window flags private const ImGuiWindowFlags _overlayFlags = - ImGuiWindowFlags.NoDecoration | - ImGuiWindowFlags.NoBackground | - ImGuiWindowFlags.NoMove | - ImGuiWindowFlags.NoSavedSettings | - ImGuiWindowFlags.NoNav | - ImGuiWindowFlags.NoInputs; + ImGuiWindowFlags.NoDecoration | + ImGuiWindowFlags.NoBackground | + ImGuiWindowFlags.NoMove | + ImGuiWindowFlags.NoSavedSettings | + ImGuiWindowFlags.NoNav | + ImGuiWindowFlags.NoInputs; private readonly List _uiRects = new(128); private ImmutableHashSet _activeBroadcastingCids = []; +#if DEBUG + // Debug controls + + // Debug counters (read-only from UI) +#endif + + private bool IsPictomancyRenderer => _configService.Current.LightfinderLabelRenderer == LightfinderLabelRenderer.Pictomancy; + public LightFinderPlateHandler( ILogger logger, IAddonLifecycle addonLifecycle, @@ -92,7 +103,26 @@ 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() @@ -164,10 +194,26 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe /// /// Draw detour for nameplate addon. /// - /// - /// private void NameplateDrawDetour(AddonEvent type, AddonArgs args) { + RefreshRendererState(); + if (!IsPictomancyRenderer) + { + ClearLabelBuffer(); + _lastNamePlateDrawFrame = 0; + return; + } + + // Hide our overlay when the user hides the entire game UI (ScrollLock). + if (_gameGui.GameUiHidden) + { + ClearLabelBuffer(); + Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length); + _lastNamePlateDrawFrame = 0; + return; + } + + // gpose: do not draw. if (_clientState.IsGPosing) { ClearLabelBuffer(); @@ -187,6 +233,10 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe if (fw != null) _lastNamePlateDrawFrame = fw->FrameCounter; +#if DEBUG + DebugLastNameplateFrame = _lastNamePlateDrawFrame; +#endif + var pNameplateAddon = (AddonNamePlate*)args.Addon.Address; if (_mpNameplateAddon != pNameplateAddon) @@ -203,6 +253,13 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe /// private void UpdateNameplateNodes() { + // If the user has hidden the UI, don't compute any labels. + if (_gameGui.GameUiHidden) + { + ClearLabelBuffer(); + return; + } + var currentHandle = _gameGui.GetAddonByName("NamePlate"); if (currentHandle.Address == nint.Zero) { @@ -266,7 +323,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe for (int i = 0; i < safeCount; ++i) { - var objectInfoPtr = vec[i]; if (objectInfoPtr == null) continue; @@ -283,7 +339,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player) continue; - // CID gating + // CID gating - only show for active broadcasters var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject); if (cid == null || !_activeBroadcastingCids.Contains(cid)) continue; @@ -319,12 +375,12 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe if (!currentConfig.LightfinderLabelShowHidden && !isNameplateVisible) continue; - // Prepare label content and scaling - var scaleMultiplier = System.Math.Clamp(currentConfig.LightfinderLabelScale, 0.5f, 2.0f); + // Prepare label content and scaling factors + var scaleMultiplier = Math.Clamp(currentConfig.LightfinderLabelScale, 0.5f, 2.0f); var baseScale = currentConfig.LightfinderLabelUseIcon ? 1.0f : 0.5f; var effectiveScale = baseScale * scaleMultiplier; var baseFontSize = currentConfig.LightfinderLabelUseIcon ? 36f : 24f; - var targetFontSize = (int)System.Math.Round(baseFontSize * scaleMultiplier); + var targetFontSize = (int)Math.Round(baseFontSize * scaleMultiplier); var labelContent = currentConfig.LightfinderLabelUseIcon ? NormalizeIconGlyph(currentConfig.LightfinderLabelIconGlyph) : _defaultLabelText; @@ -332,8 +388,8 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe if (!currentConfig.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal))) labelContent = _defaultLabelText; - var nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale); - var nodeHeight = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale); + var nodeWidth = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale); + var nodeHeight = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale); AlignmentType alignment; var textScaleY = nameText->AtkResNode.ScaleY; @@ -343,7 +399,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe var blockHeight = ResolveCache( _buffers.TextHeights, nameplateIndex, - System.Math.Abs((int)nameplateObject.TextH), + Math.Abs((int)nameplateObject.TextH), () => GetScaledTextHeight(nameText), nodeHeight); @@ -353,7 +409,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe (int)nameContainer->Height, () => { - var computed = blockHeight + (int)System.Math.Round(8 * textScaleY); + var computed = blockHeight + (int)Math.Round(8 * textScaleY); return computed <= blockHeight ? blockHeight + 1 : computed; }, blockHeight + 1); @@ -361,7 +417,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe var blockTop = containerHeight - blockHeight; if (blockTop < 0) blockTop = 0; - var verticalPadding = (int)System.Math.Round(4 * effectiveScale); + var verticalPadding = (int)Math.Round(4 * effectiveScale); var positionY = blockTop - verticalPadding; @@ -369,21 +425,14 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe var textWidth = ResolveCache( _buffers.TextWidths, nameplateIndex, - System.Math.Abs(rawTextWidth), + Math.Abs(rawTextWidth), () => GetScaledTextWidth(nameText), nodeWidth); // Text offset caching - var textOffset = (int)System.Math.Round(nameText->AtkResNode.X); + var textOffset = (int)Math.Round(nameText->AtkResNode.X); var hasValidOffset = TryCacheTextOffset(nameplateIndex, rawTextWidth, textOffset); - if (nameContainer == null) - { - if (_logger.IsEnabled(LogLevel.Debug)) - _logger.LogDebug("Nameplate {Index} container became unavailable during update, skipping.", nameplateIndex); - continue; - } - var res = nameContainer; // X scale @@ -419,7 +468,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe var offsetXScreen = currentConfig.LightfinderLabelOffsetX * worldScaleX; - // alignment based on config + // alignment based on config setting switch (currentConfig.LabelAlignment) { case LabelAlignment.Left: @@ -438,7 +487,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe } else { - // manual X positioning + // manual X positioning with optional cached offset var cachedTextOffset = _buffers.TextOffsets[nameplateIndex]; var hasCachedOffset = cachedTextOffset != int.MinValue; var baseOffsetXLocal = (!currentConfig.LightfinderLabelUseIcon && hasValidOffset && hasCachedOffset) @@ -458,16 +507,16 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe // final position before smoothing var finalPosition = new Vector2(finalX, res->ScreenY + positionYScreen); - var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; // often same for Y + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; var fw = Framework.Instance(); float dt = fw->RealFrameDeltaTime; - //smoothing.. + //smoothing.. snap.. smooth.. snap finalPosition = SnapToPixels(finalPosition, dpiScale); finalPosition = SmoothPosition(nameplateIndex, finalPosition, dt); finalPosition = SnapToPixels(finalPosition, dpiScale); - // prepare label info + // prepare label info for rendering var pivot = (currentConfig.LightfinderAutoAlign || currentConfig.LightfinderLabelUseIcon) ? AlignmentToPivot(alignment) : _defaultPivot; @@ -503,6 +552,10 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe /// private void OnUiBuilderDraw() { + RefreshRendererState(); + if (!IsPictomancyRenderer) + return; + if (!_mEnabled) return; @@ -510,7 +563,23 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe if (fw == null) return; - // Frame skip check + // If UI is hidden, do not render. + if (_gameGui.GameUiHidden) + { + ClearLabelBuffer(); + Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length); + _lastNamePlateDrawFrame = 0; + +#if DEBUG + DebugLabelCountLastFrame = 0; + DebugUiRectCountLastFrame = 0; + DebugOccludedCountLastFrame = 0; + DebugLastNameplateFrame = 0; +#endif + return; + } + + // Frame skip check - skip if more than 1 frame has passed since last nameplate draw. var frame = fw->FrameCounter; if (_lastNamePlateDrawFrame == 0 || (frame - _lastNamePlateDrawFrame) > 1) @@ -518,34 +587,62 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe ClearLabelBuffer(); Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length); +#if DEBUG + DebugLabelCountLastFrame = 0; + DebugUiRectCountLastFrame = 0; + DebugOccludedCountLastFrame = 0; + DebugLastNameplateFrame = _lastNamePlateDrawFrame; +#endif return; } - //Gpose Check + // Gpose Check - do not render. if (_clientState.IsGPosing) { ClearLabelBuffer(); Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length); _lastNamePlateDrawFrame = 0; + +#if DEBUG + DebugLabelCountLastFrame = 0; + DebugUiRectCountLastFrame = 0; + DebugOccludedCountLastFrame = 0; + DebugLastNameplateFrame = 0; +#endif return; } - // If nameplate addon is not visible, skip rendering + // If nameplate addon is not visible, skip rendering entirely. if (!IsNamePlateAddonVisible()) + { +#if DEBUG + DebugLabelCountLastFrame = 0; + DebugUiRectCountLastFrame = 0; + DebugOccludedCountLastFrame = 0; + DebugLastNameplateFrame = _lastNamePlateDrawFrame; +#endif return; + } int copyCount; lock (_labelLock) { copyCount = _labelRenderCount; if (copyCount == 0) + { +#if DEBUG + DebugLabelCountLastFrame = 0; + DebugUiRectCountLastFrame = 0; + DebugOccludedCountLastFrame = 0; + DebugLastNameplateFrame = _lastNamePlateDrawFrame; +#endif return; + } Array.Copy(_buffers.LabelRender, _buffers.LabelCopy, copyCount); } - var uiModule = fw != null ? fw->GetUIModule() : null; - + var uiModule = fw->GetUIModule(); if (uiModule != null) { var rapture = uiModule->GetRaptureAtkModule(); @@ -564,7 +661,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe var vpPos = vp.Pos; ImGuiHelpers.ForceNextWindowMainViewport(); - ImGui.SetNextWindowPos(vp.Pos); ImGui.SetNextWindowSize(vp.Size); @@ -575,54 +671,118 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe ImGui.PopStyleVar(2); - using var drawList = PictoService.Draw(); - if (drawList == null) + // Debug flags + bool dbgEnabled = false; + bool dbgDisableOcc = false; + bool dbgDrawUiRects = false; + bool dbgDrawLabelRects = false; +#if DEBUG + dbgEnabled = DebugEnabled; + dbgDisableOcc = DebugDisableOcclusion; + dbgDrawUiRects = DebugDrawUiRects; + dbgDrawLabelRects = DebugDrawLabelRects; +#endif + + int occludedThisFrame = 0; + + try + { + using var drawList = PictoService.Draw(); + if (drawList == null) + return; + + // Debug drawing uses the window drawlist (so it always draws in the correct viewport). + var dbgDl = ImGui.GetWindowDrawList(); + var useViewportOffset = ImGui.GetIO().ConfigFlags.HasFlag(ImGuiConfigFlags.ViewportsEnable); + + for (int i = 0; i < copyCount; ++i) + { + ref var info = ref _buffers.LabelCopy[i]; + + // final draw position with viewport offset (only when viewports are enabled) + var drawPos = info.ScreenPosition; + if (useViewportOffset) + drawPos += vpPos; + + var font = default(ImFontPtr); + if (info.UseIcon) + { + var ioFonts = ImGui.GetIO().Fonts; + font = ioFonts.Fonts.Size > 1 ? new ImFontPtr(ioFonts.Fonts[1]) : ImGui.GetFont(); + } + else + { + font = ImGui.GetFont(); + } + + if (!font.IsNull) + ImGui.PushFont(font); + + // calculate size for occlusion checking + var baseSize = ImGui.CalcTextSize(info.Text); + var baseFontSize = ImGui.GetFontSize(); + + if (!font.IsNull) + ImGui.PopFont(); + + // scale size based on font size + var scale = baseFontSize > 0 ? (info.FontSize / baseFontSize) : 1f; + var size = baseSize * scale; + + var topLeft = info.ScreenPosition - new Vector2(size.X * info.Pivot.X, size.Y * info.Pivot.Y); + var labelRect = new RectF(topLeft.X, topLeft.Y, topLeft.X + size.X, topLeft.Y + size.Y); + + bool wouldOcclude = IsOccludedByAnyUi(labelRect); + if (wouldOcclude) + occludedThisFrame++; + + // Debug: draw label rects + if (dbgEnabled && dbgDrawLabelRects) + { + var tl = new Vector2(labelRect.L, labelRect.T); + var br = new Vector2(labelRect.R, labelRect.B); + + if (useViewportOffset) { tl += vpPos; br += vpPos; } + + // green = visible, red = would be occluded (even if forced) + var col = wouldOcclude + ? ImGui.GetColorU32(new Vector4(1f, 0f, 0f, 0.6f)) + : ImGui.GetColorU32(new Vector4(0f, 1f, 0f, 0.6f)); + + dbgDl.AddRect(tl, br, col); + } + + // occlusion check (allow debug to disable) + if (!dbgDisableOcc && wouldOcclude) + continue; + + drawList.AddScreenText(drawPos, info.Text, info.TextColor, info.FontSize, info.Pivot, info.EdgeColor, font); + } + + // Debug: draw UI rects if any + if (dbgEnabled && dbgDrawUiRects && _uiRects.Count > 0) + { + var useOff = useViewportOffset ? vpPos : Vector2.Zero; + var col = ImGui.GetColorU32(new Vector4(1f, 1f, 1f, 0.35f)); + + for (int i = 0; i < _uiRects.Count; i++) + { + var r = _uiRects[i]; + dbgDl.AddRect(new Vector2(r.L, r.T) + useOff, new Vector2(r.R, r.B) + useOff, col); + } + } + } + finally { ImGui.End(); - return; } - for (int i = 0; i < copyCount; ++i) - { - ref var info = ref _buffers.LabelCopy[i]; - - // final draw position with viewport offset - var drawPos = info.ScreenPosition + vpPos; - var font = default(ImFontPtr); - if (info.UseIcon) - { - var ioFonts = ImGui.GetIO().Fonts; - font = ioFonts.Fonts.Size > 1 ? new ImFontPtr(ioFonts.Fonts[1]) : ImGui.GetFont(); - } - else - { - font = ImGui.GetFont(); - } - - if (!font.IsNull) - ImGui.PushFont(font); - - // calculate size for occlusion checking - var baseSize = ImGui.CalcTextSize(info.Text); - var baseFontSize = ImGui.GetFontSize(); - - if (!font.IsNull) - ImGui.PopFont(); - - // scale size based on font size - var scale = baseFontSize > 0 ? (info.FontSize / baseFontSize) : 1f; - var size = baseSize * scale; - - // label rect for occlusion checking - var topLeft = info.ScreenPosition - new Vector2(size.X * info.Pivot.X, size.Y * info.Pivot.Y); - var labelRect = new RectF(topLeft.X, topLeft.Y, topLeft.X + size.X, topLeft.Y + size.Y); - - // occlusion check - if (IsOccludedByAnyUi(labelRect)) - continue; - - drawList.AddScreenText(drawPos, info.Text, info.TextColor, info.FontSize, info.Pivot, info.EdgeColor, font); - } +#if DEBUG + DebugLabelCountLastFrame = copyCount; + DebugUiRectCountLastFrame = _uiRects.Count; + DebugOccludedCountLastFrame = occludedThisFrame; + DebugLastNameplateFrame = _lastNamePlateDrawFrame; +#endif } private static Vector2 AlignmentToPivot(AlignmentType alignment) => alignment switch @@ -670,8 +830,8 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe if (scale <= 0f) scale = 1f; - var computed = (int)System.Math.Round(rawHeight * scale); - return System.Math.Max(1, computed); + var computed = (int)Math.Round(rawHeight * scale); + return Math.Max(1, computed); } private static unsafe int GetScaledTextWidth(AtkTextNode* node) @@ -695,12 +855,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe /// /// Resolves a cached value for the given index. /// - /// - /// - /// - /// - /// - /// private static int ResolveCache( int[] cache, int index, @@ -740,9 +894,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe /// /// Snapping a position to pixel grid based on DPI scale. /// - /// Position - /// DPI Scale - /// private static Vector2 SnapToPixels(Vector2 p, float dpiScale) { // snap to pixel grid @@ -751,15 +902,9 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe return new Vector2(x, y); } - /// /// Smooths the position using exponential smoothing. /// - /// Nameplate Index - /// Final position - /// Delta Time - /// How responssive the smooting should be - /// private Vector2 SmoothPosition(int idx, Vector2 target, float dt, float responsiveness = 24f) { // exponential smoothing @@ -777,7 +922,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe var a = 1f - MathF.Exp(-responsiveness * dt); // snap if close enough - if (Vector2.DistanceSquared(cur, target) < 0.25f) + if (Vector2.DistanceSquared(cur, target) < 0.25f) return cur; // lerp towards target @@ -786,73 +931,193 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe return cur; } - /// - /// Tries to get a valid screen rect for the given addon. - /// - /// Addon UI - /// Screen positioning/param> - /// RectF of Addon - /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsFinite(float f) => !(float.IsNaN(f) || float.IsInfinity(f)); + private static bool TryGetAddonRect(AtkUnitBase* addon, Vector2 screen, out RectF rect) { - // Addon existence rect = default; if (addon == null) return false; - // Visibility check + // Addon must be visible + if (!addon->IsVisible) + return false; + + // Root must be visible var root = addon->RootNode; if (root == null || !root->IsVisible()) return false; - // Size check - float w = root->Width; - float h = root->Height; - if (w <= 0 || h <= 0) + // Must have multiple nodes to be useful + var nodeCount = addon->UldManager.NodeListCount; + var nodeList = addon->UldManager.NodeList; + if (nodeCount <= 1 || nodeList == null) return false; - // Local scale - float sx = root->ScaleX; if (sx <= 0f) sx = 1f; - float sy = root->ScaleY; if (sy <= 0f) sy = 1f; + float rsx = GetWorldScaleX(root); + float rsy = GetWorldScaleY(root); + if (!IsFinite(rsx) || rsx <= 0f) rsx = 1f; + if (!IsFinite(rsy) || rsy <= 0f) rsy = 1f; - // World/composed scale from Transform - float wsx = GetWorldScaleX(root); - float wsy = GetWorldScaleY(root); - if (wsx <= 0f) wsx = 1f; - if (wsy <= 0f) wsy = 1f; + // clamp insane root scales (rare but prevents explosions) + rsx = MathF.Min(rsx, 6f); + rsy = MathF.Min(rsy, 6f); - // World scale may include parent scaling; use it if meaningfully different. - float useX = MathF.Abs(wsx - sx) > 0.01f ? wsx : sx; - float useY = MathF.Abs(wsy - sy) > 0.01f ? wsy : sy; - - w *= useX; - h *= useY; - - if (w < 4f || h < 4f) + float rw = root->Width * rsx; + float rh = root->Height * rsy; + if (!IsFinite(rw) || !IsFinite(rh) || rw <= 2f || rh <= 2f) return false; - // Screen coords - float l = root->ScreenX; - float t = root->ScreenY; - float r = l + w; - float b = t + h; - - // Drop fullscreen-ish / insane rects - if (w >= screen.X * 0.98f && h >= screen.Y * 0.98f) + float rl = root->ScreenX; + float rt = root->ScreenY; + if (!IsFinite(rl) || !IsFinite(rt)) return false; - // Drop offscreen rects - if (l < -screen.X || t < -screen.Y || r > screen.X * 2f || b > screen.Y * 2f) + float rr = rl + rw; + float rb = rt + rh; + + // If root is basically fullscreen, it’s not a useful occluder for our purpose. + if (rw >= screen.X * 0.98f && rh >= screen.Y * 0.98f) return false; + // Clip root to screen so it stays sane + float rootL = MathF.Max(0f, rl); + float rootT = MathF.Max(0f, rt); + float rootR = MathF.Min(screen.X, rr); + float rootB = MathF.Min(screen.Y, rb); + if (rootR <= rootL || rootB <= rootT) + return false; + + // Root dimensions + var rootW = rootR - rootL; + var rootH = rootB - rootT; + + // Find union of all probably-drawable nodes intersecting root + bool any = false; + float l = float.MaxValue, t = float.MaxValue, r = float.MinValue, b = float.MinValue; + + // Allow a small bleed outside root; some addons draw small bits outside their root container. + const float rootPad = 24f; + float padL = rootL - rootPad; + float padT = rootT - rootPad; + float padR = rootR + rootPad; + float padB = rootB + rootPad; + + for (int i = 1; i < nodeCount; i++) + { + var n = nodeList[i]; + if (!IsProbablyDrawableNode(n)) + continue; + + float w = n->Width; + float h = n->Height; + if (!IsFinite(w) || !IsFinite(h) || w <= 1f || h <= 1f) + continue; + + float sx = GetWorldScaleX(n); + float sy = GetWorldScaleY(n); + + if (!IsFinite(sx) || sx <= 0f) sx = 1f; + if (!IsFinite(sy) || sy <= 0f) sy = 1f; + + sx = MathF.Min(sx, 6f); + sy = MathF.Min(sy, 6f); + + w *= sx; + h *= sy; + + if (!IsFinite(w) || !IsFinite(h) || w < 2f || h < 2f) + continue; + + float nl = n->ScreenX; + float nt = n->ScreenY; + if (!IsFinite(nl) || !IsFinite(nt)) + continue; + + float nr = nl + w; + float nb = nt + h; + + // Must intersect root (with padding). This is the big mitigation. + if (nr <= padL || nb <= padT || nl >= padR || nt >= padB) + continue; + + // Reject nodes that are wildly larger than the root (common on targeting). + if (w > rootW * 2.0f || h > rootH * 2.0f) + continue; + + // Clip node to root and then to screen (prevents offscreen junk stretching union) + float cl = MathF.Max(rootL, nl); + float ct = MathF.Max(rootT, nt); + float cr = MathF.Min(rootR, nr); + float cb = MathF.Min(rootB, nb); + + cl = MathF.Max(0f, cl); + ct = MathF.Max(0f, ct); + cr = MathF.Min(screen.X, cr); + cb = MathF.Min(screen.Y, cb); + + if (cr <= cl || cb <= ct) + continue; + + any = true; + if (cl < l) l = cl; + if (ct < t) t = ct; + if (cr > r) r = cr; + if (cb > b) b = cb; + } + + // If nothing usable, fallback to root rect (still a sane occluder) + if (!any) + { + rect = new RectF(rootL, rootT, rootR, rootB); + return true; + } + + // Validate final union rect + var uw = r - l; + var uh = b - t; + if (uw < 4f || uh < 4f) + { + rect = new RectF(rootL, rootT, rootR, rootB); + return true; + } + + // If union is excessively larger than root, fallback to root rect + if (uw > rootW * 1.35f || uh > rootH * 1.35f) + { + rect = new RectF(rootL, rootT, rootR, rootB); + return true; + } + rect = new RectF(l, t, r, b); return true; } + private static bool IsProbablyDrawableNode(AtkResNode* n) + { + if (n == null || !n->IsVisible()) + return false; + + // Check alpha + if (n->Color.A == 16) + return false; + + // Check node type + return n->Type switch + { + NodeType.Text => true, + NodeType.Image => true, + NodeType.NineGrid => true, + NodeType.Counter => true, + NodeType.Component => true, + _ => false, + }; + } + /// /// Refreshes the cached UI rects for occlusion checking. /// - /// Unit Manager private void RefreshUiRects(RaptureAtkUnitManager* unitMgr) { _uiRects.Clear(); @@ -876,13 +1141,15 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe if (TryGetAddonRect(addon, screen, out var r)) _uiRects.Add(r); } + +#if DEBUG + DebugUiRectCountLastFrame = _uiRects.Count; +#endif } /// /// Is the given label rect occluded by any UI rects? /// - /// UI/Label Rect - /// Is occluded or not private bool IsOccludedByAnyUi(RectF labelRect) { for (int i = 0; i < _uiRects.Count; i++) @@ -896,8 +1163,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe /// /// Gets the world scale X of the given node. /// - /// Node - /// World Scale of node private static float GetWorldScaleX(AtkResNode* n) { var t = n->Transform; @@ -907,8 +1172,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe /// /// Gets the world scale Y of the given node. /// - /// Node - /// World Scale of node private static float GetWorldScaleY(AtkResNode* n) { var t = n->Transform; @@ -918,8 +1181,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe /// /// Normalize an icon glyph input into a valid string. /// - /// Raw glyph input - /// Normalized glyph input internal static string NormalizeIconGlyph(string? rawInput) { if (string.IsNullOrWhiteSpace(rawInput)) @@ -947,7 +1208,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe /// /// Is the nameplate addon visible? /// - /// Is it visible? private bool IsNamePlateAddonVisible() { if (_mpNameplateAddon == null) @@ -957,20 +1217,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe return root != null && root->IsVisible(); } - /// - /// Converts raw icon glyph input into an icon editor string. - /// - /// Raw icon glyph input - /// Icon editor string - internal static string ToIconEditorString(string? rawInput) - { - var normalized = NormalizeIconGlyph(rawInput); - var runeEnumerator = normalized.EnumerateRunes(); - return runeEnumerator.MoveNext() - ? runeEnumerator.Current.Value.ToString("X4", CultureInfo.InvariantCulture) - : _defaultIconGlyph; - } - private readonly struct NameplateLabelInfo { public NameplateLabelInfo( @@ -1008,6 +1254,15 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) .Select(u => (ulong)u.PlayerCharacterId)]; + public int DebugLabelCountLastFrame { get; set; } + public int DebugUiRectCountLastFrame { get; set; } + public int DebugOccludedCountLastFrame { get; set; } + public uint DebugLastNameplateFrame { get; set; } + public bool DebugDrawUiRects { get; set; } + public bool DebugDrawLabelRects { get; set; } = true; + public bool DebugDisableOcclusion { get; set; } + public bool DebugEnabled { get; set; } + public void FlagRefresh() { _needsLabelRefresh = true; @@ -1015,6 +1270,12 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe public void OnTick(PriorityFrameworkUpdateMessage _) { + if (!IsPictomancyRenderer) + { + _needsLabelRefresh = false; + return; + } + if (_needsLabelRefresh) { UpdateNameplateNodes(); @@ -1025,7 +1286,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe /// /// Update the active broadcasting CIDs. /// - /// Inbound new CIDs public void UpdateBroadcastingCids(IEnumerable cids) { var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal); @@ -1055,7 +1315,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe public NameplateBuffers() { TextOffsets = new int[AddonNamePlate.NumNamePlateObjects]; - System.Array.Fill(TextOffsets, int.MinValue); + Array.Fill(TextOffsets, int.MinValue); } public int[] TextWidths { get; } = new int[AddonNamePlate.NumNamePlateObjects]; @@ -1067,23 +1327,20 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe public NameplateLabelInfo[] LabelCopy { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects]; public Vector2[] SmoothedPos = new Vector2[AddonNamePlate.NumNamePlateObjects]; - public bool[] HasSmoothed = new bool[AddonNamePlate.NumNamePlateObjects]; public void Clear() { - System.Array.Clear(TextWidths, 0, TextWidths.Length); - System.Array.Clear(TextHeights, 0, TextHeights.Length); - System.Array.Clear(ContainerHeights, 0, ContainerHeights.Length); - System.Array.Fill(TextOffsets, int.MinValue); + Array.Clear(TextWidths, 0, TextWidths.Length); + Array.Clear(TextHeights, 0, TextHeights.Length); + Array.Clear(ContainerHeights, 0, ContainerHeights.Length); + Array.Fill(TextOffsets, int.MinValue); } } /// /// Starts the LightFinder Plate Handler. /// - /// Cancellation Token - /// Task Completed public Task StartAsync(CancellationToken cancellationToken) { Init(); @@ -1093,8 +1350,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe /// /// Stops the LightFinder Plate Handler. /// - /// Cancellation Token - /// Task Completed public Task StopAsync(CancellationToken cancellationToken) { Uninit(); @@ -1113,4 +1368,4 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe public bool Intersects(in RectF o) => !(R <= o.L || o.R <= L || B <= o.T || o.B <= T); } -} \ No newline at end of file +} diff --git a/LightlessSync/Services/LightFinder/LightFinderScannerService.cs b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs index f50fcfc..b13ff44 100644 --- a/LightlessSync/Services/LightFinder/LightFinderScannerService.cs +++ b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs @@ -15,11 +15,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase private readonly LightFinderService _broadcastService; private readonly LightFinderPlateHandler _lightFinderPlateHandler; + private readonly LightFinderNativePlateHandler _lightFinderNativePlateHandler; private readonly ConcurrentDictionary _broadcastCache = new(StringComparer.Ordinal); private readonly Queue _lookupQueue = new(); private readonly HashSet _lookupQueuedCids = []; private readonly HashSet _syncshellCids = []; + private volatile bool _pendingLocalBroadcast; + private TimeSpan? _pendingLocalTtl; private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4); private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1); @@ -42,12 +45,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; @@ -69,6 +74,8 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase if (!_broadcastService.IsBroadcasting) return; + TryPrimeLocalBroadcastCache(); + var now = DateTime.UtcNow; foreach (var address in _actorTracker.PlayerAddresses) @@ -129,6 +136,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase .ToList(); _lightFinderPlateHandler.UpdateBroadcastingCids(activeCids); + _lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids); UpdateSyncshellBroadcasts(); } @@ -140,9 +148,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() diff --git a/LightlessSync/Services/NameplateService.cs b/LightlessSync/Services/NameplateService.cs index 4ca8a2f..bbd336a 100644 --- a/LightlessSync/Services/NameplateService.cs +++ b/LightlessSync/Services/NameplateService.cs @@ -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; /// 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? _nameplateHook = null; - private readonly ILogger _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 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(this, (_) => Refresh()); } /// - /// 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 , + /// Nameplate update handler, triggered by the signature hook service. /// - 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); } /// @@ -246,7 +234,7 @@ public unsafe class NameplateService : DisposableMediatorSubscriberBase { if (disposing) { - _nameplateHook?.Dispose(); + _nameplateUpdateHookService.NameplateUpdated -= OnNameplateUpdated; } base.Dispose(disposing); diff --git a/LightlessSync/Services/NameplateUpdateHookService.cs b/LightlessSync/Services/NameplateUpdateHookService.cs new file mode 100644 index 0000000..d524f7e --- /dev/null +++ b/LightlessSync/Services/NameplateUpdateHookService.cs @@ -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? _nameplateHook = null; + + private readonly ILogger _logger; + + public NameplateUpdateHookService(ILogger logger, IGameInteropProvider interop) + { + _logger = logger; + + interop.InitializeFromAttributes(this); + _nameplateHook?.Enable(); + } + + public event NameplateUpdatedHandler? NameplateUpdated; + + /// + /// Detour for the game's internal nameplate update function. + /// This will be called whenever the client updates any nameplate. + /// + 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(); + } +} diff --git a/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs b/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs index 20d9a8f..0526d89 100644 --- a/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs +++ b/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs @@ -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); } diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index 5f3d300..785fb21 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -832,14 +832,19 @@ public class DrawUserPair } UiSharedService.AttachToolTip("Hold CTRL and click to remove user " + (_pair.UserData.AliasOrUID) + " from Syncshell"); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserSlash, "Ban User", _menuWidth, true)) + var banEnabled = UiSharedService.CtrlPressed(); + var banLabel = banEnabled ? "Ban user" : "Ban user (Hold CTRL)"; + if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserSlash, banLabel, _menuWidth, true) && banEnabled) { _mediator.Publish(new OpenBanUserPopupMessage(_pair, group)); ImGui.CloseCurrentPopup(); } - UiSharedService.AttachToolTip("Ban user from this Syncshell"); + UiSharedService.AttachToolTip("Hold CTRL to ban user " + (_pair.UserData.AliasOrUID) + " from this Syncshell"); - ImGui.Separator(); + if (showOwnerActions) + { + ImGui.Separator(); + } } if (showOwnerActions) diff --git a/LightlessSync/UI/DataAnalysisUi.cs b/LightlessSync/UI/DataAnalysisUi.cs index a4bbf9f..32245d2 100644 --- a/LightlessSync/UI/DataAnalysisUi.cs +++ b/LightlessSync/UI/DataAnalysisUi.cs @@ -14,6 +14,7 @@ using LightlessSync.Services.TextureCompression; using LightlessSync.Utils; using Microsoft.Extensions.Logging; using OtterTex; +using System.Buffers.Binary; using System.Globalization; using System.Numerics; using SixLabors.ImageSharp; @@ -49,6 +50,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private readonly Dictionary _textureSelections = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _selectedTextureKeys = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _texturePreviews = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _textureResolutionCache = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _textureWorkspaceTabs = new(); private readonly List _storedPathsToRemove = []; private readonly Dictionary _filePathResolve = []; @@ -88,6 +90,9 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private bool _showAlreadyAddedTransients = false; private bool _acknowledgeReview = false; + private Task? _textureRowsBuildTask; + private CancellationTokenSource? _textureRowsBuildCts; + private ObjectKind _selectedObjectTab; private TextureUsageCategory? _textureCategoryFilter = null; @@ -204,9 +209,9 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase return; } - _cachedAnalysis = _characterAnalyzer.LastAnalysis.DeepClone(); + _cachedAnalysis = CloneAnalysis(_characterAnalyzer.LastAnalysis); _hasUpdate = false; - _textureRowsDirty = true; + InvalidateTextureRows(); } private void DrawContentTabs() @@ -750,7 +755,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _selectedTextureKeys.Clear(); _textureSelections.Clear(); ResetTextureFilters(); - _textureRowsDirty = true; + InvalidateTextureRows(); _conversionFailed = false; } @@ -762,6 +767,8 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase preview.Texture?.Dispose(); } _texturePreviews.Clear(); + _textureRowsBuildCts?.Cancel(); + _textureRowsBuildCts?.Dispose(); _conversionProgress.ProgressChanged -= ConversionProgress_ProgressChanged; } @@ -775,18 +782,108 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private void EnsureTextureRows() { - if (!_textureRowsDirty || _cachedAnalysis == null) + if (_cachedAnalysis == null) { return; } + if (_textureRowsDirty && _textureRowsBuildTask == null) + { + _textureRowsBuildCts?.Dispose(); + _textureRowsBuildCts = new(); + var snapshot = _cachedAnalysis; + _textureRowsBuildTask = Task.Run(() => BuildTextureRows(snapshot, _textureRowsBuildCts.Token), _textureRowsBuildCts.Token); + } + + if (_textureRowsBuildTask == null || !_textureRowsBuildTask.IsCompleted) + { + return; + } + + var completedTask = _textureRowsBuildTask; + _textureRowsBuildTask = null; + _textureRowsBuildCts?.Dispose(); + _textureRowsBuildCts = null; + + if (completedTask.IsCanceled) + { + return; + } + + if (completedTask.IsFaulted) + { + _logger.LogWarning(completedTask.Exception, "Failed to build texture rows."); + _textureRowsDirty = false; + return; + } + + ApplyTextureRowBuild(completedTask.Result); + _textureRowsDirty = false; + } + + private void ApplyTextureRowBuild(TextureRowBuildResult result) + { _textureRows.Clear(); + _textureRows.AddRange(result.Rows); + + foreach (var row in _textureRows) + { + if (row.IsAlreadyCompressed) + { + _selectedTextureKeys.Remove(row.Key); + _textureSelections.Remove(row.Key); + } + } + + _selectedTextureKeys.RemoveWhere(key => !result.ValidKeys.Contains(key)); + + foreach (var key in _texturePreviews.Keys.ToArray()) + { + if (!result.ValidKeys.Contains(key) && _texturePreviews.TryGetValue(key, out var preview)) + { + preview.Texture?.Dispose(); + _texturePreviews.Remove(key); + } + } + + foreach (var key in _textureResolutionCache.Keys.ToArray()) + { + if (!result.ValidKeys.Contains(key)) + { + _textureResolutionCache.Remove(key); + } + } + + foreach (var key in _textureSelections.Keys.ToArray()) + { + if (!result.ValidKeys.Contains(key)) + { + _textureSelections.Remove(key); + continue; + } + + _textureSelections[key] = _textureCompressionService.NormalizeTarget(_textureSelections[key]); + } + + if (!string.IsNullOrEmpty(_selectedTextureKey) && !result.ValidKeys.Contains(_selectedTextureKey)) + { + _selectedTextureKey = string.Empty; + } + } + + private TextureRowBuildResult BuildTextureRows( + Dictionary> analysis, + CancellationToken token) + { + var rows = new List(); HashSet validKeys = new(StringComparer.OrdinalIgnoreCase); - foreach (var (objectKind, entries) in _cachedAnalysis) + foreach (var (objectKind, entries) in analysis) { foreach (var entry in entries.Values) { + token.ThrowIfCancellationRequested(); + if (!string.Equals(entry.FileType, "tex", StringComparison.Ordinal)) { continue; @@ -828,17 +925,11 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase suggestion?.Reason); validKeys.Add(row.Key); - _textureRows.Add(row); - - if (row.IsAlreadyCompressed) - { - _selectedTextureKeys.Remove(row.Key); - _textureSelections.Remove(row.Key); - } + rows.Add(row); } } - _textureRows.Sort((a, b) => + rows.Sort((a, b) => { var comp = a.ObjectKind.CompareTo(b.ObjectKind); if (comp != 0) @@ -851,34 +942,14 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase return string.Compare(a.DisplayName, b.DisplayName, StringComparison.OrdinalIgnoreCase); }); - _selectedTextureKeys.RemoveWhere(key => !validKeys.Contains(key)); + return new TextureRowBuildResult(rows, validKeys); + } - foreach (var key in _texturePreviews.Keys.ToArray()) - { - if (!validKeys.Contains(key) && _texturePreviews.TryGetValue(key, out var preview)) - { - preview.Texture?.Dispose(); - _texturePreviews.Remove(key); - } - } - - foreach (var key in _textureSelections.Keys.ToArray()) - { - if (!validKeys.Contains(key)) - { - _textureSelections.Remove(key); - continue; - } - - _textureSelections[key] = _textureCompressionService.NormalizeTarget(_textureSelections[key]); - } - - if (!string.IsNullOrEmpty(_selectedTextureKey) && !validKeys.Contains(_selectedTextureKey)) - { - _selectedTextureKey = string.Empty; - } - - _textureRowsDirty = false; + private void InvalidateTextureRows() + { + _textureRowsDirty = true; + _textureRowsBuildCts?.Cancel(); + _textureResolutionCache.Clear(); } private static string MakeTextureKey(ObjectKind objectKind, string primaryFilePath) => @@ -893,6 +964,35 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _textureSearch = string.Empty; } + private static Dictionary> CloneAnalysis( + Dictionary> source) + { + var clone = new Dictionary>(source.Count); + + foreach (var (objectKind, entries) in source) + { + var entryClone = new Dictionary(entries.Count, entries.Comparer); + + foreach (var (hash, entry) in entries) + { + entryClone[hash] = new CharacterAnalyzer.FileDataEntry( + hash: hash, + fileType: entry.FileType, + gamePaths: entry.GamePaths?.ToList() ?? [], + filePaths: entry.FilePaths?.ToList() ?? [], + originalSize: entry.OriginalSize, + compressedSize: entry.CompressedSize, + triangles: entry.Triangles, + cacheEntries: entry.CacheEntries + ); + } + + clone[objectKind] = entryClone; + } + + return clone; + } + private void DrawAnalysisOverview(int totalFiles, long totalActualSize, long totalCompressedSize, long totalTriangles, string breakdownTooltip) { var scale = ImGuiHelpers.GlobalScale; @@ -1091,6 +1191,10 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase public bool IsAlreadyCompressed => CurrentTarget.HasValue; } + private sealed record TextureRowBuildResult( + List Rows, + HashSet ValidKeys); + private sealed class TexturePreviewState { public Task? LoadTask { get; set; } @@ -1099,6 +1203,22 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase public DateTime LastAccessUtc { get; set; } = DateTime.UtcNow; } + private readonly struct TextureResolutionInfo + { + public TextureResolutionInfo(ushort width, ushort height, ushort depth, ushort mipLevels) + { + Width = width; + Height = height; + Depth = depth; + MipLevels = mipLevels; + } + + public ushort Width { get; } + public ushort Height { get; } + public ushort Depth { get; } + public ushort MipLevels { get; } + } + private void DrawTextureWorkspace(ObjectKind objectKind, IReadOnlyList> otherFileGroups) { if (!_textureWorkspaceTabs.ContainsKey(objectKind)) @@ -1143,6 +1263,11 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private void DrawTextureTabContent(ObjectKind objectKind) { var scale = ImGuiHelpers.GlobalScale; + if (_textureRowsBuildTask != null && !_textureRowsBuildTask.IsCompleted && _textureRows.Count == 0) + { + UiSharedService.ColorText("Building texture list.", ImGuiColors.DalamudGrey); + return; + } var objectRows = _textureRows.Where(row => row.ObjectKind == objectKind).ToList(); var hasAnyTextureRows = objectRows.Count > 0; var availableCategories = objectRows.Select(row => row.Category) @@ -1404,6 +1529,24 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase { ResetTextureFilters(); } + + ImGuiHelpers.ScaledDummy(6); + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(4); + UiSharedService.ColorText("Texture row colors", UIColors.Get("LightlessPurple")); + DrawTextureRowLegendItem("Selected", UIColors.Get("LightlessYellow"), "This row is selected in the texture table."); + DrawTextureRowLegendItem("Already compressed", UIColors.Get("LightlessGreenDefault"), "Texture is already stored in a compressed format."); + DrawTextureRowLegendItem("Missing analysis data", UIColors.Get("DimRed"), "File size data has not been computed yet."); + } + + private static void DrawTextureRowLegendItem(string label, Vector4 color, string description) + { + var scale = ImGuiHelpers.GlobalScale; + var swatchSize = new Vector2(12f * scale, 12f * scale); + ImGui.ColorButton($"##textureRowLegend{label}", color, ImGuiColorEditFlags.NoTooltip | ImGuiColorEditFlags.NoDragDrop, swatchSize); + ImGui.SameLine(0f, 6f * scale); + var wrapPos = ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X; + UiSharedService.TextWrapped($"{label}: {description}", wrapPos); } private static void DrawEnumFilterCombo( @@ -1810,7 +1953,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase ImGui.SameLine(); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Sync, "Refresh", 130f * scale)) { - _textureRowsDirty = true; + InvalidateTextureRows(); } TextureRow? lastSelected = null; @@ -1976,7 +2119,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase { _selectedTextureKeys.Clear(); _textureSelections.Clear(); - _textureRowsDirty = true; + InvalidateTextureRows(); } } @@ -2197,6 +2340,68 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } } + private TextureResolutionInfo? GetTextureResolution(TextureRow row) + { + if (_textureResolutionCache.TryGetValue(row.Key, out var cached)) + { + return cached; + } + + var info = TryReadTextureResolution(row.PrimaryFilePath, out var resolved) + ? resolved + : (TextureResolutionInfo?)null; + _textureResolutionCache[row.Key] = info; + return info; + } + + private static bool TryReadTextureResolution(string path, out TextureResolutionInfo info) + { + info = default; + + try + { + Span buffer = stackalloc byte[16]; + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + var read = stream.Read(buffer); + if (read < buffer.Length) + { + return false; + } + + var width = BinaryPrimitives.ReadUInt16LittleEndian(buffer[8..10]); + var height = BinaryPrimitives.ReadUInt16LittleEndian(buffer[10..12]); + var depth = BinaryPrimitives.ReadUInt16LittleEndian(buffer[12..14]); + var mipLevels = BinaryPrimitives.ReadUInt16LittleEndian(buffer[14..16]); + + if (width == 0 || height == 0) + { + return false; + } + + if (depth == 0) + { + depth = 1; + } + + if (mipLevels == 0) + { + mipLevels = 1; + } + + info = new TextureResolutionInfo(width, height, depth, mipLevels); + return true; + } + catch + { + return false; + } + } + + private static string FormatTextureResolution(TextureResolutionInfo info) + => info.Depth > 1 + ? $"{info.Width} x {info.Height} x {info.Depth}" + : $"{info.Width} x {info.Height}"; + private void DrawTextureRow(TextureRow row, IReadOnlyList targets, int index) { var key = row.Key; @@ -2465,6 +2670,9 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase MetaRow(FontAwesomeIcon.LayerGroup, "Map Type", row.MapKind.ToString()); MetaRow(FontAwesomeIcon.Fingerprint, "Hash", row.Hash, UIColors.Get("LightlessBlue")); MetaRow(FontAwesomeIcon.InfoCircle, "Current Format", row.Format); + var resolution = GetTextureResolution(row); + var resolutionLabel = resolution.HasValue ? FormatTextureResolution(resolution.Value) : "Unknown"; + MetaRow(FontAwesomeIcon.Images, "Resolution", resolutionLabel); var selectedLabel = hasSelectedInfo ? selectedInfo!.Title : selectedTarget.ToString(); var selectionColor = hasSelectedInfo ? UIColors.Get("LightlessYellow") : UIColors.Get("LightlessGreen"); diff --git a/LightlessSync/UI/DtrEntry.cs b/LightlessSync/UI/DtrEntry.cs index 71eddfb..ae94d5e 100644 --- a/LightlessSync/UI/DtrEntry.cs +++ b/LightlessSync/UI/DtrEntry.cs @@ -46,10 +46,12 @@ public sealed class DtrEntry : IDisposable, IHostedService private string? _lightfinderText; private string? _lightfinderTooltip; private Colors _lightfinderColors; + private readonly object _localHashedCidLock = new(); private string? _localHashedCid; private DateTime _localHashedCidFetchedAt = DateTime.MinValue; private DateTime _localHashedCidNextErrorLog = DateTime.MinValue; private DateTime _pairRequestNextErrorLog = DateTime.MinValue; + private int _localHashedCidRefreshActive; public DtrEntry( ILogger logger, @@ -339,29 +341,61 @@ public sealed class DtrEntry : IDisposable, IHostedService private string? GetLocalHashedCid() { var now = DateTime.UtcNow; - if (_localHashedCid is not null && now - _localHashedCidFetchedAt < _localHashedCidCacheDuration) - return _localHashedCid; - - try + lock (_localHashedCidLock) { - var cid = _dalamudUtilService.GetCID(); - var hashedCid = cid.ToString().GetHash256(); - _localHashedCid = hashedCid; - _localHashedCidFetchedAt = now; - return hashedCid; - } - catch (Exception ex) - { - if (now >= _localHashedCidNextErrorLog) + if (_localHashedCid is not null && now - _localHashedCidFetchedAt < _localHashedCidCacheDuration) { - _logger.LogDebug(ex, "Failed to refresh local hashed CID for Lightfinder DTR entry."); - _localHashedCidNextErrorLog = now + _localHashedCidErrorCooldown; + return _localHashedCid; } - - _localHashedCid = null; - _localHashedCidFetchedAt = now; - return null; } + + QueueLocalHashedCidRefresh(); + + lock (_localHashedCidLock) + { + return _localHashedCid; + } + } + + private void QueueLocalHashedCidRefresh() + { + if (Interlocked.Exchange(ref _localHashedCidRefreshActive, 1) != 0) + { + return; + } + + _ = Task.Run(async () => + { + try + { + var cid = _dalamudUtilService.GetCID(); + var hashedCid = cid.ToString().GetHash256(); + lock (_localHashedCidLock) + { + _localHashedCid = hashedCid; + _localHashedCidFetchedAt = DateTime.UtcNow; + } + } + catch (Exception ex) + { + var now = DateTime.UtcNow; + lock (_localHashedCidLock) + { + if (now >= _localHashedCidNextErrorLog) + { + _logger.LogDebug(ex, "Failed to refresh local hashed CID for Lightfinder DTR entry."); + _localHashedCidNextErrorLog = now + _localHashedCidErrorCooldown; + } + + _localHashedCid = null; + _localHashedCidFetchedAt = now; + } + } + finally + { + Interlocked.Exchange(ref _localHashedCidRefreshActive, 0); + } + }); } private List GetNearbyBroadcasts() diff --git a/LightlessSync/UI/LightFinderUI.cs b/LightlessSync/UI/LightFinderUI.cs index 22911cb..0aecee3 100644 --- a/LightlessSync/UI/LightFinderUI.cs +++ b/LightlessSync/UI/LightFinderUI.cs @@ -23,6 +23,7 @@ namespace LightlessSync.UI private readonly LightFinderService _broadcastService; private readonly UiSharedService _uiSharedService; private readonly LightFinderScannerService _broadcastScannerService; + private readonly LightFinderPlateHandler _lightFinderPlateHandler; private IReadOnlyList _allSyncshells = Array.Empty(); private string _userUid = string.Empty; @@ -38,7 +39,8 @@ namespace LightlessSync.UI UiSharedService uiShared, ApiController apiController, LightFinderScannerService broadcastScannerService - ) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService) +, + LightFinderPlateHandler lightFinderPlateHandler) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService) { _broadcastService = broadcastService; _uiSharedService = uiShared; @@ -50,6 +52,7 @@ namespace LightlessSync.UI WindowBuilder.For(this) .SetSizeConstraints(new Vector2(600, 465), new Vector2(750, 525)) .Apply(); + _lightFinderPlateHandler = lightFinderPlateHandler; } private void RebuildSyncshellDropdownOptions() @@ -380,9 +383,47 @@ namespace LightlessSync.UI #if DEBUG if (ImGui.BeginTabItem("Debug")) { + if (ImGui.CollapsingHeader("LightFinder Plates", ImGuiTreeNodeFlags.DefaultOpen)) + { + var h = _lightFinderPlateHandler; + + var enabled = h.DebugEnabled; + if (ImGui.Checkbox("Enable LightFinder debug", ref enabled)) + h.DebugEnabled = enabled; + + if (h.DebugEnabled) + { + ImGui.Indent(); + + var disableOcc = h.DebugDisableOcclusion; + if (ImGui.Checkbox("Disable occlusion (force draw)", ref disableOcc)) + h.DebugDisableOcclusion = disableOcc; + + var drawUiRects = h.DebugDrawUiRects; + if (ImGui.Checkbox("Draw UI rects", ref drawUiRects)) + h.DebugDrawUiRects = drawUiRects; + + var drawLabelRects = h.DebugDrawLabelRects; + if (ImGui.Checkbox("Draw label rects", ref drawLabelRects)) + h.DebugDrawLabelRects = drawLabelRects; + + ImGui.Separator(); + ImGui.TextUnformatted($"Labels last frame: {h.DebugLabelCountLastFrame}"); + ImGui.TextUnformatted($"UI rects last frame: {h.DebugUiRectCountLastFrame}"); + ImGui.TextUnformatted($"Occluded last frame: {h.DebugOccludedCountLastFrame}"); + ImGui.TextUnformatted($"Last NamePlate frame: {h.DebugLastNameplateFrame}"); + + ImGui.Unindent(); + } + } + + ImGui.Separator(); + ImGui.Text("Broadcast Cache"); - if (ImGui.BeginTable("##BroadcastCacheTable", 4, ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY, new Vector2(-1, 225f))) + if (ImGui.BeginTable("##BroadcastCacheTable", 4, + ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY, + new Vector2(-1, 225f))) { ImGui.TableSetupColumn("CID", ImGuiTableColumnFlags.WidthStretch); ImGui.TableSetupColumn("IsBroadcasting", ImGuiTableColumnFlags.WidthStretch); diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index ad570df..a0c1787 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -2368,6 +2368,43 @@ public class SettingsUi : WindowMediatorSubscriberBase UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + ImGui.TextUnformatted("Nameplate Label Rendering"); + var labelRenderer = _configService.Current.LightfinderLabelRenderer; + var labelRendererLabel = labelRenderer switch + { + LightfinderLabelRenderer.SignatureHook => "Native nameplate (sig hook)", + _ => "ImGui Overlay", + }; + + if (ImGui.BeginCombo("Render mode", labelRendererLabel)) + { + foreach (var option in Enum.GetValues()) + { + var optionLabel = option switch + { + LightfinderLabelRenderer.SignatureHook => "Native Nameplate (sig hook)", + _ => "ImGui Overlay", + }; + + var selected = option == labelRenderer; + if (ImGui.Selectable(optionLabel, selected)) + { + _configService.Current.LightfinderLabelRenderer = option; + _configService.Save(); + _nameplateService.RequestRedraw(); + } + + if (selected) + ImGui.SetItemDefaultFocus(); + } + + ImGui.EndCombo(); + } + + _uiShared.DrawHelpText("Choose how Lightfinder labels render: the default ImGui overlay or native nameplate nodes via signature hook."); + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + ImGui.TextUnformatted("Alignment"); ImGui.BeginDisabled(autoAlign); if (ImGui.SliderInt("Label Offset X", ref offsetX, -200, 200)) @@ -2550,7 +2587,7 @@ public class SettingsUi : WindowMediatorSubscriberBase var selected = i == _lightfinderIconPresetIndex; if (ImGui.Selectable(preview, selected)) { - _lightfinderIconInput = LightFinderPlateHandler.ToIconEditorString(optionGlyph); + _lightfinderIconInput = LightFinderPlateHandler.NormalizeIconGlyph(optionGlyph); _lightfinderIconPresetIndex = i; } } @@ -4026,7 +4063,7 @@ public class SettingsUi : WindowMediatorSubscriberBase private void RefreshLightfinderIconState() { var normalized = LightFinderPlateHandler.NormalizeIconGlyph(_configService.Current.LightfinderLabelIconGlyph); - _lightfinderIconInput = LightFinderPlateHandler.ToIconEditorString(normalized); + _lightfinderIconInput = LightFinderPlateHandler.NormalizeIconGlyph(normalized); _lightfinderIconInputInitialized = true; _lightfinderIconPresetIndex = -1; @@ -4044,7 +4081,7 @@ public class SettingsUi : WindowMediatorSubscriberBase { _configService.Current.LightfinderLabelIconGlyph = normalizedGlyph; _configService.Save(); - _lightfinderIconInput = LightFinderPlateHandler.ToIconEditorString(normalizedGlyph); + _lightfinderIconInput = LightFinderPlateHandler.NormalizeIconGlyph(normalizedGlyph); _lightfinderIconPresetIndex = presetIndex; _lightfinderIconInputInitialized = true; } diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index b0b03c6..036e7d3 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -1,12 +1,15 @@ using System.Globalization; using System.Numerics; using LightlessSync.API.Data; +using LightlessSync.API.Data.Extensions; +using LightlessSync.API.Data.Enum; using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using LightlessSync.API.Dto.Chat; +using LightlessSync.API.Dto.Group; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Services; @@ -14,13 +17,17 @@ using LightlessSync.Services.Chat; using LightlessSync.Services.LightFinder; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; +using LightlessSync.UI.Models; using LightlessSync.UI.Services; using LightlessSync.UI.Style; using LightlessSync.Utils; +using Dalamud.Interface.Textures.TextureWraps; using OtterGui.Text; using LightlessSync.WebAPI; using LightlessSync.WebAPI.SignalR.Utils; using Microsoft.Extensions.Logging; +using LightlessSync.PlayerData.Factories; +using LightlessSync.PlayerData.Pairs; namespace LightlessSync.UI; @@ -31,6 +38,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private const string SettingsPopupId = "zone_chat_settings_popup"; private const string ReportPopupId = "Report Message##zone_chat_report_popup"; private const string ChannelDragPayloadId = "zone_chat_channel_drag"; + private const string EmotePickerPopupId = "zone_chat_emote_picker"; + private const int EmotePickerColumns = 10; private const float DefaultWindowOpacity = .97f; private const float DefaultUnfocusedWindowOpacity = 0.6f; private const float MinWindowOpacity = 0.05f; @@ -46,6 +55,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private readonly UiSharedService _uiSharedService; private readonly ZoneChatService _zoneChatService; private readonly PairUiService _pairUiService; + private readonly PairFactory _pairFactory; + private readonly ChatEmoteService _chatEmoteService; private readonly LightFinderService _lightFinderService; private readonly LightlessProfileManager _profileManager; private readonly ApiController _apiController; @@ -54,6 +65,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private readonly DalamudUtilService _dalamudUtilService; private readonly IUiBuilder _uiBuilder; private readonly Dictionary _draftMessages = new(StringComparer.Ordinal); + private readonly Dictionary> _pendingDraftClears = new(StringComparer.Ordinal); private readonly ImGuiWindowFlags _unpinnedWindowFlags; private float _currentWindowOpacity = DefaultWindowOpacity; private float _baseWindowOpacity = DefaultWindowOpacity; @@ -81,6 +93,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private ChatReportResult? _reportSubmissionResult; private string? _dragChannelKey; private string? _dragHoverKey; + private bool _openEmotePicker; + private string _emoteFilter = string.Empty; private bool _HideStateActive; private bool _HideStateWasOpen; private bool _pushedStyle; @@ -91,6 +105,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase UiSharedService uiSharedService, ZoneChatService zoneChatService, PairUiService pairUiService, + PairFactory pairFactory, + ChatEmoteService chatEmoteService, LightFinderService lightFinderService, LightlessProfileManager profileManager, ChatConfigService chatConfigService, @@ -104,6 +120,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase _uiSharedService = uiSharedService; _zoneChatService = zoneChatService; _pairUiService = pairUiService; + _pairFactory = pairFactory; + _chatEmoteService = chatEmoteService; _lightFinderService = lightFinderService; _profileManager = profileManager; _chatConfigService = chatConfigService; @@ -188,7 +206,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private void ApplyUiVisibilitySettings() { var config = _chatConfigService.Current; - _uiBuilder.DisableAutomaticUiHide = config.ShowWhenUiHidden; + _uiBuilder.DisableUserUiHide = true; _uiBuilder.DisableCutsceneUiHide = config.ShowInCutscenes; _uiBuilder.DisableGposeUiHide = config.ShowInGpose; } @@ -197,6 +215,11 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase { var config = _chatConfigService.Current; + if (!config.ShowWhenUiHidden && _dalamudUtilService.IsGameUiHidden) + { + return true; + } + if (config.HideInCombat && _dalamudUtilService.IsInCombat) { return true; @@ -386,6 +409,9 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase bottomColor); var showTimestamps = _chatConfigService.Current.ShowMessageTimestamps; + _chatEmoteService.EnsureGlobalEmotesLoaded(); + PairUiSnapshot? pairSnapshot = null; + var itemSpacing = ImGui.GetStyle().ItemSpacing.X; if (channel.Messages.Count == 0) { @@ -423,16 +449,109 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] "; } var color = message.FromSelf ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudWhite; + var showRoleIcons = false; + var isOwner = false; + var isModerator = false; + var isPinned = false; + if (channel.Type == ChatChannelType.Group + && payload.Sender.Kind == ChatSenderKind.IdentifiedUser + && payload.Sender.User is not null) + { + pairSnapshot ??= _pairUiService.GetSnapshot(); + var groupId = channel.Descriptor.CustomKey; + if (!string.IsNullOrWhiteSpace(groupId) + && pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo)) + { + var senderUid = payload.Sender.User.UID; + isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal); + if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info)) + { + isModerator = info.IsModerator(); + isPinned = info.IsPinned(); + } + } + + showRoleIcons = isOwner || isModerator || isPinned; + } + + ImGui.BeginGroup(); ImGui.PushStyleColor(ImGuiCol.Text, color); - ImGui.TextWrapped($"{timestampText}{message.DisplayName}: {payload.Message}"); - ImGui.PopStyleColor(); + if (showRoleIcons) + { + if (!string.IsNullOrEmpty(timestampText)) + { + ImGui.TextUnformatted(timestampText); + ImGui.SameLine(0f, 0f); + } + var hasIcon = false; + if (isModerator) + { + _uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple")); + UiSharedService.AttachToolTip("Moderator"); + hasIcon = true; + } + + if (isOwner) + { + if (hasIcon) + { + ImGui.SameLine(0f, itemSpacing); + } + + _uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow")); + UiSharedService.AttachToolTip("Owner"); + hasIcon = true; + } + + if (isPinned) + { + if (hasIcon) + { + ImGui.SameLine(0f, itemSpacing); + } + + _uiSharedService.IconText(FontAwesomeIcon.Thumbtack, UIColors.Get("LightlessBlue")); + UiSharedService.AttachToolTip("Pinned"); + hasIcon = true; + } + + if (hasIcon) + { + ImGui.SameLine(0f, itemSpacing); + } + + var messageStartX = ImGui.GetCursorPosX(); + DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX); + } + else + { + var messageStartX = ImGui.GetCursorPosX(); + DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX); + } + ImGui.PopStyleColor(); + ImGui.EndGroup(); + + ImGui.SetNextWindowSizeConstraints( + new Vector2(190f * ImGuiHelpers.GlobalScale, 0f), + new Vector2(float.MaxValue, float.MaxValue)); if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}")) { var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime(); var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture); ImGui.TextDisabled(contextTimestampText); + if (channel.Type == ChatChannelType.Group + && payload.Sender.Kind == ChatSenderKind.IdentifiedUser + && payload.Sender.User is not null) + { + var aliasOrUid = payload.Sender.User.AliasOrUID; + if (!string.IsNullOrWhiteSpace(aliasOrUid) + && !string.Equals(message.DisplayName, aliasOrUid, StringComparison.Ordinal)) + { + ImGui.TextDisabled(aliasOrUid); + } + } ImGui.Separator(); var actionIndex = 0; @@ -461,6 +580,335 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } } + private void DrawChatMessageWithEmotes(string prefix, string message, float lineStartX) + { + var segments = BuildChatSegments(prefix, message); + var firstOnLine = true; + var emoteSize = new Vector2(ImGui.GetTextLineHeight()); + var remainingWidth = ImGui.GetContentRegionAvail().X; + + foreach (var segment in segments) + { + if (segment.IsLineBreak) + { + if (firstOnLine) + { + ImGui.NewLine(); + } + ImGui.SetCursorPosX(lineStartX); + firstOnLine = true; + remainingWidth = ImGui.GetContentRegionAvail().X; + continue; + } + + if (segment.IsWhitespace && firstOnLine) + { + continue; + } + + var segmentWidth = segment.IsEmote ? emoteSize.X : ImGui.CalcTextSize(segment.Text).X; + if (!firstOnLine) + { + if (segmentWidth > remainingWidth) + { + ImGui.SetCursorPosX(lineStartX); + firstOnLine = true; + remainingWidth = ImGui.GetContentRegionAvail().X; + if (segment.IsWhitespace) + { + continue; + } + } + else + { + ImGui.SameLine(0f, 0f); + } + } + + if (segment.IsEmote && segment.Texture is not null) + { + ImGui.Image(segment.Texture.Handle, emoteSize); + if (ImGui.IsItemHovered()) + { + DrawEmoteTooltip(segment.EmoteName ?? string.Empty, segment.Texture); + } + } + else + { + ImGui.TextUnformatted(segment.Text); + } + + remainingWidth -= segmentWidth; + firstOnLine = false; + } + + } + + private void DrawEmotePickerPopup(ref string draft, string channelKey) + { + if (_openEmotePicker) + { + ImGui.OpenPopup(EmotePickerPopupId); + _openEmotePicker = false; + } + + var style = ImGui.GetStyle(); + var scale = ImGuiHelpers.GlobalScale; + var emoteSize = 32f * scale; + var itemWidth = emoteSize + (style.FramePadding.X * 2f); + var gridWidth = (itemWidth * EmotePickerColumns) + (style.ItemSpacing.X * Math.Max(0, EmotePickerColumns - 1)); + var scrollbarPadding = style.ScrollbarSize + (style.ItemSpacing.X * 2f) + (8f * scale); + var windowWidth = gridWidth + scrollbarPadding + (style.WindowPadding.X * 2f); + ImGui.SetNextWindowSize(new Vector2(windowWidth, 340f * scale), ImGuiCond.Always); + if (!ImGui.BeginPopup(EmotePickerPopupId)) + return; + + ImGui.TextUnformatted("Emotes"); + ImGui.Separator(); + + ImGui.SetNextItemWidth(-1f); + ImGui.InputTextWithHint("##emote_filter", "Search Emotes", ref _emoteFilter, 50); + ImGui.Spacing(); + + var emotes = _chatEmoteService.GetEmoteNames(); + var filter = _emoteFilter.Trim(); + var hasFilter = filter.Length > 0; + + using (var child = ImRaii.Child("emote_picker_list", new Vector2(-1f, 0f), true)) + { + if (child) + { + var any = false; + var itemHeight = emoteSize + (style.FramePadding.Y * 2f); + var cellWidth = itemWidth + style.ItemSpacing.X; + var availableWidth = Math.Max(1f, ImGui.GetContentRegionAvail().X); + var maxColumns = Math.Max(1, (int)MathF.Floor((availableWidth + style.ItemSpacing.X) / cellWidth)); + var columns = Math.Max(1, Math.Min(EmotePickerColumns, maxColumns)); + var columnIndex = 0; + foreach (var emote in emotes) + { + if (hasFilter && !emote.Contains(filter, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + any = true; + IDalamudTextureWrap? texture = null; + _chatEmoteService.TryGetEmote(emote, out texture); + + ImGui.PushID(emote); + var clicked = false; + if (texture is not null) + { + clicked = ImGui.ImageButton(texture.Handle, new Vector2(emoteSize)); + } + else + { + clicked = ImGui.Button("?", new Vector2(itemWidth, itemHeight)); + } + + if (ImGui.IsItemHovered()) + { + DrawEmoteTooltip(emote, texture); + } + + ImGui.PopID(); + + if (clicked) + { + AppendEmoteToDraft(ref draft, emote); + _draftMessages[channelKey] = draft; + _refocusChatInput = true; + _refocusChatInputKey = channelKey; + ImGui.CloseCurrentPopup(); + break; + } + + columnIndex++; + if (columnIndex < columns) + { + ImGui.SameLine(); + } + else + { + columnIndex = 0; + } + } + + if (!any) + { + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); + ImGui.TextUnformatted(emotes.Count == 0 ? "Loading emotes..." : "No emotes found."); + ImGui.PopStyleColor(); + } + } + } + + ImGui.EndPopup(); + } + + private static void AppendEmoteToDraft(ref string draft, string emote) + { + if (string.IsNullOrWhiteSpace(draft)) + { + draft = emote; + return; + } + + if (char.IsWhiteSpace(draft[^1])) + { + draft += emote; + } + else + { + draft += " " + emote; + } + } + + private List BuildChatSegments(string prefix, string message) + { + var segments = new List(Math.Max(16, message.Length / 4)); + AppendChatSegments(segments, prefix, allowEmotes: false); + AppendChatSegments(segments, message, allowEmotes: true); + return segments; + } + + private void AppendChatSegments(List segments, string text, bool allowEmotes) + { + if (string.IsNullOrEmpty(text)) + { + return; + } + + var index = 0; + while (index < text.Length) + { + if (text[index] == '\n') + { + segments.Add(ChatSegment.LineBreak()); + index++; + continue; + } + + if (text[index] == '\r') + { + index++; + continue; + } + + if (char.IsWhiteSpace(text[index])) + { + var start = index; + while (index < text.Length && char.IsWhiteSpace(text[index]) && text[index] != '\n' && text[index] != '\r') + { + index++; + } + + segments.Add(ChatSegment.FromText(text[start..index], isWhitespace: true)); + continue; + } + + var tokenStart = index; + while (index < text.Length && !char.IsWhiteSpace(text[index])) + { + index++; + } + + var token = text[tokenStart..index]; + if (allowEmotes && TrySplitToken(token, out var leading, out var core, out var trailing)) + { + if (_chatEmoteService.TryGetEmote(core, out var texture) && texture is not null) + { + if (!string.IsNullOrEmpty(leading)) + { + segments.Add(ChatSegment.FromText(leading)); + } + + segments.Add(ChatSegment.Emote(texture, core)); + + if (!string.IsNullOrEmpty(trailing)) + { + segments.Add(ChatSegment.FromText(trailing)); + } + + continue; + } + } + + segments.Add(ChatSegment.FromText(token)); + } + } + + private static bool TrySplitToken(string token, out string leading, out string core, out string trailing) + { + leading = string.Empty; + core = string.Empty; + trailing = string.Empty; + + var start = 0; + while (start < token.Length && !IsEmoteChar(token[start])) + { + start++; + } + + var end = token.Length - 1; + while (end >= start && !IsEmoteChar(token[end])) + { + end--; + } + + if (start > end) + { + return false; + } + + leading = token[..start]; + core = token[start..(end + 1)]; + trailing = token[(end + 1)..]; + return true; + } + + private static bool IsEmoteChar(char value) + { + return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '!'; + } + + private void DrawEmoteTooltip(string name, IDalamudTextureWrap? texture) + { + if (string.IsNullOrEmpty(name) && texture is null) + { + return; + } + + ImGui.BeginTooltip(); + ImGui.SetWindowFontScale(1f); + + if (texture is not null) + { + var size = 48f * ImGuiHelpers.GlobalScale; + ImGui.Image(texture.Handle, new Vector2(size)); + } + + if (!string.IsNullOrEmpty(name)) + { + if (texture is not null) + { + ImGui.Spacing(); + } + + ImGui.TextUnformatted(name); + } + + ImGui.EndTooltip(); + } + + private readonly record struct ChatSegment(string Text, IDalamudTextureWrap? Texture, string? EmoteName, bool IsEmote, bool IsWhitespace, bool IsLineBreak) + { + public static ChatSegment FromText(string text, bool isWhitespace = false) => new(text, null, null, false, isWhitespace, false); + public static ChatSegment Emote(IDalamudTextureWrap texture, string name) => new(string.Empty, texture, name, true, false, false); + public static ChatSegment LineBreak() => new(string.Empty, null, null, false, false, true); + } + private void DrawInput(ChatChannelSnapshot channel) { const int MaxMessageLength = ZoneChatService.MaxOutgoingLength; @@ -469,9 +917,10 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase draft ??= string.Empty; var style = ImGui.GetStyle(); - var sendButtonWidth = 100f * ImGuiHelpers.GlobalScale; + var sendButtonWidth = 70f * ImGuiHelpers.GlobalScale; + var emoteButtonWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Comments).X; var counterWidth = ImGui.CalcTextSize($"{MaxMessageLength}/{MaxMessageLength}").X; - var reservedWidth = sendButtonWidth + counterWidth + style.ItemSpacing.X * 2f; + var reservedWidth = sendButtonWidth + emoteButtonWidth + counterWidth + style.ItemSpacing.X * 3f; ImGui.SetNextItemWidth(-reservedWidth); var inputId = $"##chat-input-{channel.Key}"; @@ -482,7 +931,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase _refocusChatInputKey = null; } ImGui.InputText(inputId, ref draft, MaxMessageLength); - if (ImGui.IsItemActive() || ImGui.IsItemFocused()) + if (ImGui.IsItemActive()) { var drawList = ImGui.GetWindowDrawList(); var itemMin = ImGui.GetItemRectMin(); @@ -504,10 +953,22 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SameLine(); var buttonScreenPos = ImGui.GetCursorScreenPos(); var rightEdgeScreen = ImGui.GetWindowPos().X + ImGui.GetWindowContentRegionMax().X; - var desiredButtonX = rightEdgeScreen - sendButtonWidth; var minButtonX = buttonScreenPos.X + style.ItemSpacing.X; - var finalButtonX = MathF.Max(minButtonX, desiredButtonX); - ImGui.SetCursorScreenPos(new Vector2(finalButtonX, buttonScreenPos.Y)); + var desiredSendX = rightEdgeScreen - sendButtonWidth; + var sendX = MathF.Max(minButtonX + emoteButtonWidth + style.ItemSpacing.X, desiredSendX); + var emoteX = sendX - style.ItemSpacing.X - emoteButtonWidth; + + ImGui.SetCursorScreenPos(new Vector2(emoteX, buttonScreenPos.Y)); + if (_uiSharedService.IconButton(FontAwesomeIcon.Comments)) + { + _openEmotePicker = true; + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Open Emotes"); + } + + ImGui.SetCursorScreenPos(new Vector2(sendX, buttonScreenPos.Y)); var sendColor = UIColors.Get("LightlessPurpleDefault"); var sendHovered = UIColors.Get("LightlessPurple"); var sendActive = UIColors.Get("LightlessPurpleActive"); @@ -518,7 +979,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase var sendClicked = false; using (ImRaii.Disabled(!canSend)) { - if (_uiSharedService.IconTextButton(FontAwesomeIcon.PaperPlane, $"Send##chat-send-{channel.Key}", 100f * ImGuiHelpers.GlobalScale, center: true)) + if (_uiSharedService.IconTextButton(FontAwesomeIcon.PaperPlane, $"Send##chat-send-{channel.Key}", sendButtonWidth, center: true)) { sendClicked = true; } @@ -526,47 +987,68 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.PopStyleVar(); ImGui.PopStyleColor(3); + DrawEmotePickerPopup(ref draft, channel.Key); + if (canSend && (enterPressed || sendClicked)) { _refocusChatInput = true; _refocusChatInputKey = channel.Key; - if (TrySendDraft(channel, draft)) + + var draftAtSend = draft; + var sanitized = SanitizeOutgoingDraft(draftAtSend); + + if (sanitized is not null) { - _draftMessages[channel.Key] = string.Empty; - _scrollToBottom = true; + TrackPendingDraftClear(channel.Key, sanitized); + + if (TrySendDraft(channel, sanitized)) + { + _scrollToBottom = true; + + if (_draftMessages.TryGetValue(channel.Key, out var current) && + string.Equals(current, draftAtSend, StringComparison.Ordinal)) + { + draft = string.Empty; + _draftMessages[channel.Key] = draft; + } + + } + else + { + RemovePendingDraftClear(channel.Key, sanitized); + } } } } private void DrawRulesOverlay() { - var windowPos = ImGui.GetWindowPos(); - var windowSize = ImGui.GetWindowSize(); var parentContentMin = ImGui.GetWindowContentRegionMin(); var parentContentMax = ImGui.GetWindowContentRegionMax(); - var overlayPos = windowPos + parentContentMin; var overlaySize = parentContentMax - parentContentMin; if (overlaySize.X <= 0f || overlaySize.Y <= 0f) { - overlayPos = windowPos; - overlaySize = windowSize; + parentContentMin = Vector2.Zero; + overlaySize = ImGui.GetWindowSize(); } - ImGui.SetNextWindowFocus(); - ImGui.SetNextWindowPos(overlayPos); - ImGui.SetNextWindowSize(overlaySize); - ImGui.SetNextWindowBgAlpha(0.86f); - ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * ImGuiHelpers.GlobalScale); - ImGui.PushStyleColor(ImGuiCol.Border, Vector4.Zero); + var previousCursor = ImGui.GetCursorPos(); + ImGui.SetCursorPos(parentContentMin); - var overlayFlags = ImGuiWindowFlags.NoDecoration - | ImGuiWindowFlags.NoMove - | ImGuiWindowFlags.NoScrollbar + var bgColor = ImGui.GetStyle().Colors[(int)ImGuiCol.WindowBg]; + bgColor.W = 0.86f; + + ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 6f * ImGuiHelpers.GlobalScale); + ImGui.PushStyleVar(ImGuiStyleVar.ChildBorderSize, 0f); + ImGui.PushStyleColor(ImGuiCol.Border, Vector4.Zero); + ImGui.PushStyleColor(ImGuiCol.ChildBg, bgColor); + + var overlayFlags = ImGuiWindowFlags.NoScrollbar + | ImGuiWindowFlags.NoScrollWithMouse | ImGuiWindowFlags.NoSavedSettings; - var overlayOpen = true; - if (ImGui.Begin("##zone_chat_rules_overlay", ref overlayOpen, overlayFlags)) + if (ImGui.BeginChild("##zone_chat_rules_overlay", overlaySize, false, overlayFlags)) { var contentMin = ImGui.GetWindowContentRegionMin(); var contentMax = ImGui.GetWindowContentRegionMax(); @@ -686,16 +1168,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase { _showRulesOverlay = false; } - - if (!overlayOpen) - { - _showRulesOverlay = false; - } } - ImGui.End(); - ImGui.PopStyleColor(); - ImGui.PopStyleVar(); + ImGui.EndChild(); + ImGui.PopStyleColor(2); + ImGui.PopStyleVar(2); + ImGui.SetCursorPos(previousCursor); } private void DrawReportPopup() @@ -943,16 +1421,15 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase _reportPopupRequested = false; } - private bool TrySendDraft(ChatChannelSnapshot channel, string draft) + private bool TrySendDraft(ChatChannelSnapshot channel, string sanitizedMessage) { - var trimmed = draft.Trim(); - if (trimmed.Length == 0) + if (string.IsNullOrWhiteSpace(sanitizedMessage)) return false; bool succeeded; try { - succeeded = _zoneChatService.SendMessageAsync(channel.Descriptor, trimmed).GetAwaiter().GetResult(); + succeeded = _zoneChatService.SendMessageAsync(channel.Descriptor, sanitizedMessage).GetAwaiter().GetResult(); } catch (Exception ex) { @@ -987,6 +1464,21 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase { yield return reportAction; } + + var moderationActions = new List(); + foreach (var action in GetSyncshellModerationActions(channel, message, payload)) + { + moderationActions.Add(action); + } + + if (moderationActions.Count > 0) + { + yield return ChatMessageContextAction.Separator(); + foreach (var action in moderationActions) + { + yield return action; + } + } } private static bool TryCreateCopyMessageAction(ChatMessageEntry message, ChatMessageDto payload, out ChatMessageContextAction action) @@ -1094,6 +1586,91 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase return true; } + private IEnumerable GetSyncshellModerationActions(ChatChannelSnapshot channel, ChatMessageEntry message, ChatMessageDto payload) + { + if (channel.Type != ChatChannelType.Group) + yield break; + + if (message.FromSelf) + yield break; + + if (payload.Sender.Kind != ChatSenderKind.IdentifiedUser || payload.Sender.User is null) + yield break; + + var groupId = channel.Descriptor.CustomKey; + if (string.IsNullOrWhiteSpace(groupId)) + yield break; + + var snapshot = _pairUiService.GetSnapshot(); + if (!snapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo)) + yield break; + + var sender = payload.Sender.User; + var senderUid = sender.UID; + if (string.IsNullOrWhiteSpace(senderUid)) + yield break; + + var selfIsOwner = string.Equals(groupInfo.OwnerUID, _apiController.UID, StringComparison.Ordinal); + var selfIsModerator = groupInfo.GroupUserInfo.IsModerator(); + if (!selfIsOwner && !selfIsModerator) + yield break; + + var senderInfo = groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info) ? info : GroupPairUserInfo.None; + var userIsModerator = senderInfo.IsModerator(); + var userIsPinned = senderInfo.IsPinned(); + + var showModeratorActions = selfIsOwner || (selfIsModerator && !userIsModerator); + if (!showModeratorActions) + yield break; + + if (showModeratorActions) + { + var pinLabel = userIsPinned ? "Unpin user" : "Pin user"; + yield return new ChatMessageContextAction( + FontAwesomeIcon.Thumbtack, + pinLabel, + true, + () => + { + var updatedInfo = senderInfo; + updatedInfo.SetPinned(!userIsPinned); + _ = _apiController.GroupSetUserInfo(new GroupPairUserInfoDto(groupInfo.Group, sender, updatedInfo)); + }); + + var removeEnabled = UiSharedService.CtrlPressed(); + var removeLabel = removeEnabled ? "Remove user" : "Remove user (Hold CTRL)"; + yield return new ChatMessageContextAction( + FontAwesomeIcon.Trash, + removeLabel, + removeEnabled, + () => _ = _apiController.GroupRemoveUser(new GroupPairDto(groupInfo.Group, sender)), + "Syncshell action: removes the user from the syncshell, not just chat."); + + var banPair = ResolveBanPair(snapshot, senderUid, sender, groupInfo); + var banEnabled = UiSharedService.CtrlPressed(); + var banLabel = banEnabled ? "Ban user" : "Ban user (Hold CTRL)"; + yield return new ChatMessageContextAction( + FontAwesomeIcon.UserSlash, + banLabel, + banEnabled, + () => Mediator.Publish(new OpenBanUserPopupMessage(banPair!, groupInfo)), + "Hold CTRL to ban the user from the syncshell, not just chat."); + } + + } + + private Pair? ResolveBanPair(PairUiSnapshot snapshot, string senderUid, UserData sender, GroupFullInfoDto groupInfo) + { + if (snapshot.PairsByUid.TryGetValue(senderUid, out var pair)) + { + return pair; + } + + var connection = new PairConnection(sender); + var entry = new PairDisplayEntry(new PairUniqueIdentifier(senderUid), connection, new[] { groupInfo }, null); + return _pairFactory.Create(entry); + } + private Task OpenStandardProfileAsync(UserData user) { _profileManager.GetLightlessProfile(user); @@ -1124,6 +1701,92 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase { _scrollToBottom = true; } + + if (!message.Message.FromSelf || message.Message.Payload?.Message is not { Length: > 0 } payloadText) + { + return; + } + + var matchedPending = false; + if (_pendingDraftClears.TryGetValue(message.ChannelKey, out var pending)) + { + var pendingIndex = pending.FindIndex(text => string.Equals(text, payloadText, StringComparison.Ordinal)); + if (pendingIndex >= 0) + { + pending.RemoveAt(pendingIndex); + matchedPending = true; + if (pending.Count == 0) + { + _pendingDraftClears.Remove(message.ChannelKey); + } + } + } + + if (matchedPending && _draftMessages.TryGetValue(message.ChannelKey, out var currentDraft)) + { + var sanitizedCurrent = SanitizeOutgoingDraft(currentDraft); + if (sanitizedCurrent is not null && string.Equals(sanitizedCurrent, payloadText, StringComparison.Ordinal)) + { + _draftMessages[message.ChannelKey] = string.Empty; + } + } + } + + private static string? SanitizeOutgoingDraft(string draft) + { + if (string.IsNullOrWhiteSpace(draft)) + { + return null; + } + + var sanitized = draft.Trim().ReplaceLineEndings(" "); + if (sanitized.Length == 0) + { + return null; + } + + if (sanitized.Length > ZoneChatService.MaxOutgoingLength) + { + sanitized = sanitized[..ZoneChatService.MaxOutgoingLength]; + } + + return sanitized; + } + + private void TrackPendingDraftClear(string channelKey, string message) + { + if (!_pendingDraftClears.TryGetValue(channelKey, out var pending)) + { + pending = new List(); + _pendingDraftClears[channelKey] = pending; + } + + pending.Add(message); + const int MaxPendingDrafts = 12; + if (pending.Count > MaxPendingDrafts) + { + pending.RemoveAt(0); + } + } + + private void RemovePendingDraftClear(string channelKey, string message) + { + if (!_pendingDraftClears.TryGetValue(channelKey, out var pending)) + { + return; + } + + var index = pending.FindIndex(text => string.Equals(text, message, StringComparison.Ordinal)); + if (index < 0) + { + return; + } + + pending.RemoveAt(index); + if (pending.Count == 0) + { + _pendingDraftClears.Remove(channelKey); + } } private async Task OpenLightfinderProfileInternalAsync(string hashedCid) @@ -1407,6 +2070,17 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SetTooltip("Toggles the timestamp prefix on messages."); } + var showNotesInSyncshellChat = chatConfig.ShowNotesInSyncshellChat; + if (ImGui.Checkbox("Show notes in syncshell chat", ref showNotesInSyncshellChat)) + { + chatConfig.ShowNotesInSyncshellChat = showNotesInSyncshellChat; + _chatConfigService.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("When enabled, your notes replace user names in syncshell chat."); + } + ImGui.Separator(); ImGui.TextUnformatted("Chat Visibility"); @@ -1993,6 +2667,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private void DrawContextMenuAction(ChatMessageContextAction action, int index) { ImGui.PushID(index); + if (action.IsSeparator) + { + ImGui.Separator(); + ImGui.PopID(); + return; + } using var disabled = ImRaii.Disabled(!action.IsEnabled); var availableWidth = Math.Max(1f, ImGui.GetContentRegionAvail().X); @@ -2025,6 +2705,11 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase drawList.AddText(textPos, textColor, action.Label); + if (action.Tooltip is { Length: > 0 } && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + { + ImGui.SetTooltip(action.Tooltip); + } + if (clicked && action.IsEnabled) { ImGui.CloseCurrentPopup(); @@ -2034,5 +2719,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.PopID(); } - private readonly record struct ChatMessageContextAction(FontAwesomeIcon? Icon, string Label, bool IsEnabled, Action Execute); + private static void NoopContextAction() + { + } + + private readonly record struct ChatMessageContextAction(FontAwesomeIcon? Icon, string Label, bool IsEnabled, Action Execute, string? Tooltip = null, bool IsSeparator = false) + { + public static ChatMessageContextAction Separator() => new(null, string.Empty, false, ZoneChatUi.NoopContextAction, null, true); + } } diff --git a/LightlessSync/Utils/UtilsEnum/LabelAlignment.cs b/LightlessSync/Utils/UtilsEnum/LabelAlignment.cs index 66d7247..570b8b5 100644 --- a/LightlessSync/Utils/UtilsEnum/LabelAlignment.cs +++ b/LightlessSync/Utils/UtilsEnum/LabelAlignment.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace LightlessSync.UtilsEnum.Enum +namespace LightlessSync.UtilsEnum.Enum { public enum LabelAlignment { diff --git a/LightlessSync/Utils/UtilsEnum/LightfinderLabelRenderer.cs b/LightlessSync/Utils/UtilsEnum/LightfinderLabelRenderer.cs new file mode 100644 index 0000000..e87d1de --- /dev/null +++ b/LightlessSync/Utils/UtilsEnum/LightfinderLabelRenderer.cs @@ -0,0 +1,8 @@ +namespace LightlessSync.UtilsEnum.Enum +{ + public enum LightfinderLabelRenderer + { + Pictomancy, + SignatureHook, + } +}