Merge conf
This commit is contained in:
@@ -1,15 +1,23 @@
|
|||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace LightlessSync.FileCache;
|
namespace LightlessSync.FileCache;
|
||||||
|
|
||||||
public class FileCacheEntity
|
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;
|
Size = size;
|
||||||
CompressedSize = compressedSize;
|
CompressedSize = compressedSize;
|
||||||
Hash = hash;
|
Hash = hash;
|
||||||
PrefixedFilePath = path;
|
PrefixedFilePath = prefixedFilePath;
|
||||||
LastModifiedDateTicks = lastModifiedDateTicks;
|
LastModifiedDateTicks = lastModifiedDateTicks;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +31,5 @@ public class FileCacheEntity
|
|||||||
public long? Size { get; set; }
|
public long? Size { get; set; }
|
||||||
|
|
||||||
public void SetResolvedFilePath(string filePath)
|
public void SetResolvedFilePath(string filePath)
|
||||||
{
|
=> ResolvedFilepath = filePath.ToLowerInvariant().Replace("\\\\", "\\", StringComparison.Ordinal);
|
||||||
ResolvedFilepath = filePath.ToLowerInvariant().Replace("\\\\", "\\", StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,8 @@ using Microsoft.Extensions.Hosting;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace LightlessSync.FileCache;
|
namespace LightlessSync.FileCache;
|
||||||
@@ -31,6 +33,14 @@ public sealed class FileCacheManager : IHostedService
|
|||||||
private bool _csvHeaderEnsured;
|
private bool _csvHeaderEnsured;
|
||||||
public string CacheFolder => _configService.Current.CacheFolder;
|
public string CacheFolder => _configService.Current.CacheFolder;
|
||||||
|
|
||||||
|
private const string _compressedCacheExtension = ".llz4";
|
||||||
|
private readonly ConcurrentDictionary<string, SemaphoreSlim> _compressLocks = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly ConcurrentDictionary<string, SizeInfo> _sizeCache =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Auto)]
|
||||||
|
public readonly record struct SizeInfo(long Original, long Compressed);
|
||||||
|
|
||||||
public FileCacheManager(ILogger<FileCacheManager> logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator)
|
public FileCacheManager(ILogger<FileCacheManager> logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -45,6 +55,18 @@ public sealed class FileCacheManager : IHostedService
|
|||||||
private static string NormalizeSeparators(string path) => path.Replace("/", "\\", StringComparison.Ordinal)
|
private static string NormalizeSeparators(string path) => path.Replace("/", "\\", StringComparison.Ordinal)
|
||||||
.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)
|
private static string NormalizePrefixedPathKey(string prefixedPath)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(prefixedPath))
|
if (string.IsNullOrEmpty(prefixedPath))
|
||||||
@@ -111,6 +133,114 @@ public sealed class FileCacheManager : IHostedService
|
|||||||
return int.TryParse(versionSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out version);
|
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<FileCacheEntity?> 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<long> 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<byte[]> 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)
|
private string NormalizeToPrefixedPath(string path)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(path)) return string.Empty;
|
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)
|
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;
|
var fileCache = GetFileCacheByHash(fileHash)!.ResolvedFilepath;
|
||||||
return (fileHash, LZ4Wrapper.WrapHC(await File.ReadAllBytesAsync(fileCache, uploadToken).ConfigureAwait(false), 0,
|
var raw = await File.ReadAllBytesAsync(fileCache, uploadToken).ConfigureAwait(false);
|
||||||
(int)new FileInfo(fileCache).Length));
|
var compressed = LZ4Wrapper.WrapHC(raw, 0, raw.Length);
|
||||||
|
UpdateSizeInfo(fileHash, original: raw.LongLength, compressed: compressed.LongLength);
|
||||||
|
return (fileHash, compressed);
|
||||||
}
|
}
|
||||||
|
|
||||||
public FileCacheEntity? GetFileCacheByHash(string hash)
|
public FileCacheEntity? GetFileCacheByHash(string hash)
|
||||||
@@ -891,6 +1030,14 @@ public sealed class FileCacheManager : IHostedService
|
|||||||
compressed = resultCompressed;
|
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)));
|
AddHashedFile(ReplacePathPrefixes(new FileCacheEntity(hash, path, time, size, compressed)));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ using LightlessSync.Services.Mediator;
|
|||||||
using LightlessSync.Utils;
|
using LightlessSync.Utils;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using System.Linq;
|
|
||||||
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
||||||
|
|
||||||
namespace LightlessSync.FileCache;
|
namespace LightlessSync.FileCache;
|
||||||
@@ -28,7 +25,10 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
private readonly object _ownedHandlerLock = new();
|
private readonly object _ownedHandlerLock = new();
|
||||||
private readonly string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk", "kdb"];
|
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[] _handledRecordingFileTypes = ["tex", "mdl", "mtrl"];
|
||||||
|
private readonly string[] _handledFileTypesWithRecording;
|
||||||
private readonly HashSet<GameObjectHandler> _playerRelatedPointers = [];
|
private readonly HashSet<GameObjectHandler> _playerRelatedPointers = [];
|
||||||
|
private readonly object _playerRelatedLock = new();
|
||||||
|
private readonly ConcurrentDictionary<nint, GameObjectHandler> _playerRelatedByAddress = new();
|
||||||
private readonly Dictionary<nint, GameObjectHandler> _ownedHandlers = new();
|
private readonly Dictionary<nint, GameObjectHandler> _ownedHandlers = new();
|
||||||
private ConcurrentDictionary<nint, ObjectKind> _cachedFrameAddresses = new();
|
private ConcurrentDictionary<nint, ObjectKind> _cachedFrameAddresses = new();
|
||||||
private ConcurrentDictionary<ObjectKind, HashSet<string>>? _semiTransientResources = null;
|
private ConcurrentDictionary<ObjectKind, HashSet<string>>? _semiTransientResources = null;
|
||||||
@@ -42,6 +42,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
_dalamudUtil = dalamudUtil;
|
_dalamudUtil = dalamudUtil;
|
||||||
_actorObjectService = actorObjectService;
|
_actorObjectService = actorObjectService;
|
||||||
_gameObjectHandlerFactory = gameObjectHandlerFactory;
|
_gameObjectHandlerFactory = gameObjectHandlerFactory;
|
||||||
|
_handledFileTypesWithRecording = _handledRecordingFileTypes.Concat(_handledFileTypes).ToArray();
|
||||||
|
|
||||||
Mediator.Subscribe<PenumbraResourceLoadMessage>(this, Manager_PenumbraResourceLoadEvent);
|
Mediator.Subscribe<PenumbraResourceLoadMessage>(this, Manager_PenumbraResourceLoadEvent);
|
||||||
Mediator.Subscribe<ActorTrackedMessage>(this, msg => HandleActorTracked(msg.Descriptor));
|
Mediator.Subscribe<ActorTrackedMessage>(this, msg => HandleActorTracked(msg.Descriptor));
|
||||||
@@ -51,12 +52,18 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
|
Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
|
||||||
{
|
{
|
||||||
if (!msg.OwnedObject) return;
|
if (!msg.OwnedObject) return;
|
||||||
_playerRelatedPointers.Add(msg.GameObjectHandler);
|
lock (_playerRelatedLock)
|
||||||
|
{
|
||||||
|
_playerRelatedPointers.Add(msg.GameObjectHandler);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, (msg) =>
|
Mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, (msg) =>
|
||||||
{
|
{
|
||||||
if (!msg.OwnedObject) return;
|
if (!msg.OwnedObject) return;
|
||||||
_playerRelatedPointers.Remove(msg.GameObjectHandler);
|
lock (_playerRelatedLock)
|
||||||
|
{
|
||||||
|
_playerRelatedPointers.Remove(msg.GameObjectHandler);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
foreach (var descriptor in _actorObjectService.ObjectDescriptors)
|
foreach (var descriptor in _actorObjectService.ObjectDescriptors)
|
||||||
@@ -87,9 +94,12 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
_semiTransientResources = new();
|
_semiTransientResources = new();
|
||||||
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
|
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);
|
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
|
||||||
_semiTransientResources[ObjectKind.Pet] = [.. petSpecificData ?? []];
|
_semiTransientResources[ObjectKind.Pet] = new HashSet<string>(
|
||||||
|
petSpecificData ?? [],
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _semiTransientResources;
|
return _semiTransientResources;
|
||||||
@@ -127,14 +137,14 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
SemiTransientResources.TryGetValue(objectKind, out var result);
|
SemiTransientResources.TryGetValue(objectKind, out var result);
|
||||||
|
|
||||||
return result ?? new HashSet<string>(StringComparer.Ordinal);
|
return result ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void PersistTransientResources(ObjectKind objectKind)
|
public void PersistTransientResources(ObjectKind objectKind)
|
||||||
{
|
{
|
||||||
if (!SemiTransientResources.TryGetValue(objectKind, out HashSet<string>? semiTransientResources))
|
if (!SemiTransientResources.TryGetValue(objectKind, out HashSet<string>? semiTransientResources))
|
||||||
{
|
{
|
||||||
SemiTransientResources[objectKind] = semiTransientResources = new(StringComparer.Ordinal);
|
SemiTransientResources[objectKind] = semiTransientResources = new(StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!TransientResources.TryGetValue(objectKind, out var resources))
|
if (!TransientResources.TryGetValue(objectKind, out var resources))
|
||||||
@@ -152,7 +162,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
List<string> newlyAddedGamePaths;
|
List<string> newlyAddedGamePaths;
|
||||||
lock (semiTransientResources)
|
lock (semiTransientResources)
|
||||||
{
|
{
|
||||||
newlyAddedGamePaths = transientResources.Except(semiTransientResources, StringComparer.Ordinal).ToList();
|
newlyAddedGamePaths = transientResources.Except(semiTransientResources, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
foreach (var gamePath in transientResources)
|
foreach (var gamePath in transientResources)
|
||||||
{
|
{
|
||||||
semiTransientResources.Add(gamePath);
|
semiTransientResources.Add(gamePath);
|
||||||
@@ -197,12 +207,13 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
public void RemoveTransientResource(ObjectKind objectKind, string path)
|
public void RemoveTransientResource(ObjectKind objectKind, string path)
|
||||||
{
|
{
|
||||||
|
var normalizedPath = NormalizeGamePath(path);
|
||||||
if (SemiTransientResources.TryGetValue(objectKind, out var resources))
|
if (SemiTransientResources.TryGetValue(objectKind, out var resources))
|
||||||
{
|
{
|
||||||
resources.RemoveWhere(f => string.Equals(path, f, StringComparison.Ordinal));
|
resources.Remove(normalizedPath);
|
||||||
if (objectKind == ObjectKind.Player)
|
if (objectKind == ObjectKind.Player)
|
||||||
{
|
{
|
||||||
PlayerConfig.RemovePath(path, objectKind);
|
PlayerConfig.RemovePath(normalizedPath, objectKind);
|
||||||
Logger.LogTrace("Saving transient.json from {method}", nameof(RemoveTransientResource));
|
Logger.LogTrace("Saving transient.json from {method}", nameof(RemoveTransientResource));
|
||||||
_configurationService.Save();
|
_configurationService.Save();
|
||||||
}
|
}
|
||||||
@@ -211,16 +222,17 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
internal bool AddTransientResource(ObjectKind objectKind, string item)
|
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;
|
return false;
|
||||||
|
|
||||||
if (!TransientResources.TryGetValue(objectKind, out HashSet<string>? transientResource))
|
if (!TransientResources.TryGetValue(objectKind, out HashSet<string>? transientResource))
|
||||||
{
|
{
|
||||||
transientResource = new HashSet<string>(StringComparer.Ordinal);
|
transientResource = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
TransientResources[objectKind] = transientResource;
|
TransientResources[objectKind] = transientResource;
|
||||||
}
|
}
|
||||||
|
|
||||||
return transientResource.Add(item.ToLowerInvariant());
|
return transientResource.Add(normalizedItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void ClearTransientPaths(ObjectKind objectKind, List<string> list)
|
internal void ClearTransientPaths(ObjectKind objectKind, List<string> list)
|
||||||
@@ -285,33 +297,13 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
private void DalamudUtil_FrameworkUpdate()
|
private void DalamudUtil_FrameworkUpdate()
|
||||||
{
|
{
|
||||||
|
RefreshPlayerRelatedAddressMap();
|
||||||
|
|
||||||
lock (_cacheAdditionLock)
|
lock (_cacheAdditionLock)
|
||||||
{
|
{
|
||||||
_cachedHandledPaths.Clear();
|
_cachedHandledPaths.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
var activeDescriptors = new Dictionary<nint, ObjectKind>();
|
|
||||||
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)
|
if (_lastClassJobId != _dalamudUtil.ClassJobId)
|
||||||
{
|
{
|
||||||
_lastClassJobId = _dalamudUtil.ClassJobId;
|
_lastClassJobId = _dalamudUtil.ClassJobId;
|
||||||
@@ -323,7 +315,9 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
|
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
|
||||||
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
|
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
|
||||||
SemiTransientResources[ObjectKind.Pet] = [.. petSpecificData ?? []];
|
SemiTransientResources[ObjectKind.Pet] = new HashSet<string>(
|
||||||
|
petSpecificData ?? [],
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast<ObjectKind>())
|
foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast<ObjectKind>())
|
||||||
@@ -340,9 +334,12 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
_ = Task.Run(() =>
|
_ = Task.Run(() =>
|
||||||
{
|
{
|
||||||
Logger.LogDebug("Penumbra Mod Settings changed, verifying SemiTransientResources");
|
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;
|
_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<nint, ObjectKind>();
|
||||||
|
lock (_playerRelatedLock)
|
||||||
{
|
{
|
||||||
resolvedKind = ownedKind;
|
foreach (var handler in _playerRelatedPointers)
|
||||||
return true;
|
{
|
||||||
|
var address = (nint)handler.Address;
|
||||||
|
if (address != nint.Zero)
|
||||||
|
{
|
||||||
|
_playerRelatedByAddress[address] = handler;
|
||||||
|
updatedFrameAddresses[address] = handler.ObjectKind;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (descriptor.ObjectKind == DalamudObjectKind.Player)
|
_cachedFrameAddresses = updatedFrameAddresses;
|
||||||
{
|
|
||||||
resolvedKind = ObjectKind.Player;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedKind = default;
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
|
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
|
||||||
@@ -375,18 +374,15 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
if (descriptor.IsInGpose)
|
if (descriptor.IsInGpose)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (!TryResolveObjectKind(descriptor, out var resolvedKind))
|
if (descriptor.OwnedKind is not ObjectKind ownedKind)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (Logger.IsEnabled(LogLevel.Debug))
|
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;
|
_cachedFrameAddresses[descriptor.Address] = ownedKind;
|
||||||
|
|
||||||
if (descriptor.OwnedKind is not ObjectKind ownedKind)
|
|
||||||
return;
|
|
||||||
|
|
||||||
lock (_ownedHandlerLock)
|
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)
|
private void Manager_PenumbraResourceLoadEvent(PenumbraResourceLoadMessage msg)
|
||||||
{
|
{
|
||||||
var gamePath = msg.GamePath.ToLowerInvariant();
|
|
||||||
var gameObjectAddress = msg.GameObject;
|
var gameObjectAddress = msg.GameObject;
|
||||||
var filePath = msg.FilePath;
|
if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind))
|
||||||
|
|
||||||
// ignore files already processed this frame
|
|
||||||
if (_cachedHandledPaths.Contains(gamePath)) return;
|
|
||||||
|
|
||||||
lock (_cacheAdditionLock)
|
|
||||||
{
|
{
|
||||||
_cachedHandledPaths.Add(gamePath);
|
if (_actorObjectService.TryGetOwnedKind(gameObjectAddress, out var ownedKind))
|
||||||
|
{
|
||||||
|
objectKind = ownedKind;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// replace individual mtrl stuff
|
var gamePath = NormalizeGamePath(msg.GamePath);
|
||||||
if (filePath.StartsWith("|", StringComparison.OrdinalIgnoreCase))
|
if (string.IsNullOrEmpty(gamePath))
|
||||||
{
|
|
||||||
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))
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ignore files already processed this frame
|
||||||
|
lock (_cacheAdditionLock)
|
||||||
|
{
|
||||||
|
if (!_cachedHandledPaths.Add(gamePath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ignore files to not handle
|
// ignore files to not handle
|
||||||
var handledTypes = IsTransientRecording ? _handledRecordingFileTypes.Concat(_handledFileTypes) : _handledFileTypes;
|
var handledTypes = IsTransientRecording ? _handledFileTypesWithRecording : _handledFileTypes;
|
||||||
if (!handledTypes.Any(type => gamePath.EndsWith(type, StringComparison.OrdinalIgnoreCase)))
|
if (!HasHandledFileType(gamePath, handledTypes))
|
||||||
{
|
{
|
||||||
lock (_cacheAdditionLock)
|
|
||||||
{
|
|
||||||
_cachedHandledPaths.Add(gamePath);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ignore files not belonging to anything player related
|
var filePath = NormalizeFilePath(msg.FilePath);
|
||||||
if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind))
|
|
||||||
|
// ignore files that are the same
|
||||||
|
if (string.Equals(filePath, gamePath, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
lock (_cacheAdditionLock)
|
|
||||||
{
|
|
||||||
_cachedHandledPaths.Add(gamePath);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,15 +550,15 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
TransientResources[objectKind] = transientResources;
|
TransientResources[objectKind] = transientResources;
|
||||||
}
|
}
|
||||||
|
|
||||||
var owner = _playerRelatedPointers.FirstOrDefault(f => f.Address == gameObjectAddress);
|
_playerRelatedByAddress.TryGetValue(gameObjectAddress, out var owner);
|
||||||
bool alreadyTransient = false;
|
bool alreadyTransient = false;
|
||||||
|
|
||||||
bool transientContains = transientResources.Contains(replacedGamePath);
|
bool transientContains = transientResources.Contains(gamePath);
|
||||||
bool semiTransientContains = SemiTransientResources.SelectMany(k => k.Value).Any(f => string.Equals(f, gamePath, StringComparison.OrdinalIgnoreCase));
|
bool semiTransientContains = SemiTransientResources.Values.Any(value => value.Contains(gamePath));
|
||||||
if (transientContains || semiTransientContains)
|
if (transientContains || semiTransientContains)
|
||||||
{
|
{
|
||||||
if (!IsTransientRecording)
|
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);
|
transientContains, semiTransientContains);
|
||||||
alreadyTransient = true;
|
alreadyTransient = true;
|
||||||
}
|
}
|
||||||
@@ -539,10 +566,10 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
if (!IsTransientRecording)
|
if (!IsTransientRecording)
|
||||||
{
|
{
|
||||||
bool isAdded = transientResources.Add(replacedGamePath);
|
bool isAdded = transientResources.Add(gamePath);
|
||||||
if (isAdded)
|
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);
|
SendTransients(gameObjectAddress, objectKind);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -550,7 +577,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
if (owner != null && IsTransientRecording)
|
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 (!item.AddTransient || item.AlreadyTransient) continue;
|
||||||
if (!TransientResources.TryGetValue(item.Owner.ObjectKind, out var transient))
|
if (!TransientResources.TryGetValue(item.Owner.ObjectKind, out var transient))
|
||||||
{
|
{
|
||||||
TransientResources[item.Owner.ObjectKind] = transient = [];
|
TransientResources[item.Owner.ObjectKind] = transient = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.LogTrace("Adding recorded: {gamePath} => {filePath}", item.GamePath, item.FilePath);
|
Logger.LogTrace("Adding recorded: {gamePath} => {filePath}", item.GamePath, item.FilePath);
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ public sealed class IpcCallerPenumbra : IpcServiceBase
|
|||||||
public Task<(string[] forward, string[][] reverse)> ResolvePathsAsync(string[] forward, string[] reverse)
|
public Task<(string[] forward, string[][] reverse)> ResolvePathsAsync(string[] forward, string[] reverse)
|
||||||
=> _resources.ResolvePathsAsync(forward, 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)
|
public Task RedrawAsync(ILogger logger, GameObjectHandler handler, Guid applicationId, CancellationToken token)
|
||||||
=> _redraw.RedrawAsync(logger, handler, applicationId, token);
|
=> _redraw.RedrawAsync(logger, handler, applicationId, token);
|
||||||
|
|
||||||
@@ -171,11 +177,6 @@ public sealed class IpcCallerPenumbra : IpcServiceBase
|
|||||||
});
|
});
|
||||||
|
|
||||||
Mediator.Subscribe<DalamudLoginMessage>(this, _ => _shownPenumbraUnavailable = false);
|
Mediator.Subscribe<DalamudLoginMessage>(this, _ => _shownPenumbraUnavailable = false);
|
||||||
|
|
||||||
Mediator.Subscribe<ActorTrackedMessage>(this, msg => _resources.TrackActor(msg.Descriptor.Address));
|
|
||||||
Mediator.Subscribe<ActorUntrackedMessage>(this, msg => _resources.UntrackActor(msg.Descriptor.Address));
|
|
||||||
Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, msg => _resources.TrackActor(msg.GameObjectHandler.Address));
|
|
||||||
Mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, msg => _resources.UntrackActor(msg.GameObjectHandler.Address));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandlePenumbraInitialized()
|
private void HandlePenumbraInitialized()
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ public sealed class PenumbraCollections : PenumbraBase
|
|||||||
_activeTemporaryCollections.TryRemove(collectionId, out _);
|
_activeTemporaryCollections.TryRemove(collectionId, out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, IReadOnlyDictionary<string, string> modPaths)
|
public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths)
|
||||||
{
|
{
|
||||||
if (!IsAvailable || collectionId == Guid.Empty)
|
if (!IsAvailable || collectionId == Guid.Empty)
|
||||||
{
|
{
|
||||||
@@ -109,7 +109,7 @@ public sealed class PenumbraCollections : PenumbraBase
|
|||||||
var removeResult = _removeTemporaryMod.Invoke("LightlessChara_Files", collectionId, 0);
|
var removeResult = _removeTemporaryMod.Invoke("LightlessChara_Files", collectionId, 0);
|
||||||
logger.LogTrace("[{ApplicationId}] Removing temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, removeResult);
|
logger.LogTrace("[{ApplicationId}] Removing temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, removeResult);
|
||||||
|
|
||||||
var addResult = _addTemporaryMod.Invoke("LightlessChara_Files", collectionId, new Dictionary<string, string>(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);
|
logger.LogTrace("[{ApplicationId}] Setting temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, addResult);
|
||||||
}).ConfigureAwait(false);
|
}).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using Dalamud.Plugin;
|
using Dalamud.Plugin;
|
||||||
using LightlessSync.Interop.Ipc.Framework;
|
using LightlessSync.Interop.Ipc.Framework;
|
||||||
using LightlessSync.PlayerData.Handlers;
|
using LightlessSync.PlayerData.Handlers;
|
||||||
@@ -15,10 +14,11 @@ public sealed class PenumbraResource : PenumbraBase
|
|||||||
{
|
{
|
||||||
private readonly ActorObjectService _actorObjectService;
|
private readonly ActorObjectService _actorObjectService;
|
||||||
private readonly GetGameObjectResourcePaths _gameObjectResourcePaths;
|
private readonly GetGameObjectResourcePaths _gameObjectResourcePaths;
|
||||||
|
private readonly ResolveGameObjectPath _resolveGameObjectPath;
|
||||||
|
private readonly ReverseResolveGameObjectPath _reverseResolveGameObjectPath;
|
||||||
private readonly ResolvePlayerPathsAsync _resolvePlayerPaths;
|
private readonly ResolvePlayerPathsAsync _resolvePlayerPaths;
|
||||||
private readonly GetPlayerMetaManipulations _getPlayerMetaManipulations;
|
private readonly GetPlayerMetaManipulations _getPlayerMetaManipulations;
|
||||||
private readonly EventSubscriber<nint, string, string> _gameObjectResourcePathResolved;
|
private readonly EventSubscriber<nint, string, string> _gameObjectResourcePathResolved;
|
||||||
private readonly ConcurrentDictionary<IntPtr, byte> _trackedActors = new();
|
|
||||||
|
|
||||||
public PenumbraResource(
|
public PenumbraResource(
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
@@ -29,14 +29,11 @@ public sealed class PenumbraResource : PenumbraBase
|
|||||||
{
|
{
|
||||||
_actorObjectService = actorObjectService;
|
_actorObjectService = actorObjectService;
|
||||||
_gameObjectResourcePaths = new GetGameObjectResourcePaths(pluginInterface);
|
_gameObjectResourcePaths = new GetGameObjectResourcePaths(pluginInterface);
|
||||||
|
_resolveGameObjectPath = new ResolveGameObjectPath(pluginInterface);
|
||||||
|
_reverseResolveGameObjectPath = new ReverseResolveGameObjectPath(pluginInterface);
|
||||||
_resolvePlayerPaths = new ResolvePlayerPathsAsync(pluginInterface);
|
_resolvePlayerPaths = new ResolvePlayerPathsAsync(pluginInterface);
|
||||||
_getPlayerMetaManipulations = new GetPlayerMetaManipulations(pluginInterface);
|
_getPlayerMetaManipulations = new GetPlayerMetaManipulations(pluginInterface);
|
||||||
_gameObjectResourcePathResolved = GameObjectResourcePathResolved.Subscriber(pluginInterface, HandleResourceLoaded);
|
_gameObjectResourcePathResolved = GameObjectResourcePathResolved.Subscriber(pluginInterface, HandleResourceLoaded);
|
||||||
|
|
||||||
foreach (var descriptor in _actorObjectService.PlayerDescriptors)
|
|
||||||
{
|
|
||||||
TrackActor(descriptor.Address);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string Name => "Penumbra.Resources";
|
public override string Name => "Penumbra.Resources";
|
||||||
@@ -74,63 +71,34 @@ public sealed class PenumbraResource : PenumbraBase
|
|||||||
return await _resolvePlayerPaths.Invoke(forwardPaths, reversePaths).ConfigureAwait(false);
|
return await _resolvePlayerPaths.Invoke(forwardPaths, reversePaths).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void TrackActor(nint address)
|
public string ResolveGameObjectPath(string gamePath, int gameObjectIndex)
|
||||||
{
|
=> IsAvailable ? _resolveGameObjectPath.Invoke(gamePath, gameObjectIndex) : gamePath;
|
||||||
if (address != nint.Zero)
|
|
||||||
{
|
|
||||||
_trackedActors[(IntPtr)address] = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UntrackActor(nint address)
|
public string[] ReverseResolveGameObjectPath(string moddedPath, int gameObjectIndex)
|
||||||
{
|
=> IsAvailable ? _reverseResolveGameObjectPath.Invoke(moddedPath, gameObjectIndex) : Array.Empty<string>();
|
||||||
if (address != nint.Zero)
|
|
||||||
{
|
|
||||||
_trackedActors.TryRemove((IntPtr)address, out _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleResourceLoaded(nint ptr, string resolvedPath, string gamePath)
|
private void HandleResourceLoaded(nint ptr, string gamePath, string resolvedPath)
|
||||||
{
|
{
|
||||||
if (ptr == nint.Zero)
|
if (ptr == nint.Zero)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_trackedActors.ContainsKey(ptr))
|
if (!_actorObjectService.TryGetOwnedKind(ptr, out _))
|
||||||
{
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
return;
|
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)
|
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()
|
public override void Dispose()
|
||||||
|
|||||||
@@ -19,6 +19,27 @@ public class ConfigurationMigrator(ILogger<ConfigurationMigrator> logger, Transi
|
|||||||
transientConfigService.Save();
|
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)
|
if (serverConfigService.Current.Version == 1)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Migrating Server Config V1 => V2");
|
_logger.LogInformation("Migrating Server Config V1 => V2");
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public sealed class ChatConfig : ILightlessConfiguration
|
|||||||
public bool AutoEnableChatOnLogin { get; set; } = false;
|
public bool AutoEnableChatOnLogin { get; set; } = false;
|
||||||
public bool ShowRulesOverlayOnOpen { get; set; } = true;
|
public bool ShowRulesOverlayOnOpen { get; set; } = true;
|
||||||
public bool ShowMessageTimestamps { get; set; } = true;
|
public bool ShowMessageTimestamps { get; set; } = true;
|
||||||
|
public bool ShowNotesInSyncshellChat { get; set; } = true;
|
||||||
public float ChatWindowOpacity { get; set; } = .97f;
|
public float ChatWindowOpacity { get; set; } = .97f;
|
||||||
public bool FadeWhenUnfocused { get; set; } = false;
|
public bool FadeWhenUnfocused { get; set; } = false;
|
||||||
public float UnfocusedWindowOpacity { get; set; } = 0.6f;
|
public float UnfocusedWindowOpacity { get; set; } = 0.6f;
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ public class LightlessConfig : ILightlessConfiguration
|
|||||||
public bool useColoredUIDs { get; set; } = true;
|
public bool useColoredUIDs { get; set; } = true;
|
||||||
public bool BroadcastEnabled { get; set; } = false;
|
public bool BroadcastEnabled { get; set; } = false;
|
||||||
public bool LightfinderAutoEnableOnConnect { get; set; } = false;
|
public bool LightfinderAutoEnableOnConnect { get; set; } = false;
|
||||||
|
public LightfinderLabelRenderer LightfinderLabelRenderer { get; set; } = LightfinderLabelRenderer.Pictomancy;
|
||||||
public short LightfinderLabelOffsetX { get; set; } = 0;
|
public short LightfinderLabelOffsetX { get; set; } = 0;
|
||||||
public short LightfinderLabelOffsetY { get; set; } = 0;
|
public short LightfinderLabelOffsetY { get; set; } = 0;
|
||||||
public bool LightfinderLabelUseIcon { get; set; } = false;
|
public bool LightfinderLabelUseIcon { get; set; } = false;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace LightlessSync.LightlessConfiguration.Configurations;
|
|||||||
public class TransientConfig : ILightlessConfiguration
|
public class TransientConfig : ILightlessConfiguration
|
||||||
{
|
{
|
||||||
public Dictionary<string, TransientPlayerConfig> TransientConfigs { get; set; } = [];
|
public Dictionary<string, TransientPlayerConfig> TransientConfigs { get; set; } = [];
|
||||||
public int Version { get; set; } = 1;
|
public int Version { get; set; } = 2;
|
||||||
|
|
||||||
public class TransientPlayerConfig
|
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<string> NormalizeList(List<string> entries, ref bool changed, ref int removedEntries)
|
||||||
|
{
|
||||||
|
if (entries.Count == 0)
|
||||||
|
return entries;
|
||||||
|
|
||||||
|
var result = new List<string>(entries.Count);
|
||||||
|
var seen = new HashSet<string>(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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ public class PlayerDataFactory
|
|||||||
|
|
||||||
// get all remaining paths and resolve them
|
// get all remaining paths and resolve them
|
||||||
var transientPaths = ManageSemiTransientData(objectKind);
|
var transientPaths = ManageSemiTransientData(objectKind);
|
||||||
var resolvedTransientPaths = await GetFileReplacementsFromPaths(transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
|
var resolvedTransientPaths = await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
|
||||||
|
|
||||||
if (logDebug)
|
if (logDebug)
|
||||||
{
|
{
|
||||||
@@ -373,11 +373,73 @@ public class PlayerDataFactory
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(HashSet<string> forwardResolve, HashSet<string> reverseResolve)
|
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(GameObjectHandler handler, HashSet<string> forwardResolve, HashSet<string> reverseResolve)
|
||||||
{
|
{
|
||||||
var forwardPaths = forwardResolve.ToArray();
|
var forwardPaths = forwardResolve.ToArray();
|
||||||
var reversePaths = reverseResolve.ToArray();
|
var reversePaths = reverseResolve.ToArray();
|
||||||
Dictionary<string, List<string>> resolvedPaths = new(StringComparer.Ordinal);
|
Dictionary<string, List<string>> 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<string>(), Array.Empty<string[]>());
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>(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);
|
var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false);
|
||||||
for (int i = 0; i < forwardPaths.Length; i++)
|
for (int i = 0; i < forwardPaths.Length; i++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
private int _lastMissingCriticalMods;
|
private int _lastMissingCriticalMods;
|
||||||
private int _lastMissingNonCriticalMods;
|
private int _lastMissingNonCriticalMods;
|
||||||
private int _lastMissingForbiddenMods;
|
private int _lastMissingForbiddenMods;
|
||||||
|
private bool _lastMissingCachedFiles;
|
||||||
private bool _isVisible;
|
private bool _isVisible;
|
||||||
private Guid _penumbraCollection;
|
private Guid _penumbraCollection;
|
||||||
private readonly object _collectionGate = new();
|
private readonly object _collectionGate = new();
|
||||||
@@ -557,7 +558,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var shouldForce = forced || HasMissingCachedFiles(LastReceivedCharacterData);
|
var hasMissingCachedFiles = HasMissingCachedFiles(LastReceivedCharacterData);
|
||||||
|
var missingResolved = _lastMissingCachedFiles && !hasMissingCachedFiles;
|
||||||
|
_lastMissingCachedFiles = hasMissingCachedFiles;
|
||||||
|
var shouldForce = forced || missingResolved;
|
||||||
|
|
||||||
if (IsPaused())
|
if (IsPaused())
|
||||||
{
|
{
|
||||||
@@ -700,7 +704,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(replacement.FileSwapPath))
|
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());
|
Logger.LogTrace("Missing file swap path {Path} detected for {Handler}", replacement.FileSwapPath, GetLogIdentifier());
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
services.AddSingleton<FileDialogManager>();
|
services.AddSingleton<FileDialogManager>();
|
||||||
services.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", string.Empty, useEmbedded: true));
|
services.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", string.Empty, useEmbedded: true));
|
||||||
services.AddSingleton(gameGui);
|
services.AddSingleton(gameGui);
|
||||||
|
services.AddSingleton(gameInteropProvider);
|
||||||
services.AddSingleton(addonLifecycle);
|
services.AddSingleton(addonLifecycle);
|
||||||
services.AddSingleton<IUiBuilder>(pluginInterface.UiBuilder);
|
services.AddSingleton<IUiBuilder>(pluginInterface.UiBuilder);
|
||||||
|
|
||||||
@@ -116,6 +117,7 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
services.AddSingleton<ProfileTagService>();
|
services.AddSingleton<ProfileTagService>();
|
||||||
services.AddSingleton<ApiController>();
|
services.AddSingleton<ApiController>();
|
||||||
services.AddSingleton<PerformanceCollectorService>();
|
services.AddSingleton<PerformanceCollectorService>();
|
||||||
|
services.AddSingleton<NameplateUpdateHookService>();
|
||||||
services.AddSingleton<HubFactory>();
|
services.AddSingleton<HubFactory>();
|
||||||
services.AddSingleton<FileUploadManager>();
|
services.AddSingleton<FileUploadManager>();
|
||||||
services.AddSingleton<FileTransferOrchestrator>();
|
services.AddSingleton<FileTransferOrchestrator>();
|
||||||
@@ -134,6 +136,7 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
services.AddSingleton<TagHandler>();
|
services.AddSingleton<TagHandler>();
|
||||||
services.AddSingleton<PairRequestService>();
|
services.AddSingleton<PairRequestService>();
|
||||||
services.AddSingleton<ZoneChatService>();
|
services.AddSingleton<ZoneChatService>();
|
||||||
|
services.AddSingleton<ChatEmoteService>();
|
||||||
services.AddSingleton<IdDisplayHandler>();
|
services.AddSingleton<IdDisplayHandler>();
|
||||||
services.AddSingleton<PlayerPerformanceService>();
|
services.AddSingleton<PlayerPerformanceService>();
|
||||||
services.AddSingleton<PenumbraTempCollectionJanitor>();
|
services.AddSingleton<PenumbraTempCollectionJanitor>();
|
||||||
@@ -280,12 +283,22 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
pluginInterface,
|
pluginInterface,
|
||||||
sp.GetRequiredService<PictomancyService>()));
|
sp.GetRequiredService<PictomancyService>()));
|
||||||
|
|
||||||
|
services.AddSingleton(sp => new LightFinderNativePlateHandler(
|
||||||
|
sp.GetRequiredService<ILogger<LightFinderNativePlateHandler>>(),
|
||||||
|
clientState,
|
||||||
|
sp.GetRequiredService<LightlessConfigService>(),
|
||||||
|
sp.GetRequiredService<LightlessMediator>(),
|
||||||
|
objectTable,
|
||||||
|
sp.GetRequiredService<PairUiService>(),
|
||||||
|
sp.GetRequiredService<NameplateUpdateHookService>()));
|
||||||
|
|
||||||
services.AddSingleton(sp => new LightFinderScannerService(
|
services.AddSingleton(sp => new LightFinderScannerService(
|
||||||
sp.GetRequiredService<ILogger<LightFinderScannerService>>(),
|
sp.GetRequiredService<ILogger<LightFinderScannerService>>(),
|
||||||
framework,
|
framework,
|
||||||
sp.GetRequiredService<LightFinderService>(),
|
sp.GetRequiredService<LightFinderService>(),
|
||||||
sp.GetRequiredService<LightlessMediator>(),
|
sp.GetRequiredService<LightlessMediator>(),
|
||||||
sp.GetRequiredService<LightFinderPlateHandler>(),
|
sp.GetRequiredService<LightFinderPlateHandler>(),
|
||||||
|
sp.GetRequiredService<LightFinderNativePlateHandler>(),
|
||||||
sp.GetRequiredService<ActorObjectService>()));
|
sp.GetRequiredService<ActorObjectService>()));
|
||||||
|
|
||||||
services.AddSingleton(sp => new ContextMenuService(
|
services.AddSingleton(sp => new ContextMenuService(
|
||||||
@@ -467,7 +480,8 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
sp.GetRequiredService<LightlessConfigService>(),
|
sp.GetRequiredService<LightlessConfigService>(),
|
||||||
sp.GetRequiredService<UiSharedService>(),
|
sp.GetRequiredService<UiSharedService>(),
|
||||||
sp.GetRequiredService<ApiController>(),
|
sp.GetRequiredService<ApiController>(),
|
||||||
sp.GetRequiredService<LightFinderScannerService>()));
|
sp.GetRequiredService<LightFinderScannerService>(),
|
||||||
|
sp.GetRequiredService<LightFinderPlateHandler>()));
|
||||||
|
|
||||||
services.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>(sp => new SyncshellFinderUI(
|
services.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>(sp => new SyncshellFinderUI(
|
||||||
sp.GetRequiredService<ILogger<SyncshellFinderUI>>(),
|
sp.GetRequiredService<ILogger<SyncshellFinderUI>>(),
|
||||||
@@ -536,9 +550,9 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
clientState,
|
clientState,
|
||||||
gameGui,
|
gameGui,
|
||||||
objectTable,
|
objectTable,
|
||||||
gameInteropProvider,
|
|
||||||
sp.GetRequiredService<LightlessMediator>(),
|
sp.GetRequiredService<LightlessMediator>(),
|
||||||
sp.GetRequiredService<PairUiService>()));
|
sp.GetRequiredService<PairUiService>(),
|
||||||
|
sp.GetRequiredService<NameplateUpdateHookService>()));
|
||||||
|
|
||||||
// Hosted services
|
// Hosted services
|
||||||
services.AddHostedService(sp => sp.GetRequiredService<ConfigurationSaveService>());
|
services.AddHostedService(sp => sp.GetRequiredService<ConfigurationSaveService>());
|
||||||
@@ -557,6 +571,7 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
services.AddHostedService(sp => sp.GetRequiredService<ContextMenuService>());
|
services.AddHostedService(sp => sp.GetRequiredService<ContextMenuService>());
|
||||||
services.AddHostedService(sp => sp.GetRequiredService<LightFinderService>());
|
services.AddHostedService(sp => sp.GetRequiredService<LightFinderService>());
|
||||||
services.AddHostedService(sp => sp.GetRequiredService<LightFinderPlateHandler>());
|
services.AddHostedService(sp => sp.GetRequiredService<LightFinderPlateHandler>());
|
||||||
|
services.AddHostedService(sp => sp.GetRequiredService<LightFinderNativePlateHandler>());
|
||||||
}).Build();
|
}).Build();
|
||||||
|
|
||||||
_ = _host.StartAsync();
|
_ = _host.StartAsync();
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using Dalamud.Game.ClientState.Conditions;
|
using Dalamud.Game.ClientState.Conditions;
|
||||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
|
||||||
using Dalamud.Hooking;
|
using Dalamud.Hooking;
|
||||||
using Dalamud.Plugin.Services;
|
using Dalamud.Plugin.Services;
|
||||||
using FFXIVClientStructs.Interop;
|
using FFXIVClientStructs.Interop;
|
||||||
@@ -10,6 +9,8 @@ using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
|||||||
using LightlessSync.Services.Mediator;
|
using LightlessSync.Services.Mediator;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
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 DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
||||||
using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
||||||
|
|
||||||
@@ -41,7 +42,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
|||||||
private readonly ConcurrentDictionary<string, ActorDescriptor> _actorsByHash = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, ActorDescriptor> _actorsByHash = new(StringComparer.Ordinal);
|
||||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<nint, ActorDescriptor>> _actorsByName = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, ConcurrentDictionary<nint, ActorDescriptor>> _actorsByName = new(StringComparer.Ordinal);
|
||||||
private readonly ConcurrentDictionary<nint, byte> _pendingHashResolutions = new();
|
private readonly ConcurrentDictionary<nint, byte> _pendingHashResolutions = new();
|
||||||
private readonly OwnedObjectTracker _ownedTracker = new();
|
|
||||||
private ActorSnapshot _snapshot = ActorSnapshot.Empty;
|
private ActorSnapshot _snapshot = ActorSnapshot.Empty;
|
||||||
private GposeSnapshot _gposeSnapshot = GposeSnapshot.Empty;
|
private GposeSnapshot _gposeSnapshot = GposeSnapshot.Empty;
|
||||||
|
|
||||||
@@ -151,15 +151,16 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
|||||||
public bool TryGetOwnedObjectByIndex(ushort objectIndex, out LightlessObjectKind ownedKind)
|
public bool TryGetOwnedObjectByIndex(ushort objectIndex, out LightlessObjectKind ownedKind)
|
||||||
{
|
{
|
||||||
ownedKind = default;
|
ownedKind = default;
|
||||||
var ownedSnapshot = OwnedObjects;
|
var ownedDescriptors = OwnedDescriptors;
|
||||||
foreach (var (address, kind) in ownedSnapshot)
|
for (var i = 0; i < ownedDescriptors.Count; i++)
|
||||||
{
|
{
|
||||||
if (!TryGetDescriptor(address, out var descriptor))
|
var descriptor = ownedDescriptors[i];
|
||||||
|
if (descriptor.ObjectIndex != objectIndex)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (descriptor.ObjectIndex == objectIndex)
|
if (descriptor.OwnedKind is { } resolvedKind)
|
||||||
{
|
{
|
||||||
ownedKind = kind;
|
ownedKind = resolvedKind;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,7 +317,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
|||||||
_actorsByHash.Clear();
|
_actorsByHash.Clear();
|
||||||
_actorsByName.Clear();
|
_actorsByName.Clear();
|
||||||
_pendingHashResolutions.Clear();
|
_pendingHashResolutions.Clear();
|
||||||
_ownedTracker.Reset();
|
|
||||||
Volatile.Write(ref _snapshot, ActorSnapshot.Empty);
|
Volatile.Write(ref _snapshot, ActorSnapshot.Empty);
|
||||||
Volatile.Write(ref _gposeSnapshot, GposeSnapshot.Empty);
|
Volatile.Write(ref _gposeSnapshot, GposeSnapshot.Empty);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@@ -481,50 +481,196 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
|||||||
return (isLocalPlayer ? LightlessObjectKind.Player : null, entityId);
|
return (isLocalPlayer ? LightlessObjectKind.Player : null, entityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLocalPlayer)
|
var ownerId = ResolveOwnerId(gameObject);
|
||||||
{
|
var localPlayerAddress = _objectTable.LocalPlayer?.Address ?? nint.Zero;
|
||||||
var entityId = ((Character*)gameObject)->EntityId;
|
if (localPlayerAddress == nint.Zero)
|
||||||
return (LightlessObjectKind.Player, entityId);
|
return (null, ownerId);
|
||||||
}
|
|
||||||
|
|
||||||
if (_objectTable.LocalPlayer is not { } localPlayer)
|
var localEntityId = ((Character*)localPlayerAddress)->EntityId;
|
||||||
return (null, 0);
|
if (localEntityId == 0)
|
||||||
|
return (null, ownerId);
|
||||||
|
|
||||||
var ownerId = gameObject->OwnerId;
|
if (objectKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
|
||||||
if (ownerId == 0)
|
|
||||||
{
|
{
|
||||||
var character = (Character*)gameObject;
|
var expectedMinionOrMount = GetMinionOrMountAddress(localPlayerAddress, localEntityId);
|
||||||
if (character != null)
|
if (expectedMinionOrMount != nint.Zero && (nint)gameObject == expectedMinionOrMount)
|
||||||
{
|
{
|
||||||
ownerId = character->CompanionOwnerId;
|
var resolvedOwner = ownerId != 0 ? ownerId : localEntityId;
|
||||||
if (ownerId == 0)
|
return (LightlessObjectKind.MinionOrMount, resolvedOwner);
|
||||||
{
|
|
||||||
var parent = character->GetParentCharacter();
|
|
||||||
if (parent != null)
|
|
||||||
{
|
|
||||||
ownerId = parent->EntityId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ownerId == 0 || ownerId != localPlayer.EntityId)
|
if (objectKind != DalamudObjectKind.BattleNpc)
|
||||||
return (null, ownerId);
|
return (null, ownerId);
|
||||||
|
|
||||||
var ownedKind = objectKind switch
|
if (ownerId != localEntityId)
|
||||||
{
|
return (null, ownerId);
|
||||||
DalamudObjectKind.MountType => LightlessObjectKind.MinionOrMount,
|
|
||||||
DalamudObjectKind.Companion => LightlessObjectKind.MinionOrMount,
|
|
||||||
DalamudObjectKind.BattleNpc => gameObject->BattleNpcSubKind switch
|
|
||||||
{
|
|
||||||
BattleNpcSubKind.Buddy => LightlessObjectKind.Companion,
|
|
||||||
BattleNpcSubKind.Pet => LightlessObjectKind.Pet,
|
|
||||||
_ => (LightlessObjectKind?)null,
|
|
||||||
},
|
|
||||||
_ => (LightlessObjectKind?)null,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (ownedKind, ownerId);
|
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)
|
private void UntrackGameObject(nint address)
|
||||||
@@ -618,11 +764,8 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
|||||||
private void ReplaceDescriptor(ActorDescriptor existing, ActorDescriptor updated)
|
private void ReplaceDescriptor(ActorDescriptor existing, ActorDescriptor updated)
|
||||||
{
|
{
|
||||||
RemoveDescriptorFromIndexes(existing);
|
RemoveDescriptorFromIndexes(existing);
|
||||||
_ownedTracker.OnDescriptorRemoved(existing);
|
|
||||||
|
|
||||||
_activePlayers[updated.Address] = updated;
|
_activePlayers[updated.Address] = updated;
|
||||||
IndexDescriptor(updated);
|
IndexDescriptor(updated);
|
||||||
_ownedTracker.OnDescriptorAdded(updated);
|
|
||||||
UpdatePendingHashResolutions(updated);
|
UpdatePendingHashResolutions(updated);
|
||||||
PublishSnapshot();
|
PublishSnapshot();
|
||||||
}
|
}
|
||||||
@@ -690,7 +833,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
|||||||
{
|
{
|
||||||
_activePlayers[descriptor.Address] = descriptor;
|
_activePlayers[descriptor.Address] = descriptor;
|
||||||
IndexDescriptor(descriptor);
|
IndexDescriptor(descriptor);
|
||||||
_ownedTracker.OnDescriptorAdded(descriptor);
|
|
||||||
UpdatePendingHashResolutions(descriptor);
|
UpdatePendingHashResolutions(descriptor);
|
||||||
PublishSnapshot();
|
PublishSnapshot();
|
||||||
}
|
}
|
||||||
@@ -698,7 +840,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
|||||||
private void RemoveDescriptor(ActorDescriptor descriptor)
|
private void RemoveDescriptor(ActorDescriptor descriptor)
|
||||||
{
|
{
|
||||||
RemoveDescriptorFromIndexes(descriptor);
|
RemoveDescriptorFromIndexes(descriptor);
|
||||||
_ownedTracker.OnDescriptorRemoved(descriptor);
|
|
||||||
_pendingHashResolutions.TryRemove(descriptor.Address, out _);
|
_pendingHashResolutions.TryRemove(descriptor.Address, out _);
|
||||||
PublishSnapshot();
|
PublishSnapshot();
|
||||||
}
|
}
|
||||||
@@ -722,17 +863,90 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
|||||||
|
|
||||||
private void PublishSnapshot()
|
private void PublishSnapshot()
|
||||||
{
|
{
|
||||||
var playerDescriptors = _activePlayers.Values
|
var descriptors = _activePlayers.Values.ToArray();
|
||||||
.Where(descriptor => descriptor.ObjectKind == DalamudObjectKind.Player)
|
var playerCount = 0;
|
||||||
.ToArray();
|
var ownedCount = 0;
|
||||||
var ownedDescriptors = _activePlayers.Values
|
var companionCount = 0;
|
||||||
.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 ownedSnapshot = _ownedTracker.CreateSnapshot();
|
foreach (var descriptor in descriptors)
|
||||||
|
{
|
||||||
|
if (descriptor.ObjectKind == DalamudObjectKind.Player)
|
||||||
|
playerCount++;
|
||||||
|
|
||||||
|
if (descriptor.OwnedKind is not null)
|
||||||
|
ownedCount++;
|
||||||
|
|
||||||
|
if (descriptor.ObjectKind == DalamudObjectKind.Companion)
|
||||||
|
companionCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var playerDescriptors = new ActorDescriptor[playerCount];
|
||||||
|
var ownedDescriptors = new ActorDescriptor[ownedCount];
|
||||||
|
var playerAddresses = new nint[playerCount];
|
||||||
|
var renderedCompanions = new nint[companionCount];
|
||||||
|
var ownedAddresses = new nint[ownedCount];
|
||||||
|
var ownedMap = new Dictionary<nint, LightlessObjectKind>(ownedCount);
|
||||||
|
nint localPlayer = nint.Zero;
|
||||||
|
nint localPet = nint.Zero;
|
||||||
|
nint localMinionOrMount = nint.Zero;
|
||||||
|
nint localCompanion = nint.Zero;
|
||||||
|
|
||||||
|
var playerIndex = 0;
|
||||||
|
var ownedIndex = 0;
|
||||||
|
var companionIndex = 0;
|
||||||
|
|
||||||
|
foreach (var descriptor in descriptors)
|
||||||
|
{
|
||||||
|
if (descriptor.ObjectKind == DalamudObjectKind.Player)
|
||||||
|
{
|
||||||
|
playerDescriptors[playerIndex] = descriptor;
|
||||||
|
playerAddresses[playerIndex] = descriptor.Address;
|
||||||
|
playerIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descriptor.ObjectKind == DalamudObjectKind.Companion)
|
||||||
|
{
|
||||||
|
renderedCompanions[companionIndex] = descriptor.Address;
|
||||||
|
companionIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descriptor.OwnedKind is not { } ownedKind)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ownedDescriptors[ownedIndex] = descriptor;
|
||||||
|
ownedAddresses[ownedIndex] = descriptor.Address;
|
||||||
|
ownedMap[descriptor.Address] = ownedKind;
|
||||||
|
|
||||||
|
switch (ownedKind)
|
||||||
|
{
|
||||||
|
case LightlessObjectKind.Player:
|
||||||
|
localPlayer = descriptor.Address;
|
||||||
|
break;
|
||||||
|
case LightlessObjectKind.Pet:
|
||||||
|
localPet = descriptor.Address;
|
||||||
|
break;
|
||||||
|
case LightlessObjectKind.MinionOrMount:
|
||||||
|
localMinionOrMount = descriptor.Address;
|
||||||
|
break;
|
||||||
|
case LightlessObjectKind.Companion:
|
||||||
|
localCompanion = descriptor.Address;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
ownedIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ownedSnapshot = new OwnedObjectSnapshot(
|
||||||
|
playerAddresses,
|
||||||
|
renderedCompanions,
|
||||||
|
ownedAddresses,
|
||||||
|
ownedMap,
|
||||||
|
localPlayer,
|
||||||
|
localPet,
|
||||||
|
localMinionOrMount,
|
||||||
|
localCompanion);
|
||||||
var nextGeneration = Snapshot.Generation + 1;
|
var nextGeneration = Snapshot.Generation + 1;
|
||||||
var snapshot = new ActorSnapshot(playerDescriptors, ownedDescriptors, playerAddresses, ownedSnapshot, nextGeneration);
|
var snapshot = new ActorSnapshot(playerDescriptors, ownedDescriptors, playerAddresses, ownedSnapshot, nextGeneration);
|
||||||
Volatile.Write(ref _snapshot, snapshot);
|
Volatile.Write(ref _snapshot, snapshot);
|
||||||
@@ -955,109 +1169,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class OwnedObjectTracker
|
|
||||||
{
|
|
||||||
private readonly HashSet<nint> _renderedPlayers = new();
|
|
||||||
private readonly HashSet<nint> _renderedCompanions = new();
|
|
||||||
private readonly Dictionary<nint, LightlessObjectKind> _ownedObjects = new();
|
|
||||||
private nint _localPlayerAddress = nint.Zero;
|
|
||||||
private nint _localPetAddress = nint.Zero;
|
|
||||||
private nint _localMinionMountAddress = nint.Zero;
|
|
||||||
private nint _localCompanionAddress = nint.Zero;
|
|
||||||
|
|
||||||
public void OnDescriptorAdded(ActorDescriptor descriptor)
|
|
||||||
{
|
|
||||||
if (descriptor.ObjectKind == DalamudObjectKind.Player)
|
|
||||||
{
|
|
||||||
_renderedPlayers.Add(descriptor.Address);
|
|
||||||
if (descriptor.IsLocalPlayer)
|
|
||||||
_localPlayerAddress = descriptor.Address;
|
|
||||||
}
|
|
||||||
else if (descriptor.ObjectKind == DalamudObjectKind.Companion)
|
|
||||||
{
|
|
||||||
_renderedCompanions.Add(descriptor.Address);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (descriptor.OwnedKind is { } ownedKind)
|
|
||||||
{
|
|
||||||
_ownedObjects[descriptor.Address] = ownedKind;
|
|
||||||
switch (ownedKind)
|
|
||||||
{
|
|
||||||
case LightlessObjectKind.Player:
|
|
||||||
_localPlayerAddress = descriptor.Address;
|
|
||||||
break;
|
|
||||||
case LightlessObjectKind.Pet:
|
|
||||||
_localPetAddress = descriptor.Address;
|
|
||||||
break;
|
|
||||||
case LightlessObjectKind.MinionOrMount:
|
|
||||||
_localMinionMountAddress = descriptor.Address;
|
|
||||||
break;
|
|
||||||
case LightlessObjectKind.Companion:
|
|
||||||
_localCompanionAddress = descriptor.Address;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnDescriptorRemoved(ActorDescriptor descriptor)
|
|
||||||
{
|
|
||||||
if (descriptor.ObjectKind == DalamudObjectKind.Player)
|
|
||||||
{
|
|
||||||
_renderedPlayers.Remove(descriptor.Address);
|
|
||||||
if (descriptor.IsLocalPlayer && _localPlayerAddress == descriptor.Address)
|
|
||||||
_localPlayerAddress = nint.Zero;
|
|
||||||
}
|
|
||||||
else if (descriptor.ObjectKind == DalamudObjectKind.Companion)
|
|
||||||
{
|
|
||||||
_renderedCompanions.Remove(descriptor.Address);
|
|
||||||
if (_localCompanionAddress == descriptor.Address)
|
|
||||||
_localCompanionAddress = nint.Zero;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (descriptor.OwnedKind is { } ownedKind)
|
|
||||||
{
|
|
||||||
_ownedObjects.Remove(descriptor.Address);
|
|
||||||
switch (ownedKind)
|
|
||||||
{
|
|
||||||
case LightlessObjectKind.Player when _localPlayerAddress == descriptor.Address:
|
|
||||||
_localPlayerAddress = nint.Zero;
|
|
||||||
break;
|
|
||||||
case LightlessObjectKind.Pet when _localPetAddress == descriptor.Address:
|
|
||||||
_localPetAddress = nint.Zero;
|
|
||||||
break;
|
|
||||||
case LightlessObjectKind.MinionOrMount when _localMinionMountAddress == descriptor.Address:
|
|
||||||
_localMinionMountAddress = nint.Zero;
|
|
||||||
break;
|
|
||||||
case LightlessObjectKind.Companion when _localCompanionAddress == descriptor.Address:
|
|
||||||
_localCompanionAddress = nint.Zero;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public OwnedObjectSnapshot CreateSnapshot()
|
|
||||||
=> new(
|
|
||||||
_renderedPlayers.ToArray(),
|
|
||||||
_renderedCompanions.ToArray(),
|
|
||||||
_ownedObjects.Keys.ToArray(),
|
|
||||||
new Dictionary<nint, LightlessObjectKind>(_ownedObjects),
|
|
||||||
_localPlayerAddress,
|
|
||||||
_localPetAddress,
|
|
||||||
_localMinionMountAddress,
|
|
||||||
_localCompanionAddress);
|
|
||||||
|
|
||||||
public void Reset()
|
|
||||||
{
|
|
||||||
_renderedPlayers.Clear();
|
|
||||||
_renderedCompanions.Clear();
|
|
||||||
_ownedObjects.Clear();
|
|
||||||
_localPlayerAddress = nint.Zero;
|
|
||||||
_localPetAddress = nint.Zero;
|
|
||||||
_localMinionMountAddress = nint.Zero;
|
|
||||||
_localCompanionAddress = nint.Zero;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record OwnedObjectSnapshot(
|
private sealed record OwnedObjectSnapshot(
|
||||||
IReadOnlyList<nint> RenderedPlayers,
|
IReadOnlyList<nint> RenderedPlayers,
|
||||||
IReadOnlyList<nint> RenderedCompanions,
|
IReadOnlyList<nint> RenderedCompanions,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using FFXIVClientStructs.FFXIV.Client.Graphics.Render;
|
||||||
using LightlessSync.API.Data;
|
using LightlessSync.API.Data;
|
||||||
using LightlessSync.API.Data.Enum;
|
using LightlessSync.API.Data.Enum;
|
||||||
using LightlessSync.FileCache;
|
using LightlessSync.FileCache;
|
||||||
@@ -98,11 +99,13 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
|||||||
_analysisCts = null;
|
_analysisCts = null;
|
||||||
if (print) PrintAnalysis();
|
if (print) PrintAnalysis();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_analysisCts.CancelDispose();
|
_analysisCts.CancelDispose();
|
||||||
_baseAnalysisCts.Dispose();
|
_baseAnalysisCts.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateFileEntriesAsync(IEnumerable<string> filePaths, CancellationToken token)
|
public async Task UpdateFileEntriesAsync(IEnumerable<string> filePaths, CancellationToken token)
|
||||||
{
|
{
|
||||||
var normalized = new HashSet<string>(
|
var normalized = new HashSet<string>(
|
||||||
@@ -125,6 +128,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task BaseAnalysis(CharacterData charaData, CancellationToken token)
|
private async Task BaseAnalysis(CharacterData charaData, CancellationToken token)
|
||||||
{
|
{
|
||||||
if (string.Equals(charaData.DataHash.Value, _lastDataHash, StringComparison.Ordinal)) return;
|
if (string.Equals(charaData.DataHash.Value, _lastDataHash, StringComparison.Ordinal)) return;
|
||||||
@@ -136,29 +140,47 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
|||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var fileCacheEntries = (await _fileCacheManager.GetAllFileCachesByHashAsync(fileEntry.Hash, ignoreCacheEntries: true, validate: false, token).ConfigureAwait(false)).ToList();
|
var fileCacheEntries = (await _fileCacheManager
|
||||||
if (fileCacheEntries.Count == 0) continue;
|
.GetAllFileCachesByHashAsync(fileEntry.Hash, ignoreCacheEntries: true, validate: false, token)
|
||||||
var filePath = fileCacheEntries[0].ResolvedFilepath;
|
.ConfigureAwait(false))
|
||||||
FileInfo fi = new(filePath);
|
.ToList();
|
||||||
string ext = "unk?";
|
|
||||||
try
|
if (fileCacheEntries.Count == 0)
|
||||||
{
|
continue;
|
||||||
ext = fi.Extension[1..];
|
|
||||||
}
|
var resolved = fileCacheEntries[0].ResolvedFilepath;
|
||||||
catch (Exception ex)
|
|
||||||
{
|
var extWithDot = Path.GetExtension(resolved);
|
||||||
Logger.LogWarning(ex, "Could not identify extension for {path}", filePath);
|
var ext = string.IsNullOrEmpty(extWithDot) ? "unk?" : extWithDot.TrimStart('.');
|
||||||
}
|
|
||||||
var tris = await _xivDataAnalyzer.GetTrianglesByHash(fileEntry.Hash).ConfigureAwait(false);
|
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,
|
if (orig <= 0 && cached.Original > 0) orig = cached.Original;
|
||||||
[.. fileEntry.GamePaths],
|
if (comp <= 0 && cached.Compressed > 0) comp = cached.Compressed;
|
||||||
[.. fileCacheEntries.Select(c => c.ResolvedFilepath).Distinct(StringComparer.Ordinal)],
|
|
||||||
entry.Size > 0 ? entry.Size.Value : 0,
|
|
||||||
entry.CompressedSize > 0 ? entry.CompressedSize.Value : 0,
|
|
||||||
tris);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data[fileEntry.Hash] = new FileDataEntry(
|
||||||
|
fileEntry.Hash,
|
||||||
|
ext,
|
||||||
|
[.. fileEntry.GamePaths],
|
||||||
|
distinctFilePaths,
|
||||||
|
orig,
|
||||||
|
comp,
|
||||||
|
tris,
|
||||||
|
fileCacheEntries);
|
||||||
}
|
}
|
||||||
LastAnalysis[obj.Key] = data;
|
LastAnalysis[obj.Key] = data;
|
||||||
}
|
}
|
||||||
@@ -167,6 +189,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
|||||||
Mediator.Publish(new CharacterDataAnalyzedMessage());
|
Mediator.Publish(new CharacterDataAnalyzedMessage());
|
||||||
_lastDataHash = charaData.DataHash.Value;
|
_lastDataHash = charaData.DataHash.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RecalculateSummary()
|
private void RecalculateSummary()
|
||||||
{
|
{
|
||||||
var builder = ImmutableDictionary.CreateBuilder<ObjectKind, CharacterAnalysisObjectSummary>();
|
var builder = ImmutableDictionary.CreateBuilder<ObjectKind, CharacterAnalysisObjectSummary>();
|
||||||
@@ -192,6 +215,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
|||||||
|
|
||||||
_latestSummary = new CharacterAnalysisSummary(builder.ToImmutable());
|
_latestSummary = new CharacterAnalysisSummary(builder.ToImmutable());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PrintAnalysis()
|
private void PrintAnalysis()
|
||||||
{
|
{
|
||||||
if (LastAnalysis.Count == 0) return;
|
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))));
|
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.");
|
Logger.LogInformation("IMPORTANT NOTES:\n\r- For Lightless up- and downloads only the compressed size is relevant.\n\r- An unusually high total files count beyond 200 and up will also increase your download time to others significantly.");
|
||||||
}
|
}
|
||||||
internal sealed record FileDataEntry(string Hash, string FileType, List<string> GamePaths, List<string> FilePaths, long OriginalSize, long CompressedSize, long Triangles)
|
|
||||||
{
|
|
||||||
public bool IsComputed => OriginalSize > 0 && CompressedSize > 0;
|
|
||||||
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<string> Format => _format ??= CreateFormatValue();
|
|
||||||
|
|
||||||
|
internal sealed class FileDataEntry
|
||||||
|
{
|
||||||
|
public string Hash { get; }
|
||||||
|
public string FileType { get; }
|
||||||
|
public List<string> GamePaths { get; }
|
||||||
|
public List<string> FilePaths { get; }
|
||||||
|
|
||||||
|
public long OriginalSize { get; private set; }
|
||||||
|
public long CompressedSize { get; private set; }
|
||||||
|
public long Triangles { get; private set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<FileCacheEntity> CacheEntries { get; }
|
||||||
|
|
||||||
|
public bool IsComputed => OriginalSize > 0 && CompressedSize > 0;
|
||||||
|
|
||||||
|
public FileDataEntry(
|
||||||
|
string hash,
|
||||||
|
string fileType,
|
||||||
|
List<string> gamePaths,
|
||||||
|
List<string> filePaths,
|
||||||
|
long originalSize,
|
||||||
|
long compressedSize,
|
||||||
|
long triangles,
|
||||||
|
IReadOnlyList<FileCacheEntity> 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<string> Format => _format ??= CreateFormatValue();
|
||||||
private Lazy<string>? _format;
|
private Lazy<string>? _format;
|
||||||
|
|
||||||
public void RefreshFormat()
|
public void RefreshFormat() => _format = CreateFormatValue();
|
||||||
{
|
|
||||||
_format = CreateFormatValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Lazy<string> CreateFormatValue()
|
private Lazy<string> CreateFormatValue()
|
||||||
=> new(() =>
|
=> new(() =>
|
||||||
{
|
{
|
||||||
if (!string.Equals(FileType, "tex", StringComparison.Ordinal))
|
if (!string.Equals(FileType, "tex", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
275
LightlessSync/Services/Chat/ChatEmoteService.cs
Normal file
275
LightlessSync/Services/Chat/ChatEmoteService.cs
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
using Dalamud.Interface.Textures.TextureWraps;
|
||||||
|
using LightlessSync.UI;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace LightlessSync.Services.Chat;
|
||||||
|
|
||||||
|
public sealed class ChatEmoteService : IDisposable
|
||||||
|
{
|
||||||
|
private const string GlobalEmoteSetUrl = "https://7tv.io/v3/emote-sets/global";
|
||||||
|
|
||||||
|
private readonly ILogger<ChatEmoteService> _logger;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly UiSharedService _uiSharedService;
|
||||||
|
private readonly ConcurrentDictionary<string, EmoteEntry> _emotes = new(StringComparer.Ordinal);
|
||||||
|
private readonly SemaphoreSlim _downloadGate = new(3, 3);
|
||||||
|
|
||||||
|
private readonly object _loadLock = new();
|
||||||
|
private Task? _loadTask;
|
||||||
|
|
||||||
|
public ChatEmoteService(ILogger<ChatEmoteService> logger, HttpClient httpClient, UiSharedService uiSharedService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_uiSharedService = uiSharedService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EnsureGlobalEmotesLoaded()
|
||||||
|
{
|
||||||
|
lock (_loadLock)
|
||||||
|
{
|
||||||
|
if (_loadTask is not null && !_loadTask.IsCompleted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_emotes.Count > 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadTask = Task.Run(LoadGlobalEmotesAsync);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<string> GetEmoteNames()
|
||||||
|
{
|
||||||
|
EnsureGlobalEmotesLoaded();
|
||||||
|
var names = _emotes.Keys.ToArray();
|
||||||
|
Array.Sort(names, StringComparer.OrdinalIgnoreCase);
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetEmote(string code, out IDalamudTextureWrap? texture)
|
||||||
|
{
|
||||||
|
texture = null;
|
||||||
|
EnsureGlobalEmotesLoaded();
|
||||||
|
|
||||||
|
if (!_emotes.TryGetValue(code, out var entry))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Texture is not null)
|
||||||
|
{
|
||||||
|
texture = entry.Texture;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.EnsureLoading(QueueEmoteDownload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var entry in _emotes.Values)
|
||||||
|
{
|
||||||
|
entry.Texture?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_downloadGate.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadGlobalEmotesAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = await _httpClient.GetStreamAsync(GlobalEmoteSetUrl).ConfigureAwait(false);
|
||||||
|
using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!document.RootElement.TryGetProperty("emotes", out var emotes))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("7TV emote set response missing emotes array");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var emoteElement in emotes.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!emoteElement.TryGetProperty("name", out var nameElement))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = nameElement.GetString();
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = TryBuildEmoteUrl(emoteElement);
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_emotes.TryAdd(name, new EmoteEntry(url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to load 7TV emote set");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? TryBuildEmoteUrl(JsonElement emoteElement)
|
||||||
|
{
|
||||||
|
if (!emoteElement.TryGetProperty("data", out var dataElement))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dataElement.TryGetProperty("host", out var hostElement))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hostElement.TryGetProperty("url", out var urlElement))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseUrl = urlElement.GetString();
|
||||||
|
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseUrl.StartsWith("//", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
baseUrl = "https:" + baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hostElement.TryGetProperty("files", out var filesElement))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileName = PickBestStaticFile(filesElement);
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseUrl.TrimEnd('/') + "/" + fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? PickBestStaticFile(JsonElement filesElement)
|
||||||
|
{
|
||||||
|
string? png1x = null;
|
||||||
|
string? webp1x = null;
|
||||||
|
string? pngFallback = null;
|
||||||
|
string? webpFallback = null;
|
||||||
|
|
||||||
|
foreach (var file in filesElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (file.TryGetProperty("static", out var staticElement) && staticElement.ValueKind == JsonValueKind.False)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.TryGetProperty("name", out var nameElement))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = nameElement.GetString();
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.Equals("1x.png", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
png1x = name;
|
||||||
|
}
|
||||||
|
else if (name.Equals("1x.webp", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
webp1x = name;
|
||||||
|
}
|
||||||
|
else if (name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && pngFallback is null)
|
||||||
|
{
|
||||||
|
pngFallback = name;
|
||||||
|
}
|
||||||
|
else if (name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) && webpFallback is null)
|
||||||
|
{
|
||||||
|
webpFallback = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return png1x ?? webp1x ?? pngFallback ?? webpFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QueueEmoteDownload(EmoteEntry entry)
|
||||||
|
{
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await _downloadGate.WaitAsync().ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = await _httpClient.GetByteArrayAsync(entry.Url).ConfigureAwait(false);
|
||||||
|
var texture = _uiSharedService.LoadImage(data);
|
||||||
|
entry.SetTexture(texture);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to load 7TV emote {Url}", entry.Url);
|
||||||
|
entry.MarkFailed();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_downloadGate.Release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class EmoteEntry
|
||||||
|
{
|
||||||
|
private int _loadingState;
|
||||||
|
|
||||||
|
public EmoteEntry(string url)
|
||||||
|
{
|
||||||
|
Url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Url { get; }
|
||||||
|
public IDalamudTextureWrap? Texture { get; private set; }
|
||||||
|
|
||||||
|
public void EnsureLoading(Action<EmoteEntry> queueDownload)
|
||||||
|
{
|
||||||
|
if (Texture is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Interlocked.CompareExchange(ref _loadingState, 1, 0) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queueDownload(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetTexture(IDalamudTextureWrap texture)
|
||||||
|
{
|
||||||
|
Texture = texture;
|
||||||
|
Interlocked.Exchange(ref _loadingState, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkFailed()
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _loadingState, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using LightlessSync.API.Dto.Chat;
|
|||||||
using LightlessSync.API.Data.Extensions;
|
using LightlessSync.API.Data.Extensions;
|
||||||
using LightlessSync.Services.ActorTracking;
|
using LightlessSync.Services.ActorTracking;
|
||||||
using LightlessSync.Services.Mediator;
|
using LightlessSync.Services.Mediator;
|
||||||
|
using LightlessSync.Services.ServerConfiguration;
|
||||||
using LightlessSync.WebAPI;
|
using LightlessSync.WebAPI;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -25,6 +26,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
|||||||
private readonly ActorObjectService _actorObjectService;
|
private readonly ActorObjectService _actorObjectService;
|
||||||
private readonly PairUiService _pairUiService;
|
private readonly PairUiService _pairUiService;
|
||||||
private readonly ChatConfigService _chatConfigService;
|
private readonly ChatConfigService _chatConfigService;
|
||||||
|
private readonly ServerConfigurationManager _serverConfigurationManager;
|
||||||
|
|
||||||
private readonly Lock _sync = new();
|
private readonly Lock _sync = new();
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
|||||||
private readonly Dictionary<string, bool> _lastPresenceStates = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, bool> _lastPresenceStates = new(StringComparer.Ordinal);
|
||||||
private readonly Dictionary<string, string> _selfTokens = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, string> _selfTokens = new(StringComparer.Ordinal);
|
||||||
private readonly List<PendingSelfMessage> _pendingSelfMessages = new();
|
private readonly List<PendingSelfMessage> _pendingSelfMessages = new();
|
||||||
|
private readonly Dictionary<string, List<ChatMessageEntry>> _messageHistoryCache = new(StringComparer.Ordinal);
|
||||||
private List<ChatChannelSnapshot>? _cachedChannelSnapshots;
|
private List<ChatChannelSnapshot>? _cachedChannelSnapshots;
|
||||||
private bool _channelsSnapshotDirty = true;
|
private bool _channelsSnapshotDirty = true;
|
||||||
|
|
||||||
@@ -54,7 +57,8 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
|||||||
ApiController apiController,
|
ApiController apiController,
|
||||||
DalamudUtilService dalamudUtilService,
|
DalamudUtilService dalamudUtilService,
|
||||||
ActorObjectService actorObjectService,
|
ActorObjectService actorObjectService,
|
||||||
PairUiService pairUiService)
|
PairUiService pairUiService,
|
||||||
|
ServerConfigurationManager serverConfigurationManager)
|
||||||
: base(logger, mediator)
|
: base(logger, mediator)
|
||||||
{
|
{
|
||||||
_apiController = apiController;
|
_apiController = apiController;
|
||||||
@@ -62,6 +66,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
|||||||
_actorObjectService = actorObjectService;
|
_actorObjectService = actorObjectService;
|
||||||
_pairUiService = pairUiService;
|
_pairUiService = pairUiService;
|
||||||
_chatConfigService = chatConfigService;
|
_chatConfigService = chatConfigService;
|
||||||
|
_serverConfigurationManager = serverConfigurationManager;
|
||||||
|
|
||||||
_isLoggedIn = _dalamudUtilService.IsLoggedIn;
|
_isLoggedIn = _dalamudUtilService.IsLoggedIn;
|
||||||
_isConnected = _apiController.IsConnected;
|
_isConnected = _apiController.IsConnected;
|
||||||
@@ -776,6 +781,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
|||||||
using (_sync.EnterScope())
|
using (_sync.EnterScope())
|
||||||
{
|
{
|
||||||
var remainingGroups = new HashSet<string>(_groupDefinitions.Keys, StringComparer.OrdinalIgnoreCase);
|
var remainingGroups = new HashSet<string>(_groupDefinitions.Keys, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var allowRemoval = _isConnected;
|
||||||
|
|
||||||
foreach (var info in infoList)
|
foreach (var info in infoList)
|
||||||
{
|
{
|
||||||
@@ -791,18 +797,19 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
|||||||
var key = BuildChannelKey(descriptor);
|
var key = BuildChannelKey(descriptor);
|
||||||
if (!_channels.TryGetValue(key, out var state))
|
if (!_channels.TryGetValue(key, out var state))
|
||||||
{
|
{
|
||||||
state = new ChatChannelState(key, ChatChannelType.Group, info.DisplayName ?? groupId, descriptor);
|
state = new ChatChannelState(key, ChatChannelType.Group, info.DisplayName ?? groupId, descriptor);
|
||||||
state.IsConnected = _chatEnabled && _isConnected;
|
var restoredCount = RestoreCachedMessagesLocked(state);
|
||||||
state.IsAvailable = _chatEnabled && _isConnected;
|
state.IsConnected = _chatEnabled && _isConnected;
|
||||||
state.StatusText = !_chatEnabled
|
state.IsAvailable = _chatEnabled && _isConnected;
|
||||||
? "Chat services disabled"
|
state.StatusText = !_chatEnabled
|
||||||
: (_isConnected ? null : "Disconnected from chat server");
|
? "Chat services disabled"
|
||||||
_channels[key] = state;
|
: (_isConnected ? null : "Disconnected from chat server");
|
||||||
_lastReadCounts[key] = 0;
|
_channels[key] = state;
|
||||||
if (_chatEnabled)
|
_lastReadCounts[key] = restoredCount > 0 ? state.Messages.Count : 0;
|
||||||
{
|
if (_chatEnabled)
|
||||||
descriptorsToJoin.Add(descriptor);
|
{
|
||||||
}
|
descriptorsToJoin.Add(descriptor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
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 (_groupDefinitions.TryGetValue(removedGroupId, out var definition))
|
||||||
if (_channels.TryGetValue(key, out var state))
|
|
||||||
{
|
{
|
||||||
descriptorsToLeave.Add(state.Descriptor);
|
var key = BuildChannelKey(definition.Descriptor);
|
||||||
_channels.Remove(key);
|
if (_channels.TryGetValue(key, out var state))
|
||||||
_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;
|
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,
|
descriptor.Type,
|
||||||
displayName,
|
displayName,
|
||||||
descriptor.Type == ChatChannelType.Zone ? (_lastZoneDescriptor ?? descriptor) : descriptor);
|
descriptor.Type == ChatChannelType.Zone ? (_lastZoneDescriptor ?? descriptor) : descriptor);
|
||||||
|
var restoredCount = RestoreCachedMessagesLocked(state);
|
||||||
|
|
||||||
state.IsConnected = _isConnected;
|
state.IsConnected = _isConnected;
|
||||||
state.IsAvailable = descriptor.Type == ChatChannelType.Group && _isConnected;
|
state.IsAvailable = descriptor.Type == ChatChannelType.Group && _isConnected;
|
||||||
state.StatusText = descriptor.Type == ChatChannelType.Zone ? ZoneUnavailableMessage : (_isConnected ? null : "Disconnected from chat server");
|
state.StatusText = descriptor.Type == ChatChannelType.Zone ? ZoneUnavailableMessage : (_isConnected ? null : "Disconnected from chat server");
|
||||||
|
|
||||||
_channels[key] = state;
|
_channels[key] = state;
|
||||||
_lastReadCounts[key] = 0;
|
_lastReadCounts[key] = restoredCount > 0 ? state.Messages.Count : 0;
|
||||||
publishChannelList = true;
|
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.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;
|
return dto.Sender.User.AliasOrUID;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1288,11 +1309,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
|||||||
if (!_channels.TryGetValue(ZoneChannelKey, out var state))
|
if (!_channels.TryGetValue(ZoneChannelKey, out var state))
|
||||||
{
|
{
|
||||||
state = new ChatChannelState(ZoneChannelKey, ChatChannelType.Zone, "Zone Chat", new ChatChannelDescriptor { Type = ChatChannelType.Zone });
|
state = new ChatChannelState(ZoneChannelKey, ChatChannelType.Zone, "Zone Chat", new ChatChannelDescriptor { Type = ChatChannelType.Zone });
|
||||||
|
var restoredCount = RestoreCachedMessagesLocked(state);
|
||||||
state.IsConnected = _chatEnabled && _isConnected;
|
state.IsConnected = _chatEnabled && _isConnected;
|
||||||
state.IsAvailable = false;
|
state.IsAvailable = false;
|
||||||
state.StatusText = _chatEnabled ? ZoneUnavailableMessage : "Chat services disabled";
|
state.StatusText = _chatEnabled ? ZoneUnavailableMessage : "Chat services disabled";
|
||||||
_channels[ZoneChannelKey] = state;
|
_channels[ZoneChannelKey] = state;
|
||||||
_lastReadCounts[ZoneChannelKey] = 0;
|
_lastReadCounts[ZoneChannelKey] = restoredCount > 0 ? state.Messages.Count : 0;
|
||||||
UpdateChannelOrderLocked();
|
UpdateChannelOrderLocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1301,6 +1323,11 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
|||||||
|
|
||||||
private void RemoveZoneStateLocked()
|
private void RemoveZoneStateLocked()
|
||||||
{
|
{
|
||||||
|
if (_channels.TryGetValue(ZoneChannelKey, out var existing))
|
||||||
|
{
|
||||||
|
CacheMessagesLocked(existing);
|
||||||
|
}
|
||||||
|
|
||||||
if (_channels.Remove(ZoneChannelKey))
|
if (_channels.Remove(ZoneChannelKey))
|
||||||
{
|
{
|
||||||
_lastReadCounts.Remove(ZoneChannelKey);
|
_lastReadCounts.Remove(ZoneChannelKey);
|
||||||
@@ -1315,6 +1342,28 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void CacheMessagesLocked(ChatChannelState state)
|
||||||
|
{
|
||||||
|
if (state.Messages.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_messageHistoryCache[state.Key] = new List<ChatMessageEntry>(state.Messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int RestoreCachedMessagesLocked(ChatChannelState state)
|
||||||
|
{
|
||||||
|
if (_messageHistoryCache.TryGetValue(state.Key, out var cached) && cached.Count > 0)
|
||||||
|
{
|
||||||
|
state.Messages.AddRange(cached);
|
||||||
|
_messageHistoryCache.Remove(state.Key);
|
||||||
|
return cached.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class ChatChannelState
|
private sealed class ChatChannelState
|
||||||
{
|
{
|
||||||
public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor)
|
public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind;
|
||||||
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
||||||
using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
|
using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
|
||||||
using Map = Lumina.Excel.Sheets.Map;
|
using Map = Lumina.Excel.Sheets.Map;
|
||||||
@@ -84,18 +85,18 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
_configService = configService;
|
_configService = configService;
|
||||||
_playerPerformanceConfigService = playerPerformanceConfigService;
|
_playerPerformanceConfigService = playerPerformanceConfigService;
|
||||||
_pairFactory = pairFactory;
|
_pairFactory = pairFactory;
|
||||||
|
var clientLanguage = _clientState.ClientLanguage;
|
||||||
WorldData = new(() =>
|
WorldData = new(() =>
|
||||||
{
|
{
|
||||||
return gameData.GetExcelSheet<Lumina.Excel.Sheets.World>(Dalamud.Game.ClientLanguage.English)!
|
return gameData.GetExcelSheet<Lumina.Excel.Sheets.World>(clientLanguage)!
|
||||||
.Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0])))
|
.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());
|
.ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString());
|
||||||
});
|
});
|
||||||
JobData = new(() =>
|
JobData = new(() =>
|
||||||
{
|
{
|
||||||
return gameData.GetExcelSheet<ClassJob>(Dalamud.Game.ClientLanguage.English)!
|
return gameData.GetExcelSheet<ClassJob>(clientLanguage)!
|
||||||
.ToDictionary(k => k.RowId, k => k.NameEnglish.ToString());
|
.ToDictionary(k => k.RowId, k => k.Name.ToString());
|
||||||
});
|
});
|
||||||
var clientLanguage = _clientState.ClientLanguage;
|
|
||||||
TerritoryData = new(() => BuildTerritoryData(clientLanguage));
|
TerritoryData = new(() => BuildTerritoryData(clientLanguage));
|
||||||
TerritoryDataEnglish = new(() => BuildTerritoryData(Dalamud.Game.ClientLanguage.English));
|
TerritoryDataEnglish = new(() => BuildTerritoryData(Dalamud.Game.ClientLanguage.English));
|
||||||
MapData = new(() => BuildMapData(clientLanguage));
|
MapData = new(() => BuildMapData(clientLanguage));
|
||||||
@@ -275,6 +276,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
public bool IsAnythingDrawing { get; private set; } = false;
|
public bool IsAnythingDrawing { get; private set; } = false;
|
||||||
public bool IsInCutscene { get; private set; } = false;
|
public bool IsInCutscene { get; private set; } = false;
|
||||||
public bool IsInGpose { get; private set; } = false;
|
public bool IsInGpose { get; private set; } = false;
|
||||||
|
public bool IsGameUiHidden => _gameGui.GameUiHidden;
|
||||||
public bool IsLoggedIn { get; private set; }
|
public bool IsLoggedIn { get; private set; }
|
||||||
public bool IsOnFrameworkThread => _framework.IsInFrameworkUpdateThread;
|
public bool IsOnFrameworkThread => _framework.IsInFrameworkUpdateThread;
|
||||||
public bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51];
|
public bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51];
|
||||||
@@ -444,7 +446,22 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
var mgr = CharacterManager.Instance();
|
var mgr = CharacterManager.Instance();
|
||||||
playerPointer ??= GetPlayerPtr();
|
playerPointer ??= GetPlayerPtr();
|
||||||
if (playerPointer == IntPtr.Zero || (IntPtr)mgr == IntPtr.Zero) return IntPtr.Zero;
|
if (playerPointer == IntPtr.Zero || (IntPtr)mgr == IntPtr.Zero) return IntPtr.Zero;
|
||||||
return (IntPtr)mgr->LookupPetByOwnerObject((BattleChara*)playerPointer);
|
|
||||||
|
var ownerAddress = playerPointer.Value;
|
||||||
|
var ownerEntityId = ((Character*)ownerAddress)->EntityId;
|
||||||
|
if (ownerEntityId == 0) return IntPtr.Zero;
|
||||||
|
|
||||||
|
var candidate = (IntPtr)mgr->LookupPetByOwnerObject((BattleChara*)ownerAddress);
|
||||||
|
if (candidate != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
var candidateObj = (GameObject*)candidate;
|
||||||
|
if (IsPetMatch(candidateObj, ownerEntityId))
|
||||||
|
{
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return FindOwnedPet(ownerEntityId, ownerAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IntPtr> GetPetAsync(IntPtr? playerPointer = null)
|
public async Task<IntPtr> GetPetAsync(IntPtr? playerPointer = null)
|
||||||
@@ -481,6 +498,60 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
|||||||
return nint.Zero;
|
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)
|
private static unsafe uint ResolveOwnerId(GameObject* gameObject)
|
||||||
{
|
{
|
||||||
if (gameObject == null)
|
if (gameObject == null)
|
||||||
|
|||||||
@@ -0,0 +1,863 @@
|
|||||||
|
using Dalamud.Game.ClientState.Objects.Enums;
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.System.Framework;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||||
|
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||||
|
using LightlessSync.LightlessConfiguration;
|
||||||
|
using LightlessSync.Services.Mediator;
|
||||||
|
using LightlessSync.UI;
|
||||||
|
using LightlessSync.UI.Services;
|
||||||
|
using LightlessSync.Utils;
|
||||||
|
using LightlessSync.UtilsEnum.Enum;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using Task = System.Threading.Tasks.Task;
|
||||||
|
|
||||||
|
namespace LightlessSync.Services.LightFinder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Native nameplate handler that injects LightFinder labels via the signature hook path.
|
||||||
|
/// </summary>
|
||||||
|
public unsafe class LightFinderNativePlateHandler : DisposableMediatorSubscriberBase, IHostedService
|
||||||
|
{
|
||||||
|
private const uint NameplateNodeIdBase = 0x7D99D500;
|
||||||
|
private const string DefaultLabelText = "LightFinder";
|
||||||
|
|
||||||
|
private readonly ILogger<LightFinderNativePlateHandler> _logger;
|
||||||
|
private readonly IClientState _clientState;
|
||||||
|
private readonly IObjectTable _objectTable;
|
||||||
|
private readonly LightlessConfigService _configService;
|
||||||
|
private readonly PairUiService _pairUiService;
|
||||||
|
private readonly NameplateUpdateHookService _nameplateUpdateHookService;
|
||||||
|
|
||||||
|
private readonly int[] _cachedNameplateTextWidths = new int[AddonNamePlate.NumNamePlateObjects];
|
||||||
|
private readonly int[] _cachedNameplateTextHeights = new int[AddonNamePlate.NumNamePlateObjects];
|
||||||
|
private readonly int[] _cachedNameplateContainerHeights = new int[AddonNamePlate.NumNamePlateObjects];
|
||||||
|
private readonly int[] _cachedNameplateTextOffsets = new int[AddonNamePlate.NumNamePlateObjects];
|
||||||
|
private readonly string?[] _lastLabelByIndex = new string?[AddonNamePlate.NumNamePlateObjects];
|
||||||
|
|
||||||
|
private ImmutableHashSet<string> _activeBroadcastingCids = [];
|
||||||
|
private LightfinderLabelRenderer _lastRenderer;
|
||||||
|
private uint _lastSignatureUpdateFrame;
|
||||||
|
private bool _isUpdating;
|
||||||
|
private string _lastLabelContent = DefaultLabelText;
|
||||||
|
|
||||||
|
public LightFinderNativePlateHandler(
|
||||||
|
ILogger<LightFinderNativePlateHandler> logger,
|
||||||
|
IClientState clientState,
|
||||||
|
LightlessConfigService configService,
|
||||||
|
LightlessMediator mediator,
|
||||||
|
IObjectTable objectTable,
|
||||||
|
PairUiService pairUiService,
|
||||||
|
NameplateUpdateHookService nameplateUpdateHookService) : base(logger, mediator)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_clientState = clientState;
|
||||||
|
_configService = configService;
|
||||||
|
_objectTable = objectTable;
|
||||||
|
_pairUiService = pairUiService;
|
||||||
|
_nameplateUpdateHookService = nameplateUpdateHookService;
|
||||||
|
_lastRenderer = _configService.Current.LightfinderLabelRenderer;
|
||||||
|
|
||||||
|
Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsSignatureMode => _configService.Current.LightfinderLabelRenderer == LightfinderLabelRenderer.SignatureHook;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts listening for nameplate updates from the hook service.
|
||||||
|
/// </summary>
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_nameplateUpdateHookService.NameplateUpdated += OnNameplateUpdated;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops listening for nameplate updates and tears down any constructed nodes.
|
||||||
|
/// </summary>
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_nameplateUpdateHookService.NameplateUpdated -= OnNameplateUpdated;
|
||||||
|
UnsubscribeAll();
|
||||||
|
TryDestroyNameplateNodes();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Triggered by the sig hook to refresh native nameplate labels.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleNameplateUpdate(RaptureAtkModule* raptureAtkModule)
|
||||||
|
{
|
||||||
|
if (_isUpdating)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isUpdating = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RefreshRendererState();
|
||||||
|
if (!IsSignatureMode)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (raptureAtkModule == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var namePlateAddon = GetNamePlateAddon(raptureAtkModule);
|
||||||
|
if (namePlateAddon == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_clientState.IsGPosing)
|
||||||
|
{
|
||||||
|
HideAllNameplateNodes(namePlateAddon);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fw = Framework.Instance();
|
||||||
|
if (fw == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var frame = fw->FrameCounter;
|
||||||
|
if (_lastSignatureUpdateFrame == frame)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_lastSignatureUpdateFrame = frame;
|
||||||
|
UpdateNameplateNodes(namePlateAddon);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isUpdating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hook callback from the nameplate update signature.
|
||||||
|
/// </summary>
|
||||||
|
private void OnNameplateUpdated(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo,
|
||||||
|
NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
|
||||||
|
{
|
||||||
|
HandleNameplateUpdate(raptureAtkModule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the active broadcasting CID set and requests a nameplate redraw.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateBroadcastingCids(IEnumerable<string> cids)
|
||||||
|
{
|
||||||
|
var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal);
|
||||||
|
if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_activeBroadcastingCids = newSet;
|
||||||
|
if (_logger.IsEnabled(LogLevel.Trace))
|
||||||
|
_logger.LogTrace("Active broadcast IDs (native): {Cids}", string.Join(',', _activeBroadcastingCids));
|
||||||
|
RequestNameplateRedraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sync renderer state with config and clear/remove native nodes if needed.
|
||||||
|
/// </summary>
|
||||||
|
private void RefreshRendererState()
|
||||||
|
{
|
||||||
|
var renderer = _configService.Current.LightfinderLabelRenderer;
|
||||||
|
if (renderer == _lastRenderer)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_lastRenderer = renderer;
|
||||||
|
|
||||||
|
if (renderer == LightfinderLabelRenderer.SignatureHook)
|
||||||
|
{
|
||||||
|
ClearNameplateCaches();
|
||||||
|
RequestNameplateRedraw();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
TryDestroyNameplateNodes();
|
||||||
|
ClearNameplateCaches();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requests a full nameplate update through the native addon.
|
||||||
|
/// </summary>
|
||||||
|
private void RequestNameplateRedraw()
|
||||||
|
{
|
||||||
|
if (!IsSignatureMode)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var raptureAtkModule = GetRaptureAtkModule();
|
||||||
|
if (raptureAtkModule == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var namePlateAddon = GetNamePlateAddon(raptureAtkModule);
|
||||||
|
if (namePlateAddon == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
namePlateAddon->DoFullUpdate = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HashSet<ulong> VisibleUserIds
|
||||||
|
=> [.. _pairUiService.GetSnapshot().PairsByUid.Values
|
||||||
|
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
|
||||||
|
.Select(u => (ulong)u.PlayerCharacterId)];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates/updates LightFinder label nodes for active broadcasts.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateNameplateNodes(AddonNamePlate* namePlateAddon)
|
||||||
|
{
|
||||||
|
if (namePlateAddon == null)
|
||||||
|
{
|
||||||
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
|
_logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsNameplateAddonVisible(namePlateAddon))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!IsSignatureMode)
|
||||||
|
{
|
||||||
|
HideAllNameplateNodes(namePlateAddon);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_activeBroadcastingCids.Count == 0)
|
||||||
|
{
|
||||||
|
HideAllNameplateNodes(namePlateAddon);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var framework = Framework.Instance();
|
||||||
|
if (framework == null)
|
||||||
|
{
|
||||||
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
|
_logger.LogDebug("Framework instance unavailable during nameplate update, skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var uiModule = framework->GetUIModule();
|
||||||
|
if (uiModule == null)
|
||||||
|
{
|
||||||
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
|
_logger.LogDebug("UI module unavailable during nameplate update, skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ui3DModule = uiModule->GetUI3DModule();
|
||||||
|
if (ui3DModule == null)
|
||||||
|
{
|
||||||
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
|
_logger.LogDebug("UI3D module unavailable during nameplate update, skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var vec = ui3DModule->NamePlateObjectInfoPointers;
|
||||||
|
if (vec.IsEmpty)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var config = _configService.Current;
|
||||||
|
var visibleUserIdsSnapshot = VisibleUserIds;
|
||||||
|
var labelColor = UIColors.Get("Lightfinder");
|
||||||
|
var edgeColor = UIColors.Get("LightfinderEdge");
|
||||||
|
var scaleMultiplier = Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f);
|
||||||
|
var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f;
|
||||||
|
var effectiveScale = baseScale * scaleMultiplier;
|
||||||
|
var labelContent = config.LightfinderLabelUseIcon
|
||||||
|
? LightFinderPlateHandler.NormalizeIconGlyph(config.LightfinderLabelIconGlyph)
|
||||||
|
: DefaultLabelText;
|
||||||
|
|
||||||
|
if (!config.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal)))
|
||||||
|
labelContent = DefaultLabelText;
|
||||||
|
|
||||||
|
if (!string.Equals(_lastLabelContent, labelContent, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_lastLabelContent = labelContent;
|
||||||
|
Array.Fill(_lastLabelByIndex, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var desiredFontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed;
|
||||||
|
var baseFontSize = config.LightfinderLabelUseIcon ? 36f : 24f;
|
||||||
|
var desiredFontSize = (byte)Math.Clamp((int)Math.Round(baseFontSize * scaleMultiplier), 1, 255);
|
||||||
|
var desiredFlags = config.LightfinderLabelUseIcon
|
||||||
|
? TextFlags.Edge | TextFlags.Glare | TextFlags.AutoAdjustNodeSize
|
||||||
|
: TextFlags.Edge | TextFlags.Glare;
|
||||||
|
var desiredLineSpacing = (byte)Math.Clamp((int)Math.Round(24 * scaleMultiplier), 0, byte.MaxValue);
|
||||||
|
var defaultNodeWidth = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
|
||||||
|
var defaultNodeHeight = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale);
|
||||||
|
|
||||||
|
var safeCount = Math.Min(ui3DModule->NamePlateObjectInfoCount, vec.Length);
|
||||||
|
var visibleIndices = new bool[AddonNamePlate.NumNamePlateObjects];
|
||||||
|
|
||||||
|
for (int i = 0; i < safeCount; ++i)
|
||||||
|
{
|
||||||
|
var objectInfoPtr = vec[i];
|
||||||
|
if (objectInfoPtr == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var objectInfo = objectInfoPtr.Value;
|
||||||
|
if (objectInfo == null || objectInfo->GameObject == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var nameplateIndex = objectInfo->NamePlateIndex;
|
||||||
|
if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var gameObject = objectInfo->GameObject;
|
||||||
|
if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
|
||||||
|
if (cid == null || !_activeBroadcastingCids.Contains(cid))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var local = _objectTable.LocalPlayer;
|
||||||
|
if (!config.LightfinderLabelShowOwn && local != null &&
|
||||||
|
objectInfo->GameObject->GetGameObjectId() == local.GameObjectId)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var hidePaired = !config.LightfinderLabelShowPaired;
|
||||||
|
var goId = (ulong)gameObject->GetGameObjectId();
|
||||||
|
if (hidePaired && visibleUserIdsSnapshot.Contains(goId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var nameplateObject = namePlateAddon->NamePlateObjectArray[nameplateIndex];
|
||||||
|
var root = nameplateObject.RootComponentNode;
|
||||||
|
var nameContainer = nameplateObject.NameContainer;
|
||||||
|
var nameText = nameplateObject.NameText;
|
||||||
|
var marker = nameplateObject.MarkerIcon;
|
||||||
|
|
||||||
|
if (root == null || root->Component == null || nameContainer == null || nameText == null)
|
||||||
|
{
|
||||||
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
|
_logger.LogDebug("Nameplate {Index} missing required nodes during update, skipping.", nameplateIndex);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodeId = GetNameplateNodeId(nameplateIndex);
|
||||||
|
var pNode = EnsureNameplateTextNode(nameContainer, root, nodeId, out var nodeCreated);
|
||||||
|
if (pNode == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
bool isVisible =
|
||||||
|
((marker != null) && marker->AtkResNode.IsVisible()) ||
|
||||||
|
(nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) ||
|
||||||
|
config.LightfinderLabelShowHidden;
|
||||||
|
|
||||||
|
if (!isVisible)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!pNode->AtkResNode.IsVisible())
|
||||||
|
pNode->AtkResNode.ToggleVisibility(enable: true);
|
||||||
|
visibleIndices[nameplateIndex] = true;
|
||||||
|
|
||||||
|
if (nodeCreated)
|
||||||
|
pNode->AtkResNode.SetUseDepthBasedPriority(enable: true);
|
||||||
|
|
||||||
|
var scaleMatches = NearlyEqual(pNode->AtkResNode.ScaleX, effectiveScale) &&
|
||||||
|
NearlyEqual(pNode->AtkResNode.ScaleY, effectiveScale);
|
||||||
|
if (!scaleMatches)
|
||||||
|
pNode->AtkResNode.SetScale(effectiveScale, effectiveScale);
|
||||||
|
|
||||||
|
var fontTypeChanged = pNode->FontType != desiredFontType;
|
||||||
|
if (fontTypeChanged)
|
||||||
|
pNode->FontType = desiredFontType;
|
||||||
|
|
||||||
|
var fontSizeChanged = pNode->FontSize != desiredFontSize;
|
||||||
|
if (fontSizeChanged)
|
||||||
|
pNode->FontSize = desiredFontSize;
|
||||||
|
|
||||||
|
var needsTextUpdate = nodeCreated ||
|
||||||
|
!string.Equals(_lastLabelByIndex[nameplateIndex], labelContent, StringComparison.Ordinal);
|
||||||
|
if (needsTextUpdate)
|
||||||
|
{
|
||||||
|
pNode->SetText(labelContent);
|
||||||
|
_lastLabelByIndex[nameplateIndex] = labelContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
var flagsChanged = pNode->TextFlags != desiredFlags;
|
||||||
|
var nodeWidth = (int)pNode->AtkResNode.GetWidth();
|
||||||
|
if (nodeWidth <= 0)
|
||||||
|
nodeWidth = defaultNodeWidth;
|
||||||
|
var nodeHeight = defaultNodeHeight;
|
||||||
|
AlignmentType alignment;
|
||||||
|
|
||||||
|
var textScaleY = nameText->AtkResNode.ScaleY;
|
||||||
|
if (textScaleY <= 0f)
|
||||||
|
textScaleY = 1f;
|
||||||
|
|
||||||
|
var blockHeight = Math.Abs((int)nameplateObject.TextH);
|
||||||
|
if (blockHeight > 0)
|
||||||
|
{
|
||||||
|
_cachedNameplateTextHeights[nameplateIndex] = blockHeight;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
blockHeight = _cachedNameplateTextHeights[nameplateIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockHeight <= 0)
|
||||||
|
{
|
||||||
|
blockHeight = GetScaledTextHeight(nameText);
|
||||||
|
if (blockHeight <= 0)
|
||||||
|
blockHeight = nodeHeight;
|
||||||
|
|
||||||
|
_cachedNameplateTextHeights[nameplateIndex] = blockHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
var containerHeight = (int)nameContainer->Height;
|
||||||
|
if (containerHeight > 0)
|
||||||
|
{
|
||||||
|
_cachedNameplateContainerHeights[nameplateIndex] = containerHeight;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
containerHeight = _cachedNameplateContainerHeights[nameplateIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containerHeight <= 0)
|
||||||
|
{
|
||||||
|
containerHeight = blockHeight + (int)Math.Round(8 * textScaleY);
|
||||||
|
if (containerHeight <= blockHeight)
|
||||||
|
containerHeight = blockHeight + 1;
|
||||||
|
|
||||||
|
_cachedNameplateContainerHeights[nameplateIndex] = containerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
var blockTop = containerHeight - blockHeight;
|
||||||
|
if (blockTop < 0)
|
||||||
|
blockTop = 0;
|
||||||
|
var verticalPadding = (int)Math.Round(4 * effectiveScale);
|
||||||
|
|
||||||
|
var positionY = blockTop - verticalPadding - nodeHeight;
|
||||||
|
|
||||||
|
var textWidth = Math.Abs((int)nameplateObject.TextW);
|
||||||
|
if (textWidth <= 0)
|
||||||
|
{
|
||||||
|
textWidth = GetScaledTextWidth(nameText);
|
||||||
|
if (textWidth <= 0)
|
||||||
|
textWidth = nodeWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textWidth > 0)
|
||||||
|
{
|
||||||
|
_cachedNameplateTextWidths[nameplateIndex] = textWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
var textOffset = (int)Math.Round(nameText->AtkResNode.X);
|
||||||
|
var hasValidOffset = false;
|
||||||
|
|
||||||
|
if (Math.Abs((int)nameplateObject.TextW) > 0 || textOffset != 0)
|
||||||
|
{
|
||||||
|
_cachedNameplateTextOffsets[nameplateIndex] = textOffset;
|
||||||
|
hasValidOffset = true;
|
||||||
|
}
|
||||||
|
else if (_cachedNameplateTextOffsets[nameplateIndex] != int.MinValue)
|
||||||
|
{
|
||||||
|
hasValidOffset = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int positionX;
|
||||||
|
|
||||||
|
if (!config.LightfinderLabelUseIcon)
|
||||||
|
{
|
||||||
|
var needsWidthRefresh = nodeCreated || needsTextUpdate || !scaleMatches || fontTypeChanged || fontSizeChanged || flagsChanged;
|
||||||
|
if (flagsChanged)
|
||||||
|
pNode->TextFlags = desiredFlags;
|
||||||
|
|
||||||
|
if (needsWidthRefresh)
|
||||||
|
{
|
||||||
|
if (pNode->AtkResNode.Width != 0)
|
||||||
|
pNode->AtkResNode.Width = 0;
|
||||||
|
nodeWidth = (int)pNode->AtkResNode.GetWidth();
|
||||||
|
if (nodeWidth <= 0)
|
||||||
|
nodeWidth = defaultNodeWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pNode->AtkResNode.Width != (ushort)nodeWidth)
|
||||||
|
pNode->AtkResNode.Width = (ushort)nodeWidth;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var needsWidthRefresh = nodeCreated || needsTextUpdate || !scaleMatches || fontTypeChanged || fontSizeChanged || flagsChanged;
|
||||||
|
if (flagsChanged)
|
||||||
|
pNode->TextFlags = desiredFlags;
|
||||||
|
|
||||||
|
if (needsWidthRefresh && pNode->AtkResNode.Width != 0)
|
||||||
|
pNode->AtkResNode.Width = 0;
|
||||||
|
nodeWidth = pNode->AtkResNode.GetWidth();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.LightfinderAutoAlign && nameContainer != null && hasValidOffset)
|
||||||
|
{
|
||||||
|
var nameplateWidth = (int)nameContainer->Width;
|
||||||
|
|
||||||
|
int leftPos = nameplateWidth / 8;
|
||||||
|
int rightPos = nameplateWidth - nodeWidth - (nameplateWidth / 8);
|
||||||
|
int centrePos = (nameplateWidth - nodeWidth) / 2;
|
||||||
|
int staticMargin = 24;
|
||||||
|
int calcMargin = (int)(nameplateWidth * 0.08f);
|
||||||
|
|
||||||
|
switch (config.LabelAlignment)
|
||||||
|
{
|
||||||
|
case LabelAlignment.Left:
|
||||||
|
positionX = config.LightfinderLabelUseIcon ? leftPos + staticMargin : leftPos;
|
||||||
|
alignment = AlignmentType.BottomLeft;
|
||||||
|
break;
|
||||||
|
case LabelAlignment.Right:
|
||||||
|
positionX = config.LightfinderLabelUseIcon ? rightPos - staticMargin : nameplateWidth - nodeWidth + calcMargin;
|
||||||
|
alignment = AlignmentType.BottomRight;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
positionX = config.LightfinderLabelUseIcon ? centrePos : centrePos + calcMargin;
|
||||||
|
alignment = AlignmentType.Bottom;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
positionX = 58 + config.LightfinderLabelOffsetX;
|
||||||
|
alignment = AlignmentType.Bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
positionY += config.LightfinderLabelOffsetY;
|
||||||
|
|
||||||
|
alignment = (AlignmentType)Math.Clamp((int)alignment, 0, 8);
|
||||||
|
if (pNode->AtkResNode.Color.A != 255)
|
||||||
|
pNode->AtkResNode.Color.A = 255;
|
||||||
|
|
||||||
|
var textR = (byte)(labelColor.X * 255);
|
||||||
|
var textG = (byte)(labelColor.Y * 255);
|
||||||
|
var textB = (byte)(labelColor.Z * 255);
|
||||||
|
var textA = (byte)(labelColor.W * 255);
|
||||||
|
|
||||||
|
if (pNode->TextColor.R != textR || pNode->TextColor.G != textG ||
|
||||||
|
pNode->TextColor.B != textB || pNode->TextColor.A != textA)
|
||||||
|
{
|
||||||
|
pNode->TextColor.R = textR;
|
||||||
|
pNode->TextColor.G = textG;
|
||||||
|
pNode->TextColor.B = textB;
|
||||||
|
pNode->TextColor.A = textA;
|
||||||
|
}
|
||||||
|
|
||||||
|
var edgeR = (byte)(edgeColor.X * 255);
|
||||||
|
var edgeG = (byte)(edgeColor.Y * 255);
|
||||||
|
var edgeB = (byte)(edgeColor.Z * 255);
|
||||||
|
var edgeA = (byte)(edgeColor.W * 255);
|
||||||
|
|
||||||
|
if (pNode->EdgeColor.R != edgeR || pNode->EdgeColor.G != edgeG ||
|
||||||
|
pNode->EdgeColor.B != edgeB || pNode->EdgeColor.A != edgeA)
|
||||||
|
{
|
||||||
|
pNode->EdgeColor.R = edgeR;
|
||||||
|
pNode->EdgeColor.G = edgeG;
|
||||||
|
pNode->EdgeColor.B = edgeB;
|
||||||
|
pNode->EdgeColor.A = edgeA;
|
||||||
|
}
|
||||||
|
|
||||||
|
var desiredAlignment = config.LightfinderLabelUseIcon ? alignment : AlignmentType.Bottom;
|
||||||
|
if (pNode->AlignmentType != desiredAlignment)
|
||||||
|
pNode->AlignmentType = desiredAlignment;
|
||||||
|
|
||||||
|
var desiredX = (short)Math.Clamp(positionX, short.MinValue, short.MaxValue);
|
||||||
|
var desiredY = (short)Math.Clamp(positionY, short.MinValue, short.MaxValue);
|
||||||
|
if (!NearlyEqual(pNode->AtkResNode.X, desiredX) || !NearlyEqual(pNode->AtkResNode.Y, desiredY))
|
||||||
|
pNode->AtkResNode.SetPositionShort(desiredX, desiredY);
|
||||||
|
|
||||||
|
if (pNode->LineSpacing != desiredLineSpacing)
|
||||||
|
pNode->LineSpacing = desiredLineSpacing;
|
||||||
|
if (pNode->CharSpacing != 1)
|
||||||
|
pNode->CharSpacing = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
HideUnmarkedNodes(namePlateAddon, visibleIndices);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve the current RaptureAtkModule for native UI access.
|
||||||
|
/// </summary>
|
||||||
|
private static RaptureAtkModule* GetRaptureAtkModule()
|
||||||
|
{
|
||||||
|
var framework = Framework.Instance();
|
||||||
|
if (framework == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var uiModule = framework->GetUIModule();
|
||||||
|
if (uiModule == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return uiModule->GetRaptureAtkModule();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve the NamePlate addon from the given RaptureAtkModule.
|
||||||
|
/// </summary>
|
||||||
|
private static AddonNamePlate* GetNamePlateAddon(RaptureAtkModule* raptureAtkModule)
|
||||||
|
{
|
||||||
|
if (raptureAtkModule == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var addon = raptureAtkModule->RaptureAtkUnitManager.GetAddonByName("NamePlate");
|
||||||
|
return addon != null ? (AddonNamePlate*)addon : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint GetNameplateNodeId(int index)
|
||||||
|
=> NameplateNodeIdBase + (uint)index;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if the NamePlate addon is visible and safe to touch.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsNameplateAddonVisible(AddonNamePlate* namePlateAddon)
|
||||||
|
{
|
||||||
|
if (namePlateAddon == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var root = namePlateAddon->AtkUnitBase.RootNode;
|
||||||
|
return root != null && root->IsVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds a LightFinder text node by ID in the name container.
|
||||||
|
/// </summary>
|
||||||
|
private static AtkTextNode* FindNameplateTextNode(AtkResNode* nameContainer, uint nodeId)
|
||||||
|
{
|
||||||
|
if (nameContainer == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var child = nameContainer->ChildNode;
|
||||||
|
while (child != null)
|
||||||
|
{
|
||||||
|
if (child->NodeId == nodeId &&
|
||||||
|
child->Type == NodeType.Text &&
|
||||||
|
child->ParentNode == nameContainer)
|
||||||
|
return (AtkTextNode*)child;
|
||||||
|
|
||||||
|
child = child->PrevSiblingNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures a LightFinder text node exists for the given nameplate index.
|
||||||
|
/// </summary>
|
||||||
|
private static AtkTextNode* EnsureNameplateTextNode(AtkResNode* nameContainer, AtkComponentNode* root, uint nodeId, out bool created)
|
||||||
|
{
|
||||||
|
created = false;
|
||||||
|
if (nameContainer == null || root == null || root->Component == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var existing = FindNameplateTextNode(nameContainer, nodeId);
|
||||||
|
if (existing != null)
|
||||||
|
return existing;
|
||||||
|
|
||||||
|
if (nameContainer->ChildNode == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var newNode = AtkNodeHelpers.CreateOrphanTextNode(nodeId, TextFlags.Edge | TextFlags.Glare);
|
||||||
|
if (newNode == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var lastChild = nameContainer->ChildNode;
|
||||||
|
while (lastChild->PrevSiblingNode != null)
|
||||||
|
lastChild = lastChild->PrevSiblingNode;
|
||||||
|
|
||||||
|
newNode->AtkResNode.NextSiblingNode = lastChild;
|
||||||
|
newNode->AtkResNode.ParentNode = nameContainer;
|
||||||
|
lastChild->PrevSiblingNode = (AtkResNode*)newNode;
|
||||||
|
root->Component->UldManager.UpdateDrawNodeList();
|
||||||
|
newNode->AtkResNode.SetUseDepthBasedPriority(true);
|
||||||
|
|
||||||
|
created = true;
|
||||||
|
return newNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hides all native LightFinder nodes on the nameplate addon.
|
||||||
|
/// </summary>
|
||||||
|
private static void HideAllNameplateNodes(AddonNamePlate* namePlateAddon)
|
||||||
|
{
|
||||||
|
if (namePlateAddon == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!IsNameplateAddonVisible(namePlateAddon))
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
|
||||||
|
{
|
||||||
|
HideNameplateTextNode(namePlateAddon->NamePlateObjectArray[i], GetNameplateNodeId(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hides all LightFinder nodes not marked as visible this frame.
|
||||||
|
/// </summary>
|
||||||
|
private static void HideUnmarkedNodes(AddonNamePlate* namePlateAddon, bool[] visibleIndices)
|
||||||
|
{
|
||||||
|
if (namePlateAddon == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!IsNameplateAddonVisible(namePlateAddon))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var visibleLength = visibleIndices.Length;
|
||||||
|
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
|
||||||
|
{
|
||||||
|
if (i < visibleLength && visibleIndices[i])
|
||||||
|
continue;
|
||||||
|
|
||||||
|
HideNameplateTextNode(namePlateAddon->NamePlateObjectArray[i], GetNameplateNodeId(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hides the LightFinder text node for a single nameplate object.
|
||||||
|
/// </summary>
|
||||||
|
private static void HideNameplateTextNode(AddonNamePlate.NamePlateObject nameplateObject, uint nodeId)
|
||||||
|
{
|
||||||
|
var nameContainer = nameplateObject.NameContainer;
|
||||||
|
if (nameContainer == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var node = FindNameplateTextNode(nameContainer, nodeId);
|
||||||
|
if (!IsValidNameplateTextNode(node, nameContainer))
|
||||||
|
return;
|
||||||
|
|
||||||
|
node->AtkResNode.ToggleVisibility(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to destroy all constructed LightFinder nodes safely.
|
||||||
|
/// </summary>
|
||||||
|
private void TryDestroyNameplateNodes()
|
||||||
|
{
|
||||||
|
var raptureAtkModule = GetRaptureAtkModule();
|
||||||
|
if (raptureAtkModule == null)
|
||||||
|
{
|
||||||
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
|
_logger.LogDebug("Unable to destroy nameplate nodes because the RaptureAtkModule is not available.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var namePlateAddon = GetNamePlateAddon(raptureAtkModule);
|
||||||
|
if (namePlateAddon == null)
|
||||||
|
{
|
||||||
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
|
_logger.LogDebug("Unable to destroy nameplate nodes because the NamePlate addon is not available.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DestroyNameplateNodes(namePlateAddon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes all constructed LightFinder nodes from the given nameplate addon.
|
||||||
|
/// </summary>
|
||||||
|
private void DestroyNameplateNodes(AddonNamePlate* namePlateAddon)
|
||||||
|
{
|
||||||
|
if (namePlateAddon == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
|
||||||
|
{
|
||||||
|
var nameplateObject = namePlateAddon->NamePlateObjectArray[i];
|
||||||
|
var root = nameplateObject.RootComponentNode;
|
||||||
|
var nameContainer = nameplateObject.NameContainer;
|
||||||
|
if (root == null || root->Component == null || nameContainer == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var nodeId = GetNameplateNodeId(i);
|
||||||
|
var textNode = FindNameplateTextNode(nameContainer, nodeId);
|
||||||
|
if (!IsValidNameplateTextNode(textNode, nameContainer))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resNode = &textNode->AtkResNode;
|
||||||
|
|
||||||
|
if (resNode->PrevSiblingNode != null)
|
||||||
|
resNode->PrevSiblingNode->NextSiblingNode = resNode->NextSiblingNode;
|
||||||
|
if (resNode->NextSiblingNode != null)
|
||||||
|
resNode->NextSiblingNode->PrevSiblingNode = resNode->PrevSiblingNode;
|
||||||
|
|
||||||
|
root->Component->UldManager.UpdateDrawNodeList();
|
||||||
|
resNode->Destroy(true);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
_logger.LogError(e, "Unknown error while removing text node 0x{Node:X} for nameplate {Index} on component node 0x{Component:X}", (IntPtr)textNode, i, (IntPtr)root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ClearNameplateCaches();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates that a node is a LightFinder text node owned by the container.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsValidNameplateTextNode(AtkTextNode* node, AtkResNode* nameContainer)
|
||||||
|
{
|
||||||
|
if (node == null || nameContainer == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var resNode = &node->AtkResNode;
|
||||||
|
return resNode->Type == NodeType.Text && resNode->ParentNode == nameContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Float comparison helper for UI values.
|
||||||
|
/// </summary>
|
||||||
|
private static bool NearlyEqual(float a, float b, float epsilon = 0.001f)
|
||||||
|
=> Math.Abs(a - b) <= epsilon;
|
||||||
|
|
||||||
|
private static int GetScaledTextHeight(AtkTextNode* node)
|
||||||
|
{
|
||||||
|
if (node == null)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var resNode = &node->AtkResNode;
|
||||||
|
var rawHeight = (int)resNode->GetHeight();
|
||||||
|
if (rawHeight <= 0 && node->LineSpacing > 0)
|
||||||
|
rawHeight = node->LineSpacing;
|
||||||
|
if (rawHeight <= 0)
|
||||||
|
rawHeight = AtkNodeHelpers.DefaultTextNodeHeight;
|
||||||
|
|
||||||
|
var scale = resNode->ScaleY;
|
||||||
|
if (scale <= 0f)
|
||||||
|
scale = 1f;
|
||||||
|
|
||||||
|
var computed = (int)Math.Round(rawHeight * scale);
|
||||||
|
return Math.Max(1, computed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetScaledTextWidth(AtkTextNode* node)
|
||||||
|
{
|
||||||
|
if (node == null)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var resNode = &node->AtkResNode;
|
||||||
|
var rawWidth = (int)resNode->GetWidth();
|
||||||
|
if (rawWidth <= 0)
|
||||||
|
rawWidth = AtkNodeHelpers.DefaultTextNodeWidth;
|
||||||
|
|
||||||
|
var scale = resNode->ScaleX;
|
||||||
|
if (scale <= 0f)
|
||||||
|
scale = 1f;
|
||||||
|
|
||||||
|
var computed = (int)Math.Round(rawWidth * scale);
|
||||||
|
return Math.Max(1, computed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears cached text sizing and label state for nameplates.
|
||||||
|
/// </summary>
|
||||||
|
public void ClearNameplateCaches()
|
||||||
|
{
|
||||||
|
Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
|
||||||
|
Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
|
||||||
|
Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
|
||||||
|
Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
|
||||||
|
Array.Fill(_lastLabelByIndex, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ using Pictomancy;
|
|||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using Task = System.Threading.Tasks.Task;
|
using Task = System.Threading.Tasks.Task;
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
private readonly LightlessConfigService _configService;
|
private readonly LightlessConfigService _configService;
|
||||||
private readonly PairUiService _pairUiService;
|
private readonly PairUiService _pairUiService;
|
||||||
private readonly LightlessMediator _mediator;
|
private readonly LightlessMediator _mediator;
|
||||||
|
|
||||||
public LightlessMediator Mediator => _mediator;
|
public LightlessMediator Mediator => _mediator;
|
||||||
|
|
||||||
private readonly IUiBuilder _uiBuilder;
|
private readonly IUiBuilder _uiBuilder;
|
||||||
@@ -51,6 +53,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
private readonly Lock _labelLock = new();
|
private readonly Lock _labelLock = new();
|
||||||
private readonly NameplateBuffers _buffers = new();
|
private readonly NameplateBuffers _buffers = new();
|
||||||
private int _labelRenderCount;
|
private int _labelRenderCount;
|
||||||
|
private LightfinderLabelRenderer _lastRenderer;
|
||||||
|
|
||||||
private const string _defaultLabelText = "LightFinder";
|
private const string _defaultLabelText = "LightFinder";
|
||||||
private const SeIconChar _defaultIcon = SeIconChar.Hyadelyn;
|
private const SeIconChar _defaultIcon = SeIconChar.Hyadelyn;
|
||||||
@@ -60,16 +63,24 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
|
|
||||||
// / Overlay window flags
|
// / Overlay window flags
|
||||||
private const ImGuiWindowFlags _overlayFlags =
|
private const ImGuiWindowFlags _overlayFlags =
|
||||||
ImGuiWindowFlags.NoDecoration |
|
ImGuiWindowFlags.NoDecoration |
|
||||||
ImGuiWindowFlags.NoBackground |
|
ImGuiWindowFlags.NoBackground |
|
||||||
ImGuiWindowFlags.NoMove |
|
ImGuiWindowFlags.NoMove |
|
||||||
ImGuiWindowFlags.NoSavedSettings |
|
ImGuiWindowFlags.NoSavedSettings |
|
||||||
ImGuiWindowFlags.NoNav |
|
ImGuiWindowFlags.NoNav |
|
||||||
ImGuiWindowFlags.NoInputs;
|
ImGuiWindowFlags.NoInputs;
|
||||||
|
|
||||||
private readonly List<RectF> _uiRects = new(128);
|
private readonly List<RectF> _uiRects = new(128);
|
||||||
private ImmutableHashSet<string> _activeBroadcastingCids = [];
|
private ImmutableHashSet<string> _activeBroadcastingCids = [];
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
// Debug controls
|
||||||
|
|
||||||
|
// Debug counters (read-only from UI)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private bool IsPictomancyRenderer => _configService.Current.LightfinderLabelRenderer == LightfinderLabelRenderer.Pictomancy;
|
||||||
|
|
||||||
public LightFinderPlateHandler(
|
public LightFinderPlateHandler(
|
||||||
ILogger<LightFinderPlateHandler> logger,
|
ILogger<LightFinderPlateHandler> logger,
|
||||||
IAddonLifecycle addonLifecycle,
|
IAddonLifecycle addonLifecycle,
|
||||||
@@ -92,7 +103,26 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
_pairUiService = pairUiService;
|
_pairUiService = pairUiService;
|
||||||
_uiBuilder = pluginInterface.UiBuilder ?? throw new ArgumentNullException(nameof(pluginInterface));
|
_uiBuilder = pluginInterface.UiBuilder ?? throw new ArgumentNullException(nameof(pluginInterface));
|
||||||
_ = pictomancyService ?? throw new ArgumentNullException(nameof(pictomancyService));
|
_ = 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()
|
internal void Init()
|
||||||
@@ -164,10 +194,26 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Draw detour for nameplate addon.
|
/// Draw detour for nameplate addon.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="type"></param>
|
|
||||||
/// <param name="args"></param>
|
|
||||||
private void NameplateDrawDetour(AddonEvent type, AddonArgs args)
|
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)
|
if (_clientState.IsGPosing)
|
||||||
{
|
{
|
||||||
ClearLabelBuffer();
|
ClearLabelBuffer();
|
||||||
@@ -187,6 +233,10 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
if (fw != null)
|
if (fw != null)
|
||||||
_lastNamePlateDrawFrame = fw->FrameCounter;
|
_lastNamePlateDrawFrame = fw->FrameCounter;
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
DebugLastNameplateFrame = _lastNamePlateDrawFrame;
|
||||||
|
#endif
|
||||||
|
|
||||||
var pNameplateAddon = (AddonNamePlate*)args.Addon.Address;
|
var pNameplateAddon = (AddonNamePlate*)args.Addon.Address;
|
||||||
|
|
||||||
if (_mpNameplateAddon != pNameplateAddon)
|
if (_mpNameplateAddon != pNameplateAddon)
|
||||||
@@ -203,6 +253,13 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void UpdateNameplateNodes()
|
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");
|
var currentHandle = _gameGui.GetAddonByName("NamePlate");
|
||||||
if (currentHandle.Address == nint.Zero)
|
if (currentHandle.Address == nint.Zero)
|
||||||
{
|
{
|
||||||
@@ -266,7 +323,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
|
|
||||||
for (int i = 0; i < safeCount; ++i)
|
for (int i = 0; i < safeCount; ++i)
|
||||||
{
|
{
|
||||||
|
|
||||||
var objectInfoPtr = vec[i];
|
var objectInfoPtr = vec[i];
|
||||||
if (objectInfoPtr == null)
|
if (objectInfoPtr == null)
|
||||||
continue;
|
continue;
|
||||||
@@ -283,7 +339,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player)
|
if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// CID gating
|
// CID gating - only show for active broadcasters
|
||||||
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
|
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
|
||||||
if (cid == null || !_activeBroadcastingCids.Contains(cid))
|
if (cid == null || !_activeBroadcastingCids.Contains(cid))
|
||||||
continue;
|
continue;
|
||||||
@@ -319,12 +375,12 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
if (!currentConfig.LightfinderLabelShowHidden && !isNameplateVisible)
|
if (!currentConfig.LightfinderLabelShowHidden && !isNameplateVisible)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Prepare label content and scaling
|
// Prepare label content and scaling factors
|
||||||
var scaleMultiplier = System.Math.Clamp(currentConfig.LightfinderLabelScale, 0.5f, 2.0f);
|
var scaleMultiplier = Math.Clamp(currentConfig.LightfinderLabelScale, 0.5f, 2.0f);
|
||||||
var baseScale = currentConfig.LightfinderLabelUseIcon ? 1.0f : 0.5f;
|
var baseScale = currentConfig.LightfinderLabelUseIcon ? 1.0f : 0.5f;
|
||||||
var effectiveScale = baseScale * scaleMultiplier;
|
var effectiveScale = baseScale * scaleMultiplier;
|
||||||
var baseFontSize = currentConfig.LightfinderLabelUseIcon ? 36f : 24f;
|
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
|
var labelContent = currentConfig.LightfinderLabelUseIcon
|
||||||
? NormalizeIconGlyph(currentConfig.LightfinderLabelIconGlyph)
|
? NormalizeIconGlyph(currentConfig.LightfinderLabelIconGlyph)
|
||||||
: _defaultLabelText;
|
: _defaultLabelText;
|
||||||
@@ -332,8 +388,8 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
if (!currentConfig.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal)))
|
if (!currentConfig.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal)))
|
||||||
labelContent = _defaultLabelText;
|
labelContent = _defaultLabelText;
|
||||||
|
|
||||||
var nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
|
var nodeWidth = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
|
||||||
var nodeHeight = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale);
|
var nodeHeight = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale);
|
||||||
AlignmentType alignment;
|
AlignmentType alignment;
|
||||||
|
|
||||||
var textScaleY = nameText->AtkResNode.ScaleY;
|
var textScaleY = nameText->AtkResNode.ScaleY;
|
||||||
@@ -343,7 +399,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
var blockHeight = ResolveCache(
|
var blockHeight = ResolveCache(
|
||||||
_buffers.TextHeights,
|
_buffers.TextHeights,
|
||||||
nameplateIndex,
|
nameplateIndex,
|
||||||
System.Math.Abs((int)nameplateObject.TextH),
|
Math.Abs((int)nameplateObject.TextH),
|
||||||
() => GetScaledTextHeight(nameText),
|
() => GetScaledTextHeight(nameText),
|
||||||
nodeHeight);
|
nodeHeight);
|
||||||
|
|
||||||
@@ -353,7 +409,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
(int)nameContainer->Height,
|
(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;
|
return computed <= blockHeight ? blockHeight + 1 : computed;
|
||||||
},
|
},
|
||||||
blockHeight + 1);
|
blockHeight + 1);
|
||||||
@@ -361,7 +417,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
var blockTop = containerHeight - blockHeight;
|
var blockTop = containerHeight - blockHeight;
|
||||||
if (blockTop < 0)
|
if (blockTop < 0)
|
||||||
blockTop = 0;
|
blockTop = 0;
|
||||||
var verticalPadding = (int)System.Math.Round(4 * effectiveScale);
|
var verticalPadding = (int)Math.Round(4 * effectiveScale);
|
||||||
|
|
||||||
var positionY = blockTop - verticalPadding;
|
var positionY = blockTop - verticalPadding;
|
||||||
|
|
||||||
@@ -369,21 +425,14 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
var textWidth = ResolveCache(
|
var textWidth = ResolveCache(
|
||||||
_buffers.TextWidths,
|
_buffers.TextWidths,
|
||||||
nameplateIndex,
|
nameplateIndex,
|
||||||
System.Math.Abs(rawTextWidth),
|
Math.Abs(rawTextWidth),
|
||||||
() => GetScaledTextWidth(nameText),
|
() => GetScaledTextWidth(nameText),
|
||||||
nodeWidth);
|
nodeWidth);
|
||||||
|
|
||||||
// Text offset caching
|
// 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);
|
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;
|
var res = nameContainer;
|
||||||
|
|
||||||
// X scale
|
// X scale
|
||||||
@@ -419,7 +468,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
|
|
||||||
var offsetXScreen = currentConfig.LightfinderLabelOffsetX * worldScaleX;
|
var offsetXScreen = currentConfig.LightfinderLabelOffsetX * worldScaleX;
|
||||||
|
|
||||||
// alignment based on config
|
// alignment based on config setting
|
||||||
switch (currentConfig.LabelAlignment)
|
switch (currentConfig.LabelAlignment)
|
||||||
{
|
{
|
||||||
case LabelAlignment.Left:
|
case LabelAlignment.Left:
|
||||||
@@ -438,7 +487,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// manual X positioning
|
// manual X positioning with optional cached offset
|
||||||
var cachedTextOffset = _buffers.TextOffsets[nameplateIndex];
|
var cachedTextOffset = _buffers.TextOffsets[nameplateIndex];
|
||||||
var hasCachedOffset = cachedTextOffset != int.MinValue;
|
var hasCachedOffset = cachedTextOffset != int.MinValue;
|
||||||
var baseOffsetXLocal = (!currentConfig.LightfinderLabelUseIcon && hasValidOffset && hasCachedOffset)
|
var baseOffsetXLocal = (!currentConfig.LightfinderLabelUseIcon && hasValidOffset && hasCachedOffset)
|
||||||
@@ -458,16 +507,16 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
|
|
||||||
// final position before smoothing
|
// final position before smoothing
|
||||||
var finalPosition = new Vector2(finalX, res->ScreenY + positionYScreen);
|
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();
|
var fw = Framework.Instance();
|
||||||
float dt = fw->RealFrameDeltaTime;
|
float dt = fw->RealFrameDeltaTime;
|
||||||
|
|
||||||
//smoothing..
|
//smoothing.. snap.. smooth.. snap
|
||||||
finalPosition = SnapToPixels(finalPosition, dpiScale);
|
finalPosition = SnapToPixels(finalPosition, dpiScale);
|
||||||
finalPosition = SmoothPosition(nameplateIndex, finalPosition, dt);
|
finalPosition = SmoothPosition(nameplateIndex, finalPosition, dt);
|
||||||
finalPosition = SnapToPixels(finalPosition, dpiScale);
|
finalPosition = SnapToPixels(finalPosition, dpiScale);
|
||||||
|
|
||||||
// prepare label info
|
// prepare label info for rendering
|
||||||
var pivot = (currentConfig.LightfinderAutoAlign || currentConfig.LightfinderLabelUseIcon)
|
var pivot = (currentConfig.LightfinderAutoAlign || currentConfig.LightfinderLabelUseIcon)
|
||||||
? AlignmentToPivot(alignment)
|
? AlignmentToPivot(alignment)
|
||||||
: _defaultPivot;
|
: _defaultPivot;
|
||||||
@@ -503,6 +552,10 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void OnUiBuilderDraw()
|
private void OnUiBuilderDraw()
|
||||||
{
|
{
|
||||||
|
RefreshRendererState();
|
||||||
|
if (!IsPictomancyRenderer)
|
||||||
|
return;
|
||||||
|
|
||||||
if (!_mEnabled)
|
if (!_mEnabled)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -510,7 +563,23 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
if (fw == null)
|
if (fw == null)
|
||||||
return;
|
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;
|
var frame = fw->FrameCounter;
|
||||||
|
|
||||||
if (_lastNamePlateDrawFrame == 0 || (frame - _lastNamePlateDrawFrame) > 1)
|
if (_lastNamePlateDrawFrame == 0 || (frame - _lastNamePlateDrawFrame) > 1)
|
||||||
@@ -518,34 +587,62 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
ClearLabelBuffer();
|
ClearLabelBuffer();
|
||||||
Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length);
|
Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length);
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
DebugLabelCountLastFrame = 0;
|
||||||
|
DebugUiRectCountLastFrame = 0;
|
||||||
|
DebugOccludedCountLastFrame = 0;
|
||||||
|
DebugLastNameplateFrame = _lastNamePlateDrawFrame;
|
||||||
|
#endif
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
//Gpose Check
|
// Gpose Check - do not render.
|
||||||
if (_clientState.IsGPosing)
|
if (_clientState.IsGPosing)
|
||||||
{
|
{
|
||||||
ClearLabelBuffer();
|
ClearLabelBuffer();
|
||||||
Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length);
|
Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length);
|
||||||
_lastNamePlateDrawFrame = 0;
|
_lastNamePlateDrawFrame = 0;
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
DebugLabelCountLastFrame = 0;
|
||||||
|
DebugUiRectCountLastFrame = 0;
|
||||||
|
DebugOccludedCountLastFrame = 0;
|
||||||
|
DebugLastNameplateFrame = 0;
|
||||||
|
#endif
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If nameplate addon is not visible, skip rendering
|
// If nameplate addon is not visible, skip rendering entirely.
|
||||||
if (!IsNamePlateAddonVisible())
|
if (!IsNamePlateAddonVisible())
|
||||||
|
{
|
||||||
|
#if DEBUG
|
||||||
|
DebugLabelCountLastFrame = 0;
|
||||||
|
DebugUiRectCountLastFrame = 0;
|
||||||
|
DebugOccludedCountLastFrame = 0;
|
||||||
|
DebugLastNameplateFrame = _lastNamePlateDrawFrame;
|
||||||
|
#endif
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
int copyCount;
|
int copyCount;
|
||||||
lock (_labelLock)
|
lock (_labelLock)
|
||||||
{
|
{
|
||||||
copyCount = _labelRenderCount;
|
copyCount = _labelRenderCount;
|
||||||
if (copyCount == 0)
|
if (copyCount == 0)
|
||||||
|
{
|
||||||
|
#if DEBUG
|
||||||
|
DebugLabelCountLastFrame = 0;
|
||||||
|
DebugUiRectCountLastFrame = 0;
|
||||||
|
DebugOccludedCountLastFrame = 0;
|
||||||
|
DebugLastNameplateFrame = _lastNamePlateDrawFrame;
|
||||||
|
#endif
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Array.Copy(_buffers.LabelRender, _buffers.LabelCopy, copyCount);
|
Array.Copy(_buffers.LabelRender, _buffers.LabelCopy, copyCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
var uiModule = fw != null ? fw->GetUIModule() : null;
|
var uiModule = fw->GetUIModule();
|
||||||
|
|
||||||
if (uiModule != null)
|
if (uiModule != null)
|
||||||
{
|
{
|
||||||
var rapture = uiModule->GetRaptureAtkModule();
|
var rapture = uiModule->GetRaptureAtkModule();
|
||||||
@@ -564,7 +661,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
var vpPos = vp.Pos;
|
var vpPos = vp.Pos;
|
||||||
|
|
||||||
ImGuiHelpers.ForceNextWindowMainViewport();
|
ImGuiHelpers.ForceNextWindowMainViewport();
|
||||||
|
|
||||||
ImGui.SetNextWindowPos(vp.Pos);
|
ImGui.SetNextWindowPos(vp.Pos);
|
||||||
ImGui.SetNextWindowSize(vp.Size);
|
ImGui.SetNextWindowSize(vp.Size);
|
||||||
|
|
||||||
@@ -575,54 +671,118 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
|
|
||||||
ImGui.PopStyleVar(2);
|
ImGui.PopStyleVar(2);
|
||||||
|
|
||||||
using var drawList = PictoService.Draw();
|
// Debug flags
|
||||||
if (drawList == null)
|
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();
|
ImGui.End();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; i < copyCount; ++i)
|
#if DEBUG
|
||||||
{
|
DebugLabelCountLastFrame = copyCount;
|
||||||
ref var info = ref _buffers.LabelCopy[i];
|
DebugUiRectCountLastFrame = _uiRects.Count;
|
||||||
|
DebugOccludedCountLastFrame = occludedThisFrame;
|
||||||
// final draw position with viewport offset
|
DebugLastNameplateFrame = _lastNamePlateDrawFrame;
|
||||||
var drawPos = info.ScreenPosition + vpPos;
|
#endif
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Vector2 AlignmentToPivot(AlignmentType alignment) => alignment switch
|
private static Vector2 AlignmentToPivot(AlignmentType alignment) => alignment switch
|
||||||
@@ -670,8 +830,8 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
if (scale <= 0f)
|
if (scale <= 0f)
|
||||||
scale = 1f;
|
scale = 1f;
|
||||||
|
|
||||||
var computed = (int)System.Math.Round(rawHeight * scale);
|
var computed = (int)Math.Round(rawHeight * scale);
|
||||||
return System.Math.Max(1, computed);
|
return Math.Max(1, computed);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static unsafe int GetScaledTextWidth(AtkTextNode* node)
|
private static unsafe int GetScaledTextWidth(AtkTextNode* node)
|
||||||
@@ -695,12 +855,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves a cached value for the given index.
|
/// Resolves a cached value for the given index.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cache"></param>
|
|
||||||
/// <param name="index"></param>
|
|
||||||
/// <param name="rawValue"></param>
|
|
||||||
/// <param name="fallback"></param>
|
|
||||||
/// <param name="fallbackWhenZero"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
private static int ResolveCache(
|
private static int ResolveCache(
|
||||||
int[] cache,
|
int[] cache,
|
||||||
int index,
|
int index,
|
||||||
@@ -740,9 +894,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Snapping a position to pixel grid based on DPI scale.
|
/// Snapping a position to pixel grid based on DPI scale.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="p">Position</param>
|
|
||||||
/// <param name="dpiScale">DPI Scale</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
private static Vector2 SnapToPixels(Vector2 p, float dpiScale)
|
private static Vector2 SnapToPixels(Vector2 p, float dpiScale)
|
||||||
{
|
{
|
||||||
// snap to pixel grid
|
// snap to pixel grid
|
||||||
@@ -751,15 +902,9 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
return new Vector2(x, y);
|
return new Vector2(x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Smooths the position using exponential smoothing.
|
/// Smooths the position using exponential smoothing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="idx">Nameplate Index</param>
|
|
||||||
/// <param name="target">Final position</param>
|
|
||||||
/// <param name="dt">Delta Time</param>
|
|
||||||
/// <param name="responsiveness">How responssive the smooting should be</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
private Vector2 SmoothPosition(int idx, Vector2 target, float dt, float responsiveness = 24f)
|
private Vector2 SmoothPosition(int idx, Vector2 target, float dt, float responsiveness = 24f)
|
||||||
{
|
{
|
||||||
// exponential smoothing
|
// exponential smoothing
|
||||||
@@ -777,7 +922,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
var a = 1f - MathF.Exp(-responsiveness * dt);
|
var a = 1f - MathF.Exp(-responsiveness * dt);
|
||||||
|
|
||||||
// snap if close enough
|
// snap if close enough
|
||||||
if (Vector2.DistanceSquared(cur, target) < 0.25f)
|
if (Vector2.DistanceSquared(cur, target) < 0.25f)
|
||||||
return cur;
|
return cur;
|
||||||
|
|
||||||
// lerp towards target
|
// lerp towards target
|
||||||
@@ -786,73 +931,193 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
return cur;
|
return cur;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
/// Tries to get a valid screen rect for the given addon.
|
private static bool IsFinite(float f) => !(float.IsNaN(f) || float.IsInfinity(f));
|
||||||
/// </summary>
|
|
||||||
/// <param name="addon">Addon UI</param>
|
|
||||||
/// <param name="screen">Screen positioning/param>
|
|
||||||
/// <param name="rect">RectF of Addon</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
private static bool TryGetAddonRect(AtkUnitBase* addon, Vector2 screen, out RectF rect)
|
private static bool TryGetAddonRect(AtkUnitBase* addon, Vector2 screen, out RectF rect)
|
||||||
{
|
{
|
||||||
// Addon existence
|
|
||||||
rect = default;
|
rect = default;
|
||||||
if (addon == null)
|
if (addon == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Visibility check
|
// Addon must be visible
|
||||||
|
if (!addon->IsVisible)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Root must be visible
|
||||||
var root = addon->RootNode;
|
var root = addon->RootNode;
|
||||||
if (root == null || !root->IsVisible())
|
if (root == null || !root->IsVisible())
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Size check
|
// Must have multiple nodes to be useful
|
||||||
float w = root->Width;
|
var nodeCount = addon->UldManager.NodeListCount;
|
||||||
float h = root->Height;
|
var nodeList = addon->UldManager.NodeList;
|
||||||
if (w <= 0 || h <= 0)
|
if (nodeCount <= 1 || nodeList == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Local scale
|
float rsx = GetWorldScaleX(root);
|
||||||
float sx = root->ScaleX; if (sx <= 0f) sx = 1f;
|
float rsy = GetWorldScaleY(root);
|
||||||
float sy = root->ScaleY; if (sy <= 0f) sy = 1f;
|
if (!IsFinite(rsx) || rsx <= 0f) rsx = 1f;
|
||||||
|
if (!IsFinite(rsy) || rsy <= 0f) rsy = 1f;
|
||||||
|
|
||||||
// World/composed scale from Transform
|
// clamp insane root scales (rare but prevents explosions)
|
||||||
float wsx = GetWorldScaleX(root);
|
rsx = MathF.Min(rsx, 6f);
|
||||||
float wsy = GetWorldScaleY(root);
|
rsy = MathF.Min(rsy, 6f);
|
||||||
if (wsx <= 0f) wsx = 1f;
|
|
||||||
if (wsy <= 0f) wsy = 1f;
|
|
||||||
|
|
||||||
// World scale may include parent scaling; use it if meaningfully different.
|
float rw = root->Width * rsx;
|
||||||
float useX = MathF.Abs(wsx - sx) > 0.01f ? wsx : sx;
|
float rh = root->Height * rsy;
|
||||||
float useY = MathF.Abs(wsy - sy) > 0.01f ? wsy : sy;
|
if (!IsFinite(rw) || !IsFinite(rh) || rw <= 2f || rh <= 2f)
|
||||||
|
|
||||||
w *= useX;
|
|
||||||
h *= useY;
|
|
||||||
|
|
||||||
if (w < 4f || h < 4f)
|
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Screen coords
|
float rl = root->ScreenX;
|
||||||
float l = root->ScreenX;
|
float rt = root->ScreenY;
|
||||||
float t = root->ScreenY;
|
if (!IsFinite(rl) || !IsFinite(rt))
|
||||||
float r = l + w;
|
|
||||||
float b = t + h;
|
|
||||||
|
|
||||||
// Drop fullscreen-ish / insane rects
|
|
||||||
if (w >= screen.X * 0.98f && h >= screen.Y * 0.98f)
|
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Drop offscreen rects
|
float rr = rl + rw;
|
||||||
if (l < -screen.X || t < -screen.Y || r > screen.X * 2f || b > screen.Y * 2f)
|
float rb = rt + rh;
|
||||||
|
|
||||||
|
// If root is basically fullscreen, it<69>s not a useful occluder for our purpose.
|
||||||
|
if (rw >= screen.X * 0.98f && rh >= screen.Y * 0.98f)
|
||||||
return false;
|
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);
|
rect = new RectF(l, t, r, b);
|
||||||
return true;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Refreshes the cached UI rects for occlusion checking.
|
/// Refreshes the cached UI rects for occlusion checking.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="unitMgr">Unit Manager</param>
|
|
||||||
private void RefreshUiRects(RaptureAtkUnitManager* unitMgr)
|
private void RefreshUiRects(RaptureAtkUnitManager* unitMgr)
|
||||||
{
|
{
|
||||||
_uiRects.Clear();
|
_uiRects.Clear();
|
||||||
@@ -876,13 +1141,15 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
if (TryGetAddonRect(addon, screen, out var r))
|
if (TryGetAddonRect(addon, screen, out var r))
|
||||||
_uiRects.Add(r);
|
_uiRects.Add(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
DebugUiRectCountLastFrame = _uiRects.Count;
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Is the given label rect occluded by any UI rects?
|
/// Is the given label rect occluded by any UI rects?
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="labelRect">UI/Label Rect</param>
|
|
||||||
/// <returns>Is occluded or not</returns>
|
|
||||||
private bool IsOccludedByAnyUi(RectF labelRect)
|
private bool IsOccludedByAnyUi(RectF labelRect)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < _uiRects.Count; i++)
|
for (int i = 0; i < _uiRects.Count; i++)
|
||||||
@@ -896,8 +1163,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the world scale X of the given node.
|
/// Gets the world scale X of the given node.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="n">Node</param>
|
|
||||||
/// <returns>World Scale of node</returns>
|
|
||||||
private static float GetWorldScaleX(AtkResNode* n)
|
private static float GetWorldScaleX(AtkResNode* n)
|
||||||
{
|
{
|
||||||
var t = n->Transform;
|
var t = n->Transform;
|
||||||
@@ -907,8 +1172,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the world scale Y of the given node.
|
/// Gets the world scale Y of the given node.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="n">Node</param>
|
|
||||||
/// <returns>World Scale of node</returns>
|
|
||||||
private static float GetWorldScaleY(AtkResNode* n)
|
private static float GetWorldScaleY(AtkResNode* n)
|
||||||
{
|
{
|
||||||
var t = n->Transform;
|
var t = n->Transform;
|
||||||
@@ -918,8 +1181,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Normalize an icon glyph input into a valid string.
|
/// Normalize an icon glyph input into a valid string.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="rawInput">Raw glyph input</param>
|
|
||||||
/// <returns>Normalized glyph input</returns>
|
|
||||||
internal static string NormalizeIconGlyph(string? rawInput)
|
internal static string NormalizeIconGlyph(string? rawInput)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(rawInput))
|
if (string.IsNullOrWhiteSpace(rawInput))
|
||||||
@@ -947,7 +1208,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Is the nameplate addon visible?
|
/// Is the nameplate addon visible?
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>Is it visible?</returns>
|
|
||||||
private bool IsNamePlateAddonVisible()
|
private bool IsNamePlateAddonVisible()
|
||||||
{
|
{
|
||||||
if (_mpNameplateAddon == null)
|
if (_mpNameplateAddon == null)
|
||||||
@@ -957,20 +1217,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
return root != null && root->IsVisible();
|
return root != null && root->IsVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Converts raw icon glyph input into an icon editor string.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="rawInput">Raw icon glyph input</param>
|
|
||||||
/// <returns>Icon editor string</returns>
|
|
||||||
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
|
private readonly struct NameplateLabelInfo
|
||||||
{
|
{
|
||||||
public NameplateLabelInfo(
|
public NameplateLabelInfo(
|
||||||
@@ -1008,6 +1254,15 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
|
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
|
||||||
.Select(u => (ulong)u.PlayerCharacterId)];
|
.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()
|
public void FlagRefresh()
|
||||||
{
|
{
|
||||||
_needsLabelRefresh = true;
|
_needsLabelRefresh = true;
|
||||||
@@ -1015,6 +1270,12 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
|
|
||||||
public void OnTick(PriorityFrameworkUpdateMessage _)
|
public void OnTick(PriorityFrameworkUpdateMessage _)
|
||||||
{
|
{
|
||||||
|
if (!IsPictomancyRenderer)
|
||||||
|
{
|
||||||
|
_needsLabelRefresh = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_needsLabelRefresh)
|
if (_needsLabelRefresh)
|
||||||
{
|
{
|
||||||
UpdateNameplateNodes();
|
UpdateNameplateNodes();
|
||||||
@@ -1025,7 +1286,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Update the active broadcasting CIDs.
|
/// Update the active broadcasting CIDs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cids">Inbound new CIDs</param>
|
|
||||||
public void UpdateBroadcastingCids(IEnumerable<string> cids)
|
public void UpdateBroadcastingCids(IEnumerable<string> cids)
|
||||||
{
|
{
|
||||||
var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal);
|
var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal);
|
||||||
@@ -1055,7 +1315,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
public NameplateBuffers()
|
public NameplateBuffers()
|
||||||
{
|
{
|
||||||
TextOffsets = new int[AddonNamePlate.NumNamePlateObjects];
|
TextOffsets = new int[AddonNamePlate.NumNamePlateObjects];
|
||||||
System.Array.Fill(TextOffsets, int.MinValue);
|
Array.Fill(TextOffsets, int.MinValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
public int[] TextWidths { get; } = new int[AddonNamePlate.NumNamePlateObjects];
|
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 NameplateLabelInfo[] LabelCopy { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects];
|
||||||
|
|
||||||
public Vector2[] SmoothedPos = new Vector2[AddonNamePlate.NumNamePlateObjects];
|
public Vector2[] SmoothedPos = new Vector2[AddonNamePlate.NumNamePlateObjects];
|
||||||
|
|
||||||
public bool[] HasSmoothed = new bool[AddonNamePlate.NumNamePlateObjects];
|
public bool[] HasSmoothed = new bool[AddonNamePlate.NumNamePlateObjects];
|
||||||
|
|
||||||
public void Clear()
|
public void Clear()
|
||||||
{
|
{
|
||||||
System.Array.Clear(TextWidths, 0, TextWidths.Length);
|
Array.Clear(TextWidths, 0, TextWidths.Length);
|
||||||
System.Array.Clear(TextHeights, 0, TextHeights.Length);
|
Array.Clear(TextHeights, 0, TextHeights.Length);
|
||||||
System.Array.Clear(ContainerHeights, 0, ContainerHeights.Length);
|
Array.Clear(ContainerHeights, 0, ContainerHeights.Length);
|
||||||
System.Array.Fill(TextOffsets, int.MinValue);
|
Array.Fill(TextOffsets, int.MinValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts the LightFinder Plate Handler.
|
/// Starts the LightFinder Plate Handler.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">Cancellation Token</param>
|
|
||||||
/// <returns>Task Completed</returns>
|
|
||||||
public Task StartAsync(CancellationToken cancellationToken)
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Init();
|
Init();
|
||||||
@@ -1093,8 +1350,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stops the LightFinder Plate Handler.
|
/// Stops the LightFinder Plate Handler.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">Cancellation Token</param>
|
|
||||||
/// <returns>Task Completed</returns>
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Uninit();
|
Uninit();
|
||||||
@@ -1113,4 +1368,4 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
|||||||
public bool Intersects(in RectF o) =>
|
public bool Intersects(in RectF o) =>
|
||||||
!(R <= o.L || o.R <= L || B <= o.T || o.B <= T);
|
!(R <= o.L || o.R <= L || B <= o.T || o.B <= T);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
|||||||
|
|
||||||
private readonly LightFinderService _broadcastService;
|
private readonly LightFinderService _broadcastService;
|
||||||
private readonly LightFinderPlateHandler _lightFinderPlateHandler;
|
private readonly LightFinderPlateHandler _lightFinderPlateHandler;
|
||||||
|
private readonly LightFinderNativePlateHandler _lightFinderNativePlateHandler;
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, BroadcastEntry> _broadcastCache = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, BroadcastEntry> _broadcastCache = new(StringComparer.Ordinal);
|
||||||
private readonly Queue<string> _lookupQueue = new();
|
private readonly Queue<string> _lookupQueue = new();
|
||||||
private readonly HashSet<string> _lookupQueuedCids = [];
|
private readonly HashSet<string> _lookupQueuedCids = [];
|
||||||
private readonly HashSet<string> _syncshellCids = [];
|
private readonly HashSet<string> _syncshellCids = [];
|
||||||
|
private volatile bool _pendingLocalBroadcast;
|
||||||
|
private TimeSpan? _pendingLocalTtl;
|
||||||
|
|
||||||
private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4);
|
private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4);
|
||||||
private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1);
|
private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1);
|
||||||
@@ -42,12 +45,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
|||||||
LightFinderService broadcastService,
|
LightFinderService broadcastService,
|
||||||
LightlessMediator mediator,
|
LightlessMediator mediator,
|
||||||
LightFinderPlateHandler lightFinderPlateHandler,
|
LightFinderPlateHandler lightFinderPlateHandler,
|
||||||
|
LightFinderNativePlateHandler lightFinderNativePlateHandler,
|
||||||
ActorObjectService actorTracker) : base(logger, mediator)
|
ActorObjectService actorTracker) : base(logger, mediator)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_actorTracker = actorTracker;
|
_actorTracker = actorTracker;
|
||||||
_broadcastService = broadcastService;
|
_broadcastService = broadcastService;
|
||||||
_lightFinderPlateHandler = lightFinderPlateHandler;
|
_lightFinderPlateHandler = lightFinderPlateHandler;
|
||||||
|
_lightFinderNativePlateHandler = lightFinderNativePlateHandler;
|
||||||
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_framework = framework;
|
_framework = framework;
|
||||||
@@ -69,6 +74,8 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
|||||||
if (!_broadcastService.IsBroadcasting)
|
if (!_broadcastService.IsBroadcasting)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
TryPrimeLocalBroadcastCache();
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
foreach (var address in _actorTracker.PlayerAddresses)
|
foreach (var address in _actorTracker.PlayerAddresses)
|
||||||
@@ -129,6 +136,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
|
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
|
||||||
|
_lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids);
|
||||||
UpdateSyncshellBroadcasts();
|
UpdateSyncshellBroadcasts();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,9 +148,45 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
|||||||
_lookupQueue.Clear();
|
_lookupQueue.Clear();
|
||||||
_lookupQueuedCids.Clear();
|
_lookupQueuedCids.Clear();
|
||||||
_syncshellCids.Clear();
|
_syncshellCids.Clear();
|
||||||
|
_pendingLocalBroadcast = false;
|
||||||
|
_pendingLocalTtl = null;
|
||||||
|
|
||||||
_lightFinderPlateHandler.UpdateBroadcastingCids([]);
|
_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()
|
private void UpdateSyncshellBroadcasts()
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ using Dalamud.Game.ClientState.Objects.Enums;
|
|||||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||||
using Dalamud.Game.NativeWrapper;
|
using Dalamud.Game.NativeWrapper;
|
||||||
using Dalamud.Game.Text.SeStringHandling;
|
using Dalamud.Game.Text.SeStringHandling;
|
||||||
using Dalamud.Hooking;
|
|
||||||
using Dalamud.Plugin.Services;
|
using Dalamud.Plugin.Services;
|
||||||
using Dalamud.Utility;
|
using Dalamud.Utility;
|
||||||
using Dalamud.Utility.Signatures;
|
|
||||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||||
@@ -24,27 +22,22 @@ namespace LightlessSync.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public unsafe class NameplateService : DisposableMediatorSubscriberBase
|
public unsafe class NameplateService : DisposableMediatorSubscriberBase
|
||||||
{
|
{
|
||||||
private delegate nint UpdateNameplateDelegate(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex);
|
|
||||||
|
|
||||||
// Glyceri, Thanks :bow:
|
|
||||||
[Signature("40 53 55 57 41 56 48 81 EC ?? ?? ?? ?? 48 8B 84 24", DetourName = nameof(UpdateNameplateDetour))]
|
|
||||||
private readonly Hook<UpdateNameplateDelegate>? _nameplateHook = null;
|
|
||||||
|
|
||||||
private readonly ILogger<NameplateService> _logger;
|
private readonly ILogger<NameplateService> _logger;
|
||||||
private readonly LightlessConfigService _configService;
|
private readonly LightlessConfigService _configService;
|
||||||
private readonly IClientState _clientState;
|
private readonly IClientState _clientState;
|
||||||
private readonly IGameGui _gameGui;
|
private readonly IGameGui _gameGui;
|
||||||
private readonly IObjectTable _objectTable;
|
private readonly IObjectTable _objectTable;
|
||||||
private readonly PairUiService _pairUiService;
|
private readonly PairUiService _pairUiService;
|
||||||
|
private readonly NameplateUpdateHookService _nameplateUpdateHookService;
|
||||||
|
|
||||||
public NameplateService(ILogger<NameplateService> logger,
|
public NameplateService(ILogger<NameplateService> logger,
|
||||||
LightlessConfigService configService,
|
LightlessConfigService configService,
|
||||||
IClientState clientState,
|
IClientState clientState,
|
||||||
IGameGui gameGui,
|
IGameGui gameGui,
|
||||||
IObjectTable objectTable,
|
IObjectTable objectTable,
|
||||||
IGameInteropProvider interop,
|
|
||||||
LightlessMediator lightlessMediator,
|
LightlessMediator lightlessMediator,
|
||||||
PairUiService pairUiService) : base(logger, lightlessMediator)
|
PairUiService pairUiService,
|
||||||
|
NameplateUpdateHookService nameplateUpdateHookService) : base(logger, lightlessMediator)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
@@ -52,21 +45,18 @@ public unsafe class NameplateService : DisposableMediatorSubscriberBase
|
|||||||
_gameGui = gameGui;
|
_gameGui = gameGui;
|
||||||
_objectTable = objectTable;
|
_objectTable = objectTable;
|
||||||
_pairUiService = pairUiService;
|
_pairUiService = pairUiService;
|
||||||
|
_nameplateUpdateHookService = nameplateUpdateHookService;
|
||||||
|
|
||||||
interop.InitializeFromAttributes(this);
|
_nameplateUpdateHookService.NameplateUpdated += OnNameplateUpdated;
|
||||||
_nameplateHook?.Enable();
|
|
||||||
Refresh();
|
Refresh();
|
||||||
|
|
||||||
Mediator.Subscribe<VisibilityChange>(this, (_) => Refresh());
|
Mediator.Subscribe<VisibilityChange>(this, (_) => Refresh());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Detour for the game's internal nameplate update function.
|
/// Nameplate update handler, triggered by the signature hook service.
|
||||||
/// This will be called whenever the client updates any nameplate.
|
|
||||||
///
|
|
||||||
/// We hook into it to apply our own nameplate coloring logic via <see cref="SetNameplate"/>,
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private nint UpdateNameplateDetour(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
|
private void OnNameplateUpdated(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -74,10 +64,8 @@ public unsafe class NameplateService : DisposableMediatorSubscriberBase
|
|||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
_logger.LogError(e, "Error in NameplateService UpdateNameplateDetour");
|
_logger.LogError(e, "Error in NameplateService OnNameplateUpdated");
|
||||||
}
|
}
|
||||||
|
|
||||||
return _nameplateHook!.Original(raptureAtkModule, namePlateInfo, numArray, stringArray, battleChara, numArrayIndex, stringArrayIndex);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -246,7 +234,7 @@ public unsafe class NameplateService : DisposableMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
if (disposing)
|
if (disposing)
|
||||||
{
|
{
|
||||||
_nameplateHook?.Dispose();
|
_nameplateUpdateHookService.NameplateUpdated -= OnNameplateUpdated;
|
||||||
}
|
}
|
||||||
|
|
||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
|
|||||||
57
LightlessSync/Services/NameplateUpdateHookService.cs
Normal file
57
LightlessSync/Services/NameplateUpdateHookService.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
using Dalamud.Hooking;
|
||||||
|
using Dalamud.Plugin.Services;
|
||||||
|
using Dalamud.Utility.Signatures;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||||
|
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace LightlessSync.Services;
|
||||||
|
|
||||||
|
public unsafe sealed class NameplateUpdateHookService : IDisposable
|
||||||
|
{
|
||||||
|
private delegate nint UpdateNameplateDelegate(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo,
|
||||||
|
NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex);
|
||||||
|
public delegate void NameplateUpdatedHandler(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo,
|
||||||
|
NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex);
|
||||||
|
|
||||||
|
// Glyceri, Thanks :bow:
|
||||||
|
[Signature("40 53 55 57 41 56 48 81 EC ?? ?? ?? ?? 48 8B 84 24", DetourName = nameof(UpdateNameplateDetour))]
|
||||||
|
private readonly Hook<UpdateNameplateDelegate>? _nameplateHook = null;
|
||||||
|
|
||||||
|
private readonly ILogger<NameplateUpdateHookService> _logger;
|
||||||
|
|
||||||
|
public NameplateUpdateHookService(ILogger<NameplateUpdateHookService> logger, IGameInteropProvider interop)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
interop.InitializeFromAttributes(this);
|
||||||
|
_nameplateHook?.Enable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public event NameplateUpdatedHandler? NameplateUpdated;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detour for the game's internal nameplate update function.
|
||||||
|
/// This will be called whenever the client updates any nameplate.
|
||||||
|
/// </summary>
|
||||||
|
private nint UpdateNameplateDetour(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo,
|
||||||
|
NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
NameplateUpdated?.Invoke(raptureAtkModule, namePlateInfo, numArray, stringArray, battleChara, numArrayIndex, stringArrayIndex);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
_logger.LogError(e, "Error in NameplateUpdateHookService UpdateNameplateDetour");
|
||||||
|
}
|
||||||
|
|
||||||
|
return _nameplateHook!.Original(raptureAtkModule, namePlateInfo, numArray, stringArray, battleChara, numArrayIndex, stringArrayIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_nameplateHook?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -394,6 +394,21 @@ public sealed class TextureMetadataHelper
|
|||||||
if (string.IsNullOrEmpty(fileNameWithExtension) && string.IsNullOrEmpty(fileNameWithoutExtension))
|
if (string.IsNullOrEmpty(fileNameWithExtension) && string.IsNullOrEmpty(fileNameWithoutExtension))
|
||||||
return TextureMapKind.Unknown;
|
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)
|
foreach (var (kind, token) in MapTokens)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(fileNameWithExtension) &&
|
if (!string.IsNullOrEmpty(fileNameWithExtension) &&
|
||||||
@@ -563,7 +578,16 @@ public sealed class TextureMetadataHelper
|
|||||||
|
|
||||||
var normalized = format.ToUpperInvariant();
|
var normalized = format.ToUpperInvariant();
|
||||||
return normalized.Contains("A8", StringComparison.Ordinal)
|
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("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("BC3", StringComparison.Ordinal)
|
||||||
|| normalized.Contains("BC7", StringComparison.Ordinal);
|
|| normalized.Contains("BC7", StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -832,14 +832,19 @@ public class DrawUserPair
|
|||||||
}
|
}
|
||||||
UiSharedService.AttachToolTip("Hold CTRL and click to remove user " + (_pair.UserData.AliasOrUID) + " from Syncshell");
|
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));
|
_mediator.Publish(new OpenBanUserPopupMessage(_pair, group));
|
||||||
ImGui.CloseCurrentPopup();
|
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)
|
if (showOwnerActions)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ using LightlessSync.Services.TextureCompression;
|
|||||||
using LightlessSync.Utils;
|
using LightlessSync.Utils;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using OtterTex;
|
using OtterTex;
|
||||||
|
using System.Buffers.Binary;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using SixLabors.ImageSharp;
|
using SixLabors.ImageSharp;
|
||||||
@@ -49,6 +50,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
|||||||
private readonly Dictionary<string, TextureCompressionTarget> _textureSelections = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, TextureCompressionTarget> _textureSelections = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly HashSet<string> _selectedTextureKeys = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _selectedTextureKeys = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, TexturePreviewState> _texturePreviews = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, TexturePreviewState> _texturePreviews = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, TextureResolutionInfo?> _textureResolutionCache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<ObjectKind, TextureWorkspaceTab> _textureWorkspaceTabs = new();
|
private readonly Dictionary<ObjectKind, TextureWorkspaceTab> _textureWorkspaceTabs = new();
|
||||||
private readonly List<string> _storedPathsToRemove = [];
|
private readonly List<string> _storedPathsToRemove = [];
|
||||||
private readonly Dictionary<string, string> _filePathResolve = [];
|
private readonly Dictionary<string, string> _filePathResolve = [];
|
||||||
@@ -88,6 +90,9 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
|||||||
private bool _showAlreadyAddedTransients = false;
|
private bool _showAlreadyAddedTransients = false;
|
||||||
private bool _acknowledgeReview = false;
|
private bool _acknowledgeReview = false;
|
||||||
|
|
||||||
|
private Task<TextureRowBuildResult>? _textureRowsBuildTask;
|
||||||
|
private CancellationTokenSource? _textureRowsBuildCts;
|
||||||
|
|
||||||
private ObjectKind _selectedObjectTab;
|
private ObjectKind _selectedObjectTab;
|
||||||
|
|
||||||
private TextureUsageCategory? _textureCategoryFilter = null;
|
private TextureUsageCategory? _textureCategoryFilter = null;
|
||||||
@@ -204,9 +209,9 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_cachedAnalysis = _characterAnalyzer.LastAnalysis.DeepClone();
|
_cachedAnalysis = CloneAnalysis(_characterAnalyzer.LastAnalysis);
|
||||||
_hasUpdate = false;
|
_hasUpdate = false;
|
||||||
_textureRowsDirty = true;
|
InvalidateTextureRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawContentTabs()
|
private void DrawContentTabs()
|
||||||
@@ -750,7 +755,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
|||||||
_selectedTextureKeys.Clear();
|
_selectedTextureKeys.Clear();
|
||||||
_textureSelections.Clear();
|
_textureSelections.Clear();
|
||||||
ResetTextureFilters();
|
ResetTextureFilters();
|
||||||
_textureRowsDirty = true;
|
InvalidateTextureRows();
|
||||||
_conversionFailed = false;
|
_conversionFailed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,6 +767,8 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
|||||||
preview.Texture?.Dispose();
|
preview.Texture?.Dispose();
|
||||||
}
|
}
|
||||||
_texturePreviews.Clear();
|
_texturePreviews.Clear();
|
||||||
|
_textureRowsBuildCts?.Cancel();
|
||||||
|
_textureRowsBuildCts?.Dispose();
|
||||||
_conversionProgress.ProgressChanged -= ConversionProgress_ProgressChanged;
|
_conversionProgress.ProgressChanged -= ConversionProgress_ProgressChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,18 +782,108 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
private void EnsureTextureRows()
|
private void EnsureTextureRows()
|
||||||
{
|
{
|
||||||
if (!_textureRowsDirty || _cachedAnalysis == null)
|
if (_cachedAnalysis == null)
|
||||||
{
|
{
|
||||||
return;
|
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.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<ObjectKind, Dictionary<string, CharacterAnalyzer.FileDataEntry>> analysis,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
var rows = new List<TextureRow>();
|
||||||
HashSet<string> validKeys = new(StringComparer.OrdinalIgnoreCase);
|
HashSet<string> validKeys = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
foreach (var (objectKind, entries) in _cachedAnalysis)
|
foreach (var (objectKind, entries) in analysis)
|
||||||
{
|
{
|
||||||
foreach (var entry in entries.Values)
|
foreach (var entry in entries.Values)
|
||||||
{
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
if (!string.Equals(entry.FileType, "tex", StringComparison.Ordinal))
|
if (!string.Equals(entry.FileType, "tex", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -828,17 +925,11 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
|||||||
suggestion?.Reason);
|
suggestion?.Reason);
|
||||||
|
|
||||||
validKeys.Add(row.Key);
|
validKeys.Add(row.Key);
|
||||||
_textureRows.Add(row);
|
rows.Add(row);
|
||||||
|
|
||||||
if (row.IsAlreadyCompressed)
|
|
||||||
{
|
|
||||||
_selectedTextureKeys.Remove(row.Key);
|
|
||||||
_textureSelections.Remove(row.Key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_textureRows.Sort((a, b) =>
|
rows.Sort((a, b) =>
|
||||||
{
|
{
|
||||||
var comp = a.ObjectKind.CompareTo(b.ObjectKind);
|
var comp = a.ObjectKind.CompareTo(b.ObjectKind);
|
||||||
if (comp != 0)
|
if (comp != 0)
|
||||||
@@ -851,34 +942,14 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
|||||||
return string.Compare(a.DisplayName, b.DisplayName, StringComparison.OrdinalIgnoreCase);
|
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())
|
private void InvalidateTextureRows()
|
||||||
{
|
{
|
||||||
if (!validKeys.Contains(key) && _texturePreviews.TryGetValue(key, out var preview))
|
_textureRowsDirty = true;
|
||||||
{
|
_textureRowsBuildCts?.Cancel();
|
||||||
preview.Texture?.Dispose();
|
_textureResolutionCache.Clear();
|
||||||
_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 static string MakeTextureKey(ObjectKind objectKind, string primaryFilePath) =>
|
private static string MakeTextureKey(ObjectKind objectKind, string primaryFilePath) =>
|
||||||
@@ -893,6 +964,35 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
|||||||
_textureSearch = string.Empty;
|
_textureSearch = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Dictionary<ObjectKind, Dictionary<string, CharacterAnalyzer.FileDataEntry>> CloneAnalysis(
|
||||||
|
Dictionary<ObjectKind, Dictionary<string, CharacterAnalyzer.FileDataEntry>> source)
|
||||||
|
{
|
||||||
|
var clone = new Dictionary<ObjectKind, Dictionary<string, CharacterAnalyzer.FileDataEntry>>(source.Count);
|
||||||
|
|
||||||
|
foreach (var (objectKind, entries) in source)
|
||||||
|
{
|
||||||
|
var entryClone = new Dictionary<string, CharacterAnalyzer.FileDataEntry>(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)
|
private void DrawAnalysisOverview(int totalFiles, long totalActualSize, long totalCompressedSize, long totalTriangles, string breakdownTooltip)
|
||||||
{
|
{
|
||||||
var scale = ImGuiHelpers.GlobalScale;
|
var scale = ImGuiHelpers.GlobalScale;
|
||||||
@@ -1091,6 +1191,10 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
|||||||
public bool IsAlreadyCompressed => CurrentTarget.HasValue;
|
public bool IsAlreadyCompressed => CurrentTarget.HasValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record TextureRowBuildResult(
|
||||||
|
List<TextureRow> Rows,
|
||||||
|
HashSet<string> ValidKeys);
|
||||||
|
|
||||||
private sealed class TexturePreviewState
|
private sealed class TexturePreviewState
|
||||||
{
|
{
|
||||||
public Task? LoadTask { get; set; }
|
public Task? LoadTask { get; set; }
|
||||||
@@ -1099,6 +1203,22 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
|||||||
public DateTime LastAccessUtc { get; set; } = DateTime.UtcNow;
|
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<IGrouping<string, CharacterAnalyzer.FileDataEntry>> otherFileGroups)
|
private void DrawTextureWorkspace(ObjectKind objectKind, IReadOnlyList<IGrouping<string, CharacterAnalyzer.FileDataEntry>> otherFileGroups)
|
||||||
{
|
{
|
||||||
if (!_textureWorkspaceTabs.ContainsKey(objectKind))
|
if (!_textureWorkspaceTabs.ContainsKey(objectKind))
|
||||||
@@ -1143,6 +1263,11 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
|||||||
private void DrawTextureTabContent(ObjectKind objectKind)
|
private void DrawTextureTabContent(ObjectKind objectKind)
|
||||||
{
|
{
|
||||||
var scale = ImGuiHelpers.GlobalScale;
|
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 objectRows = _textureRows.Where(row => row.ObjectKind == objectKind).ToList();
|
||||||
var hasAnyTextureRows = objectRows.Count > 0;
|
var hasAnyTextureRows = objectRows.Count > 0;
|
||||||
var availableCategories = objectRows.Select(row => row.Category)
|
var availableCategories = objectRows.Select(row => row.Category)
|
||||||
@@ -1404,6 +1529,24 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
ResetTextureFilters();
|
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<T>(
|
private static void DrawEnumFilterCombo<T>(
|
||||||
@@ -1810,7 +1953,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
|||||||
ImGui.SameLine();
|
ImGui.SameLine();
|
||||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Sync, "Refresh", 130f * scale))
|
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Sync, "Refresh", 130f * scale))
|
||||||
{
|
{
|
||||||
_textureRowsDirty = true;
|
InvalidateTextureRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
TextureRow? lastSelected = null;
|
TextureRow? lastSelected = null;
|
||||||
@@ -1976,7 +2119,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
_selectedTextureKeys.Clear();
|
_selectedTextureKeys.Clear();
|
||||||
_textureSelections.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<byte> buffer = stackalloc byte[16];
|
||||||
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||||
|
var read = stream.Read(buffer);
|
||||||
|
if (read < buffer.Length)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var 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<TextureCompressionTarget> targets, int index)
|
private void DrawTextureRow(TextureRow row, IReadOnlyList<TextureCompressionTarget> targets, int index)
|
||||||
{
|
{
|
||||||
var key = row.Key;
|
var key = row.Key;
|
||||||
@@ -2465,6 +2670,9 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
|||||||
MetaRow(FontAwesomeIcon.LayerGroup, "Map Type", row.MapKind.ToString());
|
MetaRow(FontAwesomeIcon.LayerGroup, "Map Type", row.MapKind.ToString());
|
||||||
MetaRow(FontAwesomeIcon.Fingerprint, "Hash", row.Hash, UIColors.Get("LightlessBlue"));
|
MetaRow(FontAwesomeIcon.Fingerprint, "Hash", row.Hash, UIColors.Get("LightlessBlue"));
|
||||||
MetaRow(FontAwesomeIcon.InfoCircle, "Current Format", row.Format);
|
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 selectedLabel = hasSelectedInfo ? selectedInfo!.Title : selectedTarget.ToString();
|
||||||
var selectionColor = hasSelectedInfo ? UIColors.Get("LightlessYellow") : UIColors.Get("LightlessGreen");
|
var selectionColor = hasSelectedInfo ? UIColors.Get("LightlessYellow") : UIColors.Get("LightlessGreen");
|
||||||
|
|||||||
@@ -46,10 +46,12 @@ public sealed class DtrEntry : IDisposable, IHostedService
|
|||||||
private string? _lightfinderText;
|
private string? _lightfinderText;
|
||||||
private string? _lightfinderTooltip;
|
private string? _lightfinderTooltip;
|
||||||
private Colors _lightfinderColors;
|
private Colors _lightfinderColors;
|
||||||
|
private readonly object _localHashedCidLock = new();
|
||||||
private string? _localHashedCid;
|
private string? _localHashedCid;
|
||||||
private DateTime _localHashedCidFetchedAt = DateTime.MinValue;
|
private DateTime _localHashedCidFetchedAt = DateTime.MinValue;
|
||||||
private DateTime _localHashedCidNextErrorLog = DateTime.MinValue;
|
private DateTime _localHashedCidNextErrorLog = DateTime.MinValue;
|
||||||
private DateTime _pairRequestNextErrorLog = DateTime.MinValue;
|
private DateTime _pairRequestNextErrorLog = DateTime.MinValue;
|
||||||
|
private int _localHashedCidRefreshActive;
|
||||||
|
|
||||||
public DtrEntry(
|
public DtrEntry(
|
||||||
ILogger<DtrEntry> logger,
|
ILogger<DtrEntry> logger,
|
||||||
@@ -339,29 +341,61 @@ public sealed class DtrEntry : IDisposable, IHostedService
|
|||||||
private string? GetLocalHashedCid()
|
private string? GetLocalHashedCid()
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
if (_localHashedCid is not null && now - _localHashedCidFetchedAt < _localHashedCidCacheDuration)
|
lock (_localHashedCidLock)
|
||||||
return _localHashedCid;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
var cid = _dalamudUtilService.GetCID();
|
if (_localHashedCid is not null && now - _localHashedCidFetchedAt < _localHashedCidCacheDuration)
|
||||||
var hashedCid = cid.ToString().GetHash256();
|
|
||||||
_localHashedCid = hashedCid;
|
|
||||||
_localHashedCidFetchedAt = now;
|
|
||||||
return hashedCid;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
if (now >= _localHashedCidNextErrorLog)
|
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to refresh local hashed CID for Lightfinder DTR entry.");
|
return _localHashedCid;
|
||||||
_localHashedCidNextErrorLog = now + _localHashedCidErrorCooldown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_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<string> GetNearbyBroadcasts()
|
private List<string> GetNearbyBroadcasts()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ namespace LightlessSync.UI
|
|||||||
private readonly LightFinderService _broadcastService;
|
private readonly LightFinderService _broadcastService;
|
||||||
private readonly UiSharedService _uiSharedService;
|
private readonly UiSharedService _uiSharedService;
|
||||||
private readonly LightFinderScannerService _broadcastScannerService;
|
private readonly LightFinderScannerService _broadcastScannerService;
|
||||||
|
private readonly LightFinderPlateHandler _lightFinderPlateHandler;
|
||||||
|
|
||||||
private IReadOnlyList<GroupFullInfoDto> _allSyncshells = Array.Empty<GroupFullInfoDto>();
|
private IReadOnlyList<GroupFullInfoDto> _allSyncshells = Array.Empty<GroupFullInfoDto>();
|
||||||
private string _userUid = string.Empty;
|
private string _userUid = string.Empty;
|
||||||
@@ -38,7 +39,8 @@ namespace LightlessSync.UI
|
|||||||
UiSharedService uiShared,
|
UiSharedService uiShared,
|
||||||
ApiController apiController,
|
ApiController apiController,
|
||||||
LightFinderScannerService broadcastScannerService
|
LightFinderScannerService broadcastScannerService
|
||||||
) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService)
|
,
|
||||||
|
LightFinderPlateHandler lightFinderPlateHandler) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService)
|
||||||
{
|
{
|
||||||
_broadcastService = broadcastService;
|
_broadcastService = broadcastService;
|
||||||
_uiSharedService = uiShared;
|
_uiSharedService = uiShared;
|
||||||
@@ -50,6 +52,7 @@ namespace LightlessSync.UI
|
|||||||
WindowBuilder.For(this)
|
WindowBuilder.For(this)
|
||||||
.SetSizeConstraints(new Vector2(600, 465), new Vector2(750, 525))
|
.SetSizeConstraints(new Vector2(600, 465), new Vector2(750, 525))
|
||||||
.Apply();
|
.Apply();
|
||||||
|
_lightFinderPlateHandler = lightFinderPlateHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RebuildSyncshellDropdownOptions()
|
private void RebuildSyncshellDropdownOptions()
|
||||||
@@ -380,9 +383,47 @@ namespace LightlessSync.UI
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
if (ImGui.BeginTabItem("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");
|
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("CID", ImGuiTableColumnFlags.WidthStretch);
|
||||||
ImGui.TableSetupColumn("IsBroadcasting", ImGuiTableColumnFlags.WidthStretch);
|
ImGui.TableSetupColumn("IsBroadcasting", ImGuiTableColumnFlags.WidthStretch);
|
||||||
|
|||||||
@@ -2368,6 +2368,43 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f);
|
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<LightfinderLabelRenderer>())
|
||||||
|
{
|
||||||
|
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.TextUnformatted("Alignment");
|
||||||
ImGui.BeginDisabled(autoAlign);
|
ImGui.BeginDisabled(autoAlign);
|
||||||
if (ImGui.SliderInt("Label Offset X", ref offsetX, -200, 200))
|
if (ImGui.SliderInt("Label Offset X", ref offsetX, -200, 200))
|
||||||
@@ -2550,7 +2587,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
var selected = i == _lightfinderIconPresetIndex;
|
var selected = i == _lightfinderIconPresetIndex;
|
||||||
if (ImGui.Selectable(preview, selected))
|
if (ImGui.Selectable(preview, selected))
|
||||||
{
|
{
|
||||||
_lightfinderIconInput = LightFinderPlateHandler.ToIconEditorString(optionGlyph);
|
_lightfinderIconInput = LightFinderPlateHandler.NormalizeIconGlyph(optionGlyph);
|
||||||
_lightfinderIconPresetIndex = i;
|
_lightfinderIconPresetIndex = i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4026,7 +4063,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
private void RefreshLightfinderIconState()
|
private void RefreshLightfinderIconState()
|
||||||
{
|
{
|
||||||
var normalized = LightFinderPlateHandler.NormalizeIconGlyph(_configService.Current.LightfinderLabelIconGlyph);
|
var normalized = LightFinderPlateHandler.NormalizeIconGlyph(_configService.Current.LightfinderLabelIconGlyph);
|
||||||
_lightfinderIconInput = LightFinderPlateHandler.ToIconEditorString(normalized);
|
_lightfinderIconInput = LightFinderPlateHandler.NormalizeIconGlyph(normalized);
|
||||||
_lightfinderIconInputInitialized = true;
|
_lightfinderIconInputInitialized = true;
|
||||||
|
|
||||||
_lightfinderIconPresetIndex = -1;
|
_lightfinderIconPresetIndex = -1;
|
||||||
@@ -4044,7 +4081,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
_configService.Current.LightfinderLabelIconGlyph = normalizedGlyph;
|
_configService.Current.LightfinderLabelIconGlyph = normalizedGlyph;
|
||||||
_configService.Save();
|
_configService.Save();
|
||||||
_lightfinderIconInput = LightFinderPlateHandler.ToIconEditorString(normalizedGlyph);
|
_lightfinderIconInput = LightFinderPlateHandler.NormalizeIconGlyph(normalizedGlyph);
|
||||||
_lightfinderIconPresetIndex = presetIndex;
|
_lightfinderIconPresetIndex = presetIndex;
|
||||||
_lightfinderIconInputInitialized = true;
|
_lightfinderIconInputInitialized = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using LightlessSync.API.Data;
|
using LightlessSync.API.Data;
|
||||||
|
using LightlessSync.API.Data.Extensions;
|
||||||
|
using LightlessSync.API.Data.Enum;
|
||||||
using Dalamud.Bindings.ImGui;
|
using Dalamud.Bindings.ImGui;
|
||||||
using Dalamud.Interface;
|
using Dalamud.Interface;
|
||||||
using Dalamud.Interface.Colors;
|
using Dalamud.Interface.Colors;
|
||||||
using Dalamud.Interface.Utility;
|
using Dalamud.Interface.Utility;
|
||||||
using Dalamud.Interface.Utility.Raii;
|
using Dalamud.Interface.Utility.Raii;
|
||||||
using LightlessSync.API.Dto.Chat;
|
using LightlessSync.API.Dto.Chat;
|
||||||
|
using LightlessSync.API.Dto.Group;
|
||||||
using LightlessSync.LightlessConfiguration;
|
using LightlessSync.LightlessConfiguration;
|
||||||
using LightlessSync.LightlessConfiguration.Models;
|
using LightlessSync.LightlessConfiguration.Models;
|
||||||
using LightlessSync.Services;
|
using LightlessSync.Services;
|
||||||
@@ -14,13 +17,17 @@ using LightlessSync.Services.Chat;
|
|||||||
using LightlessSync.Services.LightFinder;
|
using LightlessSync.Services.LightFinder;
|
||||||
using LightlessSync.Services.Mediator;
|
using LightlessSync.Services.Mediator;
|
||||||
using LightlessSync.Services.ServerConfiguration;
|
using LightlessSync.Services.ServerConfiguration;
|
||||||
|
using LightlessSync.UI.Models;
|
||||||
using LightlessSync.UI.Services;
|
using LightlessSync.UI.Services;
|
||||||
using LightlessSync.UI.Style;
|
using LightlessSync.UI.Style;
|
||||||
using LightlessSync.Utils;
|
using LightlessSync.Utils;
|
||||||
|
using Dalamud.Interface.Textures.TextureWraps;
|
||||||
using OtterGui.Text;
|
using OtterGui.Text;
|
||||||
using LightlessSync.WebAPI;
|
using LightlessSync.WebAPI;
|
||||||
using LightlessSync.WebAPI.SignalR.Utils;
|
using LightlessSync.WebAPI.SignalR.Utils;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using LightlessSync.PlayerData.Factories;
|
||||||
|
using LightlessSync.PlayerData.Pairs;
|
||||||
|
|
||||||
namespace LightlessSync.UI;
|
namespace LightlessSync.UI;
|
||||||
|
|
||||||
@@ -31,6 +38,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
private const string SettingsPopupId = "zone_chat_settings_popup";
|
private const string SettingsPopupId = "zone_chat_settings_popup";
|
||||||
private const string ReportPopupId = "Report Message##zone_chat_report_popup";
|
private const string ReportPopupId = "Report Message##zone_chat_report_popup";
|
||||||
private const string ChannelDragPayloadId = "zone_chat_channel_drag";
|
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 DefaultWindowOpacity = .97f;
|
||||||
private const float DefaultUnfocusedWindowOpacity = 0.6f;
|
private const float DefaultUnfocusedWindowOpacity = 0.6f;
|
||||||
private const float MinWindowOpacity = 0.05f;
|
private const float MinWindowOpacity = 0.05f;
|
||||||
@@ -46,6 +55,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
private readonly UiSharedService _uiSharedService;
|
private readonly UiSharedService _uiSharedService;
|
||||||
private readonly ZoneChatService _zoneChatService;
|
private readonly ZoneChatService _zoneChatService;
|
||||||
private readonly PairUiService _pairUiService;
|
private readonly PairUiService _pairUiService;
|
||||||
|
private readonly PairFactory _pairFactory;
|
||||||
|
private readonly ChatEmoteService _chatEmoteService;
|
||||||
private readonly LightFinderService _lightFinderService;
|
private readonly LightFinderService _lightFinderService;
|
||||||
private readonly LightlessProfileManager _profileManager;
|
private readonly LightlessProfileManager _profileManager;
|
||||||
private readonly ApiController _apiController;
|
private readonly ApiController _apiController;
|
||||||
@@ -54,6 +65,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
private readonly DalamudUtilService _dalamudUtilService;
|
private readonly DalamudUtilService _dalamudUtilService;
|
||||||
private readonly IUiBuilder _uiBuilder;
|
private readonly IUiBuilder _uiBuilder;
|
||||||
private readonly Dictionary<string, string> _draftMessages = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, string> _draftMessages = new(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<string, List<string>> _pendingDraftClears = new(StringComparer.Ordinal);
|
||||||
private readonly ImGuiWindowFlags _unpinnedWindowFlags;
|
private readonly ImGuiWindowFlags _unpinnedWindowFlags;
|
||||||
private float _currentWindowOpacity = DefaultWindowOpacity;
|
private float _currentWindowOpacity = DefaultWindowOpacity;
|
||||||
private float _baseWindowOpacity = DefaultWindowOpacity;
|
private float _baseWindowOpacity = DefaultWindowOpacity;
|
||||||
@@ -81,6 +93,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
private ChatReportResult? _reportSubmissionResult;
|
private ChatReportResult? _reportSubmissionResult;
|
||||||
private string? _dragChannelKey;
|
private string? _dragChannelKey;
|
||||||
private string? _dragHoverKey;
|
private string? _dragHoverKey;
|
||||||
|
private bool _openEmotePicker;
|
||||||
|
private string _emoteFilter = string.Empty;
|
||||||
private bool _HideStateActive;
|
private bool _HideStateActive;
|
||||||
private bool _HideStateWasOpen;
|
private bool _HideStateWasOpen;
|
||||||
private bool _pushedStyle;
|
private bool _pushedStyle;
|
||||||
@@ -91,6 +105,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
UiSharedService uiSharedService,
|
UiSharedService uiSharedService,
|
||||||
ZoneChatService zoneChatService,
|
ZoneChatService zoneChatService,
|
||||||
PairUiService pairUiService,
|
PairUiService pairUiService,
|
||||||
|
PairFactory pairFactory,
|
||||||
|
ChatEmoteService chatEmoteService,
|
||||||
LightFinderService lightFinderService,
|
LightFinderService lightFinderService,
|
||||||
LightlessProfileManager profileManager,
|
LightlessProfileManager profileManager,
|
||||||
ChatConfigService chatConfigService,
|
ChatConfigService chatConfigService,
|
||||||
@@ -104,6 +120,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
_uiSharedService = uiSharedService;
|
_uiSharedService = uiSharedService;
|
||||||
_zoneChatService = zoneChatService;
|
_zoneChatService = zoneChatService;
|
||||||
_pairUiService = pairUiService;
|
_pairUiService = pairUiService;
|
||||||
|
_pairFactory = pairFactory;
|
||||||
|
_chatEmoteService = chatEmoteService;
|
||||||
_lightFinderService = lightFinderService;
|
_lightFinderService = lightFinderService;
|
||||||
_profileManager = profileManager;
|
_profileManager = profileManager;
|
||||||
_chatConfigService = chatConfigService;
|
_chatConfigService = chatConfigService;
|
||||||
@@ -188,7 +206,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
private void ApplyUiVisibilitySettings()
|
private void ApplyUiVisibilitySettings()
|
||||||
{
|
{
|
||||||
var config = _chatConfigService.Current;
|
var config = _chatConfigService.Current;
|
||||||
_uiBuilder.DisableAutomaticUiHide = config.ShowWhenUiHidden;
|
_uiBuilder.DisableUserUiHide = true;
|
||||||
_uiBuilder.DisableCutsceneUiHide = config.ShowInCutscenes;
|
_uiBuilder.DisableCutsceneUiHide = config.ShowInCutscenes;
|
||||||
_uiBuilder.DisableGposeUiHide = config.ShowInGpose;
|
_uiBuilder.DisableGposeUiHide = config.ShowInGpose;
|
||||||
}
|
}
|
||||||
@@ -197,6 +215,11 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
var config = _chatConfigService.Current;
|
var config = _chatConfigService.Current;
|
||||||
|
|
||||||
|
if (!config.ShowWhenUiHidden && _dalamudUtilService.IsGameUiHidden)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (config.HideInCombat && _dalamudUtilService.IsInCombat)
|
if (config.HideInCombat && _dalamudUtilService.IsInCombat)
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
@@ -386,6 +409,9 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
bottomColor);
|
bottomColor);
|
||||||
|
|
||||||
var showTimestamps = _chatConfigService.Current.ShowMessageTimestamps;
|
var showTimestamps = _chatConfigService.Current.ShowMessageTimestamps;
|
||||||
|
_chatEmoteService.EnsureGlobalEmotesLoaded();
|
||||||
|
PairUiSnapshot? pairSnapshot = null;
|
||||||
|
var itemSpacing = ImGui.GetStyle().ItemSpacing.X;
|
||||||
|
|
||||||
if (channel.Messages.Count == 0)
|
if (channel.Messages.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -423,16 +449,109 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] ";
|
timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] ";
|
||||||
}
|
}
|
||||||
var color = message.FromSelf ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudWhite;
|
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.PushStyleColor(ImGuiCol.Text, color);
|
||||||
ImGui.TextWrapped($"{timestampText}{message.DisplayName}: {payload.Message}");
|
if (showRoleIcons)
|
||||||
ImGui.PopStyleColor();
|
{
|
||||||
|
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}"))
|
if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}"))
|
||||||
{
|
{
|
||||||
var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime();
|
var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime();
|
||||||
var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture);
|
var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture);
|
||||||
ImGui.TextDisabled(contextTimestampText);
|
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();
|
ImGui.Separator();
|
||||||
|
|
||||||
var actionIndex = 0;
|
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<ChatSegment> BuildChatSegments(string prefix, string message)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>(Math.Max(16, message.Length / 4));
|
||||||
|
AppendChatSegments(segments, prefix, allowEmotes: false);
|
||||||
|
AppendChatSegments(segments, message, allowEmotes: true);
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AppendChatSegments(List<ChatSegment> 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)
|
private void DrawInput(ChatChannelSnapshot channel)
|
||||||
{
|
{
|
||||||
const int MaxMessageLength = ZoneChatService.MaxOutgoingLength;
|
const int MaxMessageLength = ZoneChatService.MaxOutgoingLength;
|
||||||
@@ -469,9 +917,10 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
draft ??= string.Empty;
|
draft ??= string.Empty;
|
||||||
|
|
||||||
var style = ImGui.GetStyle();
|
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 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);
|
ImGui.SetNextItemWidth(-reservedWidth);
|
||||||
var inputId = $"##chat-input-{channel.Key}";
|
var inputId = $"##chat-input-{channel.Key}";
|
||||||
@@ -482,7 +931,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
_refocusChatInputKey = null;
|
_refocusChatInputKey = null;
|
||||||
}
|
}
|
||||||
ImGui.InputText(inputId, ref draft, MaxMessageLength);
|
ImGui.InputText(inputId, ref draft, MaxMessageLength);
|
||||||
if (ImGui.IsItemActive() || ImGui.IsItemFocused())
|
if (ImGui.IsItemActive())
|
||||||
{
|
{
|
||||||
var drawList = ImGui.GetWindowDrawList();
|
var drawList = ImGui.GetWindowDrawList();
|
||||||
var itemMin = ImGui.GetItemRectMin();
|
var itemMin = ImGui.GetItemRectMin();
|
||||||
@@ -504,10 +953,22 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
ImGui.SameLine();
|
ImGui.SameLine();
|
||||||
var buttonScreenPos = ImGui.GetCursorScreenPos();
|
var buttonScreenPos = ImGui.GetCursorScreenPos();
|
||||||
var rightEdgeScreen = ImGui.GetWindowPos().X + ImGui.GetWindowContentRegionMax().X;
|
var rightEdgeScreen = ImGui.GetWindowPos().X + ImGui.GetWindowContentRegionMax().X;
|
||||||
var desiredButtonX = rightEdgeScreen - sendButtonWidth;
|
|
||||||
var minButtonX = buttonScreenPos.X + style.ItemSpacing.X;
|
var minButtonX = buttonScreenPos.X + style.ItemSpacing.X;
|
||||||
var finalButtonX = MathF.Max(minButtonX, desiredButtonX);
|
var desiredSendX = rightEdgeScreen - sendButtonWidth;
|
||||||
ImGui.SetCursorScreenPos(new Vector2(finalButtonX, buttonScreenPos.Y));
|
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 sendColor = UIColors.Get("LightlessPurpleDefault");
|
||||||
var sendHovered = UIColors.Get("LightlessPurple");
|
var sendHovered = UIColors.Get("LightlessPurple");
|
||||||
var sendActive = UIColors.Get("LightlessPurpleActive");
|
var sendActive = UIColors.Get("LightlessPurpleActive");
|
||||||
@@ -518,7 +979,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
var sendClicked = false;
|
var sendClicked = false;
|
||||||
using (ImRaii.Disabled(!canSend))
|
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;
|
sendClicked = true;
|
||||||
}
|
}
|
||||||
@@ -526,47 +987,68 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
ImGui.PopStyleVar();
|
ImGui.PopStyleVar();
|
||||||
ImGui.PopStyleColor(3);
|
ImGui.PopStyleColor(3);
|
||||||
|
|
||||||
|
DrawEmotePickerPopup(ref draft, channel.Key);
|
||||||
|
|
||||||
if (canSend && (enterPressed || sendClicked))
|
if (canSend && (enterPressed || sendClicked))
|
||||||
{
|
{
|
||||||
_refocusChatInput = true;
|
_refocusChatInput = true;
|
||||||
_refocusChatInputKey = channel.Key;
|
_refocusChatInputKey = channel.Key;
|
||||||
if (TrySendDraft(channel, draft))
|
|
||||||
|
var draftAtSend = draft;
|
||||||
|
var sanitized = SanitizeOutgoingDraft(draftAtSend);
|
||||||
|
|
||||||
|
if (sanitized is not null)
|
||||||
{
|
{
|
||||||
_draftMessages[channel.Key] = string.Empty;
|
TrackPendingDraftClear(channel.Key, sanitized);
|
||||||
_scrollToBottom = true;
|
|
||||||
|
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()
|
private void DrawRulesOverlay()
|
||||||
{
|
{
|
||||||
var windowPos = ImGui.GetWindowPos();
|
|
||||||
var windowSize = ImGui.GetWindowSize();
|
|
||||||
var parentContentMin = ImGui.GetWindowContentRegionMin();
|
var parentContentMin = ImGui.GetWindowContentRegionMin();
|
||||||
var parentContentMax = ImGui.GetWindowContentRegionMax();
|
var parentContentMax = ImGui.GetWindowContentRegionMax();
|
||||||
var overlayPos = windowPos + parentContentMin;
|
|
||||||
var overlaySize = parentContentMax - parentContentMin;
|
var overlaySize = parentContentMax - parentContentMin;
|
||||||
|
|
||||||
if (overlaySize.X <= 0f || overlaySize.Y <= 0f)
|
if (overlaySize.X <= 0f || overlaySize.Y <= 0f)
|
||||||
{
|
{
|
||||||
overlayPos = windowPos;
|
parentContentMin = Vector2.Zero;
|
||||||
overlaySize = windowSize;
|
overlaySize = ImGui.GetWindowSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui.SetNextWindowFocus();
|
var previousCursor = ImGui.GetCursorPos();
|
||||||
ImGui.SetNextWindowPos(overlayPos);
|
ImGui.SetCursorPos(parentContentMin);
|
||||||
ImGui.SetNextWindowSize(overlaySize);
|
|
||||||
ImGui.SetNextWindowBgAlpha(0.86f);
|
|
||||||
ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * ImGuiHelpers.GlobalScale);
|
|
||||||
ImGui.PushStyleColor(ImGuiCol.Border, Vector4.Zero);
|
|
||||||
|
|
||||||
var overlayFlags = ImGuiWindowFlags.NoDecoration
|
var bgColor = ImGui.GetStyle().Colors[(int)ImGuiCol.WindowBg];
|
||||||
| ImGuiWindowFlags.NoMove
|
bgColor.W = 0.86f;
|
||||||
| ImGuiWindowFlags.NoScrollbar
|
|
||||||
|
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;
|
| ImGuiWindowFlags.NoSavedSettings;
|
||||||
|
|
||||||
var overlayOpen = true;
|
if (ImGui.BeginChild("##zone_chat_rules_overlay", overlaySize, false, overlayFlags))
|
||||||
if (ImGui.Begin("##zone_chat_rules_overlay", ref overlayOpen, overlayFlags))
|
|
||||||
{
|
{
|
||||||
var contentMin = ImGui.GetWindowContentRegionMin();
|
var contentMin = ImGui.GetWindowContentRegionMin();
|
||||||
var contentMax = ImGui.GetWindowContentRegionMax();
|
var contentMax = ImGui.GetWindowContentRegionMax();
|
||||||
@@ -686,16 +1168,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
_showRulesOverlay = false;
|
_showRulesOverlay = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!overlayOpen)
|
|
||||||
{
|
|
||||||
_showRulesOverlay = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui.End();
|
ImGui.EndChild();
|
||||||
ImGui.PopStyleColor();
|
ImGui.PopStyleColor(2);
|
||||||
ImGui.PopStyleVar();
|
ImGui.PopStyleVar(2);
|
||||||
|
ImGui.SetCursorPos(previousCursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawReportPopup()
|
private void DrawReportPopup()
|
||||||
@@ -943,16 +1421,15 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
_reportPopupRequested = false;
|
_reportPopupRequested = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TrySendDraft(ChatChannelSnapshot channel, string draft)
|
private bool TrySendDraft(ChatChannelSnapshot channel, string sanitizedMessage)
|
||||||
{
|
{
|
||||||
var trimmed = draft.Trim();
|
if (string.IsNullOrWhiteSpace(sanitizedMessage))
|
||||||
if (trimmed.Length == 0)
|
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
bool succeeded;
|
bool succeeded;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
succeeded = _zoneChatService.SendMessageAsync(channel.Descriptor, trimmed).GetAwaiter().GetResult();
|
succeeded = _zoneChatService.SendMessageAsync(channel.Descriptor, sanitizedMessage).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -987,6 +1464,21 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
yield return reportAction;
|
yield return reportAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var moderationActions = new List<ChatMessageContextAction>();
|
||||||
|
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)
|
private static bool TryCreateCopyMessageAction(ChatMessageEntry message, ChatMessageDto payload, out ChatMessageContextAction action)
|
||||||
@@ -1094,6 +1586,91 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private IEnumerable<ChatMessageContextAction> 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)
|
private Task OpenStandardProfileAsync(UserData user)
|
||||||
{
|
{
|
||||||
_profileManager.GetLightlessProfile(user);
|
_profileManager.GetLightlessProfile(user);
|
||||||
@@ -1124,6 +1701,92 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
{
|
{
|
||||||
_scrollToBottom = true;
|
_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<string>();
|
||||||
|
_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)
|
private async Task OpenLightfinderProfileInternalAsync(string hashedCid)
|
||||||
@@ -1407,6 +2070,17 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
ImGui.SetTooltip("Toggles the timestamp prefix on messages.");
|
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.Separator();
|
||||||
ImGui.TextUnformatted("Chat Visibility");
|
ImGui.TextUnformatted("Chat Visibility");
|
||||||
|
|
||||||
@@ -1993,6 +2667,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
private void DrawContextMenuAction(ChatMessageContextAction action, int index)
|
private void DrawContextMenuAction(ChatMessageContextAction action, int index)
|
||||||
{
|
{
|
||||||
ImGui.PushID(index);
|
ImGui.PushID(index);
|
||||||
|
if (action.IsSeparator)
|
||||||
|
{
|
||||||
|
ImGui.Separator();
|
||||||
|
ImGui.PopID();
|
||||||
|
return;
|
||||||
|
}
|
||||||
using var disabled = ImRaii.Disabled(!action.IsEnabled);
|
using var disabled = ImRaii.Disabled(!action.IsEnabled);
|
||||||
|
|
||||||
var availableWidth = Math.Max(1f, ImGui.GetContentRegionAvail().X);
|
var availableWidth = Math.Max(1f, ImGui.GetContentRegionAvail().X);
|
||||||
@@ -2025,6 +2705,11 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
drawList.AddText(textPos, textColor, action.Label);
|
drawList.AddText(textPos, textColor, action.Label);
|
||||||
|
|
||||||
|
if (action.Tooltip is { Length: > 0 } && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
|
||||||
|
{
|
||||||
|
ImGui.SetTooltip(action.Tooltip);
|
||||||
|
}
|
||||||
|
|
||||||
if (clicked && action.IsEnabled)
|
if (clicked && action.IsEnabled)
|
||||||
{
|
{
|
||||||
ImGui.CloseCurrentPopup();
|
ImGui.CloseCurrentPopup();
|
||||||
@@ -2034,5 +2719,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
|||||||
ImGui.PopID();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
using System;
|
namespace LightlessSync.UtilsEnum.Enum
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace LightlessSync.UtilsEnum.Enum
|
|
||||||
{
|
{
|
||||||
public enum LabelAlignment
|
public enum LabelAlignment
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace LightlessSync.UtilsEnum.Enum
|
||||||
|
{
|
||||||
|
public enum LightfinderLabelRenderer
|
||||||
|
{
|
||||||
|
Pictomancy,
|
||||||
|
SignatureHook,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user