2.0.2
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m3s
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m3s
Reviewed-on: #106
This commit was merged in pull request #106.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -348,3 +348,6 @@ MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# idea
|
||||
/.idea
|
||||
|
||||
@@ -1,11 +1,39 @@
|
||||
tagline: "Lightless Sync v2.0.1"
|
||||
subline: "LIGHTLESS IS EVOLVING!!"
|
||||
changelog:
|
||||
- name: "v2.0.2"
|
||||
tagline: "Last update of 2025!... ... ... If Nothing breaks"
|
||||
date: "December 28 2025"
|
||||
# be sure to set this every new version
|
||||
isCurrent: true
|
||||
versions:
|
||||
- number: "Chat"
|
||||
icon: ""
|
||||
items:
|
||||
- "Added a 7TV emote picker to chat. You’ll now see a new button next to Send that opens an emote selector."
|
||||
- "Pin User, Remove User, and Ban User (including Syncshell) have been added when you right click a user in chat."
|
||||
- "Chatters now show status icons/labels in the Syncshell (e.g., Owner, Moderator, and Pinned when applicable)."
|
||||
- "The Rules page no longer blocks input for other open Lightless UI windows."
|
||||
- number: "LightFinder"
|
||||
icon: ""
|
||||
items:
|
||||
- "If the ImGui Lightfinder icons aren’t working correctly, you can switch back to the Nameplate signature hook. Important warning - USE AT YOUR OWN RISK: The native nameplate hook can crash the game if multiple plugins hook the nameplate function at the same time. We will not provide support about Nameplate crashes, nor will the Dalamud team, **DO NOT BOTHER THEM.**"
|
||||
- "The LightFinder label in the menu has a counter next to it showing the number of broadcasting users."
|
||||
- "There is less interference of hidden UI elements for the imGui renderer of LightFinder."
|
||||
- number: "Miscellaneous fixes"
|
||||
icon: ""
|
||||
items:
|
||||
- "Overhauled transient resources in an attempt to mitigate mount and minion problems."
|
||||
- "Some file cache entries will now be cached to reduce load on your game."
|
||||
- "Downloading and decompressing have been redone to fix the locking issues."
|
||||
- "Disabling the context menu will now hide the context menu on right clicks again. (Thanks @infiniti)"
|
||||
- "Temporary collections that were not cleared before will now be cleared when the plugin starts."
|
||||
- "Pair requests will now appear in chat if notifications are not enabled or on chat mode."
|
||||
- "Fixed an instance were an object may be null in the Download UI."
|
||||
- "API 14 - Migrate to IPlayerState service"
|
||||
- name: "v2.0.1"
|
||||
tagline: "Some Fixes"
|
||||
date: "December 23 2025"
|
||||
# be sure to set this every new version
|
||||
isCurrent: true
|
||||
versions:
|
||||
- number: "Chat"
|
||||
icon: ""
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
#nullable disable
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LightlessSync.FileCache;
|
||||
|
||||
public class FileCacheEntity
|
||||
{
|
||||
public FileCacheEntity(string hash, string path, string lastModifiedDateTicks, long? size = null, long? compressedSize = null)
|
||||
[JsonConstructor]
|
||||
public FileCacheEntity(
|
||||
string hash,
|
||||
string prefixedFilePath,
|
||||
string lastModifiedDateTicks,
|
||||
long? size = null,
|
||||
long? compressedSize = null)
|
||||
{
|
||||
Size = size;
|
||||
CompressedSize = compressedSize;
|
||||
Hash = hash;
|
||||
PrefixedFilePath = path;
|
||||
PrefixedFilePath = prefixedFilePath;
|
||||
LastModifiedDateTicks = lastModifiedDateTicks;
|
||||
}
|
||||
|
||||
@@ -23,7 +31,5 @@ public class FileCacheEntity
|
||||
public long? Size { get; set; }
|
||||
|
||||
public void SetResolvedFilePath(string filePath)
|
||||
{
|
||||
ResolvedFilepath = filePath.ToLowerInvariant().Replace("\\\\", "\\", StringComparison.Ordinal);
|
||||
}
|
||||
=> ResolvedFilepath = filePath.ToLowerInvariant().Replace("\\\\", "\\", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace LightlessSync.FileCache;
|
||||
@@ -31,6 +33,14 @@ public sealed class FileCacheManager : IHostedService
|
||||
private bool _csvHeaderEnsured;
|
||||
public string CacheFolder => _configService.Current.CacheFolder;
|
||||
|
||||
private const string _compressedCacheExtension = ".llz4";
|
||||
private readonly ConcurrentDictionary<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)
|
||||
{
|
||||
_logger = logger;
|
||||
@@ -45,6 +55,18 @@ public sealed class FileCacheManager : IHostedService
|
||||
private static string NormalizeSeparators(string path) => path.Replace("/", "\\", StringComparison.Ordinal)
|
||||
.Replace("\\\\", "\\", StringComparison.Ordinal);
|
||||
|
||||
private SemaphoreSlim GetCompressLock(string hash)
|
||||
=> _compressLocks.GetOrAdd(hash, _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
public void SetSizeInfo(string hash, long original, long compressed)
|
||||
=> _sizeCache[hash] = new SizeInfo(original, compressed);
|
||||
|
||||
public bool TryGetSizeInfo(string hash, out SizeInfo info)
|
||||
=> _sizeCache.TryGetValue(hash, out info);
|
||||
|
||||
private string GetCompressedCachePath(string hash)
|
||||
=> Path.Combine(CacheFolder, hash + _compressedCacheExtension);
|
||||
|
||||
private static string NormalizePrefixedPathKey(string prefixedPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(prefixedPath))
|
||||
@@ -111,6 +133,114 @@ public sealed class FileCacheManager : IHostedService
|
||||
return int.TryParse(versionSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out version);
|
||||
}
|
||||
|
||||
public void UpdateSizeInfo(string hash, long? original = null, long? compressed = null)
|
||||
{
|
||||
_sizeCache.AddOrUpdate(
|
||||
hash,
|
||||
_ => new SizeInfo(original ?? 0, compressed ?? 0),
|
||||
(_, old) => new SizeInfo(original ?? old.Original, compressed ?? old.Compressed));
|
||||
}
|
||||
|
||||
private void UpdateEntitiesSizes(string hash, long original, long compressed)
|
||||
{
|
||||
if (_fileCaches.TryGetValue(hash, out var dict))
|
||||
{
|
||||
foreach (var e in dict.Values)
|
||||
{
|
||||
e.Size = original;
|
||||
e.CompressedSize = compressed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void ApplySizesToEntries(IEnumerable<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)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return string.Empty;
|
||||
@@ -318,9 +448,18 @@ public sealed class FileCacheManager : IHostedService
|
||||
|
||||
public async Task<(string, byte[])> GetCompressedFileData(string fileHash, CancellationToken uploadToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(CacheFolder))
|
||||
{
|
||||
var bytes = await EnsureCompressedCacheBytesAsync(fileHash, uploadToken).ConfigureAwait(false);
|
||||
UpdateSizeInfo(fileHash, compressed: bytes.LongLength);
|
||||
return (fileHash, bytes);
|
||||
}
|
||||
|
||||
var fileCache = GetFileCacheByHash(fileHash)!.ResolvedFilepath;
|
||||
return (fileHash, LZ4Wrapper.WrapHC(await File.ReadAllBytesAsync(fileCache, uploadToken).ConfigureAwait(false), 0,
|
||||
(int)new FileInfo(fileCache).Length));
|
||||
var raw = await File.ReadAllBytesAsync(fileCache, uploadToken).ConfigureAwait(false);
|
||||
var compressed = LZ4Wrapper.WrapHC(raw, 0, raw.Length);
|
||||
UpdateSizeInfo(fileHash, original: raw.LongLength, compressed: compressed.LongLength);
|
||||
return (fileHash, compressed);
|
||||
}
|
||||
|
||||
public FileCacheEntity? GetFileCacheByHash(string hash)
|
||||
@@ -891,6 +1030,14 @@ public sealed class FileCacheManager : IHostedService
|
||||
compressed = resultCompressed;
|
||||
}
|
||||
}
|
||||
|
||||
if (size > 0 || compressed > 0)
|
||||
{
|
||||
UpdateSizeInfo(hash,
|
||||
original: size > 0 ? size : null,
|
||||
compressed: compressed > 0 ? compressed : null);
|
||||
}
|
||||
|
||||
AddHashedFile(ReplacePathPrefixes(new FileCacheEntity(hash, path, time, size, compressed)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -10,9 +10,6 @@ using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using System.Linq;
|
||||
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
||||
|
||||
namespace LightlessSync.FileCache;
|
||||
@@ -28,7 +25,10 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
private readonly object _ownedHandlerLock = new();
|
||||
private readonly string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk", "kdb"];
|
||||
private readonly string[] _handledRecordingFileTypes = ["tex", "mdl", "mtrl"];
|
||||
private readonly string[] _handledFileTypesWithRecording;
|
||||
private readonly HashSet<GameObjectHandler> _playerRelatedPointers = [];
|
||||
private readonly object _playerRelatedLock = new();
|
||||
private readonly ConcurrentDictionary<nint, GameObjectHandler> _playerRelatedByAddress = new();
|
||||
private readonly Dictionary<nint, GameObjectHandler> _ownedHandlers = new();
|
||||
private ConcurrentDictionary<nint, ObjectKind> _cachedFrameAddresses = new();
|
||||
private ConcurrentDictionary<ObjectKind, HashSet<string>>? _semiTransientResources = null;
|
||||
@@ -42,6 +42,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
_dalamudUtil = dalamudUtil;
|
||||
_actorObjectService = actorObjectService;
|
||||
_gameObjectHandlerFactory = gameObjectHandlerFactory;
|
||||
_handledFileTypesWithRecording = _handledRecordingFileTypes.Concat(_handledFileTypes).ToArray();
|
||||
|
||||
Mediator.Subscribe<PenumbraResourceLoadMessage>(this, Manager_PenumbraResourceLoadEvent);
|
||||
Mediator.Subscribe<ActorTrackedMessage>(this, msg => HandleActorTracked(msg.Descriptor));
|
||||
@@ -51,12 +52,18 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
|
||||
{
|
||||
if (!msg.OwnedObject) return;
|
||||
_playerRelatedPointers.Add(msg.GameObjectHandler);
|
||||
lock (_playerRelatedLock)
|
||||
{
|
||||
_playerRelatedPointers.Add(msg.GameObjectHandler);
|
||||
}
|
||||
});
|
||||
Mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, (msg) =>
|
||||
{
|
||||
if (!msg.OwnedObject) return;
|
||||
_playerRelatedPointers.Remove(msg.GameObjectHandler);
|
||||
lock (_playerRelatedLock)
|
||||
{
|
||||
_playerRelatedPointers.Remove(msg.GameObjectHandler);
|
||||
}
|
||||
});
|
||||
|
||||
foreach (var descriptor in _actorObjectService.ObjectDescriptors)
|
||||
@@ -78,7 +85,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
|
||||
private string PlayerPersistentDataKey => _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult() + "_" + _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult();
|
||||
private string PlayerPersistentDataKey => _dalamudUtil.GetPlayerName() + "_" + _dalamudUtil.GetHomeWorldId();
|
||||
private ConcurrentDictionary<ObjectKind, HashSet<string>> SemiTransientResources
|
||||
{
|
||||
get
|
||||
@@ -87,9 +94,12 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
_semiTransientResources = new();
|
||||
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
|
||||
_semiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.Ordinal);
|
||||
_semiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? [])
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
|
||||
_semiTransientResources[ObjectKind.Pet] = [.. petSpecificData ?? []];
|
||||
_semiTransientResources[ObjectKind.Pet] = new HashSet<string>(
|
||||
petSpecificData ?? [],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return _semiTransientResources;
|
||||
@@ -127,14 +137,14 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
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)
|
||||
{
|
||||
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))
|
||||
@@ -152,7 +162,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
List<string> newlyAddedGamePaths;
|
||||
lock (semiTransientResources)
|
||||
{
|
||||
newlyAddedGamePaths = transientResources.Except(semiTransientResources, StringComparer.Ordinal).ToList();
|
||||
newlyAddedGamePaths = transientResources.Except(semiTransientResources, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
foreach (var gamePath in transientResources)
|
||||
{
|
||||
semiTransientResources.Add(gamePath);
|
||||
@@ -197,12 +207,13 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
|
||||
public void RemoveTransientResource(ObjectKind objectKind, string path)
|
||||
{
|
||||
var normalizedPath = NormalizeGamePath(path);
|
||||
if (SemiTransientResources.TryGetValue(objectKind, out var resources))
|
||||
{
|
||||
resources.RemoveWhere(f => string.Equals(path, f, StringComparison.Ordinal));
|
||||
resources.Remove(normalizedPath);
|
||||
if (objectKind == ObjectKind.Player)
|
||||
{
|
||||
PlayerConfig.RemovePath(path, objectKind);
|
||||
PlayerConfig.RemovePath(normalizedPath, objectKind);
|
||||
Logger.LogTrace("Saving transient.json from {method}", nameof(RemoveTransientResource));
|
||||
_configurationService.Save();
|
||||
}
|
||||
@@ -211,16 +222,17 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
|
||||
internal bool AddTransientResource(ObjectKind objectKind, string item)
|
||||
{
|
||||
if (SemiTransientResources.TryGetValue(objectKind, out var semiTransient) && semiTransient != null && semiTransient.Contains(item))
|
||||
var normalizedItem = NormalizeGamePath(item);
|
||||
if (SemiTransientResources.TryGetValue(objectKind, out var semiTransient) && semiTransient != null && semiTransient.Contains(normalizedItem))
|
||||
return false;
|
||||
|
||||
if (!TransientResources.TryGetValue(objectKind, out HashSet<string>? transientResource))
|
||||
{
|
||||
transientResource = new HashSet<string>(StringComparer.Ordinal);
|
||||
transientResource = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
TransientResources[objectKind] = transientResource;
|
||||
}
|
||||
|
||||
return transientResource.Add(item.ToLowerInvariant());
|
||||
return transientResource.Add(normalizedItem);
|
||||
}
|
||||
|
||||
internal void ClearTransientPaths(ObjectKind objectKind, List<string> list)
|
||||
@@ -285,33 +297,13 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
|
||||
private void DalamudUtil_FrameworkUpdate()
|
||||
{
|
||||
RefreshPlayerRelatedAddressMap();
|
||||
|
||||
lock (_cacheAdditionLock)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
_lastClassJobId = _dalamudUtil.ClassJobId;
|
||||
@@ -323,7 +315,9 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
|
||||
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
|
||||
SemiTransientResources[ObjectKind.Pet] = [.. petSpecificData ?? []];
|
||||
SemiTransientResources[ObjectKind.Pet] = new HashSet<string>(
|
||||
petSpecificData ?? [],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast<ObjectKind>())
|
||||
@@ -340,9 +334,12 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
Logger.LogDebug("Penumbra Mod Settings changed, verifying SemiTransientResources");
|
||||
foreach (var item in _playerRelatedPointers)
|
||||
lock (_playerRelatedLock)
|
||||
{
|
||||
Mediator.Publish(new TransientResourceChangedMessage(item.Address));
|
||||
foreach (var item in _playerRelatedPointers)
|
||||
{
|
||||
Mediator.Publish(new TransientResourceChangedMessage(item.Address));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -352,22 +349,24 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
_semiTransientResources = null;
|
||||
}
|
||||
|
||||
private static bool TryResolveObjectKind(ActorObjectService.ActorDescriptor descriptor, out ObjectKind resolvedKind)
|
||||
private void RefreshPlayerRelatedAddressMap()
|
||||
{
|
||||
if (descriptor.OwnedKind is ObjectKind ownedKind)
|
||||
_playerRelatedByAddress.Clear();
|
||||
var updatedFrameAddresses = new ConcurrentDictionary<nint, ObjectKind>();
|
||||
lock (_playerRelatedLock)
|
||||
{
|
||||
resolvedKind = ownedKind;
|
||||
return true;
|
||||
foreach (var handler in _playerRelatedPointers)
|
||||
{
|
||||
var address = (nint)handler.Address;
|
||||
if (address != nint.Zero)
|
||||
{
|
||||
_playerRelatedByAddress[address] = handler;
|
||||
updatedFrameAddresses[address] = handler.ObjectKind;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (descriptor.ObjectKind == DalamudObjectKind.Player)
|
||||
{
|
||||
resolvedKind = ObjectKind.Player;
|
||||
return true;
|
||||
}
|
||||
|
||||
resolvedKind = default;
|
||||
return false;
|
||||
_cachedFrameAddresses = updatedFrameAddresses;
|
||||
}
|
||||
|
||||
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
|
||||
@@ -375,18 +374,15 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
if (descriptor.IsInGpose)
|
||||
return;
|
||||
|
||||
if (!TryResolveObjectKind(descriptor, out var resolvedKind))
|
||||
if (descriptor.OwnedKind is not ObjectKind ownedKind)
|
||||
return;
|
||||
|
||||
if (Logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
Logger.LogDebug("ActorObject tracked: {kind} addr={address:X} name={name}", resolvedKind, descriptor.Address, descriptor.Name);
|
||||
Logger.LogDebug("ActorObject tracked: {kind} addr={address:X} name={name}", ownedKind, descriptor.Address, descriptor.Name);
|
||||
}
|
||||
|
||||
_cachedFrameAddresses[descriptor.Address] = resolvedKind;
|
||||
|
||||
if (descriptor.OwnedKind is not ObjectKind ownedKind)
|
||||
return;
|
||||
_cachedFrameAddresses[descriptor.Address] = ownedKind;
|
||||
|
||||
lock (_ownedHandlerLock)
|
||||
{
|
||||
@@ -465,53 +461,84 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeGamePath(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return string.Empty;
|
||||
|
||||
return path.Replace("\\", "/", StringComparison.Ordinal).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeFilePath(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return string.Empty;
|
||||
|
||||
if (path.StartsWith("|", StringComparison.Ordinal))
|
||||
{
|
||||
var lastPipe = path.LastIndexOf('|');
|
||||
if (lastPipe >= 0 && lastPipe + 1 < path.Length)
|
||||
{
|
||||
path = path[(lastPipe + 1)..];
|
||||
}
|
||||
}
|
||||
|
||||
return NormalizeGamePath(path);
|
||||
}
|
||||
|
||||
private static bool HasHandledFileType(string gamePath, string[] handledTypes)
|
||||
{
|
||||
for (var i = 0; i < handledTypes.Length; i++)
|
||||
{
|
||||
if (gamePath.EndsWith(handledTypes[i], StringComparison.Ordinal))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void Manager_PenumbraResourceLoadEvent(PenumbraResourceLoadMessage msg)
|
||||
{
|
||||
var gamePath = msg.GamePath.ToLowerInvariant();
|
||||
var gameObjectAddress = msg.GameObject;
|
||||
var filePath = msg.FilePath;
|
||||
|
||||
// ignore files already processed this frame
|
||||
if (_cachedHandledPaths.Contains(gamePath)) return;
|
||||
|
||||
lock (_cacheAdditionLock)
|
||||
if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind))
|
||||
{
|
||||
_cachedHandledPaths.Add(gamePath);
|
||||
if (_actorObjectService.TryGetOwnedKind(gameObjectAddress, out var ownedKind))
|
||||
{
|
||||
objectKind = ownedKind;
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// replace individual mtrl stuff
|
||||
if (filePath.StartsWith("|", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
filePath = filePath.Split("|")[2];
|
||||
}
|
||||
// replace filepath
|
||||
filePath = filePath.ToLowerInvariant().Replace("\\", "/", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// ignore files that are the same
|
||||
var replacedGamePath = gamePath.ToLowerInvariant().Replace("\\", "/", StringComparison.OrdinalIgnoreCase);
|
||||
if (string.Equals(filePath, replacedGamePath, StringComparison.OrdinalIgnoreCase))
|
||||
var gamePath = NormalizeGamePath(msg.GamePath);
|
||||
if (string.IsNullOrEmpty(gamePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore files already processed this frame
|
||||
lock (_cacheAdditionLock)
|
||||
{
|
||||
if (!_cachedHandledPaths.Add(gamePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ignore files to not handle
|
||||
var handledTypes = IsTransientRecording ? _handledRecordingFileTypes.Concat(_handledFileTypes) : _handledFileTypes;
|
||||
if (!handledTypes.Any(type => gamePath.EndsWith(type, StringComparison.OrdinalIgnoreCase)))
|
||||
var handledTypes = IsTransientRecording ? _handledFileTypesWithRecording : _handledFileTypes;
|
||||
if (!HasHandledFileType(gamePath, handledTypes))
|
||||
{
|
||||
lock (_cacheAdditionLock)
|
||||
{
|
||||
_cachedHandledPaths.Add(gamePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore files not belonging to anything player related
|
||||
if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind))
|
||||
var filePath = NormalizeFilePath(msg.FilePath);
|
||||
|
||||
// ignore files that are the same
|
||||
if (string.Equals(filePath, gamePath, StringComparison.Ordinal))
|
||||
{
|
||||
lock (_cacheAdditionLock)
|
||||
{
|
||||
_cachedHandledPaths.Add(gamePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -523,15 +550,15 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
TransientResources[objectKind] = transientResources;
|
||||
}
|
||||
|
||||
var owner = _playerRelatedPointers.FirstOrDefault(f => f.Address == gameObjectAddress);
|
||||
_playerRelatedByAddress.TryGetValue(gameObjectAddress, out var owner);
|
||||
bool alreadyTransient = false;
|
||||
|
||||
bool transientContains = transientResources.Contains(replacedGamePath);
|
||||
bool semiTransientContains = SemiTransientResources.SelectMany(k => k.Value).Any(f => string.Equals(f, gamePath, StringComparison.OrdinalIgnoreCase));
|
||||
bool transientContains = transientResources.Contains(gamePath);
|
||||
bool semiTransientContains = SemiTransientResources.Values.Any(value => value.Contains(gamePath));
|
||||
if (transientContains || semiTransientContains)
|
||||
{
|
||||
if (!IsTransientRecording)
|
||||
Logger.LogTrace("Not adding {replacedPath} => {filePath}, Reason: Transient: {contains}, SemiTransient: {contains2}", replacedGamePath, filePath,
|
||||
Logger.LogTrace("Not adding {replacedPath} => {filePath}, Reason: Transient: {contains}, SemiTransient: {contains2}", gamePath, filePath,
|
||||
transientContains, semiTransientContains);
|
||||
alreadyTransient = true;
|
||||
}
|
||||
@@ -539,10 +566,10 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
if (!IsTransientRecording)
|
||||
{
|
||||
bool isAdded = transientResources.Add(replacedGamePath);
|
||||
bool isAdded = transientResources.Add(gamePath);
|
||||
if (isAdded)
|
||||
{
|
||||
Logger.LogDebug("Adding {replacedGamePath} for {gameObject} ({filePath})", replacedGamePath, owner?.ToString() ?? gameObjectAddress.ToString("X"), filePath);
|
||||
Logger.LogDebug("Adding {replacedGamePath} for {gameObject} ({filePath})", gamePath, owner?.ToString() ?? gameObjectAddress.ToString("X"), filePath);
|
||||
SendTransients(gameObjectAddress, objectKind);
|
||||
}
|
||||
}
|
||||
@@ -550,7 +577,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
|
||||
if (owner != null && IsTransientRecording)
|
||||
{
|
||||
_recordedTransients.Add(new TransientRecord(owner, replacedGamePath, filePath, alreadyTransient) { AddTransient = !alreadyTransient });
|
||||
_recordedTransients.Add(new TransientRecord(owner, gamePath, filePath, alreadyTransient) { AddTransient = !alreadyTransient });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,7 +649,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
if (!item.AddTransient || item.AlreadyTransient) continue;
|
||||
if (!TransientResources.TryGetValue(item.Owner.ObjectKind, out var transient))
|
||||
{
|
||||
TransientResources[item.Owner.ObjectKind] = transient = [];
|
||||
TransientResources[item.Owner.ObjectKind] = transient = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
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)
|
||||
=> _resources.ResolvePathsAsync(forward, reverse);
|
||||
|
||||
public string ResolveGameObjectPath(string gamePath, int objectIndex)
|
||||
=> _resources.ResolveGameObjectPath(gamePath, objectIndex);
|
||||
|
||||
public string[] ReverseResolveGameObjectPath(string moddedPath, int objectIndex)
|
||||
=> _resources.ReverseResolveGameObjectPath(moddedPath, objectIndex);
|
||||
|
||||
public Task RedrawAsync(ILogger logger, GameObjectHandler handler, Guid applicationId, CancellationToken token)
|
||||
=> _redraw.RedrawAsync(logger, handler, applicationId, token);
|
||||
|
||||
@@ -171,11 +177,6 @@ public sealed class IpcCallerPenumbra : IpcServiceBase
|
||||
});
|
||||
|
||||
Mediator.Subscribe<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()
|
||||
|
||||
@@ -92,7 +92,7 @@ public sealed class PenumbraCollections : PenumbraBase
|
||||
_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)
|
||||
{
|
||||
@@ -109,7 +109,7 @@ public sealed class PenumbraCollections : PenumbraBase
|
||||
var removeResult = _removeTemporaryMod.Invoke("LightlessChara_Files", collectionId, 0);
|
||||
logger.LogTrace("[{ApplicationId}] Removing temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, removeResult);
|
||||
|
||||
var addResult = _addTemporaryMod.Invoke("LightlessChara_Files", collectionId, new Dictionary<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);
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Dalamud.Plugin;
|
||||
using LightlessSync.Interop.Ipc.Framework;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
@@ -15,10 +14,11 @@ public sealed class PenumbraResource : PenumbraBase
|
||||
{
|
||||
private readonly ActorObjectService _actorObjectService;
|
||||
private readonly GetGameObjectResourcePaths _gameObjectResourcePaths;
|
||||
private readonly ResolveGameObjectPath _resolveGameObjectPath;
|
||||
private readonly ReverseResolveGameObjectPath _reverseResolveGameObjectPath;
|
||||
private readonly ResolvePlayerPathsAsync _resolvePlayerPaths;
|
||||
private readonly GetPlayerMetaManipulations _getPlayerMetaManipulations;
|
||||
private readonly EventSubscriber<nint, string, string> _gameObjectResourcePathResolved;
|
||||
private readonly ConcurrentDictionary<IntPtr, byte> _trackedActors = new();
|
||||
|
||||
public PenumbraResource(
|
||||
ILogger logger,
|
||||
@@ -29,14 +29,11 @@ public sealed class PenumbraResource : PenumbraBase
|
||||
{
|
||||
_actorObjectService = actorObjectService;
|
||||
_gameObjectResourcePaths = new GetGameObjectResourcePaths(pluginInterface);
|
||||
_resolveGameObjectPath = new ResolveGameObjectPath(pluginInterface);
|
||||
_reverseResolveGameObjectPath = new ReverseResolveGameObjectPath(pluginInterface);
|
||||
_resolvePlayerPaths = new ResolvePlayerPathsAsync(pluginInterface);
|
||||
_getPlayerMetaManipulations = new GetPlayerMetaManipulations(pluginInterface);
|
||||
_gameObjectResourcePathResolved = GameObjectResourcePathResolved.Subscriber(pluginInterface, HandleResourceLoaded);
|
||||
|
||||
foreach (var descriptor in _actorObjectService.PlayerDescriptors)
|
||||
{
|
||||
TrackActor(descriptor.Address);
|
||||
}
|
||||
}
|
||||
|
||||
public override string Name => "Penumbra.Resources";
|
||||
@@ -74,63 +71,34 @@ public sealed class PenumbraResource : PenumbraBase
|
||||
return await _resolvePlayerPaths.Invoke(forwardPaths, reversePaths).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void TrackActor(nint address)
|
||||
{
|
||||
if (address != nint.Zero)
|
||||
{
|
||||
_trackedActors[(IntPtr)address] = 0;
|
||||
}
|
||||
}
|
||||
public string ResolveGameObjectPath(string gamePath, int gameObjectIndex)
|
||||
=> IsAvailable ? _resolveGameObjectPath.Invoke(gamePath, gameObjectIndex) : gamePath;
|
||||
|
||||
public void UntrackActor(nint address)
|
||||
{
|
||||
if (address != nint.Zero)
|
||||
{
|
||||
_trackedActors.TryRemove((IntPtr)address, out _);
|
||||
}
|
||||
}
|
||||
public string[] ReverseResolveGameObjectPath(string moddedPath, int gameObjectIndex)
|
||||
=> IsAvailable ? _reverseResolveGameObjectPath.Invoke(moddedPath, gameObjectIndex) : Array.Empty<string>();
|
||||
|
||||
private void HandleResourceLoaded(nint ptr, string resolvedPath, string gamePath)
|
||||
private void HandleResourceLoaded(nint ptr, string gamePath, string resolvedPath)
|
||||
{
|
||||
if (ptr == nint.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_trackedActors.ContainsKey(ptr))
|
||||
{
|
||||
var descriptor = _actorObjectService.PlayerDescriptors.FirstOrDefault(d => d.Address == ptr);
|
||||
if (descriptor.Address != nint.Zero)
|
||||
{
|
||||
_trackedActors[ptr] = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.Compare(resolvedPath, gamePath, StringComparison.OrdinalIgnoreCase) == 0)
|
||||
if (!_actorObjectService.TryGetOwnedKind(ptr, out _))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Mediator.Publish(new PenumbraResourceLoadMessage(ptr, resolvedPath, gamePath));
|
||||
if (string.Compare(gamePath, resolvedPath, StringComparison.OrdinalIgnoreCase) == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Mediator.Publish(new PenumbraResourceLoadMessage(ptr, gamePath, resolvedPath));
|
||||
}
|
||||
|
||||
protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current)
|
||||
{
|
||||
if (current != IpcConnectionState.Available)
|
||||
{
|
||||
_trackedActors.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var descriptor in _actorObjectService.PlayerDescriptors)
|
||||
{
|
||||
TrackActor(descriptor.Address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
|
||||
@@ -19,6 +19,27 @@ public class ConfigurationMigrator(ILogger<ConfigurationMigrator> logger, Transi
|
||||
transientConfigService.Save();
|
||||
}
|
||||
|
||||
if (transientConfigService.Current.Version == 1)
|
||||
{
|
||||
_logger.LogInformation("Migrating Transient Config V1 => V2");
|
||||
var totalRemoved = 0;
|
||||
var configCount = 0;
|
||||
var changedCount = 0;
|
||||
foreach (var config in transientConfigService.Current.TransientConfigs.Values)
|
||||
{
|
||||
if (config.NormalizePaths(out var removed))
|
||||
changedCount++;
|
||||
|
||||
totalRemoved += removed;
|
||||
|
||||
configCount++;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Transient config normalization: processed {count} entries, updated {updated}, removed {removed} paths", configCount, changedCount, totalRemoved);
|
||||
transientConfigService.Current.Version = 2;
|
||||
transientConfigService.Save();
|
||||
}
|
||||
|
||||
if (serverConfigService.Current.Version == 1)
|
||||
{
|
||||
_logger.LogInformation("Migrating Server Config V1 => V2");
|
||||
|
||||
@@ -10,6 +10,7 @@ public sealed class ChatConfig : ILightlessConfiguration
|
||||
public bool AutoEnableChatOnLogin { get; set; } = false;
|
||||
public bool ShowRulesOverlayOnOpen { get; set; } = true;
|
||||
public bool ShowMessageTimestamps { get; set; } = true;
|
||||
public bool ShowNotesInSyncshellChat { get; set; } = true;
|
||||
public float ChatWindowOpacity { get; set; } = .97f;
|
||||
public bool FadeWhenUnfocused { get; set; } = false;
|
||||
public float UnfocusedWindowOpacity { get; set; } = 0.6f;
|
||||
|
||||
@@ -140,6 +140,7 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
public bool useColoredUIDs { get; set; } = true;
|
||||
public bool BroadcastEnabled { get; set; } = false;
|
||||
public bool LightfinderAutoEnableOnConnect { get; set; } = false;
|
||||
public LightfinderLabelRenderer LightfinderLabelRenderer { get; set; } = LightfinderLabelRenderer.Pictomancy;
|
||||
public short LightfinderLabelOffsetX { get; set; } = 0;
|
||||
public short LightfinderLabelOffsetY { get; set; } = 0;
|
||||
public bool LightfinderLabelUseIcon { get; set; } = false;
|
||||
@@ -154,4 +155,5 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
public bool SyncshellFinderEnabled { get; set; } = false;
|
||||
public string? SelectedFinderSyncshell { get; set; } = null;
|
||||
public string LastSeenVersion { get; set; } = string.Empty;
|
||||
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace LightlessSync.LightlessConfiguration.Configurations;
|
||||
public class TransientConfig : ILightlessConfiguration
|
||||
{
|
||||
public Dictionary<string, TransientPlayerConfig> TransientConfigs { get; set; } = [];
|
||||
public int Version { get; set; } = 1;
|
||||
public int Version { get; set; } = 2;
|
||||
|
||||
public class TransientPlayerConfig
|
||||
{
|
||||
@@ -88,5 +88,70 @@ public class TransientConfig : ILightlessConfiguration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool NormalizePaths(out int removedEntries)
|
||||
{
|
||||
bool changed = false;
|
||||
removedEntries = 0;
|
||||
|
||||
GlobalPersistentCache = NormalizeList(GlobalPersistentCache, ref changed, ref removedEntries);
|
||||
|
||||
foreach (var jobId in JobSpecificCache.Keys.ToList())
|
||||
{
|
||||
JobSpecificCache[jobId] = NormalizeList(JobSpecificCache[jobId], ref changed, ref removedEntries);
|
||||
}
|
||||
|
||||
foreach (var jobId in JobSpecificPetCache.Keys.ToList())
|
||||
{
|
||||
JobSpecificPetCache[jobId] = NormalizeList(JobSpecificPetCache[jobId], ref changed, ref removedEntries);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static List<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors></Authors>
|
||||
<Company></Company>
|
||||
<Version>2.0.1</Version>
|
||||
<Version>2.0.2</Version>
|
||||
<Description></Description>
|
||||
<Copyright></Copyright>
|
||||
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>
|
||||
|
||||
@@ -194,7 +194,7 @@ public class PlayerDataFactory
|
||||
|
||||
// get all remaining paths and resolve them
|
||||
var transientPaths = ManageSemiTransientData(objectKind);
|
||||
var resolvedTransientPaths = await GetFileReplacementsFromPaths(transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
|
||||
var resolvedTransientPaths = await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
|
||||
|
||||
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 reversePaths = reverseResolve.ToArray();
|
||||
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);
|
||||
for (int i = 0; i < forwardPaths.Length; i++)
|
||||
{
|
||||
|
||||
@@ -46,6 +46,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
private readonly TextureDownscaleService _textureDownscaleService;
|
||||
private readonly PairStateCache _pairStateCache;
|
||||
private readonly PairPerformanceMetricsCache _performanceMetricsCache;
|
||||
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
|
||||
private readonly PairManager _pairManager;
|
||||
private CancellationTokenSource? _applicationCancellationTokenSource;
|
||||
private Guid _applicationId;
|
||||
@@ -64,6 +65,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
private int _lastMissingCriticalMods;
|
||||
private int _lastMissingNonCriticalMods;
|
||||
private int _lastMissingForbiddenMods;
|
||||
private bool _lastMissingCachedFiles;
|
||||
private bool _isVisible;
|
||||
private Guid _penumbraCollection;
|
||||
private readonly object _collectionGate = new();
|
||||
@@ -181,7 +183,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
ServerConfigurationManager serverConfigManager,
|
||||
TextureDownscaleService textureDownscaleService,
|
||||
PairStateCache pairStateCache,
|
||||
PairPerformanceMetricsCache performanceMetricsCache) : base(logger, mediator)
|
||||
PairPerformanceMetricsCache performanceMetricsCache,
|
||||
PenumbraTempCollectionJanitor tempCollectionJanitor) : base(logger, mediator)
|
||||
{
|
||||
_pairManager = pairManager;
|
||||
Ident = ident;
|
||||
@@ -199,7 +202,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
_textureDownscaleService = textureDownscaleService;
|
||||
_pairStateCache = pairStateCache;
|
||||
_performanceMetricsCache = performanceMetricsCache;
|
||||
LastAppliedDataBytes = -1;
|
||||
_tempCollectionJanitor = tempCollectionJanitor;
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
@@ -422,6 +425,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
{
|
||||
_penumbraCollection = created;
|
||||
_pairStateCache.StoreTemporaryCollection(Ident, created);
|
||||
_tempCollectionJanitor.Register(created);
|
||||
}
|
||||
|
||||
return _penumbraCollection;
|
||||
@@ -454,6 +458,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
_needsCollectionRebuild = true;
|
||||
_forceFullReapply = true;
|
||||
_forceApplyMods = true;
|
||||
_tempCollectionJanitor.Unregister(toRelease);
|
||||
}
|
||||
|
||||
if (!releaseFromPenumbra || toRelease == Guid.Empty || !_ipcManager.Penumbra.APIAvailable)
|
||||
@@ -553,7 +558,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
return;
|
||||
}
|
||||
|
||||
var shouldForce = forced || HasMissingCachedFiles(LastReceivedCharacterData);
|
||||
var hasMissingCachedFiles = HasMissingCachedFiles(LastReceivedCharacterData);
|
||||
var missingResolved = _lastMissingCachedFiles && !hasMissingCachedFiles;
|
||||
_lastMissingCachedFiles = hasMissingCachedFiles;
|
||||
var shouldForce = forced || missingResolved;
|
||||
|
||||
if (IsPaused())
|
||||
{
|
||||
@@ -696,7 +704,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
{
|
||||
if (!string.IsNullOrEmpty(replacement.FileSwapPath))
|
||||
{
|
||||
if (!File.Exists(replacement.FileSwapPath))
|
||||
if (Path.IsPathRooted(replacement.FileSwapPath) && !File.Exists(replacement.FileSwapPath))
|
||||
{
|
||||
Logger.LogTrace("Missing file swap path {Path} detected for {Handler}", replacement.FileSwapPath, GetLogIdentifier());
|
||||
return true;
|
||||
@@ -1415,7 +1423,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
private Task _visibilityGraceTask;
|
||||
|
||||
private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData,
|
||||
bool updateModdedPaths, bool updateManip, Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths, CancellationToken downloadToken)
|
||||
bool updateModdedPaths, bool updateManip, Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths, CancellationToken downloadToken)
|
||||
{
|
||||
var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false);
|
||||
try
|
||||
@@ -1569,24 +1577,37 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
RecordFailure("Handler not available for application", "HandlerUnavailable");
|
||||
return;
|
||||
}
|
||||
_applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource();
|
||||
|
||||
var appToken = _applicationCancellationTokenSource?.Token;
|
||||
while ((!_applicationTask?.IsCompleted ?? false)
|
||||
&& !downloadToken.IsCancellationRequested
|
||||
&& (!appToken?.IsCancellationRequested ?? false))
|
||||
if (_applicationTask != null && !_applicationTask.IsCompleted)
|
||||
{
|
||||
Logger.LogDebug("[BASE-{appBase}] Waiting for current data application (Id: {id}) for player ({handler}) to finish", applicationBase, _applicationId, PlayerName);
|
||||
await Task.Delay(250).ConfigureAwait(false);
|
||||
Logger.LogDebug("[BASE-{appBase}] Cancelling current data application (Id: {id}) for player ({handler})", applicationBase, _applicationId, PlayerName);
|
||||
|
||||
var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(downloadToken, timeoutCts.Token);
|
||||
|
||||
try
|
||||
{
|
||||
await _applicationTask.WaitAsync(combinedCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Logger.LogWarning("[BASE-{appBase}] Timeout waiting for application task {id} to complete, proceeding anyway", applicationBase, _applicationId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
timeoutCts.Dispose();
|
||||
combinedCts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false))
|
||||
if (downloadToken.IsCancellationRequested)
|
||||
{
|
||||
_forceFullReapply = true;
|
||||
RecordFailure("Application cancelled", "Cancellation");
|
||||
return;
|
||||
}
|
||||
|
||||
_applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource();
|
||||
var token = _applicationCancellationTokenSource.Token;
|
||||
|
||||
_applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, wantsModApply, pendingModReapply, token);
|
||||
|
||||
@@ -31,6 +31,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
||||
private readonly TextureDownscaleService _textureDownscaleService;
|
||||
private readonly PairStateCache _pairStateCache;
|
||||
private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache;
|
||||
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
|
||||
|
||||
public PairHandlerAdapterFactory(
|
||||
ILoggerFactory loggerFactory,
|
||||
@@ -48,7 +49,8 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
||||
ServerConfigurationManager serverConfigManager,
|
||||
TextureDownscaleService textureDownscaleService,
|
||||
PairStateCache pairStateCache,
|
||||
PairPerformanceMetricsCache pairPerformanceMetricsCache)
|
||||
PairPerformanceMetricsCache pairPerformanceMetricsCache,
|
||||
PenumbraTempCollectionJanitor tempCollectionJanitor)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_mediator = mediator;
|
||||
@@ -66,6 +68,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
||||
_textureDownscaleService = textureDownscaleService;
|
||||
_pairStateCache = pairStateCache;
|
||||
_pairPerformanceMetricsCache = pairPerformanceMetricsCache;
|
||||
_tempCollectionJanitor = tempCollectionJanitor;
|
||||
}
|
||||
|
||||
public IPairHandlerAdapter Create(string ident)
|
||||
@@ -91,6 +94,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
||||
_serverConfigManager,
|
||||
_textureDownscaleService,
|
||||
_pairStateCache,
|
||||
_pairPerformanceMetricsCache);
|
||||
_pairPerformanceMetricsCache,
|
||||
_tempCollectionJanitor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ using System.Reflection;
|
||||
using OtterTex;
|
||||
using LightlessSync.Services.LightFinder;
|
||||
using LightlessSync.Services.PairProcessing;
|
||||
using LightlessSync.UI.Models;
|
||||
|
||||
namespace LightlessSync;
|
||||
|
||||
@@ -51,7 +52,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
IFramework framework, IObjectTable objectTable, IClientState clientState, ICondition condition, IChatGui chatGui,
|
||||
IGameGui gameGui, IDtrBar dtrBar, IPluginLog pluginLog, ITargetManager targetManager, INotificationManager notificationManager,
|
||||
ITextureProvider textureProvider, IContextMenu contextMenu, IGameInteropProvider gameInteropProvider, IGameConfig gameConfig,
|
||||
ISigScanner sigScanner, INamePlateGui namePlateGui, IAddonLifecycle addonLifecycle)
|
||||
ISigScanner sigScanner, INamePlateGui namePlateGui, IAddonLifecycle addonLifecycle, IPlayerState playerState)
|
||||
{
|
||||
NativeDll.Initialize(pluginInterface.AssemblyLocation.DirectoryName);
|
||||
if (!Directory.Exists(pluginInterface.ConfigDirectory.FullName))
|
||||
@@ -105,6 +106,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
services.AddSingleton<FileDialogManager>();
|
||||
services.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", string.Empty, useEmbedded: true));
|
||||
services.AddSingleton(gameGui);
|
||||
services.AddSingleton(gameInteropProvider);
|
||||
services.AddSingleton(addonLifecycle);
|
||||
services.AddSingleton<IUiBuilder>(pluginInterface.UiBuilder);
|
||||
|
||||
@@ -115,6 +117,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
services.AddSingleton<ProfileTagService>();
|
||||
services.AddSingleton<ApiController>();
|
||||
services.AddSingleton<PerformanceCollectorService>();
|
||||
services.AddSingleton<NameplateUpdateHookService>();
|
||||
services.AddSingleton<HubFactory>();
|
||||
services.AddSingleton<FileUploadManager>();
|
||||
services.AddSingleton<FileTransferOrchestrator>();
|
||||
@@ -133,8 +136,10 @@ public sealed class Plugin : IDalamudPlugin
|
||||
services.AddSingleton<TagHandler>();
|
||||
services.AddSingleton<PairRequestService>();
|
||||
services.AddSingleton<ZoneChatService>();
|
||||
services.AddSingleton<ChatEmoteService>();
|
||||
services.AddSingleton<IdDisplayHandler>();
|
||||
services.AddSingleton<PlayerPerformanceService>();
|
||||
services.AddSingleton<PenumbraTempCollectionJanitor>();
|
||||
|
||||
services.AddSingleton<TextureMetadataHelper>(sp =>
|
||||
new TextureMetadataHelper(sp.GetRequiredService<ILogger<TextureMetadataHelper>>(), gameData));
|
||||
@@ -214,6 +219,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
gameData,
|
||||
targetManager,
|
||||
gameConfig,
|
||||
playerState,
|
||||
sp.GetRequiredService<ActorObjectService>(),
|
||||
sp.GetRequiredService<BlockedCharacterHandler>(),
|
||||
sp.GetRequiredService<LightlessMediator>(),
|
||||
@@ -276,12 +282,22 @@ public sealed class Plugin : IDalamudPlugin
|
||||
pluginInterface,
|
||||
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(
|
||||
sp.GetRequiredService<ILogger<LightFinderScannerService>>(),
|
||||
framework,
|
||||
sp.GetRequiredService<LightFinderService>(),
|
||||
sp.GetRequiredService<LightlessMediator>(),
|
||||
sp.GetRequiredService<LightFinderPlateHandler>(),
|
||||
sp.GetRequiredService<LightFinderNativePlateHandler>(),
|
||||
sp.GetRequiredService<ActorObjectService>()));
|
||||
|
||||
services.AddSingleton(sp => new ContextMenuService(
|
||||
@@ -299,7 +315,10 @@ public sealed class Plugin : IDalamudPlugin
|
||||
sp.GetRequiredService<LightFinderScannerService>(),
|
||||
sp.GetRequiredService<LightFinderService>(),
|
||||
sp.GetRequiredService<LightlessProfileManager>(),
|
||||
sp.GetRequiredService<LightlessMediator>()));
|
||||
sp.GetRequiredService<LightlessMediator>(),
|
||||
chatGui,
|
||||
sp.GetRequiredService<NotificationService>())
|
||||
);
|
||||
|
||||
// IPC callers / manager
|
||||
services.AddSingleton(sp => new IpcCallerPenumbra(
|
||||
@@ -460,7 +479,8 @@ public sealed class Plugin : IDalamudPlugin
|
||||
sp.GetRequiredService<LightlessConfigService>(),
|
||||
sp.GetRequiredService<UiSharedService>(),
|
||||
sp.GetRequiredService<ApiController>(),
|
||||
sp.GetRequiredService<LightFinderScannerService>()));
|
||||
sp.GetRequiredService<LightFinderScannerService>(),
|
||||
sp.GetRequiredService<LightFinderPlateHandler>()));
|
||||
|
||||
services.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>(sp => new SyncshellFinderUI(
|
||||
sp.GetRequiredService<ILogger<SyncshellFinderUI>>(),
|
||||
@@ -529,9 +549,9 @@ public sealed class Plugin : IDalamudPlugin
|
||||
clientState,
|
||||
gameGui,
|
||||
objectTable,
|
||||
gameInteropProvider,
|
||||
sp.GetRequiredService<LightlessMediator>(),
|
||||
sp.GetRequiredService<PairUiService>()));
|
||||
sp.GetRequiredService<PairUiService>(),
|
||||
sp.GetRequiredService<NameplateUpdateHookService>()));
|
||||
|
||||
// Hosted services
|
||||
services.AddHostedService(sp => sp.GetRequiredService<ConfigurationSaveService>());
|
||||
@@ -550,6 +570,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
services.AddHostedService(sp => sp.GetRequiredService<ContextMenuService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<LightFinderService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<LightFinderPlateHandler>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<LightFinderNativePlateHandler>());
|
||||
}).Build();
|
||||
|
||||
_ = _host.StartAsync();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Dalamud.Game.ClientState.Conditions;
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Hooking;
|
||||
using Dalamud.Plugin.Services;
|
||||
using FFXIVClientStructs.Interop;
|
||||
@@ -10,6 +9,8 @@ using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind;
|
||||
using IPlayerCharacter = Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter;
|
||||
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
||||
using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
||||
|
||||
@@ -41,7 +42,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
private readonly ConcurrentDictionary<string, ActorDescriptor> _actorsByHash = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<nint, ActorDescriptor>> _actorsByName = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<nint, byte> _pendingHashResolutions = new();
|
||||
private readonly OwnedObjectTracker _ownedTracker = new();
|
||||
private ActorSnapshot _snapshot = ActorSnapshot.Empty;
|
||||
private GposeSnapshot _gposeSnapshot = GposeSnapshot.Empty;
|
||||
|
||||
@@ -151,15 +151,16 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
public bool TryGetOwnedObjectByIndex(ushort objectIndex, out LightlessObjectKind ownedKind)
|
||||
{
|
||||
ownedKind = default;
|
||||
var ownedSnapshot = OwnedObjects;
|
||||
foreach (var (address, kind) in ownedSnapshot)
|
||||
var ownedDescriptors = OwnedDescriptors;
|
||||
for (var i = 0; i < ownedDescriptors.Count; i++)
|
||||
{
|
||||
if (!TryGetDescriptor(address, out var descriptor))
|
||||
var descriptor = ownedDescriptors[i];
|
||||
if (descriptor.ObjectIndex != objectIndex)
|
||||
continue;
|
||||
|
||||
if (descriptor.ObjectIndex == objectIndex)
|
||||
if (descriptor.OwnedKind is { } resolvedKind)
|
||||
{
|
||||
ownedKind = kind;
|
||||
ownedKind = resolvedKind;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -316,7 +317,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
_actorsByHash.Clear();
|
||||
_actorsByName.Clear();
|
||||
_pendingHashResolutions.Clear();
|
||||
_ownedTracker.Reset();
|
||||
Volatile.Write(ref _snapshot, ActorSnapshot.Empty);
|
||||
Volatile.Write(ref _gposeSnapshot, GposeSnapshot.Empty);
|
||||
return Task.CompletedTask;
|
||||
@@ -481,50 +481,196 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
return (isLocalPlayer ? LightlessObjectKind.Player : null, entityId);
|
||||
}
|
||||
|
||||
if (isLocalPlayer)
|
||||
{
|
||||
var entityId = ((Character*)gameObject)->EntityId;
|
||||
return (LightlessObjectKind.Player, entityId);
|
||||
}
|
||||
var ownerId = ResolveOwnerId(gameObject);
|
||||
var localPlayerAddress = _objectTable.LocalPlayer?.Address ?? nint.Zero;
|
||||
if (localPlayerAddress == nint.Zero)
|
||||
return (null, ownerId);
|
||||
|
||||
if (_objectTable.LocalPlayer is not { } localPlayer)
|
||||
return (null, 0);
|
||||
var localEntityId = ((Character*)localPlayerAddress)->EntityId;
|
||||
if (localEntityId == 0)
|
||||
return (null, ownerId);
|
||||
|
||||
var ownerId = gameObject->OwnerId;
|
||||
if (ownerId == 0)
|
||||
if (objectKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
|
||||
{
|
||||
var character = (Character*)gameObject;
|
||||
if (character != null)
|
||||
var expectedMinionOrMount = GetMinionOrMountAddress(localPlayerAddress, localEntityId);
|
||||
if (expectedMinionOrMount != nint.Zero && (nint)gameObject == expectedMinionOrMount)
|
||||
{
|
||||
ownerId = character->CompanionOwnerId;
|
||||
if (ownerId == 0)
|
||||
{
|
||||
var parent = character->GetParentCharacter();
|
||||
if (parent != null)
|
||||
{
|
||||
ownerId = parent->EntityId;
|
||||
}
|
||||
}
|
||||
var resolvedOwner = ownerId != 0 ? ownerId : localEntityId;
|
||||
return (LightlessObjectKind.MinionOrMount, resolvedOwner);
|
||||
}
|
||||
}
|
||||
|
||||
if (ownerId == 0 || ownerId != localPlayer.EntityId)
|
||||
if (objectKind != DalamudObjectKind.BattleNpc)
|
||||
return (null, ownerId);
|
||||
|
||||
var ownedKind = objectKind switch
|
||||
{
|
||||
DalamudObjectKind.MountType => LightlessObjectKind.MinionOrMount,
|
||||
DalamudObjectKind.Companion => LightlessObjectKind.MinionOrMount,
|
||||
DalamudObjectKind.BattleNpc => gameObject->BattleNpcSubKind switch
|
||||
{
|
||||
BattleNpcSubKind.Buddy => LightlessObjectKind.Companion,
|
||||
BattleNpcSubKind.Pet => LightlessObjectKind.Pet,
|
||||
_ => (LightlessObjectKind?)null,
|
||||
},
|
||||
_ => (LightlessObjectKind?)null,
|
||||
};
|
||||
if (ownerId != localEntityId)
|
||||
return (null, ownerId);
|
||||
|
||||
return (ownedKind, ownerId);
|
||||
var expectedPet = GetPetAddress(localPlayerAddress, localEntityId);
|
||||
if (expectedPet != nint.Zero && (nint)gameObject == expectedPet)
|
||||
return (LightlessObjectKind.Pet, ownerId);
|
||||
|
||||
var expectedCompanion = GetCompanionAddress(localPlayerAddress, localEntityId);
|
||||
if (expectedCompanion != nint.Zero && (nint)gameObject == expectedCompanion)
|
||||
return (LightlessObjectKind.Companion, ownerId);
|
||||
|
||||
return (null, ownerId);
|
||||
}
|
||||
|
||||
private unsafe nint GetMinionOrMountAddress(nint localPlayerAddress, uint ownerEntityId)
|
||||
{
|
||||
if (localPlayerAddress == nint.Zero)
|
||||
return nint.Zero;
|
||||
|
||||
var playerObject = (GameObject*)localPlayerAddress;
|
||||
var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1);
|
||||
if (candidateAddress != nint.Zero)
|
||||
{
|
||||
var candidate = (GameObject*)candidateAddress;
|
||||
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
|
||||
if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
|
||||
{
|
||||
if (ownerEntityId == 0 || ResolveOwnerId(candidate) == ownerEntityId)
|
||||
return candidateAddress;
|
||||
}
|
||||
}
|
||||
|
||||
if (ownerEntityId == 0)
|
||||
return candidateAddress;
|
||||
|
||||
foreach (var obj in _objectTable)
|
||||
{
|
||||
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
|
||||
continue;
|
||||
|
||||
if (obj.ObjectKind is not (DalamudObjectKind.MountType or DalamudObjectKind.Companion))
|
||||
continue;
|
||||
|
||||
var candidate = (GameObject*)obj.Address;
|
||||
if (ResolveOwnerId(candidate) == ownerEntityId)
|
||||
return obj.Address;
|
||||
}
|
||||
|
||||
return candidateAddress;
|
||||
}
|
||||
|
||||
private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId)
|
||||
{
|
||||
if (localPlayerAddress == nint.Zero || ownerEntityId == 0)
|
||||
return nint.Zero;
|
||||
|
||||
var manager = CharacterManager.Instance();
|
||||
if (manager != null)
|
||||
{
|
||||
var candidate = (nint)manager->LookupPetByOwnerObject((BattleChara*)localPlayerAddress);
|
||||
if (candidate != nint.Zero)
|
||||
{
|
||||
var candidateObj = (GameObject*)candidate;
|
||||
if (IsPetMatch(candidateObj, ownerEntityId))
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var obj in _objectTable)
|
||||
{
|
||||
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
|
||||
continue;
|
||||
|
||||
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
|
||||
continue;
|
||||
|
||||
var candidate = (GameObject*)obj.Address;
|
||||
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
|
||||
continue;
|
||||
|
||||
if (ResolveOwnerId(candidate) == ownerEntityId)
|
||||
return obj.Address;
|
||||
}
|
||||
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
private unsafe nint GetCompanionAddress(nint localPlayerAddress, uint ownerEntityId)
|
||||
{
|
||||
if (localPlayerAddress == nint.Zero || ownerEntityId == 0)
|
||||
return nint.Zero;
|
||||
|
||||
var manager = CharacterManager.Instance();
|
||||
if (manager != null)
|
||||
{
|
||||
var candidate = (nint)manager->LookupBuddyByOwnerObject((BattleChara*)localPlayerAddress);
|
||||
if (candidate != nint.Zero)
|
||||
{
|
||||
var candidateObj = (GameObject*)candidate;
|
||||
if (IsCompanionMatch(candidateObj, ownerEntityId))
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var obj in _objectTable)
|
||||
{
|
||||
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
|
||||
continue;
|
||||
|
||||
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
|
||||
continue;
|
||||
|
||||
var candidate = (GameObject*)obj.Address;
|
||||
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Buddy)
|
||||
continue;
|
||||
|
||||
if (ResolveOwnerId(candidate) == ownerEntityId)
|
||||
return obj.Address;
|
||||
}
|
||||
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
private static unsafe bool IsPetMatch(GameObject* candidate, uint ownerEntityId)
|
||||
{
|
||||
if (candidate == null)
|
||||
return false;
|
||||
|
||||
if ((DalamudObjectKind)candidate->ObjectKind != DalamudObjectKind.BattleNpc)
|
||||
return false;
|
||||
|
||||
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
|
||||
return false;
|
||||
|
||||
return ResolveOwnerId(candidate) == ownerEntityId;
|
||||
}
|
||||
|
||||
private static unsafe bool IsCompanionMatch(GameObject* candidate, uint ownerEntityId)
|
||||
{
|
||||
if (candidate == null)
|
||||
return false;
|
||||
|
||||
if ((DalamudObjectKind)candidate->ObjectKind != DalamudObjectKind.BattleNpc)
|
||||
return false;
|
||||
|
||||
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Buddy)
|
||||
return false;
|
||||
|
||||
return ResolveOwnerId(candidate) == ownerEntityId;
|
||||
}
|
||||
|
||||
private static unsafe uint ResolveOwnerId(GameObject* gameObject)
|
||||
{
|
||||
if (gameObject == null)
|
||||
return 0;
|
||||
|
||||
if (gameObject->OwnerId != 0)
|
||||
return gameObject->OwnerId;
|
||||
|
||||
var character = (Character*)gameObject;
|
||||
if (character == null)
|
||||
return 0;
|
||||
|
||||
if (character->CompanionOwnerId != 0)
|
||||
return character->CompanionOwnerId;
|
||||
|
||||
var parent = character->GetParentCharacter();
|
||||
return parent != null ? parent->EntityId : 0;
|
||||
}
|
||||
|
||||
private void UntrackGameObject(nint address)
|
||||
@@ -618,11 +764,8 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
private void ReplaceDescriptor(ActorDescriptor existing, ActorDescriptor updated)
|
||||
{
|
||||
RemoveDescriptorFromIndexes(existing);
|
||||
_ownedTracker.OnDescriptorRemoved(existing);
|
||||
|
||||
_activePlayers[updated.Address] = updated;
|
||||
IndexDescriptor(updated);
|
||||
_ownedTracker.OnDescriptorAdded(updated);
|
||||
UpdatePendingHashResolutions(updated);
|
||||
PublishSnapshot();
|
||||
}
|
||||
@@ -690,7 +833,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
{
|
||||
_activePlayers[descriptor.Address] = descriptor;
|
||||
IndexDescriptor(descriptor);
|
||||
_ownedTracker.OnDescriptorAdded(descriptor);
|
||||
UpdatePendingHashResolutions(descriptor);
|
||||
PublishSnapshot();
|
||||
}
|
||||
@@ -698,7 +840,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
private void RemoveDescriptor(ActorDescriptor descriptor)
|
||||
{
|
||||
RemoveDescriptorFromIndexes(descriptor);
|
||||
_ownedTracker.OnDescriptorRemoved(descriptor);
|
||||
_pendingHashResolutions.TryRemove(descriptor.Address, out _);
|
||||
PublishSnapshot();
|
||||
}
|
||||
@@ -722,17 +863,90 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
|
||||
private void PublishSnapshot()
|
||||
{
|
||||
var playerDescriptors = _activePlayers.Values
|
||||
.Where(descriptor => descriptor.ObjectKind == DalamudObjectKind.Player)
|
||||
.ToArray();
|
||||
var ownedDescriptors = _activePlayers.Values
|
||||
.Where(descriptor => descriptor.OwnedKind is not null)
|
||||
.ToArray();
|
||||
var playerAddresses = new nint[playerDescriptors.Length];
|
||||
for (var i = 0; i < playerDescriptors.Length; i++)
|
||||
playerAddresses[i] = playerDescriptors[i].Address;
|
||||
var descriptors = _activePlayers.Values.ToArray();
|
||||
var playerCount = 0;
|
||||
var ownedCount = 0;
|
||||
var companionCount = 0;
|
||||
|
||||
var ownedSnapshot = _ownedTracker.CreateSnapshot();
|
||||
foreach (var descriptor in descriptors)
|
||||
{
|
||||
if (descriptor.ObjectKind == DalamudObjectKind.Player)
|
||||
playerCount++;
|
||||
|
||||
if (descriptor.OwnedKind is not null)
|
||||
ownedCount++;
|
||||
|
||||
if (descriptor.ObjectKind == DalamudObjectKind.Companion)
|
||||
companionCount++;
|
||||
}
|
||||
|
||||
var playerDescriptors = new ActorDescriptor[playerCount];
|
||||
var ownedDescriptors = new ActorDescriptor[ownedCount];
|
||||
var playerAddresses = new nint[playerCount];
|
||||
var renderedCompanions = new nint[companionCount];
|
||||
var ownedAddresses = new nint[ownedCount];
|
||||
var ownedMap = new Dictionary<nint, LightlessObjectKind>(ownedCount);
|
||||
nint localPlayer = nint.Zero;
|
||||
nint localPet = nint.Zero;
|
||||
nint localMinionOrMount = nint.Zero;
|
||||
nint localCompanion = nint.Zero;
|
||||
|
||||
var playerIndex = 0;
|
||||
var ownedIndex = 0;
|
||||
var companionIndex = 0;
|
||||
|
||||
foreach (var descriptor in descriptors)
|
||||
{
|
||||
if (descriptor.ObjectKind == DalamudObjectKind.Player)
|
||||
{
|
||||
playerDescriptors[playerIndex] = descriptor;
|
||||
playerAddresses[playerIndex] = descriptor.Address;
|
||||
playerIndex++;
|
||||
}
|
||||
|
||||
if (descriptor.ObjectKind == DalamudObjectKind.Companion)
|
||||
{
|
||||
renderedCompanions[companionIndex] = descriptor.Address;
|
||||
companionIndex++;
|
||||
}
|
||||
|
||||
if (descriptor.OwnedKind is not { } ownedKind)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ownedDescriptors[ownedIndex] = descriptor;
|
||||
ownedAddresses[ownedIndex] = descriptor.Address;
|
||||
ownedMap[descriptor.Address] = ownedKind;
|
||||
|
||||
switch (ownedKind)
|
||||
{
|
||||
case LightlessObjectKind.Player:
|
||||
localPlayer = descriptor.Address;
|
||||
break;
|
||||
case LightlessObjectKind.Pet:
|
||||
localPet = descriptor.Address;
|
||||
break;
|
||||
case LightlessObjectKind.MinionOrMount:
|
||||
localMinionOrMount = descriptor.Address;
|
||||
break;
|
||||
case LightlessObjectKind.Companion:
|
||||
localCompanion = descriptor.Address;
|
||||
break;
|
||||
}
|
||||
|
||||
ownedIndex++;
|
||||
}
|
||||
|
||||
var ownedSnapshot = new OwnedObjectSnapshot(
|
||||
playerAddresses,
|
||||
renderedCompanions,
|
||||
ownedAddresses,
|
||||
ownedMap,
|
||||
localPlayer,
|
||||
localPet,
|
||||
localMinionOrMount,
|
||||
localCompanion);
|
||||
var nextGeneration = Snapshot.Generation + 1;
|
||||
var snapshot = new ActorSnapshot(playerDescriptors, ownedDescriptors, playerAddresses, ownedSnapshot, nextGeneration);
|
||||
Volatile.Write(ref _snapshot, snapshot);
|
||||
@@ -955,109 +1169,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed class OwnedObjectTracker
|
||||
{
|
||||
private readonly HashSet<nint> _renderedPlayers = new();
|
||||
private readonly HashSet<nint> _renderedCompanions = new();
|
||||
private readonly Dictionary<nint, LightlessObjectKind> _ownedObjects = new();
|
||||
private nint _localPlayerAddress = nint.Zero;
|
||||
private nint _localPetAddress = nint.Zero;
|
||||
private nint _localMinionMountAddress = nint.Zero;
|
||||
private nint _localCompanionAddress = nint.Zero;
|
||||
|
||||
public void OnDescriptorAdded(ActorDescriptor descriptor)
|
||||
{
|
||||
if (descriptor.ObjectKind == DalamudObjectKind.Player)
|
||||
{
|
||||
_renderedPlayers.Add(descriptor.Address);
|
||||
if (descriptor.IsLocalPlayer)
|
||||
_localPlayerAddress = descriptor.Address;
|
||||
}
|
||||
else if (descriptor.ObjectKind == DalamudObjectKind.Companion)
|
||||
{
|
||||
_renderedCompanions.Add(descriptor.Address);
|
||||
}
|
||||
|
||||
if (descriptor.OwnedKind is { } ownedKind)
|
||||
{
|
||||
_ownedObjects[descriptor.Address] = ownedKind;
|
||||
switch (ownedKind)
|
||||
{
|
||||
case LightlessObjectKind.Player:
|
||||
_localPlayerAddress = descriptor.Address;
|
||||
break;
|
||||
case LightlessObjectKind.Pet:
|
||||
_localPetAddress = descriptor.Address;
|
||||
break;
|
||||
case LightlessObjectKind.MinionOrMount:
|
||||
_localMinionMountAddress = descriptor.Address;
|
||||
break;
|
||||
case LightlessObjectKind.Companion:
|
||||
_localCompanionAddress = descriptor.Address;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void OnDescriptorRemoved(ActorDescriptor descriptor)
|
||||
{
|
||||
if (descriptor.ObjectKind == DalamudObjectKind.Player)
|
||||
{
|
||||
_renderedPlayers.Remove(descriptor.Address);
|
||||
if (descriptor.IsLocalPlayer && _localPlayerAddress == descriptor.Address)
|
||||
_localPlayerAddress = nint.Zero;
|
||||
}
|
||||
else if (descriptor.ObjectKind == DalamudObjectKind.Companion)
|
||||
{
|
||||
_renderedCompanions.Remove(descriptor.Address);
|
||||
if (_localCompanionAddress == descriptor.Address)
|
||||
_localCompanionAddress = nint.Zero;
|
||||
}
|
||||
|
||||
if (descriptor.OwnedKind is { } ownedKind)
|
||||
{
|
||||
_ownedObjects.Remove(descriptor.Address);
|
||||
switch (ownedKind)
|
||||
{
|
||||
case LightlessObjectKind.Player when _localPlayerAddress == descriptor.Address:
|
||||
_localPlayerAddress = nint.Zero;
|
||||
break;
|
||||
case LightlessObjectKind.Pet when _localPetAddress == descriptor.Address:
|
||||
_localPetAddress = nint.Zero;
|
||||
break;
|
||||
case LightlessObjectKind.MinionOrMount when _localMinionMountAddress == descriptor.Address:
|
||||
_localMinionMountAddress = nint.Zero;
|
||||
break;
|
||||
case LightlessObjectKind.Companion when _localCompanionAddress == descriptor.Address:
|
||||
_localCompanionAddress = nint.Zero;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public OwnedObjectSnapshot CreateSnapshot()
|
||||
=> new(
|
||||
_renderedPlayers.ToArray(),
|
||||
_renderedCompanions.ToArray(),
|
||||
_ownedObjects.Keys.ToArray(),
|
||||
new Dictionary<nint, LightlessObjectKind>(_ownedObjects),
|
||||
_localPlayerAddress,
|
||||
_localPetAddress,
|
||||
_localMinionMountAddress,
|
||||
_localCompanionAddress);
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_renderedPlayers.Clear();
|
||||
_renderedCompanions.Clear();
|
||||
_ownedObjects.Clear();
|
||||
_localPlayerAddress = nint.Zero;
|
||||
_localPetAddress = nint.Zero;
|
||||
_localMinionMountAddress = nint.Zero;
|
||||
_localCompanionAddress = nint.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record OwnedObjectSnapshot(
|
||||
IReadOnlyList<nint> RenderedPlayers,
|
||||
IReadOnlyList<nint> RenderedCompanions,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using K4os.Compression.LZ4.Legacy;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.API.Dto.CharaData;
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.PlayerData.Factories;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using LightlessSync.Services.CharaData;
|
||||
using LightlessSync.Services.CharaData.Models;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI.Models;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI.Files;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -24,10 +27,11 @@ public sealed class CharaDataFileHandler : IDisposable
|
||||
private readonly ILogger<CharaDataFileHandler> _logger;
|
||||
private readonly LightlessCharaFileDataFactory _lightlessCharaFileDataFactory;
|
||||
private readonly PlayerDataFactory _playerDataFactory;
|
||||
private readonly NotificationService _notificationService;
|
||||
private int _globalFileCounter = 0;
|
||||
|
||||
public CharaDataFileHandler(ILogger<CharaDataFileHandler> logger, FileDownloadManagerFactory fileDownloadManagerFactory, FileUploadManager fileUploadManager, FileCacheManager fileCacheManager,
|
||||
DalamudUtilService dalamudUtilService, GameObjectHandlerFactory gameObjectHandlerFactory, PlayerDataFactory playerDataFactory)
|
||||
DalamudUtilService dalamudUtilService, GameObjectHandlerFactory gameObjectHandlerFactory, PlayerDataFactory playerDataFactory, NotificationService notificationService)
|
||||
{
|
||||
_fileDownloadManager = fileDownloadManagerFactory.Create();
|
||||
_logger = logger;
|
||||
@@ -36,6 +40,7 @@ public sealed class CharaDataFileHandler : IDisposable
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
_gameObjectHandlerFactory = gameObjectHandlerFactory;
|
||||
_playerDataFactory = playerDataFactory;
|
||||
_notificationService = notificationService;
|
||||
_lightlessCharaFileDataFactory = new(fileCacheManager);
|
||||
}
|
||||
|
||||
@@ -248,54 +253,161 @@ public sealed class CharaDataFileHandler : IDisposable
|
||||
}
|
||||
|
||||
internal async Task SaveCharaFileAsync(string description, string filePath)
|
||||
{
|
||||
var createPlayerDataStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
var data = await CreatePlayerData().ConfigureAwait(false);
|
||||
createPlayerDataStopwatch.Stop();
|
||||
_logger.LogInformation("CreatePlayerData took {elapsed}ms", createPlayerDataStopwatch.ElapsedMilliseconds);
|
||||
|
||||
if (data == null) return;
|
||||
|
||||
await Task.Run(async () => await SaveCharaFileAsyncInternal(description, filePath, data).ConfigureAwait(false)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task SaveCharaFileAsyncInternal(string description, string filePath, CharacterData data)
|
||||
{
|
||||
var tempFilePath = filePath + ".tmp";
|
||||
var overallStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var data = await CreatePlayerData().ConfigureAwait(false);
|
||||
if (data == null) return;
|
||||
|
||||
var lightlessCharaFileData = _lightlessCharaFileDataFactory.Create(description, data);
|
||||
LightlessCharaFileHeader output = new(LightlessCharaFileHeader.CurrentVersion, lightlessCharaFileData);
|
||||
|
||||
using var fs = new FileStream(tempFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
|
||||
using var fs = new FileStream(tempFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, bufferSize: 65536, useAsync: false);
|
||||
using var lz4 = new LZ4Stream(fs, LZ4StreamMode.Compress, LZ4StreamFlags.HighCompression);
|
||||
using var writer = new BinaryWriter(lz4);
|
||||
output.WriteToStream(writer);
|
||||
|
||||
int fileIndex = 0;
|
||||
long totalBytesWritten = 0;
|
||||
long totalBytesToWrite = output.CharaFileData.Files.Sum(f => f.Length);
|
||||
var fileWriteStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
const long updateIntervalMs = 1000;
|
||||
|
||||
foreach (var item in output.CharaFileData.Files)
|
||||
{
|
||||
fileIndex++;
|
||||
var fileStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var file = _fileCacheManager.GetFileCacheByHash(item.Hash)!;
|
||||
_logger.LogDebug("Saving to MCDF: {hash}:{file}", item.Hash, file.ResolvedFilepath);
|
||||
_logger.LogDebug("Saving to MCDF [{fileNum}/{totalFiles}]: {hash}:{file}", fileIndex, output.CharaFileData.Files.Count, item.Hash, file.ResolvedFilepath);
|
||||
_logger.LogDebug("\tAssociated GamePaths:");
|
||||
foreach (var path in item.GamePaths)
|
||||
{
|
||||
_logger.LogDebug("\t{path}", path);
|
||||
}
|
||||
|
||||
var fsRead = File.OpenRead(file.ResolvedFilepath);
|
||||
await using (fsRead.ConfigureAwait(false))
|
||||
using var fsRead = File.OpenRead(file.ResolvedFilepath);
|
||||
using var br = new BinaryReader(fsRead);
|
||||
byte[] buffer = new byte[item.Length];
|
||||
int bytesRead = br.Read(buffer, 0, item.Length);
|
||||
|
||||
if (bytesRead != item.Length)
|
||||
{
|
||||
using var br = new BinaryReader(fsRead);
|
||||
byte[] buffer = new byte[item.Length];
|
||||
br.Read(buffer, 0, item.Length);
|
||||
writer.Write(buffer);
|
||||
_logger.LogWarning("Expected to read {expected} bytes but got {actual} bytes from {file}", item.Length, bytesRead, file.ResolvedFilepath);
|
||||
}
|
||||
|
||||
writer.Write(buffer);
|
||||
totalBytesWritten += bytesRead;
|
||||
|
||||
fileStopwatch.Stop();
|
||||
_logger.LogDebug("Wrote file [{fileNum}/{totalFiles}] in {elapsed}ms ({sizeKb}kb)", fileIndex, output.CharaFileData.Files.Count, fileStopwatch.ElapsedMilliseconds, item.Length / 1024);
|
||||
|
||||
if (fileWriteStopwatch.ElapsedMilliseconds >= updateIntervalMs && totalBytesToWrite > 0)
|
||||
{
|
||||
float progress = (float)totalBytesWritten / totalBytesToWrite;
|
||||
var elapsed = overallStopwatch.Elapsed;
|
||||
var eta = CalculateEta(elapsed, progress);
|
||||
|
||||
var notification = new LightlessNotification
|
||||
{
|
||||
Id = "chara_file_save_progress",
|
||||
Title = "Character Data",
|
||||
Message = $"Compressing and saving character file... {(progress * 100):F0}%\nETA: {FormatTimespan(eta)}",
|
||||
Type = NotificationType.Info,
|
||||
Duration = TimeSpan.FromMinutes(5),
|
||||
ShowProgress = true,
|
||||
Progress = progress
|
||||
};
|
||||
|
||||
_notificationService.Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||
|
||||
fileWriteStopwatch.Restart();
|
||||
}
|
||||
}
|
||||
|
||||
var flushStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
writer.Flush();
|
||||
await lz4.FlushAsync().ConfigureAwait(false);
|
||||
await fs.FlushAsync().ConfigureAwait(false);
|
||||
lz4.Flush();
|
||||
fs.Flush();
|
||||
fs.Close();
|
||||
flushStopwatch.Stop();
|
||||
_logger.LogInformation("Flush operations took {elapsed}ms", flushStopwatch.ElapsedMilliseconds);
|
||||
|
||||
var moveStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
File.Move(tempFilePath, filePath, true);
|
||||
moveStopwatch.Stop();
|
||||
_logger.LogInformation("File move took {elapsed}ms", moveStopwatch.ElapsedMilliseconds);
|
||||
|
||||
overallStopwatch.Stop();
|
||||
_logger.LogInformation("SaveCharaFileAsync completed successfully in {elapsed}ms. Total bytes written: {totalBytes}mb", overallStopwatch.ElapsedMilliseconds, totalBytesWritten / (1024 * 1024));
|
||||
|
||||
_notificationService.ShowNotification(
|
||||
"Character Data",
|
||||
"Character file saved successfully!",
|
||||
NotificationType.Info,
|
||||
duration: TimeSpan.FromSeconds(5));
|
||||
|
||||
_notificationService.Mediator.Publish(new LightlessNotificationDismissMessage("chara_file_save_progress"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failure Saving Lightless Chara File, deleting output");
|
||||
File.Delete(tempFilePath);
|
||||
overallStopwatch.Stop();
|
||||
_logger.LogError(ex, "Failure Saving Lightless Chara File after {elapsed}ms, deleting output", overallStopwatch.ElapsedMilliseconds);
|
||||
try
|
||||
{
|
||||
File.Delete(tempFilePath);
|
||||
}
|
||||
catch (Exception deleteEx)
|
||||
{
|
||||
_logger.LogError(deleteEx, "Failed to delete temporary file {file}", tempFilePath);
|
||||
}
|
||||
|
||||
_notificationService.ShowErrorNotification(
|
||||
"Character Data Save Failed",
|
||||
"Failed to save character file",
|
||||
ex);
|
||||
|
||||
_notificationService.Mediator.Publish(new LightlessNotificationDismissMessage("chara_file_save_progress"));
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeSpan CalculateEta(TimeSpan elapsed, float progress)
|
||||
{
|
||||
if (progress <= 0 || elapsed.TotalSeconds < 0.1)
|
||||
return TimeSpan.Zero;
|
||||
|
||||
double totalSeconds = elapsed.TotalSeconds / progress;
|
||||
double remainingSeconds = totalSeconds - elapsed.TotalSeconds;
|
||||
|
||||
return TimeSpan.FromSeconds(Math.Max(0, remainingSeconds));
|
||||
}
|
||||
|
||||
private static string FormatTimespan(TimeSpan ts)
|
||||
{
|
||||
if (ts.TotalSeconds < 1)
|
||||
return "< 1s";
|
||||
|
||||
if (ts.TotalSeconds < 60)
|
||||
return $"{ts.TotalSeconds:F0}s";
|
||||
|
||||
if (ts.TotalMinutes < 60)
|
||||
return $"{ts.TotalMinutes:F1}m";
|
||||
|
||||
return $"{ts.TotalHours:F1}h";
|
||||
}
|
||||
|
||||
internal async Task<List<string>> UploadFiles(List<string> fileList, ValueProgress<string> uploadProgress, CancellationToken token)
|
||||
{
|
||||
return await _fileUploadManager.UploadFiles(fileList, uploadProgress, token).ConfigureAwait(false);
|
||||
|
||||
@@ -450,7 +450,7 @@ public class CharaDataGposeTogetherManager : DisposableMediatorSubscriberBase
|
||||
};
|
||||
}
|
||||
|
||||
var loc = await _dalamudUtil.GetMapDataAsync().ConfigureAwait(false);
|
||||
var loc = _dalamudUtil.GetMapData();
|
||||
worldData.LocationInfo = loc;
|
||||
|
||||
if (_forceResendWorldData || worldData != _lastWorldData)
|
||||
|
||||
@@ -254,7 +254,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
|
||||
|
||||
Logger.LogTrace("Attaching World data {data}", worldData);
|
||||
|
||||
worldData.LocationInfo = await _dalamudUtilService.GetMapDataAsync().ConfigureAwait(false);
|
||||
worldData.LocationInfo = _dalamudUtilService.GetMapData();
|
||||
|
||||
Logger.LogTrace("World data serialized: {data}", worldData);
|
||||
|
||||
|
||||
@@ -186,8 +186,8 @@ public sealed class CharaDataNearbyManager : DisposableMediatorSubscriberBase
|
||||
var previousPoses = _nearbyData.Keys.ToList();
|
||||
_nearbyData.Clear();
|
||||
|
||||
var ownLocation = await _dalamudUtilService.RunOnFrameworkThread(() => _dalamudUtilService.GetMapData()).ConfigureAwait(false);
|
||||
var player = await _dalamudUtilService.RunOnFrameworkThread(() => _dalamudUtilService.GetPlayerCharacter()).ConfigureAwait(false);
|
||||
var ownLocation = _dalamudUtilService.GetMapData();
|
||||
var player = await _dalamudUtilService.GetPlayerCharacterAsync().ConfigureAwait(false);
|
||||
var currentServer = player.CurrentWorld;
|
||||
var playerPos = player.Position;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using FFXIVClientStructs.FFXIV.Client.Graphics.Render;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.FileCache;
|
||||
@@ -98,11 +99,13 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
_analysisCts = null;
|
||||
if (print) PrintAnalysis();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_analysisCts.CancelDispose();
|
||||
_baseAnalysisCts.Dispose();
|
||||
}
|
||||
|
||||
public async Task UpdateFileEntriesAsync(IEnumerable<string> filePaths, CancellationToken token)
|
||||
{
|
||||
var normalized = new HashSet<string>(
|
||||
@@ -125,6 +128,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BaseAnalysis(CharacterData charaData, CancellationToken token)
|
||||
{
|
||||
if (string.Equals(charaData.DataHash.Value, _lastDataHash, StringComparison.Ordinal)) return;
|
||||
@@ -136,29 +140,47 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var fileCacheEntries = (await _fileCacheManager.GetAllFileCachesByHashAsync(fileEntry.Hash, ignoreCacheEntries: true, validate: false, token).ConfigureAwait(false)).ToList();
|
||||
if (fileCacheEntries.Count == 0) continue;
|
||||
var filePath = fileCacheEntries[0].ResolvedFilepath;
|
||||
FileInfo fi = new(filePath);
|
||||
string ext = "unk?";
|
||||
try
|
||||
{
|
||||
ext = fi.Extension[1..];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Could not identify extension for {path}", filePath);
|
||||
}
|
||||
var fileCacheEntries = (await _fileCacheManager
|
||||
.GetAllFileCachesByHashAsync(fileEntry.Hash, ignoreCacheEntries: true, validate: false, token)
|
||||
.ConfigureAwait(false))
|
||||
.ToList();
|
||||
|
||||
if (fileCacheEntries.Count == 0)
|
||||
continue;
|
||||
|
||||
var resolved = fileCacheEntries[0].ResolvedFilepath;
|
||||
|
||||
var extWithDot = Path.GetExtension(resolved);
|
||||
var ext = string.IsNullOrEmpty(extWithDot) ? "unk?" : extWithDot.TrimStart('.');
|
||||
|
||||
var tris = await _xivDataAnalyzer.GetTrianglesByHash(fileEntry.Hash).ConfigureAwait(false);
|
||||
foreach (var entry in fileCacheEntries)
|
||||
|
||||
var distinctFilePaths = fileCacheEntries
|
||||
.Select(c => c.ResolvedFilepath)
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
long orig = 0, comp = 0;
|
||||
var first = fileCacheEntries[0];
|
||||
if (first.Size > 0) orig = first.Size.Value;
|
||||
if (first.CompressedSize > 0) comp = first.CompressedSize.Value;
|
||||
|
||||
if (_fileCacheManager.TryGetSizeInfo(fileEntry.Hash, out var cached))
|
||||
{
|
||||
data[fileEntry.Hash] = new FileDataEntry(fileEntry.Hash, ext,
|
||||
[.. fileEntry.GamePaths],
|
||||
[.. fileCacheEntries.Select(c => c.ResolvedFilepath).Distinct(StringComparer.Ordinal)],
|
||||
entry.Size > 0 ? entry.Size.Value : 0,
|
||||
entry.CompressedSize > 0 ? entry.CompressedSize.Value : 0,
|
||||
tris);
|
||||
if (orig <= 0 && cached.Original > 0) orig = cached.Original;
|
||||
if (comp <= 0 && cached.Compressed > 0) comp = cached.Compressed;
|
||||
}
|
||||
|
||||
data[fileEntry.Hash] = new FileDataEntry(
|
||||
fileEntry.Hash,
|
||||
ext,
|
||||
[.. fileEntry.GamePaths],
|
||||
distinctFilePaths,
|
||||
orig,
|
||||
comp,
|
||||
tris,
|
||||
fileCacheEntries);
|
||||
}
|
||||
LastAnalysis[obj.Key] = data;
|
||||
}
|
||||
@@ -167,6 +189,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
Mediator.Publish(new CharacterDataAnalyzedMessage());
|
||||
_lastDataHash = charaData.DataHash.Value;
|
||||
}
|
||||
|
||||
private void RecalculateSummary()
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<ObjectKind, CharacterAnalysisObjectSummary>();
|
||||
@@ -192,6 +215,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
|
||||
_latestSummary = new CharacterAnalysisSummary(builder.ToImmutable());
|
||||
}
|
||||
|
||||
private void PrintAnalysis()
|
||||
{
|
||||
if (LastAnalysis.Count == 0) return;
|
||||
@@ -235,42 +259,79 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
UiSharedService.ByteToString(LastAnalysis.Values.Sum(c => c.Values.Sum(v => v.CompressedSize))));
|
||||
Logger.LogInformation("IMPORTANT NOTES:\n\r- For Lightless up- and downloads only the compressed size is relevant.\n\r- An unusually high total files count beyond 200 and up will also increase your download time to others significantly.");
|
||||
}
|
||||
internal sealed record FileDataEntry(string Hash, string FileType, List<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;
|
||||
|
||||
public void RefreshFormat()
|
||||
{
|
||||
_format = CreateFormatValue();
|
||||
}
|
||||
public void RefreshFormat() => _format = CreateFormatValue();
|
||||
|
||||
private Lazy<string> CreateFormatValue()
|
||||
=> new(() =>
|
||||
{
|
||||
if (!string.Equals(FileType, "tex", StringComparison.Ordinal))
|
||||
{
|
||||
if (!string.Equals(FileType, "tex", StringComparison.OrdinalIgnoreCase))
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
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.Services.ActorTracking;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Services.ServerConfiguration;
|
||||
using LightlessSync.WebAPI;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -25,6 +26,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
private readonly ActorObjectService _actorObjectService;
|
||||
private readonly PairUiService _pairUiService;
|
||||
private readonly ChatConfigService _chatConfigService;
|
||||
private readonly ServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
private readonly Lock _sync = new();
|
||||
|
||||
@@ -37,6 +39,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
private readonly Dictionary<string, bool> _lastPresenceStates = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string> _selfTokens = new(StringComparer.Ordinal);
|
||||
private readonly List<PendingSelfMessage> _pendingSelfMessages = new();
|
||||
private readonly Dictionary<string, List<ChatMessageEntry>> _messageHistoryCache = new(StringComparer.Ordinal);
|
||||
private List<ChatChannelSnapshot>? _cachedChannelSnapshots;
|
||||
private bool _channelsSnapshotDirty = true;
|
||||
|
||||
@@ -54,7 +57,8 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
ApiController apiController,
|
||||
DalamudUtilService dalamudUtilService,
|
||||
ActorObjectService actorObjectService,
|
||||
PairUiService pairUiService)
|
||||
PairUiService pairUiService,
|
||||
ServerConfigurationManager serverConfigurationManager)
|
||||
: base(logger, mediator)
|
||||
{
|
||||
_apiController = apiController;
|
||||
@@ -62,6 +66,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
_actorObjectService = actorObjectService;
|
||||
_pairUiService = pairUiService;
|
||||
_chatConfigService = chatConfigService;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
|
||||
_isLoggedIn = _dalamudUtilService.IsLoggedIn;
|
||||
_isConnected = _apiController.IsConnected;
|
||||
@@ -571,7 +576,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
|
||||
try
|
||||
{
|
||||
var location = await _dalamudUtilService.GetMapDataAsync().ConfigureAwait(false);
|
||||
var location = _dalamudUtilService.GetMapData();
|
||||
var territoryId = (ushort)location.TerritoryId;
|
||||
var worldId = (ushort)location.ServerId;
|
||||
|
||||
@@ -697,7 +702,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
{
|
||||
try
|
||||
{
|
||||
var worldId = (ushort)await _dalamudUtilService.GetWorldIdAsync().ConfigureAwait(false);
|
||||
var worldId = (ushort)_dalamudUtilService.GetWorldId();
|
||||
return definition.Descriptor with { WorldId = worldId };
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -776,6 +781,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
using (_sync.EnterScope())
|
||||
{
|
||||
var remainingGroups = new HashSet<string>(_groupDefinitions.Keys, StringComparer.OrdinalIgnoreCase);
|
||||
var allowRemoval = _isConnected;
|
||||
|
||||
foreach (var info in infoList)
|
||||
{
|
||||
@@ -791,18 +797,19 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
var key = BuildChannelKey(descriptor);
|
||||
if (!_channels.TryGetValue(key, out var state))
|
||||
{
|
||||
state = new ChatChannelState(key, ChatChannelType.Group, info.DisplayName ?? groupId, descriptor);
|
||||
state.IsConnected = _chatEnabled && _isConnected;
|
||||
state.IsAvailable = _chatEnabled && _isConnected;
|
||||
state.StatusText = !_chatEnabled
|
||||
? "Chat services disabled"
|
||||
: (_isConnected ? null : "Disconnected from chat server");
|
||||
_channels[key] = state;
|
||||
_lastReadCounts[key] = 0;
|
||||
if (_chatEnabled)
|
||||
{
|
||||
descriptorsToJoin.Add(descriptor);
|
||||
}
|
||||
state = new ChatChannelState(key, ChatChannelType.Group, info.DisplayName ?? groupId, descriptor);
|
||||
var restoredCount = RestoreCachedMessagesLocked(state);
|
||||
state.IsConnected = _chatEnabled && _isConnected;
|
||||
state.IsAvailable = _chatEnabled && _isConnected;
|
||||
state.StatusText = !_chatEnabled
|
||||
? "Chat services disabled"
|
||||
: (_isConnected ? null : "Disconnected from chat server");
|
||||
_channels[key] = state;
|
||||
_lastReadCounts[key] = restoredCount > 0 ? state.Messages.Count : 0;
|
||||
if (_chatEnabled)
|
||||
{
|
||||
descriptorsToJoin.Add(descriptor);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -816,26 +823,30 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var removedGroupId in remainingGroups)
|
||||
if (allowRemoval)
|
||||
{
|
||||
if (_groupDefinitions.TryGetValue(removedGroupId, out var definition))
|
||||
foreach (var removedGroupId in remainingGroups)
|
||||
{
|
||||
var key = BuildChannelKey(definition.Descriptor);
|
||||
if (_channels.TryGetValue(key, out var state))
|
||||
if (_groupDefinitions.TryGetValue(removedGroupId, out var definition))
|
||||
{
|
||||
descriptorsToLeave.Add(state.Descriptor);
|
||||
_channels.Remove(key);
|
||||
_lastReadCounts.Remove(key);
|
||||
_lastPresenceStates.Remove(BuildPresenceKey(state.Descriptor));
|
||||
_selfTokens.Remove(key);
|
||||
_pendingSelfMessages.RemoveAll(p => string.Equals(p.ChannelKey, key, StringComparison.Ordinal));
|
||||
if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal))
|
||||
var key = BuildChannelKey(definition.Descriptor);
|
||||
if (_channels.TryGetValue(key, out var state))
|
||||
{
|
||||
_activeChannelKey = null;
|
||||
CacheMessagesLocked(state);
|
||||
descriptorsToLeave.Add(state.Descriptor);
|
||||
_channels.Remove(key);
|
||||
_lastReadCounts.Remove(key);
|
||||
_lastPresenceStates.Remove(BuildPresenceKey(state.Descriptor));
|
||||
_selfTokens.Remove(key);
|
||||
_pendingSelfMessages.RemoveAll(p => string.Equals(p.ChannelKey, key, StringComparison.Ordinal));
|
||||
if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal))
|
||||
{
|
||||
_activeChannelKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_groupDefinitions.Remove(removedGroupId);
|
||||
_groupDefinitions.Remove(removedGroupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1013,13 +1024,14 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
descriptor.Type,
|
||||
displayName,
|
||||
descriptor.Type == ChatChannelType.Zone ? (_lastZoneDescriptor ?? descriptor) : descriptor);
|
||||
var restoredCount = RestoreCachedMessagesLocked(state);
|
||||
|
||||
state.IsConnected = _isConnected;
|
||||
state.IsAvailable = descriptor.Type == ChatChannelType.Group && _isConnected;
|
||||
state.StatusText = descriptor.Type == ChatChannelType.Zone ? ZoneUnavailableMessage : (_isConnected ? null : "Disconnected from chat server");
|
||||
|
||||
_channels[key] = state;
|
||||
_lastReadCounts[key] = 0;
|
||||
_lastReadCounts[key] = restoredCount > 0 ? state.Messages.Count : 0;
|
||||
publishChannelList = true;
|
||||
}
|
||||
|
||||
@@ -1149,7 +1161,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
{
|
||||
try
|
||||
{
|
||||
return _dalamudUtilService.GetPlayerNameAsync().ConfigureAwait(false).GetAwaiter().GetResult();
|
||||
return _dalamudUtilService.GetPlayerName();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1159,6 +1171,15 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
|
||||
if (dto.Sender.Kind == ChatSenderKind.IdentifiedUser && dto.Sender.User is not null)
|
||||
{
|
||||
if (dto.Channel.Type != ChatChannelType.Group || _chatConfigService.Current.ShowNotesInSyncshellChat)
|
||||
{
|
||||
var note = _serverConfigurationManager.GetNoteForUid(dto.Sender.User.UID);
|
||||
if (!string.IsNullOrWhiteSpace(note))
|
||||
{
|
||||
return note;
|
||||
}
|
||||
}
|
||||
|
||||
return dto.Sender.User.AliasOrUID;
|
||||
}
|
||||
|
||||
@@ -1288,11 +1309,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
if (!_channels.TryGetValue(ZoneChannelKey, out var state))
|
||||
{
|
||||
state = new ChatChannelState(ZoneChannelKey, ChatChannelType.Zone, "Zone Chat", new ChatChannelDescriptor { Type = ChatChannelType.Zone });
|
||||
var restoredCount = RestoreCachedMessagesLocked(state);
|
||||
state.IsConnected = _chatEnabled && _isConnected;
|
||||
state.IsAvailable = false;
|
||||
state.StatusText = _chatEnabled ? ZoneUnavailableMessage : "Chat services disabled";
|
||||
_channels[ZoneChannelKey] = state;
|
||||
_lastReadCounts[ZoneChannelKey] = 0;
|
||||
_lastReadCounts[ZoneChannelKey] = restoredCount > 0 ? state.Messages.Count : 0;
|
||||
UpdateChannelOrderLocked();
|
||||
}
|
||||
|
||||
@@ -1301,6 +1323,11 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
|
||||
private void RemoveZoneStateLocked()
|
||||
{
|
||||
if (_channels.TryGetValue(ZoneChannelKey, out var existing))
|
||||
{
|
||||
CacheMessagesLocked(existing);
|
||||
}
|
||||
|
||||
if (_channels.Remove(ZoneChannelKey))
|
||||
{
|
||||
_lastReadCounts.Remove(ZoneChannelKey);
|
||||
@@ -1315,6 +1342,28 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
|
||||
}
|
||||
}
|
||||
|
||||
private void CacheMessagesLocked(ChatChannelState state)
|
||||
{
|
||||
if (state.Messages.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_messageHistoryCache[state.Key] = new List<ChatMessageEntry>(state.Messages);
|
||||
}
|
||||
|
||||
private int RestoreCachedMessagesLocked(ChatChannelState state)
|
||||
{
|
||||
if (_messageHistoryCache.TryGetValue(state.Key, out var cached) && cached.Count > 0)
|
||||
{
|
||||
state.Messages.AddRange(cached);
|
||||
_messageHistoryCache.Remove(state.Key);
|
||||
return cached.Count;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private sealed class ChatChannelState
|
||||
{
|
||||
public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor)
|
||||
|
||||
@@ -4,21 +4,22 @@ using Dalamud.Plugin;
|
||||
using Dalamud.Plugin.Services;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.Services.LightFinder;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI;
|
||||
using LightlessSync.UI.Services;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using Lumina.Excel.Sheets;
|
||||
using LightlessSync.UI.Services;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using LightlessSync.UI;
|
||||
using LightlessSync.Services.LightFinder;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
internal class ContextMenuService : IHostedService
|
||||
{
|
||||
private readonly IContextMenu _contextMenu;
|
||||
private readonly IChatGui _chatGui;
|
||||
private readonly IDalamudPluginInterface _pluginInterface;
|
||||
private readonly IDataManager _gameData;
|
||||
private readonly ILogger<ContextMenuService> _logger;
|
||||
@@ -29,6 +30,7 @@ internal class ContextMenuService : IHostedService
|
||||
private readonly ApiController _apiController;
|
||||
private readonly IObjectTable _objectTable;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly NotificationService _lightlessNotification;
|
||||
private readonly LightFinderScannerService _broadcastScannerService;
|
||||
private readonly LightFinderService _broadcastService;
|
||||
private readonly LightlessProfileManager _lightlessProfileManager;
|
||||
@@ -43,7 +45,7 @@ internal class ContextMenuService : IHostedService
|
||||
ILogger<ContextMenuService> logger,
|
||||
DalamudUtilService dalamudUtil,
|
||||
ApiController apiController,
|
||||
IObjectTable objectTable,
|
||||
IObjectTable objectTable,
|
||||
LightlessConfigService configService,
|
||||
PairRequestService pairRequestService,
|
||||
PairUiService pairUiService,
|
||||
@@ -51,7 +53,9 @@ internal class ContextMenuService : IHostedService
|
||||
LightFinderScannerService broadcastScannerService,
|
||||
LightFinderService broadcastService,
|
||||
LightlessProfileManager lightlessProfileManager,
|
||||
LightlessMediator mediator)
|
||||
LightlessMediator mediator,
|
||||
IChatGui chatGui,
|
||||
NotificationService lightlessNotification)
|
||||
{
|
||||
_contextMenu = contextMenu;
|
||||
_pluginInterface = pluginInterface;
|
||||
@@ -68,6 +72,8 @@ internal class ContextMenuService : IHostedService
|
||||
_broadcastService = broadcastService;
|
||||
_lightlessProfileManager = lightlessProfileManager;
|
||||
_mediator = mediator;
|
||||
_chatGui = chatGui;
|
||||
_lightlessNotification = lightlessNotification;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
@@ -99,6 +105,12 @@ internal class ContextMenuService : IHostedService
|
||||
if (!_pluginInterface.UiBuilder.ShouldModifyUi)
|
||||
return;
|
||||
|
||||
if (!_configService.Current.EnableRightClickMenus)
|
||||
{
|
||||
_logger.LogTrace("Right-click menus are disabled in configuration.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.AddonName != null)
|
||||
{
|
||||
var addonName = args.AddonName;
|
||||
@@ -198,6 +210,18 @@ internal class ContextMenuService : IHostedService
|
||||
.Where(p => p.IsVisible && p.PlayerCharacterId != uint.MaxValue)
|
||||
.Select(p => (ulong)p.PlayerCharacterId)];
|
||||
|
||||
private void NotifyInChat(string message, NotificationType type = NotificationType.Info)
|
||||
{
|
||||
if (!_configService.Current.UseLightlessNotifications || (_configService.Current.LightlessPairRequestNotification == NotificationLocation.Chat || _configService.Current.LightlessPairRequestNotification == NotificationLocation.ChatAndLightlessUi))
|
||||
{
|
||||
var chatMsg = $"[Lightless] {message}";
|
||||
if (type == NotificationType.Error)
|
||||
_chatGui.PrintError(chatMsg);
|
||||
else
|
||||
_chatGui.Print(chatMsg);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSelection(IMenuArgs args)
|
||||
{
|
||||
if (args.Target is not MenuTargetDefault target)
|
||||
@@ -217,7 +241,7 @@ internal class ContextMenuService : IHostedService
|
||||
return;
|
||||
}
|
||||
|
||||
var senderCid = (await _dalamudUtil.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256();
|
||||
var senderCid = _dalamudUtil.GetCID().ToString().GetHash256();
|
||||
var receiverCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address);
|
||||
|
||||
_logger.LogInformation("Sending pair request: sender {SenderCid}, receiver {ReceiverCid}", senderCid, receiverCid);
|
||||
@@ -226,6 +250,9 @@ internal class ContextMenuService : IHostedService
|
||||
{
|
||||
_pairRequestService.RemoveRequest(receiverCid);
|
||||
}
|
||||
|
||||
// Notify in chat when NotificationService is disabled
|
||||
NotifyInChat($"Pair request sent to {target.TargetName}@{world.Name}.", NotificationType.Info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Dalamud.Game.ClientState.Conditions;
|
||||
using Dalamud.Game.ClientState.Objects;
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Game.ClientState.Objects.Types;
|
||||
using Dalamud.Plugin.Services;
|
||||
@@ -24,6 +23,7 @@ using Microsoft.Extensions.Logging;
|
||||
using System.Numerics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind;
|
||||
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
||||
using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
|
||||
using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags;
|
||||
@@ -37,6 +37,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
private readonly ICondition _condition;
|
||||
private readonly IDataManager _gameData;
|
||||
private readonly IGameConfig _gameConfig;
|
||||
private readonly IPlayerState _playerState;
|
||||
private readonly BlockedCharacterHandler _blockedCharacterHandler;
|
||||
private readonly IFramework _framework;
|
||||
private readonly IGameGui _gameGui;
|
||||
@@ -60,7 +61,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
private Lazy<ulong> _cid;
|
||||
|
||||
public DalamudUtilService(ILogger<DalamudUtilService> logger, IClientState clientState, IObjectTable objectTable, IFramework framework,
|
||||
IGameGui gameGui, ICondition condition, IDataManager gameData, ITargetManager targetManager, IGameConfig gameConfig,
|
||||
IGameGui gameGui, ICondition condition, IDataManager gameData, ITargetManager targetManager, IGameConfig gameConfig, IPlayerState playerState,
|
||||
ActorObjectService actorObjectService, BlockedCharacterHandler blockedCharacterHandler, LightlessMediator mediator, PerformanceCollectorService performanceCollector,
|
||||
LightlessConfigService configService, PlayerPerformanceConfigService playerPerformanceConfigService, Lazy<PairFactory> pairFactory)
|
||||
{
|
||||
@@ -72,6 +73,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
_condition = condition;
|
||||
_gameData = gameData;
|
||||
_gameConfig = gameConfig;
|
||||
_playerState = playerState;
|
||||
_actorObjectService = actorObjectService;
|
||||
_targetManager = targetManager;
|
||||
_blockedCharacterHandler = blockedCharacterHandler;
|
||||
@@ -80,21 +82,27 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
_configService = configService;
|
||||
_playerPerformanceConfigService = playerPerformanceConfigService;
|
||||
_pairFactory = pairFactory;
|
||||
var clientLanguage = _clientState.ClientLanguage;
|
||||
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])))
|
||||
.ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString());
|
||||
});
|
||||
JobData = new(() =>
|
||||
{
|
||||
return gameData.GetExcelSheet<ClassJob>(Dalamud.Game.ClientLanguage.English)!
|
||||
.ToDictionary(k => k.RowId, k => k.NameEnglish.ToString());
|
||||
return gameData.GetExcelSheet<ClassJob>(clientLanguage)!
|
||||
.ToDictionary(k => k.RowId, k => k.Name.ToString());
|
||||
});
|
||||
var clientLanguage = _clientState.ClientLanguage;
|
||||
TerritoryData = new(() => BuildTerritoryData(clientLanguage));
|
||||
TerritoryDataEnglish = new(() => BuildTerritoryData(Dalamud.Game.ClientLanguage.English));
|
||||
MapData = new(() => BuildMapData(clientLanguage));
|
||||
ContentFinderData = new Lazy<Dictionary<uint, string>>(() =>
|
||||
{
|
||||
return _gameData.GetExcelSheet<TerritoryType>()!
|
||||
.Where(w => w.RowId != 0 && !string.IsNullOrEmpty(w.ContentFinderCondition.ValueNullable?.Name.ToString()))
|
||||
.ToDictionary(w => w.RowId, w => w.ContentFinderCondition.Value.Name.ToString());
|
||||
});
|
||||
mediator.Subscribe<TargetPairMessage>(this, (msg) =>
|
||||
{
|
||||
if (clientState.IsPvP) return;
|
||||
@@ -265,6 +273,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
public bool IsAnythingDrawing { get; private set; } = false;
|
||||
public bool IsInCutscene { get; private set; } = false;
|
||||
public bool IsInGpose { get; private set; } = false;
|
||||
public bool IsGameUiHidden => _gameGui.GameUiHidden;
|
||||
public bool IsLoggedIn { get; private set; }
|
||||
public bool IsOnFrameworkThread => _framework.IsInFrameworkUpdateThread;
|
||||
public bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51];
|
||||
@@ -279,6 +288,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
public Lazy<Dictionary<uint, string>> TerritoryData { get; private set; }
|
||||
public Lazy<Dictionary<uint, string>> TerritoryDataEnglish { get; private set; }
|
||||
public Lazy<Dictionary<uint, (Map Map, string MapName)>> MapData { get; private set; }
|
||||
public Lazy<Dictionary<uint, string>> ContentFinderData { get; private set; }
|
||||
public bool IsLodEnabled { get; private set; }
|
||||
public LightlessMediator Mediator { get; }
|
||||
|
||||
@@ -372,7 +382,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
public bool GetIsPlayerPresent()
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
return _objectTable.LocalPlayer != null && _objectTable.LocalPlayer.IsValid();
|
||||
return _objectTable.LocalPlayer != null && _objectTable.LocalPlayer.IsValid() && _playerState.IsLoaded;
|
||||
}
|
||||
|
||||
public async Task<bool> GetIsPlayerPresentAsync()
|
||||
@@ -433,7 +443,22 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
var mgr = CharacterManager.Instance();
|
||||
playerPointer ??= GetPlayerPtr();
|
||||
if (playerPointer == IntPtr.Zero || (IntPtr)mgr == IntPtr.Zero) return IntPtr.Zero;
|
||||
return (IntPtr)mgr->LookupPetByOwnerObject((BattleChara*)playerPointer);
|
||||
|
||||
var ownerAddress = playerPointer.Value;
|
||||
var ownerEntityId = ((Character*)ownerAddress)->EntityId;
|
||||
if (ownerEntityId == 0) return IntPtr.Zero;
|
||||
|
||||
var candidate = (IntPtr)mgr->LookupPetByOwnerObject((BattleChara*)ownerAddress);
|
||||
if (candidate != IntPtr.Zero)
|
||||
{
|
||||
var candidateObj = (GameObject*)candidate;
|
||||
if (IsPetMatch(candidateObj, ownerEntityId))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return FindOwnedPet(ownerEntityId, ownerAddress);
|
||||
}
|
||||
|
||||
public async Task<IntPtr> GetPetAsync(IntPtr? playerPointer = null)
|
||||
@@ -470,6 +495,60 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
private unsafe nint FindOwnedPet(uint ownerEntityId, nint ownerAddress)
|
||||
{
|
||||
if (ownerEntityId == 0)
|
||||
{
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
foreach (var obj in _objectTable)
|
||||
{
|
||||
if (obj is null || obj.Address == nint.Zero || obj.Address == ownerAddress)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidate = (GameObject*)obj.Address;
|
||||
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ResolveOwnerId(candidate) == ownerEntityId)
|
||||
{
|
||||
return obj.Address;
|
||||
}
|
||||
}
|
||||
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
private static unsafe bool IsPetMatch(GameObject* candidate, uint ownerEntityId)
|
||||
{
|
||||
if (candidate == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((DalamudObjectKind)candidate->ObjectKind != DalamudObjectKind.BattleNpc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ResolveOwnerId(candidate) == ownerEntityId;
|
||||
}
|
||||
|
||||
private static unsafe uint ResolveOwnerId(GameObject* gameObject)
|
||||
{
|
||||
if (gameObject == null)
|
||||
@@ -517,34 +596,17 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
|
||||
public string GetPlayerName()
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
return _objectTable.LocalPlayer?.Name.ToString() ?? "--";
|
||||
}
|
||||
|
||||
public async Task<string> GetPlayerNameAsync()
|
||||
{
|
||||
return await RunOnFrameworkThread(GetPlayerName).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ulong> GetCIDAsync()
|
||||
{
|
||||
return await RunOnFrameworkThread(GetCID).ConfigureAwait(false);
|
||||
return _playerState.CharacterName;
|
||||
}
|
||||
|
||||
public unsafe ulong GetCID()
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
var playerChar = GetPlayerCharacter();
|
||||
|
||||
if (playerChar == null || playerChar.Address == IntPtr.Zero)
|
||||
return 0;
|
||||
|
||||
return ((BattleChara*)playerChar.Address)->Character.ContentId;
|
||||
return _playerState.ContentId;
|
||||
}
|
||||
|
||||
public async Task<string> GetPlayerNameHashedAsync()
|
||||
public string GetPlayerNameHashed()
|
||||
{
|
||||
return await RunOnFrameworkThread(() => _cid.Value.ToString().GetHash256()).ConfigureAwait(false);
|
||||
return _cid.Value.ToString().GetHash256();
|
||||
}
|
||||
|
||||
public static unsafe bool TryGetHashedCID(IPlayerCharacter? playerCharacter, out string hashedCid)
|
||||
@@ -583,54 +645,100 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
|
||||
public uint GetHomeWorldId()
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
return _objectTable.LocalPlayer?.HomeWorld.RowId ?? 0;
|
||||
return _playerState.HomeWorld.RowId;
|
||||
}
|
||||
|
||||
public uint GetWorldId()
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
return _objectTable.LocalPlayer!.CurrentWorld.RowId;
|
||||
return _playerState.CurrentWorld.RowId;
|
||||
}
|
||||
|
||||
public unsafe LocationInfo GetMapData()
|
||||
{
|
||||
EnsureIsOnFramework();
|
||||
var agentMap = AgentMap.Instance();
|
||||
var houseMan = HousingManager.Instance();
|
||||
uint serverId = 0;
|
||||
if (_objectTable.LocalPlayer == null) serverId = 0;
|
||||
else serverId = _objectTable.LocalPlayer.CurrentWorld.RowId;
|
||||
uint mapId = agentMap == null ? 0 : agentMap->CurrentMapId;
|
||||
uint territoryId = agentMap == null ? 0 : agentMap->CurrentTerritoryId;
|
||||
uint divisionId = houseMan == null ? 0 : (uint)(houseMan->GetCurrentDivision());
|
||||
uint wardId = houseMan == null ? 0 : (uint)(houseMan->GetCurrentWard() + 1);
|
||||
uint houseId = 0;
|
||||
var tempHouseId = houseMan == null ? 0 : (houseMan->GetCurrentPlot());
|
||||
if (!houseMan->IsInside()) tempHouseId = 0;
|
||||
if (tempHouseId < -1)
|
||||
{
|
||||
divisionId = tempHouseId == -127 ? 2 : (uint)1;
|
||||
tempHouseId = 100;
|
||||
}
|
||||
if (tempHouseId == -1) tempHouseId = 0;
|
||||
houseId = (uint)tempHouseId;
|
||||
if (houseId != 0)
|
||||
{
|
||||
territoryId = HousingManager.GetOriginalHouseTerritoryTypeId();
|
||||
}
|
||||
uint roomId = houseMan == null ? 0 : (uint)(houseMan->GetCurrentRoom());
|
||||
|
||||
return new LocationInfo()
|
||||
var location = new LocationInfo();
|
||||
location.ServerId = _playerState.CurrentWorld.RowId;
|
||||
//location.InstanceId = UIState.Instance()->PublicInstance.InstanceId; //TODO:Need API update first
|
||||
location.TerritoryId = _clientState.TerritoryType;
|
||||
location.MapId = _clientState.MapId;
|
||||
if (houseMan != null)
|
||||
{
|
||||
ServerId = serverId,
|
||||
MapId = mapId,
|
||||
TerritoryId = territoryId,
|
||||
DivisionId = divisionId,
|
||||
WardId = wardId,
|
||||
HouseId = houseId,
|
||||
RoomId = roomId
|
||||
};
|
||||
if (houseMan->IsInside())
|
||||
{
|
||||
location.TerritoryId = HousingManager.GetOriginalHouseTerritoryTypeId();
|
||||
var house = houseMan->GetCurrentIndoorHouseId();
|
||||
location.WardId = house.WardIndex + 1u;
|
||||
location.HouseId = house.IsApartment ? 100 : house.PlotIndex + 1u;
|
||||
location.RoomId = (uint)house.RoomNumber;
|
||||
location.DivisionId = house.IsApartment ? house.ApartmentDivision + 1u : houseMan->GetCurrentDivision();
|
||||
}
|
||||
else if (houseMan->IsInWorkshop())
|
||||
{
|
||||
var workShop = houseMan->WorkshopTerritory;
|
||||
var house = workShop->HouseId;
|
||||
location.WardId = house.WardIndex + 1u;
|
||||
location.HouseId = house.PlotIndex + 1u;
|
||||
}
|
||||
else if (houseMan->IsOutside())
|
||||
{
|
||||
var outside = houseMan->OutdoorTerritory;
|
||||
var house = outside->HouseId;
|
||||
location.WardId = house.WardIndex + 1u;
|
||||
location.HouseId = (uint)houseMan->GetCurrentPlot() + 1;
|
||||
location.DivisionId = houseMan->GetCurrentDivision();
|
||||
}
|
||||
//_logger.LogWarning(LocationToString(location));
|
||||
}
|
||||
return location;
|
||||
}
|
||||
|
||||
public string LocationToString(LocationInfo location)
|
||||
{
|
||||
if (location.ServerId is 0 || location.TerritoryId is 0) return String.Empty;
|
||||
var str = WorldData.Value[(ushort)location.ServerId];
|
||||
|
||||
if (ContentFinderData.Value.TryGetValue(location.TerritoryId , out var dutyName))
|
||||
{
|
||||
str += $" - [In Duty]{dutyName}";
|
||||
}
|
||||
else
|
||||
{
|
||||
if (location.HouseId is not 0 || location.MapId is 0) // Dont show mapName when in house/no map available
|
||||
{
|
||||
str += $" - {TerritoryData.Value[(ushort)location.TerritoryId]}";
|
||||
}
|
||||
else
|
||||
{
|
||||
str += $" - {MapData.Value[(ushort)location.MapId].MapName}";
|
||||
}
|
||||
|
||||
// if (location.InstanceId is not 0)
|
||||
// {
|
||||
// str += ((SeIconChar)(57520 + location.InstanceId)).ToIconString();
|
||||
// }
|
||||
|
||||
if (location.WardId is not 0)
|
||||
{
|
||||
str += $" Ward #{location.WardId}";
|
||||
}
|
||||
|
||||
if (location.HouseId is not 0 and not 100)
|
||||
{
|
||||
str += $" House #{location.HouseId}";
|
||||
}
|
||||
else if (location.HouseId is 100)
|
||||
{
|
||||
str += $" {(location.DivisionId == 2 ? "[Subdivision]" : "")} Apartment";
|
||||
}
|
||||
|
||||
if (location.RoomId is not 0)
|
||||
{
|
||||
str += $" Room #{location.RoomId}";
|
||||
}
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
public unsafe void SetMarkerAndOpenMap(Vector3 position, Map map)
|
||||
@@ -642,21 +750,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
agentMap->SetFlagMapMarker(map.TerritoryType.RowId, map.RowId, position);
|
||||
}
|
||||
|
||||
public async Task<LocationInfo> GetMapDataAsync()
|
||||
{
|
||||
return await RunOnFrameworkThread(GetMapData).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<uint> GetWorldIdAsync()
|
||||
{
|
||||
return await RunOnFrameworkThread(GetWorldId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<uint> GetHomeWorldIdAsync()
|
||||
{
|
||||
return await RunOnFrameworkThread(GetHomeWorldId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public unsafe bool IsGameObjectPresent(IntPtr key)
|
||||
{
|
||||
return _objectTable.Any(f => f.Address == key);
|
||||
|
||||
@@ -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.Globalization;
|
||||
using System.Numerics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using Task = System.Threading.Tasks.Task;
|
||||
|
||||
@@ -41,6 +42,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly PairUiService _pairUiService;
|
||||
private readonly LightlessMediator _mediator;
|
||||
|
||||
public LightlessMediator Mediator => _mediator;
|
||||
|
||||
private readonly IUiBuilder _uiBuilder;
|
||||
@@ -51,6 +53,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
private readonly Lock _labelLock = new();
|
||||
private readonly NameplateBuffers _buffers = new();
|
||||
private int _labelRenderCount;
|
||||
private LightfinderLabelRenderer _lastRenderer;
|
||||
|
||||
private const string _defaultLabelText = "LightFinder";
|
||||
private const SeIconChar _defaultIcon = SeIconChar.Hyadelyn;
|
||||
@@ -60,16 +63,24 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
|
||||
// / Overlay window flags
|
||||
private const ImGuiWindowFlags _overlayFlags =
|
||||
ImGuiWindowFlags.NoDecoration |
|
||||
ImGuiWindowFlags.NoBackground |
|
||||
ImGuiWindowFlags.NoMove |
|
||||
ImGuiWindowFlags.NoSavedSettings |
|
||||
ImGuiWindowFlags.NoNav |
|
||||
ImGuiWindowFlags.NoInputs;
|
||||
ImGuiWindowFlags.NoDecoration |
|
||||
ImGuiWindowFlags.NoBackground |
|
||||
ImGuiWindowFlags.NoMove |
|
||||
ImGuiWindowFlags.NoSavedSettings |
|
||||
ImGuiWindowFlags.NoNav |
|
||||
ImGuiWindowFlags.NoInputs;
|
||||
|
||||
private readonly List<RectF> _uiRects = new(128);
|
||||
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(
|
||||
ILogger<LightFinderPlateHandler> logger,
|
||||
IAddonLifecycle addonLifecycle,
|
||||
@@ -92,7 +103,26 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
_pairUiService = pairUiService;
|
||||
_uiBuilder = pluginInterface.UiBuilder ?? throw new ArgumentNullException(nameof(pluginInterface));
|
||||
_ = pictomancyService ?? throw new ArgumentNullException(nameof(pictomancyService));
|
||||
_lastRenderer = _configService.Current.LightfinderLabelRenderer;
|
||||
}
|
||||
|
||||
private void RefreshRendererState()
|
||||
{
|
||||
var renderer = _configService.Current.LightfinderLabelRenderer;
|
||||
if (renderer == _lastRenderer)
|
||||
return;
|
||||
|
||||
_lastRenderer = renderer;
|
||||
|
||||
if (renderer == LightfinderLabelRenderer.Pictomancy)
|
||||
{
|
||||
FlagRefresh();
|
||||
}
|
||||
else
|
||||
{
|
||||
ClearNameplateCaches();
|
||||
_lastNamePlateDrawFrame = 0;
|
||||
}
|
||||
}
|
||||
|
||||
internal void Init()
|
||||
@@ -164,10 +194,26 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
/// <summary>
|
||||
/// Draw detour for nameplate addon.
|
||||
/// </summary>
|
||||
/// <param name="type"></param>
|
||||
/// <param name="args"></param>
|
||||
private void NameplateDrawDetour(AddonEvent type, AddonArgs args)
|
||||
{
|
||||
RefreshRendererState();
|
||||
if (!IsPictomancyRenderer)
|
||||
{
|
||||
ClearLabelBuffer();
|
||||
_lastNamePlateDrawFrame = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide our overlay when the user hides the entire game UI (ScrollLock).
|
||||
if (_gameGui.GameUiHidden)
|
||||
{
|
||||
ClearLabelBuffer();
|
||||
Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length);
|
||||
_lastNamePlateDrawFrame = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// gpose: do not draw.
|
||||
if (_clientState.IsGPosing)
|
||||
{
|
||||
ClearLabelBuffer();
|
||||
@@ -187,6 +233,10 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
if (fw != null)
|
||||
_lastNamePlateDrawFrame = fw->FrameCounter;
|
||||
|
||||
#if DEBUG
|
||||
DebugLastNameplateFrame = _lastNamePlateDrawFrame;
|
||||
#endif
|
||||
|
||||
var pNameplateAddon = (AddonNamePlate*)args.Addon.Address;
|
||||
|
||||
if (_mpNameplateAddon != pNameplateAddon)
|
||||
@@ -203,6 +253,13 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
/// </summary>
|
||||
private void UpdateNameplateNodes()
|
||||
{
|
||||
// If the user has hidden the UI, don't compute any labels.
|
||||
if (_gameGui.GameUiHidden)
|
||||
{
|
||||
ClearLabelBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
var currentHandle = _gameGui.GetAddonByName("NamePlate");
|
||||
if (currentHandle.Address == nint.Zero)
|
||||
{
|
||||
@@ -266,7 +323,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
|
||||
for (int i = 0; i < safeCount; ++i)
|
||||
{
|
||||
|
||||
var objectInfoPtr = vec[i];
|
||||
if (objectInfoPtr == null)
|
||||
continue;
|
||||
@@ -283,7 +339,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player)
|
||||
continue;
|
||||
|
||||
// CID gating
|
||||
// CID gating - only show for active broadcasters
|
||||
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
|
||||
if (cid == null || !_activeBroadcastingCids.Contains(cid))
|
||||
continue;
|
||||
@@ -319,12 +375,12 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
if (!currentConfig.LightfinderLabelShowHidden && !isNameplateVisible)
|
||||
continue;
|
||||
|
||||
// Prepare label content and scaling
|
||||
var scaleMultiplier = System.Math.Clamp(currentConfig.LightfinderLabelScale, 0.5f, 2.0f);
|
||||
// Prepare label content and scaling factors
|
||||
var scaleMultiplier = Math.Clamp(currentConfig.LightfinderLabelScale, 0.5f, 2.0f);
|
||||
var baseScale = currentConfig.LightfinderLabelUseIcon ? 1.0f : 0.5f;
|
||||
var effectiveScale = baseScale * scaleMultiplier;
|
||||
var baseFontSize = currentConfig.LightfinderLabelUseIcon ? 36f : 24f;
|
||||
var targetFontSize = (int)System.Math.Round(baseFontSize * scaleMultiplier);
|
||||
var targetFontSize = (int)Math.Round(baseFontSize * scaleMultiplier);
|
||||
var labelContent = currentConfig.LightfinderLabelUseIcon
|
||||
? NormalizeIconGlyph(currentConfig.LightfinderLabelIconGlyph)
|
||||
: _defaultLabelText;
|
||||
@@ -332,8 +388,8 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
if (!currentConfig.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal)))
|
||||
labelContent = _defaultLabelText;
|
||||
|
||||
var nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
|
||||
var nodeHeight = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale);
|
||||
var nodeWidth = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
|
||||
var nodeHeight = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale);
|
||||
AlignmentType alignment;
|
||||
|
||||
var textScaleY = nameText->AtkResNode.ScaleY;
|
||||
@@ -343,7 +399,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
var blockHeight = ResolveCache(
|
||||
_buffers.TextHeights,
|
||||
nameplateIndex,
|
||||
System.Math.Abs((int)nameplateObject.TextH),
|
||||
Math.Abs((int)nameplateObject.TextH),
|
||||
() => GetScaledTextHeight(nameText),
|
||||
nodeHeight);
|
||||
|
||||
@@ -353,7 +409,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
(int)nameContainer->Height,
|
||||
() =>
|
||||
{
|
||||
var computed = blockHeight + (int)System.Math.Round(8 * textScaleY);
|
||||
var computed = blockHeight + (int)Math.Round(8 * textScaleY);
|
||||
return computed <= blockHeight ? blockHeight + 1 : computed;
|
||||
},
|
||||
blockHeight + 1);
|
||||
@@ -361,7 +417,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
var blockTop = containerHeight - blockHeight;
|
||||
if (blockTop < 0)
|
||||
blockTop = 0;
|
||||
var verticalPadding = (int)System.Math.Round(4 * effectiveScale);
|
||||
var verticalPadding = (int)Math.Round(4 * effectiveScale);
|
||||
|
||||
var positionY = blockTop - verticalPadding;
|
||||
|
||||
@@ -369,21 +425,14 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
var textWidth = ResolveCache(
|
||||
_buffers.TextWidths,
|
||||
nameplateIndex,
|
||||
System.Math.Abs(rawTextWidth),
|
||||
Math.Abs(rawTextWidth),
|
||||
() => GetScaledTextWidth(nameText),
|
||||
nodeWidth);
|
||||
|
||||
// Text offset caching
|
||||
var textOffset = (int)System.Math.Round(nameText->AtkResNode.X);
|
||||
var textOffset = (int)Math.Round(nameText->AtkResNode.X);
|
||||
var hasValidOffset = TryCacheTextOffset(nameplateIndex, rawTextWidth, textOffset);
|
||||
|
||||
if (nameContainer == null)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
_logger.LogDebug("Nameplate {Index} container became unavailable during update, skipping.", nameplateIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
var res = nameContainer;
|
||||
|
||||
// X scale
|
||||
@@ -419,7 +468,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
|
||||
var offsetXScreen = currentConfig.LightfinderLabelOffsetX * worldScaleX;
|
||||
|
||||
// alignment based on config
|
||||
// alignment based on config setting
|
||||
switch (currentConfig.LabelAlignment)
|
||||
{
|
||||
case LabelAlignment.Left:
|
||||
@@ -438,7 +487,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
}
|
||||
else
|
||||
{
|
||||
// manual X positioning
|
||||
// manual X positioning with optional cached offset
|
||||
var cachedTextOffset = _buffers.TextOffsets[nameplateIndex];
|
||||
var hasCachedOffset = cachedTextOffset != int.MinValue;
|
||||
var baseOffsetXLocal = (!currentConfig.LightfinderLabelUseIcon && hasValidOffset && hasCachedOffset)
|
||||
@@ -458,16 +507,16 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
|
||||
// final position before smoothing
|
||||
var finalPosition = new Vector2(finalX, res->ScreenY + positionYScreen);
|
||||
var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; // often same for Y
|
||||
var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X;
|
||||
var fw = Framework.Instance();
|
||||
float dt = fw->RealFrameDeltaTime;
|
||||
|
||||
//smoothing..
|
||||
//smoothing.. snap.. smooth.. snap
|
||||
finalPosition = SnapToPixels(finalPosition, dpiScale);
|
||||
finalPosition = SmoothPosition(nameplateIndex, finalPosition, dt);
|
||||
finalPosition = SnapToPixels(finalPosition, dpiScale);
|
||||
|
||||
// prepare label info
|
||||
// prepare label info for rendering
|
||||
var pivot = (currentConfig.LightfinderAutoAlign || currentConfig.LightfinderLabelUseIcon)
|
||||
? AlignmentToPivot(alignment)
|
||||
: _defaultPivot;
|
||||
@@ -503,6 +552,10 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
/// </summary>
|
||||
private void OnUiBuilderDraw()
|
||||
{
|
||||
RefreshRendererState();
|
||||
if (!IsPictomancyRenderer)
|
||||
return;
|
||||
|
||||
if (!_mEnabled)
|
||||
return;
|
||||
|
||||
@@ -510,7 +563,23 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
if (fw == null)
|
||||
return;
|
||||
|
||||
// Frame skip check
|
||||
// If UI is hidden, do not render.
|
||||
if (_gameGui.GameUiHidden)
|
||||
{
|
||||
ClearLabelBuffer();
|
||||
Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length);
|
||||
_lastNamePlateDrawFrame = 0;
|
||||
|
||||
#if DEBUG
|
||||
DebugLabelCountLastFrame = 0;
|
||||
DebugUiRectCountLastFrame = 0;
|
||||
DebugOccludedCountLastFrame = 0;
|
||||
DebugLastNameplateFrame = 0;
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
// Frame skip check - skip if more than 1 frame has passed since last nameplate draw.
|
||||
var frame = fw->FrameCounter;
|
||||
|
||||
if (_lastNamePlateDrawFrame == 0 || (frame - _lastNamePlateDrawFrame) > 1)
|
||||
@@ -518,34 +587,62 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
ClearLabelBuffer();
|
||||
Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length);
|
||||
|
||||
#if DEBUG
|
||||
DebugLabelCountLastFrame = 0;
|
||||
DebugUiRectCountLastFrame = 0;
|
||||
DebugOccludedCountLastFrame = 0;
|
||||
DebugLastNameplateFrame = _lastNamePlateDrawFrame;
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
//Gpose Check
|
||||
// Gpose Check - do not render.
|
||||
if (_clientState.IsGPosing)
|
||||
{
|
||||
ClearLabelBuffer();
|
||||
Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length);
|
||||
_lastNamePlateDrawFrame = 0;
|
||||
|
||||
#if DEBUG
|
||||
DebugLabelCountLastFrame = 0;
|
||||
DebugUiRectCountLastFrame = 0;
|
||||
DebugOccludedCountLastFrame = 0;
|
||||
DebugLastNameplateFrame = 0;
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
// If nameplate addon is not visible, skip rendering
|
||||
// If nameplate addon is not visible, skip rendering entirely.
|
||||
if (!IsNamePlateAddonVisible())
|
||||
{
|
||||
#if DEBUG
|
||||
DebugLabelCountLastFrame = 0;
|
||||
DebugUiRectCountLastFrame = 0;
|
||||
DebugOccludedCountLastFrame = 0;
|
||||
DebugLastNameplateFrame = _lastNamePlateDrawFrame;
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
int copyCount;
|
||||
lock (_labelLock)
|
||||
{
|
||||
copyCount = _labelRenderCount;
|
||||
if (copyCount == 0)
|
||||
{
|
||||
#if DEBUG
|
||||
DebugLabelCountLastFrame = 0;
|
||||
DebugUiRectCountLastFrame = 0;
|
||||
DebugOccludedCountLastFrame = 0;
|
||||
DebugLastNameplateFrame = _lastNamePlateDrawFrame;
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
Array.Copy(_buffers.LabelRender, _buffers.LabelCopy, copyCount);
|
||||
}
|
||||
|
||||
var uiModule = fw != null ? fw->GetUIModule() : null;
|
||||
|
||||
var uiModule = fw->GetUIModule();
|
||||
if (uiModule != null)
|
||||
{
|
||||
var rapture = uiModule->GetRaptureAtkModule();
|
||||
@@ -564,7 +661,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
var vpPos = vp.Pos;
|
||||
|
||||
ImGuiHelpers.ForceNextWindowMainViewport();
|
||||
|
||||
ImGui.SetNextWindowPos(vp.Pos);
|
||||
ImGui.SetNextWindowSize(vp.Size);
|
||||
|
||||
@@ -575,54 +671,118 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
|
||||
ImGui.PopStyleVar(2);
|
||||
|
||||
using var drawList = PictoService.Draw();
|
||||
if (drawList == null)
|
||||
// Debug flags
|
||||
bool dbgEnabled = false;
|
||||
bool dbgDisableOcc = false;
|
||||
bool dbgDrawUiRects = false;
|
||||
bool dbgDrawLabelRects = false;
|
||||
#if DEBUG
|
||||
dbgEnabled = DebugEnabled;
|
||||
dbgDisableOcc = DebugDisableOcclusion;
|
||||
dbgDrawUiRects = DebugDrawUiRects;
|
||||
dbgDrawLabelRects = DebugDrawLabelRects;
|
||||
#endif
|
||||
|
||||
int occludedThisFrame = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var drawList = PictoService.Draw();
|
||||
if (drawList == null)
|
||||
return;
|
||||
|
||||
// Debug drawing uses the window drawlist (so it always draws in the correct viewport).
|
||||
var dbgDl = ImGui.GetWindowDrawList();
|
||||
var useViewportOffset = ImGui.GetIO().ConfigFlags.HasFlag(ImGuiConfigFlags.ViewportsEnable);
|
||||
|
||||
for (int i = 0; i < copyCount; ++i)
|
||||
{
|
||||
ref var info = ref _buffers.LabelCopy[i];
|
||||
|
||||
// final draw position with viewport offset (only when viewports are enabled)
|
||||
var drawPos = info.ScreenPosition;
|
||||
if (useViewportOffset)
|
||||
drawPos += vpPos;
|
||||
|
||||
var font = default(ImFontPtr);
|
||||
if (info.UseIcon)
|
||||
{
|
||||
var ioFonts = ImGui.GetIO().Fonts;
|
||||
font = ioFonts.Fonts.Size > 1 ? new ImFontPtr(ioFonts.Fonts[1]) : ImGui.GetFont();
|
||||
}
|
||||
else
|
||||
{
|
||||
font = ImGui.GetFont();
|
||||
}
|
||||
|
||||
if (!font.IsNull)
|
||||
ImGui.PushFont(font);
|
||||
|
||||
// calculate size for occlusion checking
|
||||
var baseSize = ImGui.CalcTextSize(info.Text);
|
||||
var baseFontSize = ImGui.GetFontSize();
|
||||
|
||||
if (!font.IsNull)
|
||||
ImGui.PopFont();
|
||||
|
||||
// scale size based on font size
|
||||
var scale = baseFontSize > 0 ? (info.FontSize / baseFontSize) : 1f;
|
||||
var size = baseSize * scale;
|
||||
|
||||
var topLeft = info.ScreenPosition - new Vector2(size.X * info.Pivot.X, size.Y * info.Pivot.Y);
|
||||
var labelRect = new RectF(topLeft.X, topLeft.Y, topLeft.X + size.X, topLeft.Y + size.Y);
|
||||
|
||||
bool wouldOcclude = IsOccludedByAnyUi(labelRect);
|
||||
if (wouldOcclude)
|
||||
occludedThisFrame++;
|
||||
|
||||
// Debug: draw label rects
|
||||
if (dbgEnabled && dbgDrawLabelRects)
|
||||
{
|
||||
var tl = new Vector2(labelRect.L, labelRect.T);
|
||||
var br = new Vector2(labelRect.R, labelRect.B);
|
||||
|
||||
if (useViewportOffset) { tl += vpPos; br += vpPos; }
|
||||
|
||||
// green = visible, red = would be occluded (even if forced)
|
||||
var col = wouldOcclude
|
||||
? ImGui.GetColorU32(new Vector4(1f, 0f, 0f, 0.6f))
|
||||
: ImGui.GetColorU32(new Vector4(0f, 1f, 0f, 0.6f));
|
||||
|
||||
dbgDl.AddRect(tl, br, col);
|
||||
}
|
||||
|
||||
// occlusion check (allow debug to disable)
|
||||
if (!dbgDisableOcc && wouldOcclude)
|
||||
continue;
|
||||
|
||||
drawList.AddScreenText(drawPos, info.Text, info.TextColor, info.FontSize, info.Pivot, info.EdgeColor, font);
|
||||
}
|
||||
|
||||
// Debug: draw UI rects if any
|
||||
if (dbgEnabled && dbgDrawUiRects && _uiRects.Count > 0)
|
||||
{
|
||||
var useOff = useViewportOffset ? vpPos : Vector2.Zero;
|
||||
var col = ImGui.GetColorU32(new Vector4(1f, 1f, 1f, 0.35f));
|
||||
|
||||
for (int i = 0; i < _uiRects.Count; i++)
|
||||
{
|
||||
var r = _uiRects[i];
|
||||
dbgDl.AddRect(new Vector2(r.L, r.T) + useOff, new Vector2(r.R, r.B) + useOff, col);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ImGui.End();
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < copyCount; ++i)
|
||||
{
|
||||
ref var info = ref _buffers.LabelCopy[i];
|
||||
|
||||
// final draw position with viewport offset
|
||||
var drawPos = info.ScreenPosition + vpPos;
|
||||
var font = default(ImFontPtr);
|
||||
if (info.UseIcon)
|
||||
{
|
||||
var ioFonts = ImGui.GetIO().Fonts;
|
||||
font = ioFonts.Fonts.Size > 1 ? new ImFontPtr(ioFonts.Fonts[1]) : ImGui.GetFont();
|
||||
}
|
||||
else
|
||||
{
|
||||
font = ImGui.GetFont();
|
||||
}
|
||||
|
||||
if (!font.IsNull)
|
||||
ImGui.PushFont(font);
|
||||
|
||||
// calculate size for occlusion checking
|
||||
var baseSize = ImGui.CalcTextSize(info.Text);
|
||||
var baseFontSize = ImGui.GetFontSize();
|
||||
|
||||
if (!font.IsNull)
|
||||
ImGui.PopFont();
|
||||
|
||||
// scale size based on font size
|
||||
var scale = baseFontSize > 0 ? (info.FontSize / baseFontSize) : 1f;
|
||||
var size = baseSize * scale;
|
||||
|
||||
// label rect for occlusion checking
|
||||
var topLeft = info.ScreenPosition - new Vector2(size.X * info.Pivot.X, size.Y * info.Pivot.Y);
|
||||
var labelRect = new RectF(topLeft.X, topLeft.Y, topLeft.X + size.X, topLeft.Y + size.Y);
|
||||
|
||||
// occlusion check
|
||||
if (IsOccludedByAnyUi(labelRect))
|
||||
continue;
|
||||
|
||||
drawList.AddScreenText(drawPos, info.Text, info.TextColor, info.FontSize, info.Pivot, info.EdgeColor, font);
|
||||
}
|
||||
#if DEBUG
|
||||
DebugLabelCountLastFrame = copyCount;
|
||||
DebugUiRectCountLastFrame = _uiRects.Count;
|
||||
DebugOccludedCountLastFrame = occludedThisFrame;
|
||||
DebugLastNameplateFrame = _lastNamePlateDrawFrame;
|
||||
#endif
|
||||
}
|
||||
|
||||
private static Vector2 AlignmentToPivot(AlignmentType alignment) => alignment switch
|
||||
@@ -670,8 +830,8 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
if (scale <= 0f)
|
||||
scale = 1f;
|
||||
|
||||
var computed = (int)System.Math.Round(rawHeight * scale);
|
||||
return System.Math.Max(1, computed);
|
||||
var computed = (int)Math.Round(rawHeight * scale);
|
||||
return Math.Max(1, computed);
|
||||
}
|
||||
|
||||
private static unsafe int GetScaledTextWidth(AtkTextNode* node)
|
||||
@@ -695,12 +855,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
/// <summary>
|
||||
/// Resolves a cached value for the given index.
|
||||
/// </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(
|
||||
int[] cache,
|
||||
int index,
|
||||
@@ -740,9 +894,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
/// <summary>
|
||||
/// Snapping a position to pixel grid based on DPI scale.
|
||||
/// </summary>
|
||||
/// <param name="p">Position</param>
|
||||
/// <param name="dpiScale">DPI Scale</param>
|
||||
/// <returns></returns>
|
||||
private static Vector2 SnapToPixels(Vector2 p, float dpiScale)
|
||||
{
|
||||
// snap to pixel grid
|
||||
@@ -751,15 +902,9 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
return new Vector2(x, y);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Smooths the position using exponential smoothing.
|
||||
/// </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)
|
||||
{
|
||||
// exponential smoothing
|
||||
@@ -777,7 +922,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
var a = 1f - MathF.Exp(-responsiveness * dt);
|
||||
|
||||
// snap if close enough
|
||||
if (Vector2.DistanceSquared(cur, target) < 0.25f)
|
||||
if (Vector2.DistanceSquared(cur, target) < 0.25f)
|
||||
return cur;
|
||||
|
||||
// lerp towards target
|
||||
@@ -786,73 +931,193 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
return cur;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get a valid screen rect for the given addon.
|
||||
/// </summary>
|
||||
/// <param name="addon">Addon UI</param>
|
||||
/// <param name="screen">Screen positioning/param>
|
||||
/// <param name="rect">RectF of Addon</param>
|
||||
/// <returns></returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static bool IsFinite(float f) => !(float.IsNaN(f) || float.IsInfinity(f));
|
||||
|
||||
private static bool TryGetAddonRect(AtkUnitBase* addon, Vector2 screen, out RectF rect)
|
||||
{
|
||||
// Addon existence
|
||||
rect = default;
|
||||
if (addon == null)
|
||||
return false;
|
||||
|
||||
// Visibility check
|
||||
// Addon must be visible
|
||||
if (!addon->IsVisible)
|
||||
return false;
|
||||
|
||||
// Root must be visible
|
||||
var root = addon->RootNode;
|
||||
if (root == null || !root->IsVisible())
|
||||
return false;
|
||||
|
||||
// Size check
|
||||
float w = root->Width;
|
||||
float h = root->Height;
|
||||
if (w <= 0 || h <= 0)
|
||||
// Must have multiple nodes to be useful
|
||||
var nodeCount = addon->UldManager.NodeListCount;
|
||||
var nodeList = addon->UldManager.NodeList;
|
||||
if (nodeCount <= 1 || nodeList == null)
|
||||
return false;
|
||||
|
||||
// Local scale
|
||||
float sx = root->ScaleX; if (sx <= 0f) sx = 1f;
|
||||
float sy = root->ScaleY; if (sy <= 0f) sy = 1f;
|
||||
float rsx = GetWorldScaleX(root);
|
||||
float rsy = GetWorldScaleY(root);
|
||||
if (!IsFinite(rsx) || rsx <= 0f) rsx = 1f;
|
||||
if (!IsFinite(rsy) || rsy <= 0f) rsy = 1f;
|
||||
|
||||
// World/composed scale from Transform
|
||||
float wsx = GetWorldScaleX(root);
|
||||
float wsy = GetWorldScaleY(root);
|
||||
if (wsx <= 0f) wsx = 1f;
|
||||
if (wsy <= 0f) wsy = 1f;
|
||||
// clamp insane root scales (rare but prevents explosions)
|
||||
rsx = MathF.Min(rsx, 6f);
|
||||
rsy = MathF.Min(rsy, 6f);
|
||||
|
||||
// World scale may include parent scaling; use it if meaningfully different.
|
||||
float useX = MathF.Abs(wsx - sx) > 0.01f ? wsx : sx;
|
||||
float useY = MathF.Abs(wsy - sy) > 0.01f ? wsy : sy;
|
||||
|
||||
w *= useX;
|
||||
h *= useY;
|
||||
|
||||
if (w < 4f || h < 4f)
|
||||
float rw = root->Width * rsx;
|
||||
float rh = root->Height * rsy;
|
||||
if (!IsFinite(rw) || !IsFinite(rh) || rw <= 2f || rh <= 2f)
|
||||
return false;
|
||||
|
||||
// Screen coords
|
||||
float l = root->ScreenX;
|
||||
float t = root->ScreenY;
|
||||
float r = l + w;
|
||||
float b = t + h;
|
||||
|
||||
// Drop fullscreen-ish / insane rects
|
||||
if (w >= screen.X * 0.98f && h >= screen.Y * 0.98f)
|
||||
float rl = root->ScreenX;
|
||||
float rt = root->ScreenY;
|
||||
if (!IsFinite(rl) || !IsFinite(rt))
|
||||
return false;
|
||||
|
||||
// Drop offscreen rects
|
||||
if (l < -screen.X || t < -screen.Y || r > screen.X * 2f || b > screen.Y * 2f)
|
||||
float rr = rl + rw;
|
||||
float rb = rt + rh;
|
||||
|
||||
// If root is basically fullscreen, it<69>s not a useful occluder for our purpose.
|
||||
if (rw >= screen.X * 0.98f && rh >= screen.Y * 0.98f)
|
||||
return false;
|
||||
|
||||
// Clip root to screen so it stays sane
|
||||
float rootL = MathF.Max(0f, rl);
|
||||
float rootT = MathF.Max(0f, rt);
|
||||
float rootR = MathF.Min(screen.X, rr);
|
||||
float rootB = MathF.Min(screen.Y, rb);
|
||||
if (rootR <= rootL || rootB <= rootT)
|
||||
return false;
|
||||
|
||||
// Root dimensions
|
||||
var rootW = rootR - rootL;
|
||||
var rootH = rootB - rootT;
|
||||
|
||||
// Find union of all probably-drawable nodes intersecting root
|
||||
bool any = false;
|
||||
float l = float.MaxValue, t = float.MaxValue, r = float.MinValue, b = float.MinValue;
|
||||
|
||||
// Allow a small bleed outside root; some addons draw small bits outside their root container.
|
||||
const float rootPad = 24f;
|
||||
float padL = rootL - rootPad;
|
||||
float padT = rootT - rootPad;
|
||||
float padR = rootR + rootPad;
|
||||
float padB = rootB + rootPad;
|
||||
|
||||
for (int i = 1; i < nodeCount; i++)
|
||||
{
|
||||
var n = nodeList[i];
|
||||
if (!IsProbablyDrawableNode(n))
|
||||
continue;
|
||||
|
||||
float w = n->Width;
|
||||
float h = n->Height;
|
||||
if (!IsFinite(w) || !IsFinite(h) || w <= 1f || h <= 1f)
|
||||
continue;
|
||||
|
||||
float sx = GetWorldScaleX(n);
|
||||
float sy = GetWorldScaleY(n);
|
||||
|
||||
if (!IsFinite(sx) || sx <= 0f) sx = 1f;
|
||||
if (!IsFinite(sy) || sy <= 0f) sy = 1f;
|
||||
|
||||
sx = MathF.Min(sx, 6f);
|
||||
sy = MathF.Min(sy, 6f);
|
||||
|
||||
w *= sx;
|
||||
h *= sy;
|
||||
|
||||
if (!IsFinite(w) || !IsFinite(h) || w < 2f || h < 2f)
|
||||
continue;
|
||||
|
||||
float nl = n->ScreenX;
|
||||
float nt = n->ScreenY;
|
||||
if (!IsFinite(nl) || !IsFinite(nt))
|
||||
continue;
|
||||
|
||||
float nr = nl + w;
|
||||
float nb = nt + h;
|
||||
|
||||
// Must intersect root (with padding). This is the big mitigation.
|
||||
if (nr <= padL || nb <= padT || nl >= padR || nt >= padB)
|
||||
continue;
|
||||
|
||||
// Reject nodes that are wildly larger than the root (common on targeting).
|
||||
if (w > rootW * 2.0f || h > rootH * 2.0f)
|
||||
continue;
|
||||
|
||||
// Clip node to root and then to screen (prevents offscreen junk stretching union)
|
||||
float cl = MathF.Max(rootL, nl);
|
||||
float ct = MathF.Max(rootT, nt);
|
||||
float cr = MathF.Min(rootR, nr);
|
||||
float cb = MathF.Min(rootB, nb);
|
||||
|
||||
cl = MathF.Max(0f, cl);
|
||||
ct = MathF.Max(0f, ct);
|
||||
cr = MathF.Min(screen.X, cr);
|
||||
cb = MathF.Min(screen.Y, cb);
|
||||
|
||||
if (cr <= cl || cb <= ct)
|
||||
continue;
|
||||
|
||||
any = true;
|
||||
if (cl < l) l = cl;
|
||||
if (ct < t) t = ct;
|
||||
if (cr > r) r = cr;
|
||||
if (cb > b) b = cb;
|
||||
}
|
||||
|
||||
// If nothing usable, fallback to root rect (still a sane occluder)
|
||||
if (!any)
|
||||
{
|
||||
rect = new RectF(rootL, rootT, rootR, rootB);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Validate final union rect
|
||||
var uw = r - l;
|
||||
var uh = b - t;
|
||||
if (uw < 4f || uh < 4f)
|
||||
{
|
||||
rect = new RectF(rootL, rootT, rootR, rootB);
|
||||
return true;
|
||||
}
|
||||
|
||||
// If union is excessively larger than root, fallback to root rect
|
||||
if (uw > rootW * 1.35f || uh > rootH * 1.35f)
|
||||
{
|
||||
rect = new RectF(rootL, rootT, rootR, rootB);
|
||||
return true;
|
||||
}
|
||||
|
||||
rect = new RectF(l, t, r, b);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsProbablyDrawableNode(AtkResNode* n)
|
||||
{
|
||||
if (n == null || !n->IsVisible())
|
||||
return false;
|
||||
|
||||
// Check alpha
|
||||
if (n->Color.A == 16)
|
||||
return false;
|
||||
|
||||
// Check node type
|
||||
return n->Type switch
|
||||
{
|
||||
NodeType.Text => true,
|
||||
NodeType.Image => true,
|
||||
NodeType.NineGrid => true,
|
||||
NodeType.Counter => true,
|
||||
NodeType.Component => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the cached UI rects for occlusion checking.
|
||||
/// </summary>
|
||||
/// <param name="unitMgr">Unit Manager</param>
|
||||
private void RefreshUiRects(RaptureAtkUnitManager* unitMgr)
|
||||
{
|
||||
_uiRects.Clear();
|
||||
@@ -876,13 +1141,15 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
if (TryGetAddonRect(addon, screen, out var r))
|
||||
_uiRects.Add(r);
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
DebugUiRectCountLastFrame = _uiRects.Count;
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Is the given label rect occluded by any UI rects?
|
||||
/// </summary>
|
||||
/// <param name="labelRect">UI/Label Rect</param>
|
||||
/// <returns>Is occluded or not</returns>
|
||||
private bool IsOccludedByAnyUi(RectF labelRect)
|
||||
{
|
||||
for (int i = 0; i < _uiRects.Count; i++)
|
||||
@@ -896,8 +1163,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
/// <summary>
|
||||
/// Gets the world scale X of the given node.
|
||||
/// </summary>
|
||||
/// <param name="n">Node</param>
|
||||
/// <returns>World Scale of node</returns>
|
||||
private static float GetWorldScaleX(AtkResNode* n)
|
||||
{
|
||||
var t = n->Transform;
|
||||
@@ -907,8 +1172,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
/// <summary>
|
||||
/// Gets the world scale Y of the given node.
|
||||
/// </summary>
|
||||
/// <param name="n">Node</param>
|
||||
/// <returns>World Scale of node</returns>
|
||||
private static float GetWorldScaleY(AtkResNode* n)
|
||||
{
|
||||
var t = n->Transform;
|
||||
@@ -918,8 +1181,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
/// <summary>
|
||||
/// Normalize an icon glyph input into a valid string.
|
||||
/// </summary>
|
||||
/// <param name="rawInput">Raw glyph input</param>
|
||||
/// <returns>Normalized glyph input</returns>
|
||||
internal static string NormalizeIconGlyph(string? rawInput)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawInput))
|
||||
@@ -947,7 +1208,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
/// <summary>
|
||||
/// Is the nameplate addon visible?
|
||||
/// </summary>
|
||||
/// <returns>Is it visible?</returns>
|
||||
private bool IsNamePlateAddonVisible()
|
||||
{
|
||||
if (_mpNameplateAddon == null)
|
||||
@@ -957,20 +1217,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
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
|
||||
{
|
||||
public NameplateLabelInfo(
|
||||
@@ -1008,6 +1254,15 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
|
||||
.Select(u => (ulong)u.PlayerCharacterId)];
|
||||
|
||||
public int DebugLabelCountLastFrame { get; set; }
|
||||
public int DebugUiRectCountLastFrame { get; set; }
|
||||
public int DebugOccludedCountLastFrame { get; set; }
|
||||
public uint DebugLastNameplateFrame { get; set; }
|
||||
public bool DebugDrawUiRects { get; set; }
|
||||
public bool DebugDrawLabelRects { get; set; } = true;
|
||||
public bool DebugDisableOcclusion { get; set; }
|
||||
public bool DebugEnabled { get; set; }
|
||||
|
||||
public void FlagRefresh()
|
||||
{
|
||||
_needsLabelRefresh = true;
|
||||
@@ -1015,6 +1270,12 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
|
||||
public void OnTick(PriorityFrameworkUpdateMessage _)
|
||||
{
|
||||
if (!IsPictomancyRenderer)
|
||||
{
|
||||
_needsLabelRefresh = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_needsLabelRefresh)
|
||||
{
|
||||
UpdateNameplateNodes();
|
||||
@@ -1025,7 +1286,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
/// <summary>
|
||||
/// Update the active broadcasting CIDs.
|
||||
/// </summary>
|
||||
/// <param name="cids">Inbound new CIDs</param>
|
||||
public void UpdateBroadcastingCids(IEnumerable<string> cids)
|
||||
{
|
||||
var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal);
|
||||
@@ -1055,7 +1315,7 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
public NameplateBuffers()
|
||||
{
|
||||
TextOffsets = new int[AddonNamePlate.NumNamePlateObjects];
|
||||
System.Array.Fill(TextOffsets, int.MinValue);
|
||||
Array.Fill(TextOffsets, int.MinValue);
|
||||
}
|
||||
|
||||
public int[] TextWidths { get; } = new int[AddonNamePlate.NumNamePlateObjects];
|
||||
@@ -1067,23 +1327,20 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
public NameplateLabelInfo[] LabelCopy { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects];
|
||||
|
||||
public Vector2[] SmoothedPos = new Vector2[AddonNamePlate.NumNamePlateObjects];
|
||||
|
||||
public bool[] HasSmoothed = new bool[AddonNamePlate.NumNamePlateObjects];
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
System.Array.Clear(TextWidths, 0, TextWidths.Length);
|
||||
System.Array.Clear(TextHeights, 0, TextHeights.Length);
|
||||
System.Array.Clear(ContainerHeights, 0, ContainerHeights.Length);
|
||||
System.Array.Fill(TextOffsets, int.MinValue);
|
||||
Array.Clear(TextWidths, 0, TextWidths.Length);
|
||||
Array.Clear(TextHeights, 0, TextHeights.Length);
|
||||
Array.Clear(ContainerHeights, 0, ContainerHeights.Length);
|
||||
Array.Fill(TextOffsets, int.MinValue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the LightFinder Plate Handler.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation Token</param>
|
||||
/// <returns>Task Completed</returns>
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Init();
|
||||
@@ -1093,8 +1350,6 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
/// <summary>
|
||||
/// Stops the LightFinder Plate Handler.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation Token</param>
|
||||
/// <returns>Task Completed</returns>
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Uninit();
|
||||
@@ -1113,4 +1368,4 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe
|
||||
public bool Intersects(in RectF o) =>
|
||||
!(R <= o.L || o.R <= L || B <= o.T || o.B <= T);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
|
||||
private readonly LightFinderService _broadcastService;
|
||||
private readonly LightFinderPlateHandler _lightFinderPlateHandler;
|
||||
private readonly LightFinderNativePlateHandler _lightFinderNativePlateHandler;
|
||||
|
||||
private readonly ConcurrentDictionary<string, BroadcastEntry> _broadcastCache = new(StringComparer.Ordinal);
|
||||
private readonly Queue<string> _lookupQueue = new();
|
||||
private readonly HashSet<string> _lookupQueuedCids = [];
|
||||
private readonly HashSet<string> _syncshellCids = [];
|
||||
private volatile bool _pendingLocalBroadcast;
|
||||
private TimeSpan? _pendingLocalTtl;
|
||||
|
||||
private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4);
|
||||
private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1);
|
||||
@@ -42,12 +45,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
LightFinderService broadcastService,
|
||||
LightlessMediator mediator,
|
||||
LightFinderPlateHandler lightFinderPlateHandler,
|
||||
LightFinderNativePlateHandler lightFinderNativePlateHandler,
|
||||
ActorObjectService actorTracker) : base(logger, mediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_actorTracker = actorTracker;
|
||||
_broadcastService = broadcastService;
|
||||
_lightFinderPlateHandler = lightFinderPlateHandler;
|
||||
_lightFinderNativePlateHandler = lightFinderNativePlateHandler;
|
||||
|
||||
_logger = logger;
|
||||
_framework = framework;
|
||||
@@ -69,6 +74,8 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
if (!_broadcastService.IsBroadcasting)
|
||||
return;
|
||||
|
||||
TryPrimeLocalBroadcastCache();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var address in _actorTracker.PlayerAddresses)
|
||||
@@ -129,6 +136,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
.ToList();
|
||||
|
||||
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
|
||||
_lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids);
|
||||
UpdateSyncshellBroadcasts();
|
||||
}
|
||||
|
||||
@@ -140,9 +148,45 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
_lookupQueue.Clear();
|
||||
_lookupQueuedCids.Clear();
|
||||
_syncshellCids.Clear();
|
||||
_pendingLocalBroadcast = false;
|
||||
_pendingLocalTtl = null;
|
||||
|
||||
_lightFinderPlateHandler.UpdateBroadcastingCids([]);
|
||||
_lightFinderNativePlateHandler.UpdateBroadcastingCids([]);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingLocalBroadcast = true;
|
||||
_pendingLocalTtl = msg.Ttl;
|
||||
TryPrimeLocalBroadcastCache();
|
||||
}
|
||||
|
||||
private void TryPrimeLocalBroadcastCache()
|
||||
{
|
||||
if (!_pendingLocalBroadcast)
|
||||
return;
|
||||
|
||||
if (!TryGetLocalHashedCid(out var localCid))
|
||||
return;
|
||||
|
||||
var ttl = _pendingLocalTtl ?? _maxAllowedTtl;
|
||||
var expiry = DateTime.UtcNow + ttl;
|
||||
|
||||
_broadcastCache.AddOrUpdate(localCid,
|
||||
new BroadcastEntry(true, expiry, null),
|
||||
(_, old) => new BroadcastEntry(true, expiry, old.GID));
|
||||
|
||||
_pendingLocalBroadcast = false;
|
||||
_pendingLocalTtl = null;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var activeCids = _broadcastCache
|
||||
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now)
|
||||
.Select(e => e.Key)
|
||||
.ToList();
|
||||
|
||||
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
|
||||
_lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids);
|
||||
}
|
||||
|
||||
private void UpdateSyncshellBroadcasts()
|
||||
|
||||
@@ -67,7 +67,7 @@ public class LightFinderService : IHostedService, IMediatorSubscriber
|
||||
{
|
||||
try
|
||||
{
|
||||
var cid = await _dalamudUtil.GetCIDAsync().ConfigureAwait(false);
|
||||
var cid = _dalamudUtil.GetCID();
|
||||
return cid.ToString().GetHash256();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -2,10 +2,8 @@ using Dalamud.Game.ClientState.Objects.Enums;
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Game.NativeWrapper;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Dalamud.Hooking;
|
||||
using Dalamud.Plugin.Services;
|
||||
using Dalamud.Utility;
|
||||
using Dalamud.Utility.Signatures;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
@@ -24,27 +22,22 @@ namespace LightlessSync.Services;
|
||||
/// </summary>
|
||||
public unsafe class NameplateService : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private delegate nint UpdateNameplateDelegate(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex);
|
||||
|
||||
// Glyceri, Thanks :bow:
|
||||
[Signature("40 53 55 57 41 56 48 81 EC ?? ?? ?? ?? 48 8B 84 24", DetourName = nameof(UpdateNameplateDetour))]
|
||||
private readonly Hook<UpdateNameplateDelegate>? _nameplateHook = null;
|
||||
|
||||
private readonly ILogger<NameplateService> _logger;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly IClientState _clientState;
|
||||
private readonly IGameGui _gameGui;
|
||||
private readonly IObjectTable _objectTable;
|
||||
private readonly PairUiService _pairUiService;
|
||||
private readonly NameplateUpdateHookService _nameplateUpdateHookService;
|
||||
|
||||
public NameplateService(ILogger<NameplateService> logger,
|
||||
LightlessConfigService configService,
|
||||
IClientState clientState,
|
||||
IGameGui gameGui,
|
||||
IObjectTable objectTable,
|
||||
IGameInteropProvider interop,
|
||||
LightlessMediator lightlessMediator,
|
||||
PairUiService pairUiService) : base(logger, lightlessMediator)
|
||||
PairUiService pairUiService,
|
||||
NameplateUpdateHookService nameplateUpdateHookService) : base(logger, lightlessMediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
@@ -52,21 +45,18 @@ public unsafe class NameplateService : DisposableMediatorSubscriberBase
|
||||
_gameGui = gameGui;
|
||||
_objectTable = objectTable;
|
||||
_pairUiService = pairUiService;
|
||||
_nameplateUpdateHookService = nameplateUpdateHookService;
|
||||
|
||||
interop.InitializeFromAttributes(this);
|
||||
_nameplateHook?.Enable();
|
||||
_nameplateUpdateHookService.NameplateUpdated += OnNameplateUpdated;
|
||||
Refresh();
|
||||
|
||||
Mediator.Subscribe<VisibilityChange>(this, (_) => Refresh());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detour for the game's internal nameplate update function.
|
||||
/// This will be called whenever the client updates any nameplate.
|
||||
///
|
||||
/// We hook into it to apply our own nameplate coloring logic via <see cref="SetNameplate"/>,
|
||||
/// Nameplate update handler, triggered by the signature hook service.
|
||||
/// </summary>
|
||||
private nint UpdateNameplateDetour(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
|
||||
private void OnNameplateUpdated(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -74,10 +64,8 @@ public unsafe class NameplateService : DisposableMediatorSubscriberBase
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Error in NameplateService UpdateNameplateDetour");
|
||||
_logger.LogError(e, "Error in NameplateService OnNameplateUpdated");
|
||||
}
|
||||
|
||||
return _nameplateHook!.Original(raptureAtkModule, namePlateInfo, numArray, stringArray, battleChara, numArrayIndex, stringArrayIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -246,7 +234,7 @@ public unsafe class NameplateService : DisposableMediatorSubscriberBase
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_nameplateHook?.Dispose();
|
||||
_nameplateUpdateHookService.NameplateUpdated -= OnNameplateUpdated;
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
|
||||
57
LightlessSync/Services/NameplateUpdateHookService.cs
Normal file
57
LightlessSync/Services/NameplateUpdateHookService.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using Dalamud.Hooking;
|
||||
using Dalamud.Plugin.Services;
|
||||
using Dalamud.Utility.Signatures;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public unsafe sealed class NameplateUpdateHookService : IDisposable
|
||||
{
|
||||
private delegate nint UpdateNameplateDelegate(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo,
|
||||
NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex);
|
||||
public delegate void NameplateUpdatedHandler(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo,
|
||||
NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex);
|
||||
|
||||
// Glyceri, Thanks :bow:
|
||||
[Signature("40 53 55 57 41 56 48 81 EC ?? ?? ?? ?? 48 8B 84 24", DetourName = nameof(UpdateNameplateDetour))]
|
||||
private readonly Hook<UpdateNameplateDelegate>? _nameplateHook = null;
|
||||
|
||||
private readonly ILogger<NameplateUpdateHookService> _logger;
|
||||
|
||||
public NameplateUpdateHookService(ILogger<NameplateUpdateHookService> logger, IGameInteropProvider interop)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
interop.InitializeFromAttributes(this);
|
||||
_nameplateHook?.Enable();
|
||||
}
|
||||
|
||||
public event NameplateUpdatedHandler? NameplateUpdated;
|
||||
|
||||
/// <summary>
|
||||
/// Detour for the game's internal nameplate update function.
|
||||
/// This will be called whenever the client updates any nameplate.
|
||||
/// </summary>
|
||||
private nint UpdateNameplateDetour(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo,
|
||||
NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
|
||||
{
|
||||
try
|
||||
{
|
||||
NameplateUpdated?.Invoke(raptureAtkModule, namePlateInfo, numArray, stringArray, battleChara, numArrayIndex, stringArrayIndex);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Error in NameplateUpdateHookService UpdateNameplateDetour");
|
||||
}
|
||||
|
||||
return _nameplateHook!.Original(raptureAtkModule, namePlateInfo, numArray, stringArray, battleChara, numArrayIndex, stringArrayIndex);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_nameplateHook?.Dispose();
|
||||
}
|
||||
}
|
||||
71
LightlessSync/Services/PenumbraTempCollectionJanitor.cs
Normal file
71
LightlessSync/Services/PenumbraTempCollectionJanitor.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using LightlessSync.Interop.Ipc;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private readonly IpcManager _ipc;
|
||||
private readonly LightlessConfigService _config;
|
||||
private int _ran;
|
||||
|
||||
public PenumbraTempCollectionJanitor(
|
||||
ILogger<PenumbraTempCollectionJanitor> logger,
|
||||
LightlessMediator mediator,
|
||||
IpcManager ipc,
|
||||
LightlessConfigService config) : base(logger, mediator)
|
||||
{
|
||||
_ipc = ipc;
|
||||
_config = config;
|
||||
|
||||
Mediator.Subscribe<PenumbraInitializedMessage>(this, _ => CleanupOrphansOnBoot());
|
||||
}
|
||||
|
||||
public void Register(Guid id)
|
||||
{
|
||||
if (id == Guid.Empty) return;
|
||||
if (_config.Current.OrphanableTempCollections.Add(id))
|
||||
_config.Save();
|
||||
}
|
||||
|
||||
public void Unregister(Guid id)
|
||||
{
|
||||
if (id == Guid.Empty) return;
|
||||
if (_config.Current.OrphanableTempCollections.Remove(id))
|
||||
_config.Save();
|
||||
}
|
||||
|
||||
private void CleanupOrphansOnBoot()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _ran, 1) == 1)
|
||||
return;
|
||||
|
||||
if (!_ipc.Penumbra.APIAvailable)
|
||||
return;
|
||||
|
||||
var ids = _config.Current.OrphanableTempCollections.ToArray();
|
||||
if (ids.Length == 0)
|
||||
return;
|
||||
|
||||
var appId = Guid.NewGuid();
|
||||
Logger.LogInformation("Cleaning up {count} orphaned Lightless temp collections found in configuration", ids.Length);
|
||||
|
||||
foreach (var id in ids)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ipc.Penumbra.RemoveTemporaryCollectionAsync(Logger, appId, id)
|
||||
.GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogDebug(ex, "Failed removing orphaned temp collection {id}", id);
|
||||
}
|
||||
}
|
||||
|
||||
_config.Current.OrphanableTempCollections.Clear();
|
||||
_config.Save();
|
||||
}
|
||||
}
|
||||
@@ -101,9 +101,9 @@ public class ServerConfigurationManager
|
||||
}
|
||||
hasMulti = false;
|
||||
|
||||
var charaName = _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult();
|
||||
var worldId = _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult();
|
||||
var cid = _dalamudUtil.GetCIDAsync().GetAwaiter().GetResult();
|
||||
var charaName = _dalamudUtil.GetPlayerName();
|
||||
var worldId = _dalamudUtil.GetHomeWorldId();
|
||||
var cid = _dalamudUtil.GetCID();
|
||||
|
||||
var auth = currentServer.Authentications.FindAll(f => string.Equals(f.CharacterName, charaName) && f.WorldId == worldId);
|
||||
if (auth.Count >= 2)
|
||||
@@ -148,9 +148,9 @@ public class ServerConfigurationManager
|
||||
}
|
||||
hasMulti = false;
|
||||
|
||||
var charaName = _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult();
|
||||
var worldId = _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult();
|
||||
var cid = _dalamudUtil.GetCIDAsync().GetAwaiter().GetResult();
|
||||
var charaName = _dalamudUtil.GetPlayerName();
|
||||
var worldId = _dalamudUtil.GetHomeWorldId();
|
||||
var cid = _dalamudUtil.GetCID();
|
||||
if (!currentServer.Authentications.Any() && currentServer.SecretKeys.Any())
|
||||
{
|
||||
currentServer.Authentications.Add(new Authentication()
|
||||
@@ -268,16 +268,16 @@ public class ServerConfigurationManager
|
||||
{
|
||||
if (serverSelectionIndex == -1) serverSelectionIndex = CurrentServerIndex;
|
||||
var server = GetServerByIndex(serverSelectionIndex);
|
||||
if (server.Authentications.Exists(c => string.Equals(c.CharacterName, _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult(), StringComparison.Ordinal)
|
||||
&& c.WorldId == _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult()))
|
||||
if (server.Authentications.Exists(c => string.Equals(c.CharacterName, _dalamudUtil.GetPlayerName(), StringComparison.Ordinal)
|
||||
&& c.WorldId == _dalamudUtil.GetHomeWorldId()))
|
||||
return;
|
||||
|
||||
server.Authentications.Add(new Authentication()
|
||||
{
|
||||
CharacterName = _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult(),
|
||||
WorldId = _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult(),
|
||||
CharacterName = _dalamudUtil.GetPlayerName(),
|
||||
WorldId = _dalamudUtil.GetHomeWorldId(),
|
||||
SecretKeyIdx = !server.UseOAuth2 ? server.SecretKeys.Last().Key : -1,
|
||||
LastSeenCID = _dalamudUtil.GetCIDAsync().GetAwaiter().GetResult()
|
||||
LastSeenCID = _dalamudUtil.GetCID()
|
||||
});
|
||||
Save();
|
||||
}
|
||||
|
||||
@@ -394,6 +394,21 @@ public sealed class TextureMetadataHelper
|
||||
if (string.IsNullOrEmpty(fileNameWithExtension) && string.IsNullOrEmpty(fileNameWithoutExtension))
|
||||
return TextureMapKind.Unknown;
|
||||
|
||||
if (normalized.Contains("/eye/eyelids_shadow.tex", StringComparison.Ordinal))
|
||||
return TextureMapKind.Normal;
|
||||
|
||||
if (normalized.Contains("/ui/map/", StringComparison.Ordinal) && !string.IsNullOrEmpty(fileNameWithoutExtension))
|
||||
{
|
||||
if (fileNameWithoutExtension.EndsWith("m_m", StringComparison.Ordinal)
|
||||
|| fileNameWithoutExtension.EndsWith("m_s", StringComparison.Ordinal))
|
||||
return TextureMapKind.Mask;
|
||||
|
||||
if (fileNameWithoutExtension.EndsWith("_m", StringComparison.Ordinal)
|
||||
|| fileNameWithoutExtension.EndsWith("_s", StringComparison.Ordinal)
|
||||
|| fileNameWithoutExtension.EndsWith("d", StringComparison.Ordinal))
|
||||
return TextureMapKind.Diffuse;
|
||||
}
|
||||
|
||||
foreach (var (kind, token) in MapTokens)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(fileNameWithExtension) &&
|
||||
@@ -563,7 +578,16 @@ public sealed class TextureMetadataHelper
|
||||
|
||||
var normalized = format.ToUpperInvariant();
|
||||
return normalized.Contains("A8", StringComparison.Ordinal)
|
||||
|| normalized.Contains("A1", StringComparison.Ordinal)
|
||||
|| normalized.Contains("A4", StringComparison.Ordinal)
|
||||
|| normalized.Contains("A16", StringComparison.Ordinal)
|
||||
|| normalized.Contains("A32", StringComparison.Ordinal)
|
||||
|| normalized.Contains("ARGB", StringComparison.Ordinal)
|
||||
|| normalized.Contains("RGBA", StringComparison.Ordinal)
|
||||
|| normalized.Contains("BGRA", StringComparison.Ordinal)
|
||||
|| normalized.Contains("DXT3", StringComparison.Ordinal)
|
||||
|| normalized.Contains("DXT5", StringComparison.Ordinal)
|
||||
|| normalized.Contains("BC2", StringComparison.Ordinal)
|
||||
|| normalized.Contains("BC3", StringComparison.Ordinal)
|
||||
|| normalized.Contains("BC7", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ public class UiFactory
|
||||
groupData: groupData,
|
||||
isLightfinderContext: isLightfinderContext,
|
||||
lightfinderCid: lightfinderCid,
|
||||
performanceCollector: _performanceCollectorService);
|
||||
performanceCollector: _performanceCollectorService,
|
||||
_apiController);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,14 +757,19 @@ public class DrawUserPair
|
||||
}
|
||||
UiSharedService.AttachToolTip("Hold CTRL and click to remove user " + (_pair.UserData.AliasOrUID) + " from Syncshell");
|
||||
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserSlash, "Ban User", _menuWidth, true))
|
||||
var banEnabled = UiSharedService.CtrlPressed();
|
||||
var banLabel = banEnabled ? "Ban user" : "Ban user (Hold CTRL)";
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserSlash, banLabel, _menuWidth, true) && banEnabled)
|
||||
{
|
||||
_mediator.Publish(new OpenBanUserPopupMessage(_pair, group));
|
||||
ImGui.CloseCurrentPopup();
|
||||
}
|
||||
UiSharedService.AttachToolTip("Ban user from this Syncshell");
|
||||
UiSharedService.AttachToolTip("Hold CTRL to ban user " + (_pair.UserData.AliasOrUID) + " from this Syncshell");
|
||||
|
||||
ImGui.Separator();
|
||||
if (showOwnerActions)
|
||||
{
|
||||
ImGui.Separator();
|
||||
}
|
||||
}
|
||||
|
||||
if (showOwnerActions)
|
||||
|
||||
@@ -14,6 +14,7 @@ using LightlessSync.Services.TextureCompression;
|
||||
using LightlessSync.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OtterTex;
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using SixLabors.ImageSharp;
|
||||
@@ -49,6 +50,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
private readonly Dictionary<string, TextureCompressionTarget> _textureSelections = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _selectedTextureKeys = 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 List<string> _storedPathsToRemove = [];
|
||||
private readonly Dictionary<string, string> _filePathResolve = [];
|
||||
@@ -88,6 +90,9 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
private bool _showAlreadyAddedTransients = false;
|
||||
private bool _acknowledgeReview = false;
|
||||
|
||||
private Task<TextureRowBuildResult>? _textureRowsBuildTask;
|
||||
private CancellationTokenSource? _textureRowsBuildCts;
|
||||
|
||||
private ObjectKind _selectedObjectTab;
|
||||
|
||||
private TextureUsageCategory? _textureCategoryFilter = null;
|
||||
@@ -204,9 +209,9 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
return;
|
||||
}
|
||||
|
||||
_cachedAnalysis = _characterAnalyzer.LastAnalysis.DeepClone();
|
||||
_cachedAnalysis = CloneAnalysis(_characterAnalyzer.LastAnalysis);
|
||||
_hasUpdate = false;
|
||||
_textureRowsDirty = true;
|
||||
InvalidateTextureRows();
|
||||
}
|
||||
|
||||
private void DrawContentTabs()
|
||||
@@ -750,7 +755,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
_selectedTextureKeys.Clear();
|
||||
_textureSelections.Clear();
|
||||
ResetTextureFilters();
|
||||
_textureRowsDirty = true;
|
||||
InvalidateTextureRows();
|
||||
_conversionFailed = false;
|
||||
}
|
||||
|
||||
@@ -762,6 +767,8 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
preview.Texture?.Dispose();
|
||||
}
|
||||
_texturePreviews.Clear();
|
||||
_textureRowsBuildCts?.Cancel();
|
||||
_textureRowsBuildCts?.Dispose();
|
||||
_conversionProgress.ProgressChanged -= ConversionProgress_ProgressChanged;
|
||||
}
|
||||
|
||||
@@ -775,18 +782,108 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
|
||||
private void EnsureTextureRows()
|
||||
{
|
||||
if (!_textureRowsDirty || _cachedAnalysis == null)
|
||||
if (_cachedAnalysis == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_textureRowsDirty && _textureRowsBuildTask == null)
|
||||
{
|
||||
_textureRowsBuildCts?.Dispose();
|
||||
_textureRowsBuildCts = new();
|
||||
var snapshot = _cachedAnalysis;
|
||||
_textureRowsBuildTask = Task.Run(() => BuildTextureRows(snapshot, _textureRowsBuildCts.Token), _textureRowsBuildCts.Token);
|
||||
}
|
||||
|
||||
if (_textureRowsBuildTask == null || !_textureRowsBuildTask.IsCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var completedTask = _textureRowsBuildTask;
|
||||
_textureRowsBuildTask = null;
|
||||
_textureRowsBuildCts?.Dispose();
|
||||
_textureRowsBuildCts = null;
|
||||
|
||||
if (completedTask.IsCanceled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (completedTask.IsFaulted)
|
||||
{
|
||||
_logger.LogWarning(completedTask.Exception, "Failed to build texture rows.");
|
||||
_textureRowsDirty = false;
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyTextureRowBuild(completedTask.Result);
|
||||
_textureRowsDirty = false;
|
||||
}
|
||||
|
||||
private void ApplyTextureRowBuild(TextureRowBuildResult result)
|
||||
{
|
||||
_textureRows.Clear();
|
||||
_textureRows.AddRange(result.Rows);
|
||||
|
||||
foreach (var row in _textureRows)
|
||||
{
|
||||
if (row.IsAlreadyCompressed)
|
||||
{
|
||||
_selectedTextureKeys.Remove(row.Key);
|
||||
_textureSelections.Remove(row.Key);
|
||||
}
|
||||
}
|
||||
|
||||
_selectedTextureKeys.RemoveWhere(key => !result.ValidKeys.Contains(key));
|
||||
|
||||
foreach (var key in _texturePreviews.Keys.ToArray())
|
||||
{
|
||||
if (!result.ValidKeys.Contains(key) && _texturePreviews.TryGetValue(key, out var preview))
|
||||
{
|
||||
preview.Texture?.Dispose();
|
||||
_texturePreviews.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var key in _textureResolutionCache.Keys.ToArray())
|
||||
{
|
||||
if (!result.ValidKeys.Contains(key))
|
||||
{
|
||||
_textureResolutionCache.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var key in _textureSelections.Keys.ToArray())
|
||||
{
|
||||
if (!result.ValidKeys.Contains(key))
|
||||
{
|
||||
_textureSelections.Remove(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
_textureSelections[key] = _textureCompressionService.NormalizeTarget(_textureSelections[key]);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_selectedTextureKey) && !result.ValidKeys.Contains(_selectedTextureKey))
|
||||
{
|
||||
_selectedTextureKey = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private TextureRowBuildResult BuildTextureRows(
|
||||
Dictionary<ObjectKind, Dictionary<string, CharacterAnalyzer.FileDataEntry>> analysis,
|
||||
CancellationToken token)
|
||||
{
|
||||
var rows = new List<TextureRow>();
|
||||
HashSet<string> validKeys = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var (objectKind, entries) in _cachedAnalysis)
|
||||
foreach (var (objectKind, entries) in analysis)
|
||||
{
|
||||
foreach (var entry in entries.Values)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
if (!string.Equals(entry.FileType, "tex", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
@@ -828,17 +925,11 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
suggestion?.Reason);
|
||||
|
||||
validKeys.Add(row.Key);
|
||||
_textureRows.Add(row);
|
||||
|
||||
if (row.IsAlreadyCompressed)
|
||||
{
|
||||
_selectedTextureKeys.Remove(row.Key);
|
||||
_textureSelections.Remove(row.Key);
|
||||
}
|
||||
rows.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
_textureRows.Sort((a, b) =>
|
||||
rows.Sort((a, b) =>
|
||||
{
|
||||
var comp = a.ObjectKind.CompareTo(b.ObjectKind);
|
||||
if (comp != 0)
|
||||
@@ -851,34 +942,14 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
return string.Compare(a.DisplayName, b.DisplayName, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
|
||||
_selectedTextureKeys.RemoveWhere(key => !validKeys.Contains(key));
|
||||
return new TextureRowBuildResult(rows, validKeys);
|
||||
}
|
||||
|
||||
foreach (var key in _texturePreviews.Keys.ToArray())
|
||||
{
|
||||
if (!validKeys.Contains(key) && _texturePreviews.TryGetValue(key, out var preview))
|
||||
{
|
||||
preview.Texture?.Dispose();
|
||||
_texturePreviews.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var key in _textureSelections.Keys.ToArray())
|
||||
{
|
||||
if (!validKeys.Contains(key))
|
||||
{
|
||||
_textureSelections.Remove(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
_textureSelections[key] = _textureCompressionService.NormalizeTarget(_textureSelections[key]);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_selectedTextureKey) && !validKeys.Contains(_selectedTextureKey))
|
||||
{
|
||||
_selectedTextureKey = string.Empty;
|
||||
}
|
||||
|
||||
_textureRowsDirty = false;
|
||||
private void InvalidateTextureRows()
|
||||
{
|
||||
_textureRowsDirty = true;
|
||||
_textureRowsBuildCts?.Cancel();
|
||||
_textureResolutionCache.Clear();
|
||||
}
|
||||
|
||||
private static string MakeTextureKey(ObjectKind objectKind, string primaryFilePath) =>
|
||||
@@ -893,6 +964,35 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
_textureSearch = string.Empty;
|
||||
}
|
||||
|
||||
private static Dictionary<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)
|
||||
{
|
||||
var scale = ImGuiHelpers.GlobalScale;
|
||||
@@ -1091,6 +1191,10 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
public bool IsAlreadyCompressed => CurrentTarget.HasValue;
|
||||
}
|
||||
|
||||
private sealed record TextureRowBuildResult(
|
||||
List<TextureRow> Rows,
|
||||
HashSet<string> ValidKeys);
|
||||
|
||||
private sealed class TexturePreviewState
|
||||
{
|
||||
public Task? LoadTask { get; set; }
|
||||
@@ -1099,6 +1203,22 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
public DateTime LastAccessUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private readonly struct TextureResolutionInfo
|
||||
{
|
||||
public TextureResolutionInfo(ushort width, ushort height, ushort depth, ushort mipLevels)
|
||||
{
|
||||
Width = width;
|
||||
Height = height;
|
||||
Depth = depth;
|
||||
MipLevels = mipLevels;
|
||||
}
|
||||
|
||||
public ushort Width { get; }
|
||||
public ushort Height { get; }
|
||||
public ushort Depth { get; }
|
||||
public ushort MipLevels { get; }
|
||||
}
|
||||
|
||||
private void DrawTextureWorkspace(ObjectKind objectKind, IReadOnlyList<IGrouping<string, CharacterAnalyzer.FileDataEntry>> otherFileGroups)
|
||||
{
|
||||
if (!_textureWorkspaceTabs.ContainsKey(objectKind))
|
||||
@@ -1143,6 +1263,11 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
private void DrawTextureTabContent(ObjectKind objectKind)
|
||||
{
|
||||
var scale = ImGuiHelpers.GlobalScale;
|
||||
if (_textureRowsBuildTask != null && !_textureRowsBuildTask.IsCompleted && _textureRows.Count == 0)
|
||||
{
|
||||
UiSharedService.ColorText("Building texture list.", ImGuiColors.DalamudGrey);
|
||||
return;
|
||||
}
|
||||
var objectRows = _textureRows.Where(row => row.ObjectKind == objectKind).ToList();
|
||||
var hasAnyTextureRows = objectRows.Count > 0;
|
||||
var availableCategories = objectRows.Select(row => row.Category)
|
||||
@@ -1404,6 +1529,24 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
ResetTextureFilters();
|
||||
}
|
||||
|
||||
ImGuiHelpers.ScaledDummy(6);
|
||||
ImGui.Separator();
|
||||
ImGuiHelpers.ScaledDummy(4);
|
||||
UiSharedService.ColorText("Texture row colors", UIColors.Get("LightlessPurple"));
|
||||
DrawTextureRowLegendItem("Selected", UIColors.Get("LightlessYellow"), "This row is selected in the texture table.");
|
||||
DrawTextureRowLegendItem("Already compressed", UIColors.Get("LightlessGreenDefault"), "Texture is already stored in a compressed format.");
|
||||
DrawTextureRowLegendItem("Missing analysis data", UIColors.Get("DimRed"), "File size data has not been computed yet.");
|
||||
}
|
||||
|
||||
private static void DrawTextureRowLegendItem(string label, Vector4 color, string description)
|
||||
{
|
||||
var scale = ImGuiHelpers.GlobalScale;
|
||||
var swatchSize = new Vector2(12f * scale, 12f * scale);
|
||||
ImGui.ColorButton($"##textureRowLegend{label}", color, ImGuiColorEditFlags.NoTooltip | ImGuiColorEditFlags.NoDragDrop, swatchSize);
|
||||
ImGui.SameLine(0f, 6f * scale);
|
||||
var wrapPos = ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X;
|
||||
UiSharedService.TextWrapped($"{label}: {description}", wrapPos);
|
||||
}
|
||||
|
||||
private static void DrawEnumFilterCombo<T>(
|
||||
@@ -1810,7 +1953,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
ImGui.SameLine();
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Sync, "Refresh", 130f * scale))
|
||||
{
|
||||
_textureRowsDirty = true;
|
||||
InvalidateTextureRows();
|
||||
}
|
||||
|
||||
TextureRow? lastSelected = null;
|
||||
@@ -1976,7 +2119,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
_selectedTextureKeys.Clear();
|
||||
_textureSelections.Clear();
|
||||
_textureRowsDirty = true;
|
||||
InvalidateTextureRows();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2197,6 +2340,68 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
|
||||
private TextureResolutionInfo? GetTextureResolution(TextureRow row)
|
||||
{
|
||||
if (_textureResolutionCache.TryGetValue(row.Key, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var info = TryReadTextureResolution(row.PrimaryFilePath, out var resolved)
|
||||
? resolved
|
||||
: (TextureResolutionInfo?)null;
|
||||
_textureResolutionCache[row.Key] = info;
|
||||
return info;
|
||||
}
|
||||
|
||||
private static bool TryReadTextureResolution(string path, out TextureResolutionInfo info)
|
||||
{
|
||||
info = default;
|
||||
|
||||
try
|
||||
{
|
||||
Span<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)
|
||||
{
|
||||
var key = row.Key;
|
||||
@@ -2465,6 +2670,9 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
|
||||
MetaRow(FontAwesomeIcon.LayerGroup, "Map Type", row.MapKind.ToString());
|
||||
MetaRow(FontAwesomeIcon.Fingerprint, "Hash", row.Hash, UIColors.Get("LightlessBlue"));
|
||||
MetaRow(FontAwesomeIcon.InfoCircle, "Current Format", row.Format);
|
||||
var resolution = GetTextureResolution(row);
|
||||
var resolutionLabel = resolution.HasValue ? FormatTextureResolution(resolution.Value) : "Unknown";
|
||||
MetaRow(FontAwesomeIcon.Images, "Resolution", resolutionLabel);
|
||||
|
||||
var selectedLabel = hasSelectedInfo ? selectedInfo!.Title : selectedTarget.ToString();
|
||||
var selectionColor = hasSelectedInfo ? UIColors.Get("LightlessYellow") : UIColors.Get("LightlessGreen");
|
||||
|
||||
@@ -178,6 +178,11 @@ public class DownloadUi : WindowMediatorSubscriberBase
|
||||
foreach (var transfer in transfers)
|
||||
{
|
||||
var transferKey = transfer.Key;
|
||||
|
||||
// Skip if no valid game object
|
||||
if (transferKey.GetGameObject() == null)
|
||||
continue;
|
||||
|
||||
var rawPos = _dalamudUtilService.WorldToScreen(transferKey.GetGameObject());
|
||||
|
||||
// If RawPos is zero, remove it from smoothed dictionary
|
||||
|
||||
@@ -46,10 +46,12 @@ public sealed class DtrEntry : IDisposable, IHostedService
|
||||
private string? _lightfinderText;
|
||||
private string? _lightfinderTooltip;
|
||||
private Colors _lightfinderColors;
|
||||
private readonly object _localHashedCidLock = new();
|
||||
private string? _localHashedCid;
|
||||
private DateTime _localHashedCidFetchedAt = DateTime.MinValue;
|
||||
private DateTime _localHashedCidNextErrorLog = DateTime.MinValue;
|
||||
private DateTime _pairRequestNextErrorLog = DateTime.MinValue;
|
||||
private int _localHashedCidRefreshActive;
|
||||
|
||||
public DtrEntry(
|
||||
ILogger<DtrEntry> logger,
|
||||
@@ -339,29 +341,61 @@ public sealed class DtrEntry : IDisposable, IHostedService
|
||||
private string? GetLocalHashedCid()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (_localHashedCid is not null && now - _localHashedCidFetchedAt < _localHashedCidCacheDuration)
|
||||
return _localHashedCid;
|
||||
|
||||
try
|
||||
lock (_localHashedCidLock)
|
||||
{
|
||||
var cid = _dalamudUtilService.GetCIDAsync().GetAwaiter().GetResult();
|
||||
var hashedCid = cid.ToString().GetHash256();
|
||||
_localHashedCid = hashedCid;
|
||||
_localHashedCidFetchedAt = now;
|
||||
return hashedCid;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (now >= _localHashedCidNextErrorLog)
|
||||
if (_localHashedCid is not null && now - _localHashedCidFetchedAt < _localHashedCidCacheDuration)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to refresh local hashed CID for Lightfinder DTR entry.");
|
||||
_localHashedCidNextErrorLog = now + _localHashedCidErrorCooldown;
|
||||
return _localHashedCid;
|
||||
}
|
||||
|
||||
_localHashedCid = null;
|
||||
_localHashedCidFetchedAt = now;
|
||||
return null;
|
||||
}
|
||||
|
||||
QueueLocalHashedCidRefresh();
|
||||
|
||||
lock (_localHashedCidLock)
|
||||
{
|
||||
return _localHashedCid;
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueLocalHashedCidRefresh()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _localHashedCidRefreshActive, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var cid = _dalamudUtilService.GetCID();
|
||||
var hashedCid = cid.ToString().GetHash256();
|
||||
lock (_localHashedCidLock)
|
||||
{
|
||||
_localHashedCid = hashedCid;
|
||||
_localHashedCidFetchedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
lock (_localHashedCidLock)
|
||||
{
|
||||
if (now >= _localHashedCidNextErrorLog)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to refresh local hashed CID for Lightfinder DTR entry.");
|
||||
_localHashedCidNextErrorLog = now + _localHashedCidErrorCooldown;
|
||||
}
|
||||
|
||||
_localHashedCid = null;
|
||||
_localHashedCidFetchedAt = now;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _localHashedCidRefreshActive, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private List<string> GetNearbyBroadcasts()
|
||||
|
||||
@@ -23,6 +23,7 @@ namespace LightlessSync.UI
|
||||
private readonly LightFinderService _broadcastService;
|
||||
private readonly UiSharedService _uiSharedService;
|
||||
private readonly LightFinderScannerService _broadcastScannerService;
|
||||
private readonly LightFinderPlateHandler _lightFinderPlateHandler;
|
||||
|
||||
private IReadOnlyList<GroupFullInfoDto> _allSyncshells = Array.Empty<GroupFullInfoDto>();
|
||||
private string _userUid = string.Empty;
|
||||
@@ -38,7 +39,8 @@ namespace LightlessSync.UI
|
||||
UiSharedService uiShared,
|
||||
ApiController apiController,
|
||||
LightFinderScannerService broadcastScannerService
|
||||
) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService)
|
||||
,
|
||||
LightFinderPlateHandler lightFinderPlateHandler) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService)
|
||||
{
|
||||
_broadcastService = broadcastService;
|
||||
_uiSharedService = uiShared;
|
||||
@@ -50,6 +52,7 @@ namespace LightlessSync.UI
|
||||
WindowBuilder.For(this)
|
||||
.SetSizeConstraints(new Vector2(600, 465), new Vector2(750, 525))
|
||||
.Apply();
|
||||
_lightFinderPlateHandler = lightFinderPlateHandler;
|
||||
}
|
||||
|
||||
private void RebuildSyncshellDropdownOptions()
|
||||
@@ -380,9 +383,47 @@ namespace LightlessSync.UI
|
||||
#if DEBUG
|
||||
if (ImGui.BeginTabItem("Debug"))
|
||||
{
|
||||
if (ImGui.CollapsingHeader("LightFinder Plates", ImGuiTreeNodeFlags.DefaultOpen))
|
||||
{
|
||||
var h = _lightFinderPlateHandler;
|
||||
|
||||
var enabled = h.DebugEnabled;
|
||||
if (ImGui.Checkbox("Enable LightFinder debug", ref enabled))
|
||||
h.DebugEnabled = enabled;
|
||||
|
||||
if (h.DebugEnabled)
|
||||
{
|
||||
ImGui.Indent();
|
||||
|
||||
var disableOcc = h.DebugDisableOcclusion;
|
||||
if (ImGui.Checkbox("Disable occlusion (force draw)", ref disableOcc))
|
||||
h.DebugDisableOcclusion = disableOcc;
|
||||
|
||||
var drawUiRects = h.DebugDrawUiRects;
|
||||
if (ImGui.Checkbox("Draw UI rects", ref drawUiRects))
|
||||
h.DebugDrawUiRects = drawUiRects;
|
||||
|
||||
var drawLabelRects = h.DebugDrawLabelRects;
|
||||
if (ImGui.Checkbox("Draw label rects", ref drawLabelRects))
|
||||
h.DebugDrawLabelRects = drawLabelRects;
|
||||
|
||||
ImGui.Separator();
|
||||
ImGui.TextUnformatted($"Labels last frame: {h.DebugLabelCountLastFrame}");
|
||||
ImGui.TextUnformatted($"UI rects last frame: {h.DebugUiRectCountLastFrame}");
|
||||
ImGui.TextUnformatted($"Occluded last frame: {h.DebugOccludedCountLastFrame}");
|
||||
ImGui.TextUnformatted($"Last NamePlate frame: {h.DebugLastNameplateFrame}");
|
||||
|
||||
ImGui.Unindent();
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.Separator();
|
||||
|
||||
ImGui.Text("Broadcast Cache");
|
||||
|
||||
if (ImGui.BeginTable("##BroadcastCacheTable", 4, ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY, new Vector2(-1, 225f)))
|
||||
if (ImGui.BeginTable("##BroadcastCacheTable", 4,
|
||||
ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY,
|
||||
new Vector2(-1, 225f)))
|
||||
{
|
||||
ImGui.TableSetupColumn("CID", ImGuiTableColumnFlags.WidthStretch);
|
||||
ImGui.TableSetupColumn("IsBroadcasting", ImGuiTableColumnFlags.WidthStretch);
|
||||
|
||||
@@ -84,6 +84,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
private bool _pairDiagnosticsEnabled;
|
||||
private string? _selectedPairDebugUid = null;
|
||||
private string _lightfinderIconInput = string.Empty;
|
||||
private bool _showLightfinderRendererWarning = false;
|
||||
private LightfinderLabelRenderer _pendingLightfinderRenderer = LightfinderLabelRenderer.Pictomancy;
|
||||
private bool _lightfinderIconInputInitialized = false;
|
||||
private int _lightfinderIconPresetIndex = -1;
|
||||
private static readonly LightlessConfig DefaultConfig = new();
|
||||
@@ -2368,6 +2370,78 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
|
||||
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f);
|
||||
|
||||
ImGui.TextUnformatted("Nameplate Label Rendering");
|
||||
var labelRenderer = _configService.Current.LightfinderLabelRenderer;
|
||||
var labelRendererLabel = labelRenderer switch
|
||||
{
|
||||
LightfinderLabelRenderer.SignatureHook => "Native Nameplate Rendering",
|
||||
_ => "ImGui Overlay",
|
||||
};
|
||||
|
||||
if (ImGui.BeginCombo("Render mode", labelRendererLabel))
|
||||
{
|
||||
foreach (var option in Enum.GetValues<LightfinderLabelRenderer>())
|
||||
{
|
||||
var optionLabel = option switch
|
||||
{
|
||||
LightfinderLabelRenderer.SignatureHook => "Native Nameplate Rendering",
|
||||
_ => "ImGui Overlay",
|
||||
};
|
||||
|
||||
var selected = option == labelRenderer;
|
||||
if (ImGui.Selectable(optionLabel, selected))
|
||||
{
|
||||
if (option == LightfinderLabelRenderer.SignatureHook)
|
||||
{
|
||||
_pendingLightfinderRenderer = option;
|
||||
_showLightfinderRendererWarning = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_configService.Current.LightfinderLabelRenderer = option;
|
||||
_configService.Save();
|
||||
_nameplateService.RequestRedraw();
|
||||
}
|
||||
}
|
||||
if (selected)
|
||||
ImGui.SetItemDefaultFocus();
|
||||
}
|
||||
|
||||
ImGui.EndCombo();
|
||||
}
|
||||
|
||||
if (_showLightfinderRendererWarning)
|
||||
{
|
||||
ImGui.SetNextWindowSize(new Vector2(450f, 0f), ImGuiCond.Appearing);
|
||||
ImGui.OpenPopup("Nameplate Warning");
|
||||
}
|
||||
|
||||
if (ImGui.BeginPopupModal("Nameplate Warning", ref _showLightfinderRendererWarning, ImGuiWindowFlags.AlwaysAutoResize))
|
||||
{
|
||||
ImGui.TextColored(UIColors.Get("DimRed"), "USE AT YOUR RISK!");
|
||||
ImGui.Spacing();
|
||||
ImGui.TextWrapped("Writing on to the native Nameplates is known to be unstable and MAY cause crashes. DO NOT REPORT THOSE CRASHES TO DALAMUD. We will also not be supporting Nameplate crashes. You have been warned.");
|
||||
ImGui.Spacing();
|
||||
ImGui.TextWrapped("By accepting this warning, you understand that you are using this feature at risk of crashing.");
|
||||
ImGui.Spacing();
|
||||
|
||||
var buttonWidth = ImGui.GetContentRegionAvail().X;
|
||||
if (ImGui.Button("I Understand", new Vector2(buttonWidth, 0)))
|
||||
{
|
||||
_configService.Current.LightfinderLabelRenderer = _pendingLightfinderRenderer;
|
||||
_configService.Save();
|
||||
_nameplateService.RequestRedraw();
|
||||
_showLightfinderRendererWarning = false;
|
||||
ImGui.CloseCurrentPopup();
|
||||
}
|
||||
|
||||
ImGui.EndPopup();
|
||||
}
|
||||
|
||||
_uiShared.DrawHelpText("Choose how Lightfinder labels render: the default ImGui overlay or native nameplate nodes via signature hook.");
|
||||
|
||||
UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f);
|
||||
|
||||
ImGui.TextUnformatted("Alignment");
|
||||
ImGui.BeginDisabled(autoAlign);
|
||||
if (ImGui.SliderInt("Label Offset X", ref offsetX, -200, 200))
|
||||
@@ -2550,7 +2624,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
var selected = i == _lightfinderIconPresetIndex;
|
||||
if (ImGui.Selectable(preview, selected))
|
||||
{
|
||||
_lightfinderIconInput = LightFinderPlateHandler.ToIconEditorString(optionGlyph);
|
||||
_lightfinderIconInput = LightFinderPlateHandler.NormalizeIconGlyph(optionGlyph);
|
||||
_lightfinderIconPresetIndex = i;
|
||||
}
|
||||
}
|
||||
@@ -4026,7 +4100,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
private void RefreshLightfinderIconState()
|
||||
{
|
||||
var normalized = LightFinderPlateHandler.NormalizeIconGlyph(_configService.Current.LightfinderLabelIconGlyph);
|
||||
_lightfinderIconInput = LightFinderPlateHandler.ToIconEditorString(normalized);
|
||||
_lightfinderIconInput = LightFinderPlateHandler.NormalizeIconGlyph(normalized);
|
||||
_lightfinderIconInputInitialized = true;
|
||||
|
||||
_lightfinderIconPresetIndex = -1;
|
||||
@@ -4044,7 +4118,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
_configService.Current.LightfinderLabelIconGlyph = normalizedGlyph;
|
||||
_configService.Save();
|
||||
_lightfinderIconInput = LightFinderPlateHandler.ToIconEditorString(normalizedGlyph);
|
||||
_lightfinderIconInput = LightFinderPlateHandler.NormalizeIconGlyph(normalizedGlyph);
|
||||
_lightfinderIconPresetIndex = presetIndex;
|
||||
_lightfinderIconInputInitialized = true;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ using LightlessSync.Services.ServerConfiguration;
|
||||
using LightlessSync.UI.Services;
|
||||
using LightlessSync.UI.Tags;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Numerics;
|
||||
|
||||
@@ -22,6 +23,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase
|
||||
private readonly PairUiService _pairUiService;
|
||||
private readonly ServerConfigurationManager _serverManager;
|
||||
private readonly ProfileTagService _profileTagService;
|
||||
private readonly ApiController _apiController;
|
||||
private readonly UiSharedService _uiSharedService;
|
||||
private readonly UserData? _userData;
|
||||
private readonly GroupData? _groupData;
|
||||
@@ -60,7 +62,8 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase
|
||||
GroupData? groupData,
|
||||
bool isLightfinderContext,
|
||||
string? lightfinderCid,
|
||||
PerformanceCollectorService performanceCollector)
|
||||
PerformanceCollectorService performanceCollector,
|
||||
ApiController apiController)
|
||||
: base(logger, mediator, BuildWindowTitle(
|
||||
userData,
|
||||
groupData,
|
||||
@@ -94,6 +97,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase
|
||||
.Apply();
|
||||
|
||||
IsOpen = true;
|
||||
_apiController = apiController;
|
||||
}
|
||||
|
||||
public Pair? Pair { get; }
|
||||
@@ -248,19 +252,33 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase
|
||||
ResetBannerTexture();
|
||||
_lastBannerPicture = bannerBytes;
|
||||
}
|
||||
|
||||
string? noteText = null;
|
||||
string statusLabel = _isLightfinderContext ? "Exploring" : "Offline";
|
||||
|
||||
var isSelfProfile = !_isLightfinderContext
|
||||
&& _userData is not null
|
||||
&& !string.IsNullOrEmpty(_apiController.UID)
|
||||
&& string.Equals(_userData.UID, _apiController.UID, StringComparison.Ordinal);
|
||||
|
||||
string statusLabel = _isLightfinderContext
|
||||
? "Exploring"
|
||||
: isSelfProfile ? "Online" : "Offline";
|
||||
|
||||
string? visiblePlayerName = null;
|
||||
bool directPair = false;
|
||||
bool youPaused = false;
|
||||
bool theyPaused = false;
|
||||
List<string> syncshellLines = [];
|
||||
|
||||
if (!_isLightfinderContext)
|
||||
{
|
||||
noteText = _serverManager.GetNoteForUid(_userData!.UID);
|
||||
}
|
||||
|
||||
if (!_isLightfinderContext && Pair != null)
|
||||
{
|
||||
var snapshot = _pairUiService.GetSnapshot();
|
||||
noteText = _serverManager.GetNoteForUid(Pair.UserData.UID);
|
||||
|
||||
statusLabel = Pair.IsVisible ? "Visible" : (Pair.IsOnline ? "Online" : "Offline");
|
||||
visiblePlayerName = Pair.IsVisible ? Pair.PlayerName : null;
|
||||
|
||||
@@ -282,11 +300,15 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase
|
||||
var groupLabel = snapshot.GroupsByGid.TryGetValue(gid, out var groupInfo)
|
||||
? groupInfo.GroupAliasOrGID
|
||||
: gid;
|
||||
|
||||
var groupNote = _serverManager.GetNoteForGid(gid);
|
||||
syncshellLines.Add(string.IsNullOrEmpty(groupNote) ? groupLabel : $"{groupNote} ({groupLabel})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelfProfile)
|
||||
statusLabel = "Online";
|
||||
}
|
||||
|
||||
var presenceTokens = new List<PresenceToken>
|
||||
|
||||
@@ -116,7 +116,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
|
||||
var purple = UIColors.Get("LightlessPurple");
|
||||
var gradLeft = purple.WithAlpha(0.0f);
|
||||
var gradLeft = purple.WithAlpha(0.0f);
|
||||
var gradRight = purple.WithAlpha(0.85f);
|
||||
|
||||
uint colTopLeft = ImGui.ColorConvertFloat4ToU32(gradLeft);
|
||||
@@ -162,7 +162,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
|
||||
var subtitlePos = new Vector2(
|
||||
pMin.X + 12f * scale,
|
||||
titlePos.Y + titleHeight - 2f * scale);
|
||||
titlePos.Y + titleHeight - 2f * scale);
|
||||
|
||||
ImGui.SetCursorScreenPos(subtitlePos);
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey);
|
||||
@@ -392,25 +392,27 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
}
|
||||
UiSharedService.AttachToolTip("When enabled, inactive non-pinned, non-moderator users will be pruned automatically on the server.");
|
||||
|
||||
ImGui.SameLine();
|
||||
ImGui.SetNextItemWidth(150);
|
||||
|
||||
using (ImRaii.Disabled(!_autoPruneEnabled))
|
||||
{
|
||||
_uiSharedService.DrawCombo(
|
||||
"Day(s) of inactivity",
|
||||
[1, 3, 7, 14, 30, 90],
|
||||
days => $"{days} day(s)",
|
||||
selected =>
|
||||
{
|
||||
_autoPruneDays = selected;
|
||||
SavePruneSettings();
|
||||
},
|
||||
_autoPruneDays);
|
||||
}
|
||||
|
||||
if (!_autoPruneEnabled)
|
||||
{
|
||||
ImGui.BeginDisabled();
|
||||
}
|
||||
ImGui.SameLine();
|
||||
ImGui.SetNextItemWidth(150);
|
||||
_uiSharedService.DrawCombo(
|
||||
"Day(s) of inactivity (gets checked hourly)",
|
||||
[0, 1, 3, 7, 14, 30, 90],
|
||||
(count) => count == 0 ? "2 hours(s)" : count + " day(s)",
|
||||
selected =>
|
||||
{
|
||||
_autoPruneDays = selected;
|
||||
SavePruneSettings();
|
||||
},
|
||||
_autoPruneDays);
|
||||
|
||||
if (!_autoPruneEnabled)
|
||||
{
|
||||
ImGui.EndDisabled();
|
||||
UiSharedService.ColorTextWrapped(
|
||||
"Automatic prune is currently disabled. Enable it and choose an inactivity threshold to let the server clean up inactive users automatically.",
|
||||
ImGuiColors.DalamudGrey);
|
||||
@@ -593,7 +595,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
_uiSharedService.DrawCombo(
|
||||
"Day(s) of inactivity",
|
||||
[0, 1, 3, 7, 14, 30, 90],
|
||||
(count) => count == 0 ? "15 minute(s)" : count + " day(s)",
|
||||
(count) => count == 0 ? "2 hours(s)" : count + " day(s)",
|
||||
(selected) =>
|
||||
{
|
||||
_pruneDays = selected;
|
||||
@@ -663,8 +665,8 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
var style = ImGui.GetStyle();
|
||||
float fullW = ImGui.GetContentRegionAvail().X;
|
||||
|
||||
float colIdentity = fullW * 0.45f;
|
||||
float colMeta = fullW * 0.35f;
|
||||
float colIdentity = fullW * 0.45f;
|
||||
float colMeta = fullW * 0.35f;
|
||||
float colActions = fullW - colIdentity - colMeta - style.ItemSpacing.X * 2.0f;
|
||||
|
||||
// Header
|
||||
@@ -873,7 +875,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
|
||||
var boolcolor = UiSharedService.GetBoolColor(pair.IsOnline);
|
||||
UiSharedService.ColorText(text, boolcolor);
|
||||
|
||||
|
||||
if (ImGui.IsItemClicked())
|
||||
ImGui.SetClipboardText(pair.UserData.AliasOrUID);
|
||||
|
||||
@@ -1093,6 +1095,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
|
||||
ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale));
|
||||
}
|
||||
|
||||
private void SavePruneSettings()
|
||||
{
|
||||
if (_autoPruneDays <= 0)
|
||||
@@ -1100,8 +1103,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
|
||||
_autoPruneEnabled = false;
|
||||
}
|
||||
|
||||
var enabled = _autoPruneEnabled && _autoPruneDays > 0;
|
||||
var dto = new GroupPruneSettingsDto(Group: GroupFullInfo.Group, AutoPruneEnabled: enabled, AutoPruneDays: enabled ? _autoPruneDays : 0);
|
||||
var dto = new GroupPruneSettingsDto(Group: GroupFullInfo.Group, AutoPruneEnabled: _autoPruneEnabled, AutoPruneDays: _autoPruneDays);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -440,7 +440,7 @@ public class TopTabMenu
|
||||
|
||||
try
|
||||
{
|
||||
var myCidHash = (await _dalamudUtilService.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256();
|
||||
var myCidHash = _dalamudUtilService.GetCID().ToString().GetHash256();
|
||||
await _apiController.TryPairWithContentId(request.HashedCid).ConfigureAwait(false);
|
||||
_pairRequestService.RemoveRequest(request.HashedCid);
|
||||
|
||||
@@ -781,7 +781,8 @@ public class TopTabMenu
|
||||
{
|
||||
var buttonX = (availableWidth - (spacingX)) / 2f;
|
||||
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.PersonCirclePlus, "Lightfinder", buttonX, center: true))
|
||||
var lightFinderLabel = GetLightfinderFinderLabel();
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.PersonCirclePlus, lightFinderLabel, buttonX, center: true))
|
||||
{
|
||||
_lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
|
||||
}
|
||||
@@ -795,6 +796,20 @@ public class TopTabMenu
|
||||
}
|
||||
}
|
||||
|
||||
private string GetLightfinderFinderLabel()
|
||||
{
|
||||
string label = "Lightfinder";
|
||||
|
||||
if (_lightFinderService.IsBroadcasting)
|
||||
{
|
||||
var hashExclude = _dalamudUtilService.GetCID().ToString().GetHash256();
|
||||
var nearbyCount = _lightFinderScannerService.GetActiveBroadcasts(hashExclude).Count;
|
||||
return $"{label} ({nearbyCount})";
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
private string GetSyncshellFinderLabel()
|
||||
{
|
||||
if (!_lightFinderService.IsBroadcasting)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Extensions;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.Colors;
|
||||
using Dalamud.Interface.Utility;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
using LightlessSync.API.Dto.Chat;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.Services;
|
||||
@@ -14,13 +17,17 @@ using LightlessSync.Services.Chat;
|
||||
using LightlessSync.Services.LightFinder;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.Services.ServerConfiguration;
|
||||
using LightlessSync.UI.Models;
|
||||
using LightlessSync.UI.Services;
|
||||
using LightlessSync.UI.Style;
|
||||
using LightlessSync.Utils;
|
||||
using Dalamud.Interface.Textures.TextureWraps;
|
||||
using OtterGui.Text;
|
||||
using LightlessSync.WebAPI;
|
||||
using LightlessSync.WebAPI.SignalR.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using LightlessSync.PlayerData.Factories;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
|
||||
namespace LightlessSync.UI;
|
||||
|
||||
@@ -31,6 +38,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
private const string SettingsPopupId = "zone_chat_settings_popup";
|
||||
private const string ReportPopupId = "Report Message##zone_chat_report_popup";
|
||||
private const string ChannelDragPayloadId = "zone_chat_channel_drag";
|
||||
private const string EmotePickerPopupId = "zone_chat_emote_picker";
|
||||
private const int EmotePickerColumns = 10;
|
||||
private const float DefaultWindowOpacity = .97f;
|
||||
private const float DefaultUnfocusedWindowOpacity = 0.6f;
|
||||
private const float MinWindowOpacity = 0.05f;
|
||||
@@ -46,6 +55,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
private readonly UiSharedService _uiSharedService;
|
||||
private readonly ZoneChatService _zoneChatService;
|
||||
private readonly PairUiService _pairUiService;
|
||||
private readonly PairFactory _pairFactory;
|
||||
private readonly ChatEmoteService _chatEmoteService;
|
||||
private readonly LightFinderService _lightFinderService;
|
||||
private readonly LightlessProfileManager _profileManager;
|
||||
private readonly ApiController _apiController;
|
||||
@@ -54,6 +65,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private readonly IUiBuilder _uiBuilder;
|
||||
private readonly Dictionary<string, string> _draftMessages = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, List<string>> _pendingDraftClears = new(StringComparer.Ordinal);
|
||||
private readonly ImGuiWindowFlags _unpinnedWindowFlags;
|
||||
private float _currentWindowOpacity = DefaultWindowOpacity;
|
||||
private float _baseWindowOpacity = DefaultWindowOpacity;
|
||||
@@ -81,6 +93,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
private ChatReportResult? _reportSubmissionResult;
|
||||
private string? _dragChannelKey;
|
||||
private string? _dragHoverKey;
|
||||
private bool _openEmotePicker;
|
||||
private string _emoteFilter = string.Empty;
|
||||
private bool _HideStateActive;
|
||||
private bool _HideStateWasOpen;
|
||||
private bool _pushedStyle;
|
||||
@@ -91,6 +105,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
UiSharedService uiSharedService,
|
||||
ZoneChatService zoneChatService,
|
||||
PairUiService pairUiService,
|
||||
PairFactory pairFactory,
|
||||
ChatEmoteService chatEmoteService,
|
||||
LightFinderService lightFinderService,
|
||||
LightlessProfileManager profileManager,
|
||||
ChatConfigService chatConfigService,
|
||||
@@ -104,6 +120,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
_uiSharedService = uiSharedService;
|
||||
_zoneChatService = zoneChatService;
|
||||
_pairUiService = pairUiService;
|
||||
_pairFactory = pairFactory;
|
||||
_chatEmoteService = chatEmoteService;
|
||||
_lightFinderService = lightFinderService;
|
||||
_profileManager = profileManager;
|
||||
_chatConfigService = chatConfigService;
|
||||
@@ -188,7 +206,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
private void ApplyUiVisibilitySettings()
|
||||
{
|
||||
var config = _chatConfigService.Current;
|
||||
_uiBuilder.DisableAutomaticUiHide = config.ShowWhenUiHidden;
|
||||
_uiBuilder.DisableUserUiHide = true;
|
||||
_uiBuilder.DisableCutsceneUiHide = config.ShowInCutscenes;
|
||||
_uiBuilder.DisableGposeUiHide = config.ShowInGpose;
|
||||
}
|
||||
@@ -197,6 +215,11 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
var config = _chatConfigService.Current;
|
||||
|
||||
if (!config.ShowWhenUiHidden && _dalamudUtilService.IsGameUiHidden)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (config.HideInCombat && _dalamudUtilService.IsInCombat)
|
||||
{
|
||||
return true;
|
||||
@@ -386,6 +409,9 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
bottomColor);
|
||||
|
||||
var showTimestamps = _chatConfigService.Current.ShowMessageTimestamps;
|
||||
_chatEmoteService.EnsureGlobalEmotesLoaded();
|
||||
PairUiSnapshot? pairSnapshot = null;
|
||||
var itemSpacing = ImGui.GetStyle().ItemSpacing.X;
|
||||
|
||||
if (channel.Messages.Count == 0)
|
||||
{
|
||||
@@ -423,16 +449,109 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
timestampText = $"[{message.ReceivedAtUtc.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture)}] ";
|
||||
}
|
||||
var color = message.FromSelf ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudWhite;
|
||||
var showRoleIcons = false;
|
||||
var isOwner = false;
|
||||
var isModerator = false;
|
||||
var isPinned = false;
|
||||
|
||||
if (channel.Type == ChatChannelType.Group
|
||||
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
|
||||
&& payload.Sender.User is not null)
|
||||
{
|
||||
pairSnapshot ??= _pairUiService.GetSnapshot();
|
||||
var groupId = channel.Descriptor.CustomKey;
|
||||
if (!string.IsNullOrWhiteSpace(groupId)
|
||||
&& pairSnapshot.GroupsByGid.TryGetValue(groupId, out var groupInfo))
|
||||
{
|
||||
var senderUid = payload.Sender.User.UID;
|
||||
isOwner = string.Equals(senderUid, groupInfo.OwnerUID, StringComparison.Ordinal);
|
||||
if (groupInfo.GroupPairUserInfos.TryGetValue(senderUid, out var info))
|
||||
{
|
||||
isModerator = info.IsModerator();
|
||||
isPinned = info.IsPinned();
|
||||
}
|
||||
}
|
||||
|
||||
showRoleIcons = isOwner || isModerator || isPinned;
|
||||
}
|
||||
|
||||
ImGui.BeginGroup();
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, color);
|
||||
ImGui.TextWrapped($"{timestampText}{message.DisplayName}: {payload.Message}");
|
||||
ImGui.PopStyleColor();
|
||||
if (showRoleIcons)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(timestampText))
|
||||
{
|
||||
ImGui.TextUnformatted(timestampText);
|
||||
ImGui.SameLine(0f, 0f);
|
||||
}
|
||||
|
||||
var hasIcon = false;
|
||||
if (isModerator)
|
||||
{
|
||||
_uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple"));
|
||||
UiSharedService.AttachToolTip("Moderator");
|
||||
hasIcon = true;
|
||||
}
|
||||
|
||||
if (isOwner)
|
||||
{
|
||||
if (hasIcon)
|
||||
{
|
||||
ImGui.SameLine(0f, itemSpacing);
|
||||
}
|
||||
|
||||
_uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow"));
|
||||
UiSharedService.AttachToolTip("Owner");
|
||||
hasIcon = true;
|
||||
}
|
||||
|
||||
if (isPinned)
|
||||
{
|
||||
if (hasIcon)
|
||||
{
|
||||
ImGui.SameLine(0f, itemSpacing);
|
||||
}
|
||||
|
||||
_uiSharedService.IconText(FontAwesomeIcon.Thumbtack, UIColors.Get("LightlessBlue"));
|
||||
UiSharedService.AttachToolTip("Pinned");
|
||||
hasIcon = true;
|
||||
}
|
||||
|
||||
if (hasIcon)
|
||||
{
|
||||
ImGui.SameLine(0f, itemSpacing);
|
||||
}
|
||||
|
||||
var messageStartX = ImGui.GetCursorPosX();
|
||||
DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX);
|
||||
}
|
||||
else
|
||||
{
|
||||
var messageStartX = ImGui.GetCursorPosX();
|
||||
DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX);
|
||||
}
|
||||
ImGui.PopStyleColor();
|
||||
ImGui.EndGroup();
|
||||
|
||||
ImGui.SetNextWindowSizeConstraints(
|
||||
new Vector2(190f * ImGuiHelpers.GlobalScale, 0f),
|
||||
new Vector2(float.MaxValue, float.MaxValue));
|
||||
if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}"))
|
||||
{
|
||||
var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime();
|
||||
var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture);
|
||||
ImGui.TextDisabled(contextTimestampText);
|
||||
if (channel.Type == ChatChannelType.Group
|
||||
&& payload.Sender.Kind == ChatSenderKind.IdentifiedUser
|
||||
&& payload.Sender.User is not null)
|
||||
{
|
||||
var aliasOrUid = payload.Sender.User.AliasOrUID;
|
||||
if (!string.IsNullOrWhiteSpace(aliasOrUid)
|
||||
&& !string.Equals(message.DisplayName, aliasOrUid, StringComparison.Ordinal))
|
||||
{
|
||||
ImGui.TextDisabled(aliasOrUid);
|
||||
}
|
||||
}
|
||||
ImGui.Separator();
|
||||
|
||||
var actionIndex = 0;
|
||||
@@ -461,6 +580,335 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawChatMessageWithEmotes(string prefix, string message, float lineStartX)
|
||||
{
|
||||
var segments = BuildChatSegments(prefix, message);
|
||||
var firstOnLine = true;
|
||||
var emoteSize = new Vector2(ImGui.GetTextLineHeight());
|
||||
var remainingWidth = ImGui.GetContentRegionAvail().X;
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
if (segment.IsLineBreak)
|
||||
{
|
||||
if (firstOnLine)
|
||||
{
|
||||
ImGui.NewLine();
|
||||
}
|
||||
ImGui.SetCursorPosX(lineStartX);
|
||||
firstOnLine = true;
|
||||
remainingWidth = ImGui.GetContentRegionAvail().X;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segment.IsWhitespace && firstOnLine)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var segmentWidth = segment.IsEmote ? emoteSize.X : ImGui.CalcTextSize(segment.Text).X;
|
||||
if (!firstOnLine)
|
||||
{
|
||||
if (segmentWidth > remainingWidth)
|
||||
{
|
||||
ImGui.SetCursorPosX(lineStartX);
|
||||
firstOnLine = true;
|
||||
remainingWidth = ImGui.GetContentRegionAvail().X;
|
||||
if (segment.IsWhitespace)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.SameLine(0f, 0f);
|
||||
}
|
||||
}
|
||||
|
||||
if (segment.IsEmote && segment.Texture is not null)
|
||||
{
|
||||
ImGui.Image(segment.Texture.Handle, emoteSize);
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
DrawEmoteTooltip(segment.EmoteName ?? string.Empty, segment.Texture);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.TextUnformatted(segment.Text);
|
||||
}
|
||||
|
||||
remainingWidth -= segmentWidth;
|
||||
firstOnLine = false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void DrawEmotePickerPopup(ref string draft, string channelKey)
|
||||
{
|
||||
if (_openEmotePicker)
|
||||
{
|
||||
ImGui.OpenPopup(EmotePickerPopupId);
|
||||
_openEmotePicker = false;
|
||||
}
|
||||
|
||||
var style = ImGui.GetStyle();
|
||||
var scale = ImGuiHelpers.GlobalScale;
|
||||
var emoteSize = 32f * scale;
|
||||
var itemWidth = emoteSize + (style.FramePadding.X * 2f);
|
||||
var gridWidth = (itemWidth * EmotePickerColumns) + (style.ItemSpacing.X * Math.Max(0, EmotePickerColumns - 1));
|
||||
var scrollbarPadding = style.ScrollbarSize + (style.ItemSpacing.X * 2f) + (8f * scale);
|
||||
var windowWidth = gridWidth + scrollbarPadding + (style.WindowPadding.X * 2f);
|
||||
ImGui.SetNextWindowSize(new Vector2(windowWidth, 340f * scale), ImGuiCond.Always);
|
||||
if (!ImGui.BeginPopup(EmotePickerPopupId))
|
||||
return;
|
||||
|
||||
ImGui.TextUnformatted("Emotes");
|
||||
ImGui.Separator();
|
||||
|
||||
ImGui.SetNextItemWidth(-1f);
|
||||
ImGui.InputTextWithHint("##emote_filter", "Search Emotes", ref _emoteFilter, 50);
|
||||
ImGui.Spacing();
|
||||
|
||||
var emotes = _chatEmoteService.GetEmoteNames();
|
||||
var filter = _emoteFilter.Trim();
|
||||
var hasFilter = filter.Length > 0;
|
||||
|
||||
using (var child = ImRaii.Child("emote_picker_list", new Vector2(-1f, 0f), true))
|
||||
{
|
||||
if (child)
|
||||
{
|
||||
var any = false;
|
||||
var itemHeight = emoteSize + (style.FramePadding.Y * 2f);
|
||||
var cellWidth = itemWidth + style.ItemSpacing.X;
|
||||
var availableWidth = Math.Max(1f, ImGui.GetContentRegionAvail().X);
|
||||
var maxColumns = Math.Max(1, (int)MathF.Floor((availableWidth + style.ItemSpacing.X) / cellWidth));
|
||||
var columns = Math.Max(1, Math.Min(EmotePickerColumns, maxColumns));
|
||||
var columnIndex = 0;
|
||||
foreach (var emote in emotes)
|
||||
{
|
||||
if (hasFilter && !emote.Contains(filter, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
any = true;
|
||||
IDalamudTextureWrap? texture = null;
|
||||
_chatEmoteService.TryGetEmote(emote, out texture);
|
||||
|
||||
ImGui.PushID(emote);
|
||||
var clicked = false;
|
||||
if (texture is not null)
|
||||
{
|
||||
clicked = ImGui.ImageButton(texture.Handle, new Vector2(emoteSize));
|
||||
}
|
||||
else
|
||||
{
|
||||
clicked = ImGui.Button("?", new Vector2(itemWidth, itemHeight));
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
DrawEmoteTooltip(emote, texture);
|
||||
}
|
||||
|
||||
ImGui.PopID();
|
||||
|
||||
if (clicked)
|
||||
{
|
||||
AppendEmoteToDraft(ref draft, emote);
|
||||
_draftMessages[channelKey] = draft;
|
||||
_refocusChatInput = true;
|
||||
_refocusChatInputKey = channelKey;
|
||||
ImGui.CloseCurrentPopup();
|
||||
break;
|
||||
}
|
||||
|
||||
columnIndex++;
|
||||
if (columnIndex < columns)
|
||||
{
|
||||
ImGui.SameLine();
|
||||
}
|
||||
else
|
||||
{
|
||||
columnIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!any)
|
||||
{
|
||||
ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3);
|
||||
ImGui.TextUnformatted(emotes.Count == 0 ? "Loading emotes..." : "No emotes found.");
|
||||
ImGui.PopStyleColor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.EndPopup();
|
||||
}
|
||||
|
||||
private static void AppendEmoteToDraft(ref string draft, string emote)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(draft))
|
||||
{
|
||||
draft = emote;
|
||||
return;
|
||||
}
|
||||
|
||||
if (char.IsWhiteSpace(draft[^1]))
|
||||
{
|
||||
draft += emote;
|
||||
}
|
||||
else
|
||||
{
|
||||
draft += " " + emote;
|
||||
}
|
||||
}
|
||||
|
||||
private List<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)
|
||||
{
|
||||
const int MaxMessageLength = ZoneChatService.MaxOutgoingLength;
|
||||
@@ -469,9 +917,10 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
draft ??= string.Empty;
|
||||
|
||||
var style = ImGui.GetStyle();
|
||||
var sendButtonWidth = 100f * ImGuiHelpers.GlobalScale;
|
||||
var sendButtonWidth = 70f * ImGuiHelpers.GlobalScale;
|
||||
var emoteButtonWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Comments).X;
|
||||
var counterWidth = ImGui.CalcTextSize($"{MaxMessageLength}/{MaxMessageLength}").X;
|
||||
var reservedWidth = sendButtonWidth + counterWidth + style.ItemSpacing.X * 2f;
|
||||
var reservedWidth = sendButtonWidth + emoteButtonWidth + counterWidth + style.ItemSpacing.X * 3f;
|
||||
|
||||
ImGui.SetNextItemWidth(-reservedWidth);
|
||||
var inputId = $"##chat-input-{channel.Key}";
|
||||
@@ -482,7 +931,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
_refocusChatInputKey = null;
|
||||
}
|
||||
ImGui.InputText(inputId, ref draft, MaxMessageLength);
|
||||
if (ImGui.IsItemActive() || ImGui.IsItemFocused())
|
||||
if (ImGui.IsItemActive())
|
||||
{
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
var itemMin = ImGui.GetItemRectMin();
|
||||
@@ -504,10 +953,22 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
ImGui.SameLine();
|
||||
var buttonScreenPos = ImGui.GetCursorScreenPos();
|
||||
var rightEdgeScreen = ImGui.GetWindowPos().X + ImGui.GetWindowContentRegionMax().X;
|
||||
var desiredButtonX = rightEdgeScreen - sendButtonWidth;
|
||||
var minButtonX = buttonScreenPos.X + style.ItemSpacing.X;
|
||||
var finalButtonX = MathF.Max(minButtonX, desiredButtonX);
|
||||
ImGui.SetCursorScreenPos(new Vector2(finalButtonX, buttonScreenPos.Y));
|
||||
var desiredSendX = rightEdgeScreen - sendButtonWidth;
|
||||
var sendX = MathF.Max(minButtonX + emoteButtonWidth + style.ItemSpacing.X, desiredSendX);
|
||||
var emoteX = sendX - style.ItemSpacing.X - emoteButtonWidth;
|
||||
|
||||
ImGui.SetCursorScreenPos(new Vector2(emoteX, buttonScreenPos.Y));
|
||||
if (_uiSharedService.IconButton(FontAwesomeIcon.Comments))
|
||||
{
|
||||
_openEmotePicker = true;
|
||||
}
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.SetTooltip("Open Emotes");
|
||||
}
|
||||
|
||||
ImGui.SetCursorScreenPos(new Vector2(sendX, buttonScreenPos.Y));
|
||||
var sendColor = UIColors.Get("LightlessPurpleDefault");
|
||||
var sendHovered = UIColors.Get("LightlessPurple");
|
||||
var sendActive = UIColors.Get("LightlessPurpleActive");
|
||||
@@ -518,7 +979,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
var sendClicked = false;
|
||||
using (ImRaii.Disabled(!canSend))
|
||||
{
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.PaperPlane, $"Send##chat-send-{channel.Key}", 100f * ImGuiHelpers.GlobalScale, center: true))
|
||||
if (_uiSharedService.IconTextButton(FontAwesomeIcon.PaperPlane, $"Send##chat-send-{channel.Key}", sendButtonWidth, center: true))
|
||||
{
|
||||
sendClicked = true;
|
||||
}
|
||||
@@ -526,47 +987,71 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
ImGui.PopStyleVar();
|
||||
ImGui.PopStyleColor(3);
|
||||
|
||||
DrawEmotePickerPopup(ref draft, channel.Key);
|
||||
|
||||
if (canSend && (enterPressed || sendClicked))
|
||||
{
|
||||
_refocusChatInput = true;
|
||||
_refocusChatInputKey = channel.Key;
|
||||
if (TrySendDraft(channel, draft))
|
||||
|
||||
var draftAtSend = draft;
|
||||
var sanitized = SanitizeOutgoingDraft(draftAtSend);
|
||||
|
||||
if (sanitized is not null)
|
||||
{
|
||||
_draftMessages[channel.Key] = string.Empty;
|
||||
TrackPendingDraftClear(channel.Key, sanitized);
|
||||
draft = string.Empty;
|
||||
_draftMessages[channel.Key] = draft;
|
||||
_scrollToBottom = true;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var succeeded = await _zoneChatService.SendMessageAsync(channel.Descriptor, sanitized).ConfigureAwait(false);
|
||||
if (!succeeded)
|
||||
{
|
||||
RemovePendingDraftClear(channel.Key, sanitized);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send chat message");
|
||||
RemovePendingDraftClear(channel.Key, sanitized);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawRulesOverlay()
|
||||
{
|
||||
var windowPos = ImGui.GetWindowPos();
|
||||
var windowSize = ImGui.GetWindowSize();
|
||||
var parentContentMin = ImGui.GetWindowContentRegionMin();
|
||||
var parentContentMax = ImGui.GetWindowContentRegionMax();
|
||||
var overlayPos = windowPos + parentContentMin;
|
||||
var overlaySize = parentContentMax - parentContentMin;
|
||||
|
||||
if (overlaySize.X <= 0f || overlaySize.Y <= 0f)
|
||||
{
|
||||
overlayPos = windowPos;
|
||||
overlaySize = windowSize;
|
||||
parentContentMin = Vector2.Zero;
|
||||
overlaySize = ImGui.GetWindowSize();
|
||||
}
|
||||
|
||||
ImGui.SetNextWindowFocus();
|
||||
ImGui.SetNextWindowPos(overlayPos);
|
||||
ImGui.SetNextWindowSize(overlaySize);
|
||||
ImGui.SetNextWindowBgAlpha(0.86f);
|
||||
ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * ImGuiHelpers.GlobalScale);
|
||||
ImGui.PushStyleColor(ImGuiCol.Border, Vector4.Zero);
|
||||
var previousCursor = ImGui.GetCursorPos();
|
||||
ImGui.SetCursorPos(parentContentMin);
|
||||
|
||||
var overlayFlags = ImGuiWindowFlags.NoDecoration
|
||||
| ImGuiWindowFlags.NoMove
|
||||
| ImGuiWindowFlags.NoScrollbar
|
||||
var bgColor = ImGui.GetStyle().Colors[(int)ImGuiCol.WindowBg];
|
||||
bgColor.W = 0.86f;
|
||||
|
||||
ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 6f * ImGuiHelpers.GlobalScale);
|
||||
ImGui.PushStyleVar(ImGuiStyleVar.ChildBorderSize, 0f);
|
||||
ImGui.PushStyleColor(ImGuiCol.Border, Vector4.Zero);
|
||||
ImGui.PushStyleColor(ImGuiCol.ChildBg, bgColor);
|
||||
|
||||
var overlayFlags = ImGuiWindowFlags.NoScrollbar
|
||||
| ImGuiWindowFlags.NoScrollWithMouse
|
||||
| ImGuiWindowFlags.NoSavedSettings;
|
||||
|
||||
var overlayOpen = true;
|
||||
if (ImGui.Begin("##zone_chat_rules_overlay", ref overlayOpen, overlayFlags))
|
||||
if (ImGui.BeginChild("##zone_chat_rules_overlay", overlaySize, false, overlayFlags))
|
||||
{
|
||||
var contentMin = ImGui.GetWindowContentRegionMin();
|
||||
var contentMax = ImGui.GetWindowContentRegionMax();
|
||||
@@ -686,16 +1171,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
_showRulesOverlay = false;
|
||||
}
|
||||
|
||||
if (!overlayOpen)
|
||||
{
|
||||
_showRulesOverlay = false;
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.End();
|
||||
ImGui.PopStyleColor();
|
||||
ImGui.PopStyleVar();
|
||||
ImGui.EndChild();
|
||||
ImGui.PopStyleColor(2);
|
||||
ImGui.PopStyleVar(2);
|
||||
ImGui.SetCursorPos(previousCursor);
|
||||
}
|
||||
|
||||
private void DrawReportPopup()
|
||||
@@ -943,16 +1424,15 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
_reportPopupRequested = false;
|
||||
}
|
||||
|
||||
private bool TrySendDraft(ChatChannelSnapshot channel, string draft)
|
||||
private bool TrySendDraft(ChatChannelSnapshot channel, string sanitizedMessage)
|
||||
{
|
||||
var trimmed = draft.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
if (string.IsNullOrWhiteSpace(sanitizedMessage))
|
||||
return false;
|
||||
|
||||
bool succeeded;
|
||||
try
|
||||
{
|
||||
succeeded = _zoneChatService.SendMessageAsync(channel.Descriptor, trimmed).GetAwaiter().GetResult();
|
||||
succeeded = _zoneChatService.SendMessageAsync(channel.Descriptor, sanitizedMessage).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -987,6 +1467,21 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
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)
|
||||
@@ -1094,6 +1589,91 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
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)
|
||||
{
|
||||
_profileManager.GetLightlessProfile(user);
|
||||
@@ -1124,6 +1704,92 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
{
|
||||
_scrollToBottom = true;
|
||||
}
|
||||
|
||||
if (!message.Message.FromSelf || message.Message.Payload?.Message is not { Length: > 0 } payloadText)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var matchedPending = false;
|
||||
if (_pendingDraftClears.TryGetValue(message.ChannelKey, out var pending))
|
||||
{
|
||||
var pendingIndex = pending.FindIndex(text => string.Equals(text, payloadText, StringComparison.Ordinal));
|
||||
if (pendingIndex >= 0)
|
||||
{
|
||||
pending.RemoveAt(pendingIndex);
|
||||
matchedPending = true;
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
_pendingDraftClears.Remove(message.ChannelKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedPending && _draftMessages.TryGetValue(message.ChannelKey, out var currentDraft))
|
||||
{
|
||||
var sanitizedCurrent = SanitizeOutgoingDraft(currentDraft);
|
||||
if (sanitizedCurrent is not null && string.Equals(sanitizedCurrent, payloadText, StringComparison.Ordinal))
|
||||
{
|
||||
_draftMessages[message.ChannelKey] = string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? SanitizeOutgoingDraft(string draft)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(draft))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sanitized = draft.Trim().ReplaceLineEndings(" ");
|
||||
if (sanitized.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sanitized.Length > ZoneChatService.MaxOutgoingLength)
|
||||
{
|
||||
sanitized = sanitized[..ZoneChatService.MaxOutgoingLength];
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private void TrackPendingDraftClear(string channelKey, string message)
|
||||
{
|
||||
if (!_pendingDraftClears.TryGetValue(channelKey, out var pending))
|
||||
{
|
||||
pending = new List<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)
|
||||
@@ -1407,6 +2073,17 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
ImGui.SetTooltip("Toggles the timestamp prefix on messages.");
|
||||
}
|
||||
|
||||
var showNotesInSyncshellChat = chatConfig.ShowNotesInSyncshellChat;
|
||||
if (ImGui.Checkbox("Show notes in syncshell chat", ref showNotesInSyncshellChat))
|
||||
{
|
||||
chatConfig.ShowNotesInSyncshellChat = showNotesInSyncshellChat;
|
||||
_chatConfigService.Save();
|
||||
}
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.SetTooltip("When enabled, your notes replace user names in syncshell chat.");
|
||||
}
|
||||
|
||||
ImGui.Separator();
|
||||
ImGui.TextUnformatted("Chat Visibility");
|
||||
|
||||
@@ -1993,6 +2670,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
private void DrawContextMenuAction(ChatMessageContextAction action, int index)
|
||||
{
|
||||
ImGui.PushID(index);
|
||||
if (action.IsSeparator)
|
||||
{
|
||||
ImGui.Separator();
|
||||
ImGui.PopID();
|
||||
return;
|
||||
}
|
||||
using var disabled = ImRaii.Disabled(!action.IsEnabled);
|
||||
|
||||
var availableWidth = Math.Max(1f, ImGui.GetContentRegionAvail().X);
|
||||
@@ -2025,6 +2708,11 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
|
||||
drawList.AddText(textPos, textColor, action.Label);
|
||||
|
||||
if (action.Tooltip is { Length: > 0 } && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
|
||||
{
|
||||
ImGui.SetTooltip(action.Tooltip);
|
||||
}
|
||||
|
||||
if (clicked && action.IsEnabled)
|
||||
{
|
||||
ImGui.CloseCurrentPopup();
|
||||
@@ -2034,5 +2722,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase
|
||||
ImGui.PopID();
|
||||
}
|
||||
|
||||
private readonly record struct ChatMessageContextAction(FontAwesomeIcon? Icon, string Label, bool IsEnabled, Action Execute);
|
||||
private static void NoopContextAction()
|
||||
{
|
||||
}
|
||||
|
||||
private readonly record struct ChatMessageContextAction(FontAwesomeIcon? Icon, string Label, bool IsEnabled, Action Execute, string? Tooltip = null, bool IsSeparator = false)
|
||||
{
|
||||
public static ChatMessageContextAction Separator() => new(null, string.Empty, false, ZoneChatUi.NoopContextAction, null, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LightlessSync.UtilsEnum.Enum
|
||||
namespace LightlessSync.UtilsEnum.Enum
|
||||
{
|
||||
public enum LabelAlignment
|
||||
{
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace LightlessSync.UtilsEnum.Enum
|
||||
{
|
||||
public enum LightfinderLabelRenderer
|
||||
{
|
||||
Pictomancy,
|
||||
SignatureHook,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,56 +18,72 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
|
||||
private readonly LightlessConfigService _lightlessConfig;
|
||||
private readonly object _semaphoreModificationLock = new();
|
||||
private readonly TokenProvider _tokenProvider;
|
||||
|
||||
private int _availableDownloadSlots;
|
||||
private SemaphoreSlim _downloadSemaphore;
|
||||
|
||||
private int CurrentlyUsedDownloadSlots => _availableDownloadSlots - _downloadSemaphore.CurrentCount;
|
||||
|
||||
public FileTransferOrchestrator(ILogger<FileTransferOrchestrator> logger, LightlessConfigService lightlessConfig,
|
||||
LightlessMediator mediator, TokenProvider tokenProvider, HttpClient httpClient) : base(logger, mediator)
|
||||
public FileTransferOrchestrator(
|
||||
ILogger<FileTransferOrchestrator> logger,
|
||||
LightlessConfigService lightlessConfig,
|
||||
LightlessMediator mediator,
|
||||
TokenProvider tokenProvider,
|
||||
HttpClient httpClient) : base(logger, mediator)
|
||||
{
|
||||
_lightlessConfig = lightlessConfig;
|
||||
_tokenProvider = tokenProvider;
|
||||
_httpClient = httpClient;
|
||||
|
||||
var ver = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LightlessSync", ver!.Major + "." + ver!.Minor + "." + ver!.Build));
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.Add(
|
||||
new ProductInfoHeaderValue("LightlessSync", $"{ver!.Major}.{ver.Minor}.{ver.Build}"));
|
||||
|
||||
_availableDownloadSlots = lightlessConfig.Current.ParallelDownloads;
|
||||
_downloadSemaphore = new(_availableDownloadSlots, _availableDownloadSlots);
|
||||
_availableDownloadSlots = Math.Max(1, lightlessConfig.Current.ParallelDownloads);
|
||||
_downloadSemaphore = new SemaphoreSlim(_availableDownloadSlots, _availableDownloadSlots);
|
||||
|
||||
Mediator.Subscribe<ConnectedMessage>(this, (msg) =>
|
||||
{
|
||||
FilesCdnUri = msg.Connection.ServerInfo.FileServerAddress;
|
||||
});
|
||||
|
||||
Mediator.Subscribe<DisconnectedMessage>(this, (msg) =>
|
||||
{
|
||||
FilesCdnUri = null;
|
||||
});
|
||||
Mediator.Subscribe<DownloadReadyMessage>(this, (msg) =>
|
||||
{
|
||||
_downloadReady[msg.RequestId] = true;
|
||||
});
|
||||
Mediator.Subscribe<ConnectedMessage>(this, msg => FilesCdnUri = msg.Connection.ServerInfo.FileServerAddress);
|
||||
Mediator.Subscribe<DisconnectedMessage>(this, _ => FilesCdnUri = null);
|
||||
Mediator.Subscribe<DownloadReadyMessage>(this, msg => _downloadReady[msg.RequestId] = true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Files CDN Uri from server
|
||||
/// </summary>
|
||||
public Uri? FilesCdnUri { private set; get; }
|
||||
|
||||
/// <summary>
|
||||
/// Forbidden file transfers given by server
|
||||
/// </summary>
|
||||
public List<FileTransfer> ForbiddenTransfers { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Is the FileTransferOrchestrator initialized
|
||||
/// </summary>
|
||||
public bool IsInitialized => FilesCdnUri != null;
|
||||
|
||||
public void ClearDownloadRequest(Guid guid)
|
||||
{
|
||||
_downloadReady.Remove(guid, out _);
|
||||
}
|
||||
/// <summary>
|
||||
/// Configured parallel downloads in settings (ParallelDownloads)
|
||||
/// </summary>
|
||||
public int ConfiguredParallelDownloads => Math.Max(1, _lightlessConfig.Current.ParallelDownloads);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the download request for the given guid
|
||||
/// </summary>
|
||||
/// <param name="guid">Guid of download request</param>
|
||||
public void ClearDownloadRequest(Guid guid) => _downloadReady.Remove(guid, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Is the download ready for the given guid
|
||||
/// </summary>
|
||||
/// <param name="guid">Guid of download request</param>
|
||||
/// <returns>Completion of the download</returns>
|
||||
public bool IsDownloadReady(Guid guid)
|
||||
{
|
||||
if (_downloadReady.TryGetValue(guid, out bool isReady) && isReady)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
=> _downloadReady.TryGetValue(guid, out bool isReady) && isReady;
|
||||
|
||||
/// <summary>
|
||||
/// Release a download slot after download is complete
|
||||
/// </summary>
|
||||
public void ReleaseDownloadSlot()
|
||||
{
|
||||
try
|
||||
@@ -81,60 +97,26 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> SendRequestAsync(HttpMethod method, Uri uri,
|
||||
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead,
|
||||
bool withToken = true)
|
||||
{
|
||||
return await SendRequestInternalAsync(() => new HttpRequestMessage(method, uri),
|
||||
ct, httpCompletionOption, withToken, allowRetry: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> SendRequestAsync<T>(HttpMethod method, Uri uri, T content, CancellationToken ct,
|
||||
bool withToken = true) where T : class
|
||||
{
|
||||
return await SendRequestInternalAsync(() =>
|
||||
{
|
||||
var requestMessage = new HttpRequestMessage(method, uri);
|
||||
if (content is not ByteArrayContent byteArrayContent)
|
||||
{
|
||||
requestMessage.Content = JsonContent.Create(content);
|
||||
}
|
||||
else
|
||||
{
|
||||
var clonedContent = new ByteArrayContent(byteArrayContent.ReadAsByteArrayAsync().GetAwaiter().GetResult());
|
||||
foreach (var header in byteArrayContent.Headers)
|
||||
{
|
||||
clonedContent.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
requestMessage.Content = clonedContent;
|
||||
}
|
||||
|
||||
return requestMessage;
|
||||
}, ct, HttpCompletionOption.ResponseContentRead, withToken,
|
||||
allowRetry: content is not HttpContent || content is ByteArrayContent).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> SendRequestStreamAsync(HttpMethod method, Uri uri, ProgressableStreamContent content,
|
||||
CancellationToken ct, bool withToken = true)
|
||||
{
|
||||
return await SendRequestInternalAsync(() =>
|
||||
{
|
||||
var requestMessage = new HttpRequestMessage(method, uri)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
return requestMessage;
|
||||
}, ct, HttpCompletionOption.ResponseContentRead, withToken, allowRetry: false).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for an available download slot asyncronously
|
||||
/// </summary>
|
||||
/// <param name="token">Cancellation Token</param>
|
||||
/// <returns>Task of the slot</returns>
|
||||
public async Task WaitForDownloadSlotAsync(CancellationToken token)
|
||||
{
|
||||
lock (_semaphoreModificationLock)
|
||||
{
|
||||
if (_availableDownloadSlots != _lightlessConfig.Current.ParallelDownloads && _availableDownloadSlots == _downloadSemaphore.CurrentCount)
|
||||
var desired = Math.Max(1, _lightlessConfig.Current.ParallelDownloads);
|
||||
|
||||
if (_availableDownloadSlots != desired &&
|
||||
_availableDownloadSlots == _downloadSemaphore.CurrentCount)
|
||||
{
|
||||
_availableDownloadSlots = _lightlessConfig.Current.ParallelDownloads;
|
||||
_downloadSemaphore = new(_availableDownloadSlots, _availableDownloadSlots);
|
||||
_availableDownloadSlots = desired;
|
||||
|
||||
var old = _downloadSemaphore;
|
||||
_downloadSemaphore = new SemaphoreSlim(_availableDownloadSlots, _availableDownloadSlots);
|
||||
|
||||
try { old.Dispose(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,10 +124,15 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
|
||||
Mediator.Publish(new DownloadLimitChangedMessage());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download limit per slot in bytes
|
||||
/// </summary>
|
||||
/// <returns>Bytes of the download limit</returns>
|
||||
public long DownloadLimitPerSlot()
|
||||
{
|
||||
var limit = _lightlessConfig.Current.DownloadSpeedLimitInBytes;
|
||||
if (limit <= 0) return 0;
|
||||
|
||||
limit = _lightlessConfig.Current.DownloadSpeedType switch
|
||||
{
|
||||
LightlessConfiguration.Models.DownloadSpeeds.Bps => limit,
|
||||
@@ -153,22 +140,113 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
|
||||
LightlessConfiguration.Models.DownloadSpeeds.MBps => limit * 1024 * 1024,
|
||||
_ => limit,
|
||||
};
|
||||
var currentUsedDlSlots = CurrentlyUsedDownloadSlots;
|
||||
var avaialble = _availableDownloadSlots;
|
||||
var currentCount = _downloadSemaphore.CurrentCount;
|
||||
var dividedLimit = limit / (currentUsedDlSlots == 0 ? 1 : currentUsedDlSlots);
|
||||
if (dividedLimit < 0)
|
||||
|
||||
var usedSlots = CurrentlyUsedDownloadSlots;
|
||||
var divided = limit / (usedSlots <= 0 ? 1 : usedSlots);
|
||||
|
||||
if (divided < 0)
|
||||
{
|
||||
Logger.LogWarning("Calculated Bandwidth Limit is negative, returning Infinity: {value}, CurrentlyUsedDownloadSlots is {currentSlots}, " +
|
||||
"DownloadSpeedLimit is {limit}, available slots: {avail}, current count: {count}", dividedLimit, currentUsedDlSlots, limit, avaialble, currentCount);
|
||||
Logger.LogWarning(
|
||||
"Calculated Bandwidth Limit is negative, returning Infinity: {value}, usedSlots={usedSlots}, limit={limit}, avail={avail}, currentCount={count}",
|
||||
divided, usedSlots, limit, _availableDownloadSlots, _downloadSemaphore.CurrentCount);
|
||||
return long.MaxValue;
|
||||
}
|
||||
return Math.Clamp(dividedLimit, 1, long.MaxValue);
|
||||
|
||||
return Math.Clamp(divided, 1, long.MaxValue);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendRequestInternalAsync(Func<HttpRequestMessage> requestFactory,
|
||||
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead,
|
||||
bool withToken = true, bool allowRetry = true)
|
||||
/// <summary>
|
||||
/// sends an HTTP request without content serialization
|
||||
/// </summary>
|
||||
/// <param name="method">HttpMethod for the request</param>
|
||||
/// <param name="uri">Uri for the request</param>
|
||||
/// <param name="ct">Cancellation Token</param>
|
||||
/// <param name="httpCompletionOption">Enum of HttpCollectionOption</param>
|
||||
/// <param name="withToken">Include Cancellation Token</param>
|
||||
/// <returns>Http response of the request</returns>
|
||||
public async Task<HttpResponseMessage> SendRequestAsync(
|
||||
HttpMethod method,
|
||||
Uri uri,
|
||||
CancellationToken? ct = null,
|
||||
HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead,
|
||||
bool withToken = true)
|
||||
{
|
||||
return await SendRequestInternalAsync(
|
||||
() => new HttpRequestMessage(method, uri),
|
||||
ct,
|
||||
httpCompletionOption,
|
||||
withToken,
|
||||
allowRetry: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an HTTP request with JSON content serialization
|
||||
/// </summary>
|
||||
/// <typeparam name="T">HttpResponseMessage</typeparam>
|
||||
/// <param name="method">Http method</param>
|
||||
/// <param name="uri">Url of the direct download link</param>
|
||||
/// <param name="content">content of the request</param>
|
||||
/// <param name="ct">cancellation token</param>
|
||||
/// <param name="withToken">include cancellation token</param>
|
||||
/// <returns></returns>
|
||||
public async Task<HttpResponseMessage> SendRequestAsync<T>(
|
||||
HttpMethod method,
|
||||
Uri uri,
|
||||
T content,
|
||||
CancellationToken ct,
|
||||
bool withToken = true) where T : class
|
||||
{
|
||||
return await SendRequestInternalAsync(() =>
|
||||
{
|
||||
var requestMessage = new HttpRequestMessage(method, uri);
|
||||
|
||||
if (content is ByteArrayContent byteArrayContent)
|
||||
{
|
||||
var bytes = byteArrayContent.ReadAsByteArrayAsync(ct).GetAwaiter().GetResult();
|
||||
var cloned = new ByteArrayContent(bytes);
|
||||
foreach (var header in byteArrayContent.Headers)
|
||||
cloned.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
|
||||
requestMessage.Content = cloned;
|
||||
}
|
||||
else
|
||||
{
|
||||
requestMessage.Content = JsonContent.Create(content);
|
||||
}
|
||||
|
||||
return requestMessage;
|
||||
}, ct, HttpCompletionOption.ResponseContentRead, withToken,
|
||||
allowRetry: content is not HttpContent || content is ByteArrayContent).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> SendRequestStreamAsync(
|
||||
HttpMethod method,
|
||||
Uri uri,
|
||||
ProgressableStreamContent content,
|
||||
CancellationToken ct,
|
||||
bool withToken = true)
|
||||
{
|
||||
return await SendRequestInternalAsync(() =>
|
||||
{
|
||||
return new HttpRequestMessage(method, uri) { Content = content };
|
||||
}, ct, HttpCompletionOption.ResponseContentRead, withToken, allowRetry: false).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// sends an HTTP request with optional retry logic for transient network errors
|
||||
/// </summary>
|
||||
/// <param name="requestFactory">Request factory</param>
|
||||
/// <param name="ct">Cancellation Token</param>
|
||||
/// <param name="httpCompletionOption">Http Options</param>
|
||||
/// <param name="withToken">With cancellation token</param>
|
||||
/// <param name="allowRetry">Allows retry of request</param>
|
||||
/// <returns>Response message of request</returns>
|
||||
private async Task<HttpResponseMessage> SendRequestInternalAsync(
|
||||
Func<HttpRequestMessage> requestFactory,
|
||||
CancellationToken? ct = null,
|
||||
HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead,
|
||||
bool withToken = true,
|
||||
bool allowRetry = true)
|
||||
{
|
||||
const int maxAttempts = 2;
|
||||
var attempt = 0;
|
||||
@@ -184,8 +262,11 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
|
||||
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
if (requestMessage.Content != null && requestMessage.Content is not StreamContent && requestMessage.Content is not ByteArrayContent)
|
||||
if (requestMessage.Content != null &&
|
||||
requestMessage.Content is not StreamContent &&
|
||||
requestMessage.Content is not ByteArrayContent)
|
||||
{
|
||||
// log content for debugging
|
||||
var content = await ((JsonContent)requestMessage.Content).ReadAsStringAsync().ConfigureAwait(false);
|
||||
Logger.LogDebug("Sending {method} to {uri} (Content: {content})", requestMessage.Method, requestMessage.RequestUri, content);
|
||||
}
|
||||
@@ -196,9 +277,10 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
|
||||
|
||||
try
|
||||
{
|
||||
if (ct != null)
|
||||
return await _httpClient.SendAsync(requestMessage, httpCompletionOption, ct.Value).ConfigureAwait(false);
|
||||
return await _httpClient.SendAsync(requestMessage, httpCompletionOption).ConfigureAwait(false);
|
||||
// send request
|
||||
return ct != null
|
||||
? await _httpClient.SendAsync(requestMessage, httpCompletionOption, ct.Value).ConfigureAwait(false)
|
||||
: await _httpClient.SendAsync(requestMessage, httpCompletionOption).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
@@ -208,14 +290,11 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
|
||||
{
|
||||
Logger.LogWarning(ex, "Transient error during SendRequestInternal for {uri}, retrying attempt {attempt}/{maxAttempts}",
|
||||
requestMessage.RequestUri, attempt, maxAttempts);
|
||||
|
||||
if (ct.HasValue)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), ct.Value).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -225,6 +304,11 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Is the exception a transient network exception
|
||||
/// </summary>
|
||||
/// <param name="ex">expection</param>
|
||||
/// <returns>Is transient network expection</returns>
|
||||
private static bool IsTransientNetworkException(Exception ex)
|
||||
{
|
||||
var current = ex;
|
||||
@@ -232,12 +316,13 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
|
||||
{
|
||||
if (current is SocketException socketEx)
|
||||
{
|
||||
return socketEx.SocketErrorCode is SocketError.ConnectionReset or SocketError.ConnectionAborted or SocketError.TimedOut;
|
||||
return socketEx.SocketErrorCode is
|
||||
SocketError.ConnectionReset or
|
||||
SocketError.ConnectionAborted or
|
||||
SocketError.TimedOut;
|
||||
}
|
||||
|
||||
current = current.InnerException;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ public partial class ApiController
|
||||
CensusDataDto? censusDto = null;
|
||||
if (_serverManager.SendCensusData && _lastCensus != null)
|
||||
{
|
||||
var world = await _dalamudUtil.GetWorldIdAsync().ConfigureAwait(false);
|
||||
var world = _dalamudUtil.GetWorldId();
|
||||
censusDto = new((ushort)world, _lastCensus.RaceId, _lastCensus.TribeId, _lastCensus.Gender);
|
||||
Logger.LogDebug("Attaching Census Data: {data}", censusDto);
|
||||
}
|
||||
|
||||
@@ -544,8 +544,8 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
||||
|
||||
private void DalamudUtilOnLogIn()
|
||||
{
|
||||
var charaName = _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult();
|
||||
var worldId = _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult();
|
||||
var charaName = _dalamudUtil.GetPlayerName();
|
||||
var worldId = _dalamudUtil.GetHomeWorldId();
|
||||
var auth = _serverManager.CurrentServer.Authentications.Find(f => string.Equals(f.CharacterName, charaName, StringComparison.Ordinal) && f.WorldId == worldId);
|
||||
if (auth?.AutoLogin ?? false)
|
||||
{
|
||||
@@ -653,7 +653,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
|
||||
CensusDataDto? dto = null;
|
||||
if (_serverManager.SendCensusData && _lastCensus != null)
|
||||
{
|
||||
var world = await _dalamudUtil.GetWorldIdAsync().ConfigureAwait(false);
|
||||
var world = _dalamudUtil.GetWorldId();
|
||||
dto = new((ushort)world, _lastCensus.RaceId, _lastCensus.TribeId, _lastCensus.Gender);
|
||||
Logger.LogDebug("Attaching Census Data: {data}", dto);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ public sealed class TokenProvider : IDisposable, IMediatorSubscriber
|
||||
result = await _httpClient.PostAsync(tokenUri, new FormUrlEncodedContent(
|
||||
[
|
||||
new KeyValuePair<string, string>("auth", auth),
|
||||
new KeyValuePair<string, string>("charaIdent", await _dalamudUtil.GetPlayerNameHashedAsync().ConfigureAwait(false)),
|
||||
new KeyValuePair<string, string>("charaIdent", _dalamudUtil.GetPlayerNameHashed()),
|
||||
]), ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
@@ -152,7 +152,7 @@ public sealed class TokenProvider : IDisposable, IMediatorSubscriber
|
||||
JwtIdentifier jwtIdentifier;
|
||||
try
|
||||
{
|
||||
var playerIdentifier = await _dalamudUtil.GetPlayerNameHashedAsync().ConfigureAwait(false);
|
||||
var playerIdentifier = _dalamudUtil.GetPlayerNameHashed();
|
||||
|
||||
if (string.IsNullOrEmpty(playerIdentifier))
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user