Compare commits

...

47 Commits

Author SHA1 Message Date
defnotken
de2b537fb1 bump
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m7s
2025-12-27 22:03:19 -06:00
defnotken
838f4d1b1b Merge branch '2.0.2' into dev 2025-12-27 22:02:45 -06:00
474fd5ef11 Merge pull request 'Fix Async hiccup in chat.' (#117) from quick-chat-fix into 2.0.2
Reviewed-on: #117
Reviewed-by: cake <cake@noreply.git.lightless-sync.org>
2025-12-28 03:58:35 +00:00
defnotken
759066731e Fix Async hiccup in chat. 2025-12-27 21:57:01 -06:00
defnotken
3a838077ac Merge branch '2.0.2' into dev
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m10s
2025-12-27 21:40:07 -06:00
defnotken
ff88e5f856 Merge branch '2.0.2' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 2.0.2 2025-12-27 21:38:44 -06:00
defnotken
61bac0d39d Changelog update 2025-12-27 21:38:11 -06:00
5b3d00b90a API14 Updates - Migrate to IPlayerState (#113)
- use IPlayerState for DalamudUtilService and make things less async
- make LocationInfo work with ContentFinderData

Co-authored-by: Tsubasahane <wozaiha@gmail.com>
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #113
Reviewed-by: cake <cake@noreply.git.lightless-sync.org>
Co-authored-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
Co-committed-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
2025-12-28 03:26:07 +00:00
defnotken
fe9122e0d2 build out dev 2025-12-27 20:50:09 -06:00
e14d50674d Update World Data/Job Data to client lang (#115)
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #115
Reviewed-by: cake <cake@noreply.git.lightless-sync.org>
Co-authored-by: defnotken <defnotken@noreply.git.lightless-sync.org>
Co-committed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-28 02:46:47 +00:00
129cf14151 Merge pull request 'Fixed chat input not clearing after sending.' (#116) from fix-chat-send into 2.0.2
Reviewed-on: #116
2025-12-28 02:46:21 +00:00
cake
dba04d740b Fixed chat input not clearing after sending. 2025-12-28 03:45:02 +01:00
04d7a66317 Merge pull request 'Fixed some occlusion checking on invincible elements, added debug mode imgui lightfinder, added caching file cache.' (#114) from debug-mode-lightfinder-imgui into 2.0.2
Reviewed-on: #114
2025-12-28 02:24:50 +00:00
cake
2abc92fc61 Fixed warnings 2025-12-28 03:17:27 +01:00
cake
a3ea48c6e1 Fixed some comments 2025-12-28 03:15:15 +01:00
cake
deb99628f6 Added debug mode for lightfinder IMGUI, added caching of file cache entries to reduce load of loading all entries again. 2025-12-28 03:01:02 +01:00
f69effb8a3 fix syncing.. 2025-12-28 10:48:40 +09:00
8f32b375dd boom 2025-12-28 05:24:12 +09:00
1632258c4f Merge pull request 'mcdf-background-creation' (#112) from mcdf-background-creation into 2.0.2
Reviewed-on: #112
2025-12-27 01:33:31 +00:00
a5786e1d5b Merge branch '2.0.2' into mcdf-background-creation 2025-12-26 21:20:09 +00:00
0b32639f99 Added chat notification pair request send (#111)
Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #111
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-26 20:43:19 +00:00
65dea18f5f Added count to lightfinder label (#110)
[[https://lightless.media/u/3J6Um2OI.png](url)](https://lightless.media/u/3J6Um2OI.png)

Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #110
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-26 20:43:09 +00:00
6546a658f3 Added temporary storage of guids of collections to be wiped on bootup when crash/reload (#109)
Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #109
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-26 20:43:01 +00:00
8a41baa88b Fix context menu option from settings. (#108)
Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #108
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-26 20:42:51 +00:00
88cb778791 Refactored most of file download, redone it so correct usage of slots and better thread management. (#107)
Before: https://lightless.media/u/n5DhLTPR.mp4

After: https://lightless.media/u/sqvDR0Ho.mp4

Usage of the locks is way more optimized.

Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #107
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-26 20:42:43 +00:00
defnotken
6892d81041 Add notifications 2025-12-24 01:34:37 -06:00
defnotken
a47ca4452a oops 2025-12-24 01:06:49 -06:00
defnotken
32df21bf4a Make mcdf safe in the background. WIP: Progress notif 2025-12-24 01:05:21 -06:00
defnotken
1a2885fd74 ver bump 2025-12-23 20:12:39 -06:00
e470222fe6 Merge pull request '2.0.1' (#99) from 2.0.1 into master
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m3s
Reviewed-on: #99
2025-12-24 02:11:07 +00:00
defnotken
eb83ca90cb Changelog 2025-12-23 20:06:39 -06:00
defnotken
35f0f6da5e removing unused call
Some checks failed
Tag and Release Lightless / tag-and-release (push) Has been cancelled
2025-12-23 17:46:13 -06:00
7d151dac2b hopefully it's fine now? 2025-12-24 07:43:23 +09:00
defnotken
2eba5a1f30 Bumping API 2025-12-23 11:20:52 -06:00
0a6cb05883 Merge pull request 'Fix bug with GPose Actors Page not showing up' (#105) from brio-actor-page-fix into 2.0.1
Reviewed-on: #105
2025-12-22 16:01:57 +00:00
Minmoose
838495810e Update DalamudUtilService.cs 2025-12-22 09:59:35 -06:00
a207c8994b Merge pull request 'Added documentation/comments, Added gpose detection.' (#104) from documentation-gpose-lightfinder-plate into 2.0.1
Reviewed-on: #104
2025-12-22 15:00:12 +00:00
cake
9b4e48ad3e Added documentation/comments, Added gpose detection. 2025-12-22 15:40:31 +01:00
fb4810980e Merge pull request 'Fixed many issues with lightfinder, added back icon support' (#102) from lightfinder-changes-picto into 2.0.1
Reviewed-on: #102
2025-12-22 03:34:27 +00:00
51e107d30a Merge pull request 'Added null check on GetCid if it is empty, would return zero' (#103) from fix-cid-settings into 2.0.1
Reviewed-on: #103
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-22 03:09:39 +00:00
cake
cc011743af Added null check on GetCid if it is empty, would return zero 2025-12-22 03:36:06 +01:00
cake
f47fbda0d9 Fixed many issues with lightfinder, added back icon support 2025-12-22 03:06:07 +01:00
fd3b42eff1 Merge pull request 'Null reference exception fix for setting' (#101) from null-reference-validation into 2.0.1
Reviewed-on: #101
2025-12-21 22:53:44 +00:00
3262664d1c Merge branch '2.0.1' into null-reference-validation 2025-12-21 22:42:50 +00:00
afa0d9f101 Merge pull request 'imgui push/pop' (#100) from updatenotes-changes into 2.0.1
Reviewed-on: #100
2025-12-21 22:41:50 +00:00
defnotken
a66a9407f5 Null reference exception fix for setting 2025-12-21 16:36:23 -06:00
choco
34bbc34b5b imgui push/pop 2025-12-21 20:56:15 +01:00
61 changed files with 7389 additions and 2450 deletions

3
.gitignore vendored
View File

@@ -348,3 +348,6 @@ MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder # Ionide (cross platform F# VS Code tools) working folder
.ionide/ .ionide/
# idea
/.idea

View File

@@ -1,11 +1,72 @@
tagline: "Lightless Sync v2.0.0" tagline: "Lightless Sync v2.0.1"
subline: "LIGHTLESS IS EVOLVING!!" subline: "LIGHTLESS IS EVOLVING!!"
changelog: 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. Youll 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 arent 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"
versions:
- number: "Chat"
icon: ""
items:
- "You can turn off the syncshell chat as Owner by going to the Syncshell Admin panel -> Owner -> Enable/Disable Chat."
- "Fixed an issue where you can't chat due to regions being in a different language."
- number: "LightFinder"
icon: ""
items:
- "The icon/Lightfinder Text will be hidden when Game UI is hidden and behind game elements/UI"
- "Able to select an icon for the selected list or a custom glyph if you know the code."
- "Smoothing and reducing jitter on the icon/Lightfinder Text."
- "Fixed so higher scaled UI options (100/150/200% UI scale) wouldn't break the element."
- "Detects if GPose is active, wouldn't render the elements"
- number: "Miscellaneous fixes"
icon: ""
items:
- "Fixed the null error given on GetCID when transferring between zones/housing."
- "Added push/pop on certain ImGUI elements to remove them after being used. "
- "Having all tabs open in the Main UI wouldn't lag out the game anymore."
- "Cycle pause has been adjusted to the old function. There is a separate button to pause normally, now called 'Toggle (Un)Pause State'."
- "Changes have been made to the character redraw to address the issues with the building character data constantly being redrawn and the redrawn behavior with Honorific titles."
- "GPose characters should appear again in the actor screen"
- "Lightspeed download console messages are no longer shown as warnings."
- number: "Server Updates"
icon: ""
items:
- "Changes have been made to the disabling of your profile. It should save again."
- "Ability added to toggle chats from syncshell to be disabled."
- "Files are continuously being deleted due to high volumes in storage, potentially causing MCDOs to have missing files. We have increased the limit of the storage in our configurations to see if that helps."
- name: "v2.0.0" - name: "v2.0.0"
tagline: "Thank you for 4 months!" tagline: "Thank you for 4 months!"
date: "December 2025" date: "December 2025"
# be sure to set this every new version
isCurrent: true
versions: versions:
- number: "Lightless Chat" - number: "Lightless Chat"
icon: "" icon: ""

View File

@@ -1,15 +1,23 @@
#nullable disable #nullable disable
using System.Text.Json.Serialization;
namespace LightlessSync.FileCache; namespace LightlessSync.FileCache;
public class FileCacheEntity public class FileCacheEntity
{ {
public FileCacheEntity(string hash, string path, string lastModifiedDateTicks, long? size = null, long? compressedSize = null) [JsonConstructor]
public FileCacheEntity(
string hash,
string prefixedFilePath,
string lastModifiedDateTicks,
long? size = null,
long? compressedSize = null)
{ {
Size = size; Size = size;
CompressedSize = compressedSize; CompressedSize = compressedSize;
Hash = hash; Hash = hash;
PrefixedFilePath = path; PrefixedFilePath = prefixedFilePath;
LastModifiedDateTicks = lastModifiedDateTicks; LastModifiedDateTicks = lastModifiedDateTicks;
} }
@@ -23,7 +31,5 @@ public class FileCacheEntity
public long? Size { get; set; } public long? Size { get; set; }
public void SetResolvedFilePath(string filePath) public void SetResolvedFilePath(string filePath)
{ => ResolvedFilepath = filePath.ToLowerInvariant().Replace("\\\\", "\\", StringComparison.Ordinal);
ResolvedFilepath = filePath.ToLowerInvariant().Replace("\\\\", "\\", StringComparison.Ordinal);
}
} }

View File

@@ -7,6 +7,8 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using System.Text; using System.Text;
namespace LightlessSync.FileCache; namespace LightlessSync.FileCache;
@@ -31,6 +33,14 @@ public sealed class FileCacheManager : IHostedService
private bool _csvHeaderEnsured; private bool _csvHeaderEnsured;
public string CacheFolder => _configService.Current.CacheFolder; public string CacheFolder => _configService.Current.CacheFolder;
private const string _compressedCacheExtension = ".llz4";
private readonly ConcurrentDictionary<string, SemaphoreSlim> _compressLocks = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, SizeInfo> _sizeCache =
new(StringComparer.OrdinalIgnoreCase);
[StructLayout(LayoutKind.Auto)]
public readonly record struct SizeInfo(long Original, long Compressed);
public FileCacheManager(ILogger<FileCacheManager> logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator) public FileCacheManager(ILogger<FileCacheManager> logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator)
{ {
_logger = logger; _logger = logger;
@@ -45,6 +55,18 @@ public sealed class FileCacheManager : IHostedService
private static string NormalizeSeparators(string path) => path.Replace("/", "\\", StringComparison.Ordinal) private static string NormalizeSeparators(string path) => path.Replace("/", "\\", StringComparison.Ordinal)
.Replace("\\\\", "\\", StringComparison.Ordinal); .Replace("\\\\", "\\", StringComparison.Ordinal);
private SemaphoreSlim GetCompressLock(string hash)
=> _compressLocks.GetOrAdd(hash, _ => new SemaphoreSlim(1, 1));
public void SetSizeInfo(string hash, long original, long compressed)
=> _sizeCache[hash] = new SizeInfo(original, compressed);
public bool TryGetSizeInfo(string hash, out SizeInfo info)
=> _sizeCache.TryGetValue(hash, out info);
private string GetCompressedCachePath(string hash)
=> Path.Combine(CacheFolder, hash + _compressedCacheExtension);
private static string NormalizePrefixedPathKey(string prefixedPath) private static string NormalizePrefixedPathKey(string prefixedPath)
{ {
if (string.IsNullOrEmpty(prefixedPath)) if (string.IsNullOrEmpty(prefixedPath))
@@ -111,6 +133,114 @@ public sealed class FileCacheManager : IHostedService
return int.TryParse(versionSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out version); return int.TryParse(versionSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out version);
} }
public void UpdateSizeInfo(string hash, long? original = null, long? compressed = null)
{
_sizeCache.AddOrUpdate(
hash,
_ => new SizeInfo(original ?? 0, compressed ?? 0),
(_, old) => new SizeInfo(original ?? old.Original, compressed ?? old.Compressed));
}
private void UpdateEntitiesSizes(string hash, long original, long compressed)
{
if (_fileCaches.TryGetValue(hash, out var dict))
{
foreach (var e in dict.Values)
{
e.Size = original;
e.CompressedSize = compressed;
}
}
}
public static void ApplySizesToEntries(IEnumerable<FileCacheEntity?> entries, long original, long compressed)
{
foreach (var e in entries)
{
if (e == null) continue;
e.Size = original;
e.CompressedSize = compressed > 0 ? compressed : null;
}
}
public async Task<long> GetCompressedSizeAsync(string hash, CancellationToken token)
{
if (_sizeCache.TryGetValue(hash, out var info) && info.Compressed > 0)
return info.Compressed;
if (_fileCaches.TryGetValue(hash, out var dict))
{
var any = dict.Values.FirstOrDefault();
if (any != null && any.CompressedSize > 0)
{
UpdateSizeInfo(hash, original: any.Size > 0 ? any.Size : null, compressed: any.CompressedSize);
return (long)any.CompressedSize;
}
}
if (!string.IsNullOrWhiteSpace(CacheFolder))
{
var path = GetCompressedCachePath(hash);
if (File.Exists(path))
{
var len = new FileInfo(path).Length;
UpdateSizeInfo(hash, compressed: len);
return len;
}
var bytes = await EnsureCompressedCacheBytesAsync(hash, token).ConfigureAwait(false);
return bytes.LongLength;
}
var fallback = await GetCompressedFileData(hash, token).ConfigureAwait(false);
return fallback.Item2.LongLength;
}
private async Task<byte[]> EnsureCompressedCacheBytesAsync(string hash, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(CacheFolder))
throw new InvalidOperationException("CacheFolder is not set; cannot persist compressed cache.");
Directory.CreateDirectory(CacheFolder);
var compressedPath = GetCompressedCachePath(hash);
if (File.Exists(compressedPath))
return await File.ReadAllBytesAsync(compressedPath, token).ConfigureAwait(false);
var sem = GetCompressLock(hash);
await sem.WaitAsync(token).ConfigureAwait(false);
try
{
if (File.Exists(compressedPath))
return await File.ReadAllBytesAsync(compressedPath, token).ConfigureAwait(false);
var entity = GetFileCacheByHash(hash);
if (entity == null || string.IsNullOrWhiteSpace(entity.ResolvedFilepath))
throw new InvalidOperationException($"No local file cache found for hash {hash}.");
var sourcePath = entity.ResolvedFilepath;
var originalSize = new FileInfo(sourcePath).Length;
var raw = await File.ReadAllBytesAsync(sourcePath, token).ConfigureAwait(false);
var compressed = LZ4Wrapper.WrapHC(raw, 0, raw.Length);
var tmpPath = compressedPath + ".tmp";
await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false);
File.Move(tmpPath, compressedPath, overwrite: true);
var compressedSize = compressed.LongLength;
SetSizeInfo(hash, originalSize, compressedSize);
UpdateEntitiesSizes(hash, originalSize, compressedSize);
return compressed;
}
finally
{
sem.Release();
}
}
private string NormalizeToPrefixedPath(string path) private string NormalizeToPrefixedPath(string path)
{ {
if (string.IsNullOrEmpty(path)) return string.Empty; if (string.IsNullOrEmpty(path)) return string.Empty;
@@ -318,9 +448,18 @@ public sealed class FileCacheManager : IHostedService
public async Task<(string, byte[])> GetCompressedFileData(string fileHash, CancellationToken uploadToken) public async Task<(string, byte[])> GetCompressedFileData(string fileHash, CancellationToken uploadToken)
{ {
if (!string.IsNullOrWhiteSpace(CacheFolder))
{
var bytes = await EnsureCompressedCacheBytesAsync(fileHash, uploadToken).ConfigureAwait(false);
UpdateSizeInfo(fileHash, compressed: bytes.LongLength);
return (fileHash, bytes);
}
var fileCache = GetFileCacheByHash(fileHash)!.ResolvedFilepath; var fileCache = GetFileCacheByHash(fileHash)!.ResolvedFilepath;
return (fileHash, LZ4Wrapper.WrapHC(await File.ReadAllBytesAsync(fileCache, uploadToken).ConfigureAwait(false), 0, var raw = await File.ReadAllBytesAsync(fileCache, uploadToken).ConfigureAwait(false);
(int)new FileInfo(fileCache).Length)); var compressed = LZ4Wrapper.WrapHC(raw, 0, raw.Length);
UpdateSizeInfo(fileHash, original: raw.LongLength, compressed: compressed.LongLength);
return (fileHash, compressed);
} }
public FileCacheEntity? GetFileCacheByHash(string hash) public FileCacheEntity? GetFileCacheByHash(string hash)
@@ -891,6 +1030,14 @@ public sealed class FileCacheManager : IHostedService
compressed = resultCompressed; compressed = resultCompressed;
} }
} }
if (size > 0 || compressed > 0)
{
UpdateSizeInfo(hash,
original: size > 0 ? size : null,
compressed: compressed > 0 ? compressed : null);
}
AddHashedFile(ReplacePathPrefixes(new FileCacheEntity(hash, path, time, size, compressed))); AddHashedFile(ReplacePathPrefixes(new FileCacheEntity(hash, path, time, size, compressed)));
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -10,9 +10,6 @@ using LightlessSync.Services.Mediator;
using LightlessSync.Utils; using LightlessSync.Utils;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Linq;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
namespace LightlessSync.FileCache; namespace LightlessSync.FileCache;
@@ -28,7 +25,10 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
private readonly object _ownedHandlerLock = new(); private readonly object _ownedHandlerLock = new();
private readonly string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk", "kdb"]; private readonly string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk", "kdb"];
private readonly string[] _handledRecordingFileTypes = ["tex", "mdl", "mtrl"]; private readonly string[] _handledRecordingFileTypes = ["tex", "mdl", "mtrl"];
private readonly string[] _handledFileTypesWithRecording;
private readonly HashSet<GameObjectHandler> _playerRelatedPointers = []; private readonly HashSet<GameObjectHandler> _playerRelatedPointers = [];
private readonly object _playerRelatedLock = new();
private readonly ConcurrentDictionary<nint, GameObjectHandler> _playerRelatedByAddress = new();
private readonly Dictionary<nint, GameObjectHandler> _ownedHandlers = new(); private readonly Dictionary<nint, GameObjectHandler> _ownedHandlers = new();
private ConcurrentDictionary<nint, ObjectKind> _cachedFrameAddresses = new(); private ConcurrentDictionary<nint, ObjectKind> _cachedFrameAddresses = new();
private ConcurrentDictionary<ObjectKind, HashSet<string>>? _semiTransientResources = null; private ConcurrentDictionary<ObjectKind, HashSet<string>>? _semiTransientResources = null;
@@ -42,6 +42,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
_dalamudUtil = dalamudUtil; _dalamudUtil = dalamudUtil;
_actorObjectService = actorObjectService; _actorObjectService = actorObjectService;
_gameObjectHandlerFactory = gameObjectHandlerFactory; _gameObjectHandlerFactory = gameObjectHandlerFactory;
_handledFileTypesWithRecording = _handledRecordingFileTypes.Concat(_handledFileTypes).ToArray();
Mediator.Subscribe<PenumbraResourceLoadMessage>(this, Manager_PenumbraResourceLoadEvent); Mediator.Subscribe<PenumbraResourceLoadMessage>(this, Manager_PenumbraResourceLoadEvent);
Mediator.Subscribe<ActorTrackedMessage>(this, msg => HandleActorTracked(msg.Descriptor)); Mediator.Subscribe<ActorTrackedMessage>(this, msg => HandleActorTracked(msg.Descriptor));
@@ -51,15 +52,21 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) => Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
{ {
if (!msg.OwnedObject) return; if (!msg.OwnedObject) return;
_playerRelatedPointers.Add(msg.GameObjectHandler); lock (_playerRelatedLock)
{
_playerRelatedPointers.Add(msg.GameObjectHandler);
}
}); });
Mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, (msg) => Mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, (msg) =>
{ {
if (!msg.OwnedObject) return; if (!msg.OwnedObject) return;
_playerRelatedPointers.Remove(msg.GameObjectHandler); lock (_playerRelatedLock)
{
_playerRelatedPointers.Remove(msg.GameObjectHandler);
}
}); });
foreach (var descriptor in _actorObjectService.PlayerDescriptors) foreach (var descriptor in _actorObjectService.ObjectDescriptors)
{ {
HandleActorTracked(descriptor); HandleActorTracked(descriptor);
} }
@@ -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 private ConcurrentDictionary<ObjectKind, HashSet<string>> SemiTransientResources
{ {
get get
@@ -87,9 +94,12 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
{ {
_semiTransientResources = new(); _semiTransientResources = new();
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData); PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
_semiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.Ordinal); _semiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? [])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData); PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
_semiTransientResources[ObjectKind.Pet] = [.. petSpecificData ?? []]; _semiTransientResources[ObjectKind.Pet] = new HashSet<string>(
petSpecificData ?? [],
StringComparer.OrdinalIgnoreCase);
} }
return _semiTransientResources; return _semiTransientResources;
@@ -127,14 +137,14 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
{ {
SemiTransientResources.TryGetValue(objectKind, out var result); SemiTransientResources.TryGetValue(objectKind, out var result);
return result ?? new HashSet<string>(StringComparer.Ordinal); return result ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase);
} }
public void PersistTransientResources(ObjectKind objectKind) public void PersistTransientResources(ObjectKind objectKind)
{ {
if (!SemiTransientResources.TryGetValue(objectKind, out HashSet<string>? semiTransientResources)) if (!SemiTransientResources.TryGetValue(objectKind, out HashSet<string>? semiTransientResources))
{ {
SemiTransientResources[objectKind] = semiTransientResources = new(StringComparer.Ordinal); SemiTransientResources[objectKind] = semiTransientResources = new(StringComparer.OrdinalIgnoreCase);
} }
if (!TransientResources.TryGetValue(objectKind, out var resources)) if (!TransientResources.TryGetValue(objectKind, out var resources))
@@ -152,7 +162,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
List<string> newlyAddedGamePaths; List<string> newlyAddedGamePaths;
lock (semiTransientResources) lock (semiTransientResources)
{ {
newlyAddedGamePaths = transientResources.Except(semiTransientResources, StringComparer.Ordinal).ToList(); newlyAddedGamePaths = transientResources.Except(semiTransientResources, StringComparer.OrdinalIgnoreCase).ToList();
foreach (var gamePath in transientResources) foreach (var gamePath in transientResources)
{ {
semiTransientResources.Add(gamePath); semiTransientResources.Add(gamePath);
@@ -197,12 +207,13 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
public void RemoveTransientResource(ObjectKind objectKind, string path) public void RemoveTransientResource(ObjectKind objectKind, string path)
{ {
var normalizedPath = NormalizeGamePath(path);
if (SemiTransientResources.TryGetValue(objectKind, out var resources)) if (SemiTransientResources.TryGetValue(objectKind, out var resources))
{ {
resources.RemoveWhere(f => string.Equals(path, f, StringComparison.Ordinal)); resources.Remove(normalizedPath);
if (objectKind == ObjectKind.Player) if (objectKind == ObjectKind.Player)
{ {
PlayerConfig.RemovePath(path, objectKind); PlayerConfig.RemovePath(normalizedPath, objectKind);
Logger.LogTrace("Saving transient.json from {method}", nameof(RemoveTransientResource)); Logger.LogTrace("Saving transient.json from {method}", nameof(RemoveTransientResource));
_configurationService.Save(); _configurationService.Save();
} }
@@ -211,16 +222,17 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
internal bool AddTransientResource(ObjectKind objectKind, string item) internal bool AddTransientResource(ObjectKind objectKind, string item)
{ {
if (SemiTransientResources.TryGetValue(objectKind, out var semiTransient) && semiTransient != null && semiTransient.Contains(item)) var normalizedItem = NormalizeGamePath(item);
if (SemiTransientResources.TryGetValue(objectKind, out var semiTransient) && semiTransient != null && semiTransient.Contains(normalizedItem))
return false; return false;
if (!TransientResources.TryGetValue(objectKind, out HashSet<string>? transientResource)) if (!TransientResources.TryGetValue(objectKind, out HashSet<string>? transientResource))
{ {
transientResource = new HashSet<string>(StringComparer.Ordinal); transientResource = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
TransientResources[objectKind] = transientResource; TransientResources[objectKind] = transientResource;
} }
return transientResource.Add(item.ToLowerInvariant()); return transientResource.Add(normalizedItem);
} }
internal void ClearTransientPaths(ObjectKind objectKind, List<string> list) internal void ClearTransientPaths(ObjectKind objectKind, List<string> list)
@@ -285,33 +297,13 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
private void DalamudUtil_FrameworkUpdate() private void DalamudUtil_FrameworkUpdate()
{ {
RefreshPlayerRelatedAddressMap();
lock (_cacheAdditionLock) lock (_cacheAdditionLock)
{ {
_cachedHandledPaths.Clear(); _cachedHandledPaths.Clear();
} }
var activeDescriptors = new Dictionary<nint, ObjectKind>();
foreach (var descriptor in _actorObjectService.PlayerDescriptors)
{
if (TryResolveObjectKind(descriptor, out var resolvedKind))
{
activeDescriptors[descriptor.Address] = resolvedKind;
}
}
foreach (var address in _cachedFrameAddresses.Keys.ToList())
{
if (!activeDescriptors.ContainsKey(address))
{
_cachedFrameAddresses.TryRemove(address, out _);
}
}
foreach (var descriptor in activeDescriptors)
{
_cachedFrameAddresses[descriptor.Key] = descriptor.Value;
}
if (_lastClassJobId != _dalamudUtil.ClassJobId) if (_lastClassJobId != _dalamudUtil.ClassJobId)
{ {
_lastClassJobId = _dalamudUtil.ClassJobId; _lastClassJobId = _dalamudUtil.ClassJobId;
@@ -323,7 +315,9 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData); PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase); SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData); PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
SemiTransientResources[ObjectKind.Pet] = [.. petSpecificData ?? []]; SemiTransientResources[ObjectKind.Pet] = new HashSet<string>(
petSpecificData ?? [],
StringComparer.OrdinalIgnoreCase);
} }
foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast<ObjectKind>()) foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast<ObjectKind>())
@@ -340,9 +334,12 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
_ = Task.Run(() => _ = Task.Run(() =>
{ {
Logger.LogDebug("Penumbra Mod Settings changed, verifying SemiTransientResources"); Logger.LogDebug("Penumbra Mod Settings changed, verifying SemiTransientResources");
foreach (var item in _playerRelatedPointers) lock (_playerRelatedLock)
{ {
Mediator.Publish(new TransientResourceChangedMessage(item.Address)); foreach (var item in _playerRelatedPointers)
{
Mediator.Publish(new TransientResourceChangedMessage(item.Address));
}
} }
}); });
} }
@@ -352,22 +349,24 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
_semiTransientResources = null; _semiTransientResources = null;
} }
private static bool TryResolveObjectKind(ActorObjectService.ActorDescriptor descriptor, out ObjectKind resolvedKind) private void RefreshPlayerRelatedAddressMap()
{ {
if (descriptor.OwnedKind is ObjectKind ownedKind) _playerRelatedByAddress.Clear();
var updatedFrameAddresses = new ConcurrentDictionary<nint, ObjectKind>();
lock (_playerRelatedLock)
{ {
resolvedKind = ownedKind; foreach (var handler in _playerRelatedPointers)
return true; {
var address = (nint)handler.Address;
if (address != nint.Zero)
{
_playerRelatedByAddress[address] = handler;
updatedFrameAddresses[address] = handler.ObjectKind;
}
}
} }
if (descriptor.ObjectKind == DalamudObjectKind.Player) _cachedFrameAddresses = updatedFrameAddresses;
{
resolvedKind = ObjectKind.Player;
return true;
}
resolvedKind = default;
return false;
} }
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor) private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
@@ -375,18 +374,15 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
if (descriptor.IsInGpose) if (descriptor.IsInGpose)
return; return;
if (!TryResolveObjectKind(descriptor, out var resolvedKind)) if (descriptor.OwnedKind is not ObjectKind ownedKind)
return; return;
if (Logger.IsEnabled(LogLevel.Debug)) if (Logger.IsEnabled(LogLevel.Debug))
{ {
Logger.LogDebug("ActorObject tracked: {kind} addr={address:X} name={name}", resolvedKind, descriptor.Address, descriptor.Name); Logger.LogDebug("ActorObject tracked: {kind} addr={address:X} name={name}", ownedKind, descriptor.Address, descriptor.Name);
} }
_cachedFrameAddresses[descriptor.Address] = resolvedKind; _cachedFrameAddresses[descriptor.Address] = ownedKind;
if (descriptor.OwnedKind is not ObjectKind ownedKind)
return;
lock (_ownedHandlerLock) lock (_ownedHandlerLock)
{ {
@@ -465,53 +461,84 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
} }
} }
private static string NormalizeGamePath(string path)
{
if (string.IsNullOrEmpty(path))
return string.Empty;
return path.Replace("\\", "/", StringComparison.Ordinal).ToLowerInvariant();
}
private static string NormalizeFilePath(string path)
{
if (string.IsNullOrEmpty(path))
return string.Empty;
if (path.StartsWith("|", StringComparison.Ordinal))
{
var lastPipe = path.LastIndexOf('|');
if (lastPipe >= 0 && lastPipe + 1 < path.Length)
{
path = path[(lastPipe + 1)..];
}
}
return NormalizeGamePath(path);
}
private static bool HasHandledFileType(string gamePath, string[] handledTypes)
{
for (var i = 0; i < handledTypes.Length; i++)
{
if (gamePath.EndsWith(handledTypes[i], StringComparison.Ordinal))
return true;
}
return false;
}
private void Manager_PenumbraResourceLoadEvent(PenumbraResourceLoadMessage msg) private void Manager_PenumbraResourceLoadEvent(PenumbraResourceLoadMessage msg)
{ {
var gamePath = msg.GamePath.ToLowerInvariant();
var gameObjectAddress = msg.GameObject; var gameObjectAddress = msg.GameObject;
var filePath = msg.FilePath; if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind))
// ignore files already processed this frame
if (_cachedHandledPaths.Contains(gamePath)) return;
lock (_cacheAdditionLock)
{ {
_cachedHandledPaths.Add(gamePath); if (_actorObjectService.TryGetOwnedKind(gameObjectAddress, out var ownedKind))
{
objectKind = ownedKind;
}
else
{
return;
}
} }
// replace individual mtrl stuff var gamePath = NormalizeGamePath(msg.GamePath);
if (filePath.StartsWith("|", StringComparison.OrdinalIgnoreCase)) if (string.IsNullOrEmpty(gamePath))
{
filePath = filePath.Split("|")[2];
}
// replace filepath
filePath = filePath.ToLowerInvariant().Replace("\\", "/", StringComparison.OrdinalIgnoreCase);
// ignore files that are the same
var replacedGamePath = gamePath.ToLowerInvariant().Replace("\\", "/", StringComparison.OrdinalIgnoreCase);
if (string.Equals(filePath, replacedGamePath, StringComparison.OrdinalIgnoreCase))
{ {
return; return;
} }
// ignore files already processed this frame
lock (_cacheAdditionLock)
{
if (!_cachedHandledPaths.Add(gamePath))
{
return;
}
}
// ignore files to not handle // ignore files to not handle
var handledTypes = IsTransientRecording ? _handledRecordingFileTypes.Concat(_handledFileTypes) : _handledFileTypes; var handledTypes = IsTransientRecording ? _handledFileTypesWithRecording : _handledFileTypes;
if (!handledTypes.Any(type => gamePath.EndsWith(type, StringComparison.OrdinalIgnoreCase))) if (!HasHandledFileType(gamePath, handledTypes))
{ {
lock (_cacheAdditionLock)
{
_cachedHandledPaths.Add(gamePath);
}
return; return;
} }
// ignore files not belonging to anything player related var filePath = NormalizeFilePath(msg.FilePath);
if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind))
// ignore files that are the same
if (string.Equals(filePath, gamePath, StringComparison.Ordinal))
{ {
lock (_cacheAdditionLock)
{
_cachedHandledPaths.Add(gamePath);
}
return; return;
} }
@@ -523,15 +550,15 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
TransientResources[objectKind] = transientResources; TransientResources[objectKind] = transientResources;
} }
var owner = _playerRelatedPointers.FirstOrDefault(f => f.Address == gameObjectAddress); _playerRelatedByAddress.TryGetValue(gameObjectAddress, out var owner);
bool alreadyTransient = false; bool alreadyTransient = false;
bool transientContains = transientResources.Contains(replacedGamePath); bool transientContains = transientResources.Contains(gamePath);
bool semiTransientContains = SemiTransientResources.SelectMany(k => k.Value).Any(f => string.Equals(f, gamePath, StringComparison.OrdinalIgnoreCase)); bool semiTransientContains = SemiTransientResources.Values.Any(value => value.Contains(gamePath));
if (transientContains || semiTransientContains) if (transientContains || semiTransientContains)
{ {
if (!IsTransientRecording) if (!IsTransientRecording)
Logger.LogTrace("Not adding {replacedPath} => {filePath}, Reason: Transient: {contains}, SemiTransient: {contains2}", replacedGamePath, filePath, Logger.LogTrace("Not adding {replacedPath} => {filePath}, Reason: Transient: {contains}, SemiTransient: {contains2}", gamePath, filePath,
transientContains, semiTransientContains); transientContains, semiTransientContains);
alreadyTransient = true; alreadyTransient = true;
} }
@@ -539,10 +566,10 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
{ {
if (!IsTransientRecording) if (!IsTransientRecording)
{ {
bool isAdded = transientResources.Add(replacedGamePath); bool isAdded = transientResources.Add(gamePath);
if (isAdded) if (isAdded)
{ {
Logger.LogDebug("Adding {replacedGamePath} for {gameObject} ({filePath})", replacedGamePath, owner?.ToString() ?? gameObjectAddress.ToString("X"), filePath); Logger.LogDebug("Adding {replacedGamePath} for {gameObject} ({filePath})", gamePath, owner?.ToString() ?? gameObjectAddress.ToString("X"), filePath);
SendTransients(gameObjectAddress, objectKind); SendTransients(gameObjectAddress, objectKind);
} }
} }
@@ -550,7 +577,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
if (owner != null && IsTransientRecording) if (owner != null && IsTransientRecording)
{ {
_recordedTransients.Add(new TransientRecord(owner, replacedGamePath, filePath, alreadyTransient) { AddTransient = !alreadyTransient }); _recordedTransients.Add(new TransientRecord(owner, gamePath, filePath, alreadyTransient) { AddTransient = !alreadyTransient });
} }
} }
@@ -622,7 +649,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
if (!item.AddTransient || item.AlreadyTransient) continue; if (!item.AddTransient || item.AlreadyTransient) continue;
if (!TransientResources.TryGetValue(item.Owner.ObjectKind, out var transient)) if (!TransientResources.TryGetValue(item.Owner.ObjectKind, out var transient))
{ {
TransientResources[item.Owner.ObjectKind] = transient = []; TransientResources[item.Owner.ObjectKind] = transient = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
} }
Logger.LogTrace("Adding recorded: {gamePath} => {filePath}", item.GamePath, item.FilePath); Logger.LogTrace("Adding recorded: {gamePath} => {filePath}", item.GamePath, item.FilePath);

View File

@@ -95,6 +95,12 @@ public sealed class IpcCallerPenumbra : IpcServiceBase
public Task<(string[] forward, string[][] reverse)> ResolvePathsAsync(string[] forward, string[] reverse) public Task<(string[] forward, string[][] reverse)> ResolvePathsAsync(string[] forward, string[] reverse)
=> _resources.ResolvePathsAsync(forward, reverse); => _resources.ResolvePathsAsync(forward, reverse);
public string ResolveGameObjectPath(string gamePath, int objectIndex)
=> _resources.ResolveGameObjectPath(gamePath, objectIndex);
public string[] ReverseResolveGameObjectPath(string moddedPath, int objectIndex)
=> _resources.ReverseResolveGameObjectPath(moddedPath, objectIndex);
public Task RedrawAsync(ILogger logger, GameObjectHandler handler, Guid applicationId, CancellationToken token) public Task RedrawAsync(ILogger logger, GameObjectHandler handler, Guid applicationId, CancellationToken token)
=> _redraw.RedrawAsync(logger, handler, applicationId, token); => _redraw.RedrawAsync(logger, handler, applicationId, token);
@@ -171,11 +177,6 @@ public sealed class IpcCallerPenumbra : IpcServiceBase
}); });
Mediator.Subscribe<DalamudLoginMessage>(this, _ => _shownPenumbraUnavailable = false); Mediator.Subscribe<DalamudLoginMessage>(this, _ => _shownPenumbraUnavailable = false);
Mediator.Subscribe<ActorTrackedMessage>(this, msg => _resources.TrackActor(msg.Descriptor.Address));
Mediator.Subscribe<ActorUntrackedMessage>(this, msg => _resources.UntrackActor(msg.Descriptor.Address));
Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, msg => _resources.TrackActor(msg.GameObjectHandler.Address));
Mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, msg => _resources.UntrackActor(msg.GameObjectHandler.Address));
} }
private void HandlePenumbraInitialized() private void HandlePenumbraInitialized()

View File

@@ -92,7 +92,7 @@ public sealed class PenumbraCollections : PenumbraBase
_activeTemporaryCollections.TryRemove(collectionId, out _); _activeTemporaryCollections.TryRemove(collectionId, out _);
} }
public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, IReadOnlyDictionary<string, string> modPaths) public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths)
{ {
if (!IsAvailable || collectionId == Guid.Empty) if (!IsAvailable || collectionId == Guid.Empty)
{ {
@@ -109,7 +109,7 @@ public sealed class PenumbraCollections : PenumbraBase
var removeResult = _removeTemporaryMod.Invoke("LightlessChara_Files", collectionId, 0); var removeResult = _removeTemporaryMod.Invoke("LightlessChara_Files", collectionId, 0);
logger.LogTrace("[{ApplicationId}] Removing temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, removeResult); logger.LogTrace("[{ApplicationId}] Removing temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, removeResult);
var addResult = _addTemporaryMod.Invoke("LightlessChara_Files", collectionId, new Dictionary<string, string>(modPaths), string.Empty, 0); var addResult = _addTemporaryMod.Invoke("LightlessChara_Files", collectionId, modPaths, string.Empty, 0);
logger.LogTrace("[{ApplicationId}] Setting temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, addResult); logger.LogTrace("[{ApplicationId}] Setting temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, addResult);
}).ConfigureAwait(false); }).ConfigureAwait(false);
} }

View File

@@ -1,4 +1,3 @@
using System.Collections.Concurrent;
using Dalamud.Plugin; using Dalamud.Plugin;
using LightlessSync.Interop.Ipc.Framework; using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
@@ -15,10 +14,11 @@ public sealed class PenumbraResource : PenumbraBase
{ {
private readonly ActorObjectService _actorObjectService; private readonly ActorObjectService _actorObjectService;
private readonly GetGameObjectResourcePaths _gameObjectResourcePaths; private readonly GetGameObjectResourcePaths _gameObjectResourcePaths;
private readonly ResolveGameObjectPath _resolveGameObjectPath;
private readonly ReverseResolveGameObjectPath _reverseResolveGameObjectPath;
private readonly ResolvePlayerPathsAsync _resolvePlayerPaths; private readonly ResolvePlayerPathsAsync _resolvePlayerPaths;
private readonly GetPlayerMetaManipulations _getPlayerMetaManipulations; private readonly GetPlayerMetaManipulations _getPlayerMetaManipulations;
private readonly EventSubscriber<nint, string, string> _gameObjectResourcePathResolved; private readonly EventSubscriber<nint, string, string> _gameObjectResourcePathResolved;
private readonly ConcurrentDictionary<IntPtr, byte> _trackedActors = new();
public PenumbraResource( public PenumbraResource(
ILogger logger, ILogger logger,
@@ -29,14 +29,11 @@ public sealed class PenumbraResource : PenumbraBase
{ {
_actorObjectService = actorObjectService; _actorObjectService = actorObjectService;
_gameObjectResourcePaths = new GetGameObjectResourcePaths(pluginInterface); _gameObjectResourcePaths = new GetGameObjectResourcePaths(pluginInterface);
_resolveGameObjectPath = new ResolveGameObjectPath(pluginInterface);
_reverseResolveGameObjectPath = new ReverseResolveGameObjectPath(pluginInterface);
_resolvePlayerPaths = new ResolvePlayerPathsAsync(pluginInterface); _resolvePlayerPaths = new ResolvePlayerPathsAsync(pluginInterface);
_getPlayerMetaManipulations = new GetPlayerMetaManipulations(pluginInterface); _getPlayerMetaManipulations = new GetPlayerMetaManipulations(pluginInterface);
_gameObjectResourcePathResolved = GameObjectResourcePathResolved.Subscriber(pluginInterface, HandleResourceLoaded); _gameObjectResourcePathResolved = GameObjectResourcePathResolved.Subscriber(pluginInterface, HandleResourceLoaded);
foreach (var descriptor in _actorObjectService.PlayerDescriptors)
{
TrackActor(descriptor.Address);
}
} }
public override string Name => "Penumbra.Resources"; public override string Name => "Penumbra.Resources";
@@ -74,63 +71,34 @@ public sealed class PenumbraResource : PenumbraBase
return await _resolvePlayerPaths.Invoke(forwardPaths, reversePaths).ConfigureAwait(false); return await _resolvePlayerPaths.Invoke(forwardPaths, reversePaths).ConfigureAwait(false);
} }
public void TrackActor(nint address) public string ResolveGameObjectPath(string gamePath, int gameObjectIndex)
{ => IsAvailable ? _resolveGameObjectPath.Invoke(gamePath, gameObjectIndex) : gamePath;
if (address != nint.Zero)
{
_trackedActors[(IntPtr)address] = 0;
}
}
public void UntrackActor(nint address) public string[] ReverseResolveGameObjectPath(string moddedPath, int gameObjectIndex)
{ => IsAvailable ? _reverseResolveGameObjectPath.Invoke(moddedPath, gameObjectIndex) : Array.Empty<string>();
if (address != nint.Zero)
{
_trackedActors.TryRemove((IntPtr)address, out _);
}
}
private void HandleResourceLoaded(nint ptr, string resolvedPath, string gamePath) private void HandleResourceLoaded(nint ptr, string gamePath, string resolvedPath)
{ {
if (ptr == nint.Zero) if (ptr == nint.Zero)
{ {
return; return;
} }
if (!_trackedActors.ContainsKey(ptr)) if (!_actorObjectService.TryGetOwnedKind(ptr, out _))
{
var descriptor = _actorObjectService.PlayerDescriptors.FirstOrDefault(d => d.Address == ptr);
if (descriptor.Address != nint.Zero)
{
_trackedActors[ptr] = 0;
}
else
{
return;
}
}
if (string.Compare(resolvedPath, gamePath, StringComparison.OrdinalIgnoreCase) == 0)
{ {
return; return;
} }
Mediator.Publish(new PenumbraResourceLoadMessage(ptr, resolvedPath, gamePath)); if (string.Compare(gamePath, resolvedPath, StringComparison.OrdinalIgnoreCase) == 0)
{
return;
}
Mediator.Publish(new PenumbraResourceLoadMessage(ptr, gamePath, resolvedPath));
} }
protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current) protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current)
{ {
if (current != IpcConnectionState.Available)
{
_trackedActors.Clear();
}
else
{
foreach (var descriptor in _actorObjectService.PlayerDescriptors)
{
TrackActor(descriptor.Address);
}
}
} }
public override void Dispose() public override void Dispose()

View File

@@ -19,6 +19,27 @@ public class ConfigurationMigrator(ILogger<ConfigurationMigrator> logger, Transi
transientConfigService.Save(); transientConfigService.Save();
} }
if (transientConfigService.Current.Version == 1)
{
_logger.LogInformation("Migrating Transient Config V1 => V2");
var totalRemoved = 0;
var configCount = 0;
var changedCount = 0;
foreach (var config in transientConfigService.Current.TransientConfigs.Values)
{
if (config.NormalizePaths(out var removed))
changedCount++;
totalRemoved += removed;
configCount++;
}
_logger.LogInformation("Transient config normalization: processed {count} entries, updated {updated}, removed {removed} paths", configCount, changedCount, totalRemoved);
transientConfigService.Current.Version = 2;
transientConfigService.Save();
}
if (serverConfigService.Current.Version == 1) if (serverConfigService.Current.Version == 1)
{ {
_logger.LogInformation("Migrating Server Config V1 => V2"); _logger.LogInformation("Migrating Server Config V1 => V2");

View File

@@ -10,6 +10,7 @@ public sealed class ChatConfig : ILightlessConfiguration
public bool AutoEnableChatOnLogin { get; set; } = false; public bool AutoEnableChatOnLogin { get; set; } = false;
public bool ShowRulesOverlayOnOpen { get; set; } = true; public bool ShowRulesOverlayOnOpen { get; set; } = true;
public bool ShowMessageTimestamps { get; set; } = true; public bool ShowMessageTimestamps { get; set; } = true;
public bool ShowNotesInSyncshellChat { get; set; } = true;
public float ChatWindowOpacity { get; set; } = .97f; public float ChatWindowOpacity { get; set; } = .97f;
public bool FadeWhenUnfocused { get; set; } = false; public bool FadeWhenUnfocused { get; set; } = false;
public float UnfocusedWindowOpacity { get; set; } = 0.6f; public float UnfocusedWindowOpacity { get; set; } = 0.6f;

View File

@@ -140,6 +140,7 @@ public class LightlessConfig : ILightlessConfiguration
public bool useColoredUIDs { get; set; } = true; public bool useColoredUIDs { get; set; } = true;
public bool BroadcastEnabled { get; set; } = false; public bool BroadcastEnabled { get; set; } = false;
public bool LightfinderAutoEnableOnConnect { get; set; } = false; public bool LightfinderAutoEnableOnConnect { get; set; } = false;
public LightfinderLabelRenderer LightfinderLabelRenderer { get; set; } = LightfinderLabelRenderer.Pictomancy;
public short LightfinderLabelOffsetX { get; set; } = 0; public short LightfinderLabelOffsetX { get; set; } = 0;
public short LightfinderLabelOffsetY { get; set; } = 0; public short LightfinderLabelOffsetY { get; set; } = 0;
public bool LightfinderLabelUseIcon { get; set; } = false; public bool LightfinderLabelUseIcon { get; set; } = false;
@@ -154,4 +155,5 @@ public class LightlessConfig : ILightlessConfiguration
public bool SyncshellFinderEnabled { get; set; } = false; public bool SyncshellFinderEnabled { get; set; } = false;
public string? SelectedFinderSyncshell { get; set; } = null; public string? SelectedFinderSyncshell { get; set; } = null;
public string LastSeenVersion { get; set; } = string.Empty; public string LastSeenVersion { get; set; } = string.Empty;
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
} }

View File

@@ -5,7 +5,7 @@ namespace LightlessSync.LightlessConfiguration.Configurations;
public class TransientConfig : ILightlessConfiguration public class TransientConfig : ILightlessConfiguration
{ {
public Dictionary<string, TransientPlayerConfig> TransientConfigs { get; set; } = []; public Dictionary<string, TransientPlayerConfig> TransientConfigs { get; set; } = [];
public int Version { get; set; } = 1; public int Version { get; set; } = 2;
public class TransientPlayerConfig public class TransientPlayerConfig
{ {
@@ -88,5 +88,70 @@ public class TransientConfig : ILightlessConfiguration
} }
} }
} }
public bool NormalizePaths(out int removedEntries)
{
bool changed = false;
removedEntries = 0;
GlobalPersistentCache = NormalizeList(GlobalPersistentCache, ref changed, ref removedEntries);
foreach (var jobId in JobSpecificCache.Keys.ToList())
{
JobSpecificCache[jobId] = NormalizeList(JobSpecificCache[jobId], ref changed, ref removedEntries);
}
foreach (var jobId in JobSpecificPetCache.Keys.ToList())
{
JobSpecificPetCache[jobId] = NormalizeList(JobSpecificPetCache[jobId], ref changed, ref removedEntries);
}
return changed;
}
private static List<string> NormalizeList(List<string> entries, ref bool changed, ref int removedEntries)
{
if (entries.Count == 0)
return entries;
var result = new List<string>(entries.Count);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in entries)
{
var normalized = NormalizePath(entry);
if (string.IsNullOrEmpty(normalized))
{
changed = true;
continue;
}
if (!string.Equals(entry, normalized, StringComparison.Ordinal))
{
changed = true;
}
if (seen.Add(normalized))
{
result.Add(normalized);
}
else
{
changed = true;
}
}
removedEntries += entries.Count - result.Count;
return result;
}
private static string NormalizePath(string path)
{
if (string.IsNullOrEmpty(path))
return string.Empty;
return path.Replace("\\", "/", StringComparison.Ordinal).ToLowerInvariant();
}
} }
} }

View File

@@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<Authors></Authors> <Authors></Authors>
<Company></Company> <Company></Company>
<Version>2.0.1</Version> <Version>2.0.1.70</Version>
<Description></Description> <Description></Description>
<Copyright></Copyright> <Copyright></Copyright>
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl> <PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>

View File

@@ -194,7 +194,7 @@ public class PlayerDataFactory
// get all remaining paths and resolve them // get all remaining paths and resolve them
var transientPaths = ManageSemiTransientData(objectKind); var transientPaths = ManageSemiTransientData(objectKind);
var resolvedTransientPaths = await GetFileReplacementsFromPaths(transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false); var resolvedTransientPaths = await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
if (logDebug) if (logDebug)
{ {
@@ -373,11 +373,73 @@ public class PlayerDataFactory
} }
} }
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(HashSet<string> forwardResolve, HashSet<string> reverseResolve) private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(GameObjectHandler handler, HashSet<string> forwardResolve, HashSet<string> reverseResolve)
{ {
var forwardPaths = forwardResolve.ToArray(); var forwardPaths = forwardResolve.ToArray();
var reversePaths = reverseResolve.ToArray(); var reversePaths = reverseResolve.ToArray();
Dictionary<string, List<string>> resolvedPaths = new(StringComparer.Ordinal); Dictionary<string, List<string>> resolvedPaths = new(StringComparer.Ordinal);
if (handler.ObjectKind != ObjectKind.Player)
{
var (objectIndex, forwardResolved, reverseResolved) = await _dalamudUtil.RunOnFrameworkThread(() =>
{
var idx = handler.GetGameObject()?.ObjectIndex;
if (!idx.HasValue)
{
return ((int?)null, Array.Empty<string>(), Array.Empty<string[]>());
}
var resolvedForward = new string[forwardPaths.Length];
for (int i = 0; i < forwardPaths.Length; i++)
{
resolvedForward[i] = _ipcManager.Penumbra.ResolveGameObjectPath(forwardPaths[i], idx.Value);
}
var resolvedReverse = new string[reversePaths.Length][];
for (int i = 0; i < reversePaths.Length; i++)
{
resolvedReverse[i] = _ipcManager.Penumbra.ReverseResolveGameObjectPath(reversePaths[i], idx.Value);
}
return (idx, resolvedForward, resolvedReverse);
}).ConfigureAwait(false);
if (objectIndex.HasValue)
{
for (int i = 0; i < forwardPaths.Length; i++)
{
var filePath = forwardResolved[i]?.ToLowerInvariant();
if (string.IsNullOrEmpty(filePath))
{
continue;
}
if (resolvedPaths.TryGetValue(filePath, out var list))
{
list.Add(forwardPaths[i].ToLowerInvariant());
}
else
{
resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
}
}
for (int i = 0; i < reversePaths.Length; i++)
{
var filePath = reversePaths[i].ToLowerInvariant();
if (resolvedPaths.TryGetValue(filePath, out var list))
{
list.AddRange(reverseResolved[i].Select(c => c.ToLowerInvariant()));
}
else
{
resolvedPaths[filePath] = new List<string>(reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList());
}
}
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
}
}
var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false); var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false);
for (int i = 0; i < forwardPaths.Length; i++) for (int i = 0; i < forwardPaths.Length; i++)
{ {

View File

@@ -16,6 +16,8 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
private readonly Func<IntPtr> _getAddress; private readonly Func<IntPtr> _getAddress;
private readonly bool _isOwnedObject; private readonly bool _isOwnedObject;
private readonly PerformanceCollectorService _performanceCollector; private readonly PerformanceCollectorService _performanceCollector;
private readonly object _frameworkUpdateGate = new();
private bool _frameworkUpdateSubscribed;
private byte _classJob = 0; private byte _classJob = 0;
private Task? _delayedZoningTask; private Task? _delayedZoningTask;
private bool _haltProcessing = false; private bool _haltProcessing = false;
@@ -47,7 +49,10 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
}); });
} }
Mediator.Subscribe<FrameworkUpdateMessage>(this, (_) => FrameworkUpdate()); if (_isOwnedObject)
{
EnableFrameworkUpdates();
}
Mediator.Subscribe<ZoneSwitchEndMessage>(this, (_) => ZoneSwitchEnd()); Mediator.Subscribe<ZoneSwitchEndMessage>(this, (_) => ZoneSwitchEnd());
Mediator.Subscribe<ZoneSwitchStartMessage>(this, (_) => ZoneSwitchStart()); Mediator.Subscribe<ZoneSwitchStartMessage>(this, (_) => ZoneSwitchStart());
@@ -109,7 +114,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
{ {
while (await _dalamudUtil.RunOnFrameworkThread(() => while (await _dalamudUtil.RunOnFrameworkThread(() =>
{ {
if (_haltProcessing) CheckAndUpdateObject(); EnsureLatestObjectState();
if (CurrentDrawCondition != DrawCondition.None) return true; if (CurrentDrawCondition != DrawCondition.None) return true;
var gameObj = _dalamudUtil.CreateGameObject(Address); var gameObj = _dalamudUtil.CreateGameObject(Address);
if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter chara) if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter chara)
@@ -148,6 +153,11 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
_haltProcessing = false; _haltProcessing = false;
} }
public void Refresh()
{
_dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult();
}
public async Task<bool> IsBeingDrawnRunOnFrameworkAsync() public async Task<bool> IsBeingDrawnRunOnFrameworkAsync()
{ {
return await _dalamudUtil.RunOnFrameworkThread(IsBeingDrawn).ConfigureAwait(false); return await _dalamudUtil.RunOnFrameworkThread(IsBeingDrawn).ConfigureAwait(false);
@@ -361,7 +371,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
private bool IsBeingDrawn() private bool IsBeingDrawn()
{ {
if (_haltProcessing) CheckAndUpdateObject(); EnsureLatestObjectState();
if (_dalamudUtil.IsAnythingDrawing) if (_dalamudUtil.IsAnythingDrawing)
{ {
@@ -373,6 +383,28 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
return CurrentDrawCondition != DrawCondition.None; return CurrentDrawCondition != DrawCondition.None;
} }
private void EnsureLatestObjectState()
{
if (_haltProcessing || !_frameworkUpdateSubscribed)
{
CheckAndUpdateObject();
}
}
private void EnableFrameworkUpdates()
{
lock (_frameworkUpdateGate)
{
if (_frameworkUpdateSubscribed)
{
return;
}
Mediator.Subscribe<FrameworkUpdateMessage>(this, _ => FrameworkUpdate());
_frameworkUpdateSubscribed = true;
}
}
private unsafe DrawCondition IsBeingDrawnUnsafe() private unsafe DrawCondition IsBeingDrawnUnsafe()
{ {
if (Address == IntPtr.Zero) return DrawCondition.ObjectZero; if (Address == IntPtr.Zero) return DrawCondition.ObjectZero;

View File

@@ -25,6 +25,11 @@
bool IsDownloading { get; } bool IsDownloading { get; }
int PendingDownloadCount { get; } int PendingDownloadCount { get; }
int ForbiddenDownloadCount { get; } int ForbiddenDownloadCount { get; }
bool PendingModReapply { get; }
bool ModApplyDeferred { get; }
int MissingCriticalMods { get; }
int MissingNonCriticalMods { get; }
int MissingForbiddenMods { get; }
DateTime? InvisibleSinceUtc { get; } DateTime? InvisibleSinceUtc { get; }
DateTime? VisibilityEvictionDueAtUtc { get; } DateTime? VisibilityEvictionDueAtUtc { get; }

View File

@@ -87,22 +87,25 @@ public class Pair
return; return;
} }
if (args.Target is not MenuTargetDefault target || target.TargetObjectId != handler.PlayerCharacterId || IsPaused) if (args.Target is not MenuTargetDefault target || target.TargetObjectId != handler.PlayerCharacterId)
{ {
return; return;
} }
UiSharedService.AddContextMenuItem(args, name: "Open Profile", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => if (!IsPaused)
{ {
_mediator.Publish(new ProfileOpenStandaloneMessage(this)); UiSharedService.AddContextMenuItem(args, name: "Open Profile", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
return Task.CompletedTask; {
}); _mediator.Publish(new ProfileOpenStandaloneMessage(this));
return Task.CompletedTask;
});
UiSharedService.AddContextMenuItem(args, name: "Reapply last data", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => UiSharedService.AddContextMenuItem(args, name: "Reapply last data", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
{ {
ApplyLastReceivedData(forced: true); ApplyLastReceivedData(forced: true);
return Task.CompletedTask; return Task.CompletedTask;
}); });
}
UiSharedService.AddContextMenuItem(args, name: "Change Permissions", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => UiSharedService.AddContextMenuItem(args, name: "Change Permissions", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
{ {
@@ -110,7 +113,24 @@ public class Pair
return Task.CompletedTask; return Task.CompletedTask;
}); });
UiSharedService.AddContextMenuItem(args, name: "Cycle pause state", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => if (IsPaused)
{
UiSharedService.AddContextMenuItem(args, name: "Toggle Unpause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
{
_ = _apiController.Value.UnpauseAsync(UserData);
return Task.CompletedTask;
});
}
else
{
UiSharedService.AddContextMenuItem(args, name: "Toggle Pause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
{
_ = _apiController.Value.PauseAsync(UserData);
return Task.CompletedTask;
});
}
UiSharedService.AddContextMenuItem(args, name: "Cycle Pause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
{ {
TriggerCyclePause(); TriggerCyclePause();
return Task.CompletedTask; return Task.CompletedTask;
@@ -218,6 +238,11 @@ public class Pair
handler.IsApplying, handler.IsApplying,
handler.IsDownloading, handler.IsDownloading,
handler.PendingDownloadCount, handler.PendingDownloadCount,
handler.ForbiddenDownloadCount); handler.ForbiddenDownloadCount,
handler.PendingModReapply,
handler.ModApplyDeferred,
handler.MissingCriticalMods,
handler.MissingNonCriticalMods,
handler.MissingForbiddenMods);
} }
} }

View File

@@ -16,7 +16,12 @@ public sealed record PairDebugInfo(
bool IsApplying, bool IsApplying,
bool IsDownloading, bool IsDownloading,
int PendingDownloadCount, int PendingDownloadCount,
int ForbiddenDownloadCount) int ForbiddenDownloadCount,
bool PendingModReapply,
bool ModApplyDeferred,
int MissingCriticalMods,
int MissingNonCriticalMods,
int MissingForbiddenMods)
{ {
public static PairDebugInfo Empty { get; } = new( public static PairDebugInfo Empty { get; } = new(
false, false,
@@ -34,5 +39,10 @@ public sealed record PairDebugInfo(
false, false,
false, false,
0, 0,
0,
false,
false,
0,
0,
0); 0);
} }

View File

@@ -8,6 +8,7 @@ using LightlessSync.Interop.Ipc;
using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Events; using LightlessSync.Services.Events;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.Services.PairProcessing; using LightlessSync.Services.PairProcessing;
@@ -18,6 +19,7 @@ using LightlessSync.WebAPI.Files;
using LightlessSync.WebAPI.Files.Models; using LightlessSync.WebAPI.Files.Models;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer; using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer;
@@ -31,6 +33,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
private sealed record CombatData(Guid ApplicationId, CharacterData CharacterData, bool Forced); private sealed record CombatData(Guid ApplicationId, CharacterData CharacterData, bool Forced);
private readonly DalamudUtilService _dalamudUtil; private readonly DalamudUtilService _dalamudUtil;
private readonly ActorObjectService _actorObjectService;
private readonly FileDownloadManager _downloadManager; private readonly FileDownloadManager _downloadManager;
private readonly FileCacheManager _fileDbManager; private readonly FileCacheManager _fileDbManager;
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory; private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
@@ -43,6 +46,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
private readonly TextureDownscaleService _textureDownscaleService; private readonly TextureDownscaleService _textureDownscaleService;
private readonly PairStateCache _pairStateCache; private readonly PairStateCache _pairStateCache;
private readonly PairPerformanceMetricsCache _performanceMetricsCache; private readonly PairPerformanceMetricsCache _performanceMetricsCache;
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
private readonly PairManager _pairManager; private readonly PairManager _pairManager;
private CancellationTokenSource? _applicationCancellationTokenSource; private CancellationTokenSource? _applicationCancellationTokenSource;
private Guid _applicationId; private Guid _applicationId;
@@ -56,11 +60,16 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
private bool _forceFullReapply; private bool _forceFullReapply;
private Dictionary<(string GamePath, string? Hash), string>? _lastAppliedModdedPaths; private Dictionary<(string GamePath, string? Hash), string>? _lastAppliedModdedPaths;
private bool _needsCollectionRebuild; private bool _needsCollectionRebuild;
private bool _pendingModReapply;
private bool _lastModApplyDeferred;
private int _lastMissingCriticalMods;
private int _lastMissingNonCriticalMods;
private int _lastMissingForbiddenMods;
private bool _lastMissingCachedFiles;
private bool _isVisible; private bool _isVisible;
private Guid _penumbraCollection; private Guid _penumbraCollection;
private readonly object _collectionGate = new(); private readonly object _collectionGate = new();
private bool _redrawOnNextApplication = false; private bool _redrawOnNextApplication = false;
private bool _explicitRedrawQueued;
private readonly object _initializationGate = new(); private readonly object _initializationGate = new();
private readonly object _pauseLock = new(); private readonly object _pauseLock = new();
private Task _pauseTransitionTask = Task.CompletedTask; private Task _pauseTransitionTask = Task.CompletedTask;
@@ -73,8 +82,23 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
private readonly object _visibilityGraceGate = new(); private readonly object _visibilityGraceGate = new();
private CancellationTokenSource? _visibilityGraceCts; private CancellationTokenSource? _visibilityGraceCts;
private static readonly TimeSpan VisibilityEvictionGrace = TimeSpan.FromMinutes(1); private static readonly TimeSpan VisibilityEvictionGrace = TimeSpan.FromMinutes(1);
private static readonly HashSet<string> NonPriorityModExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".tmb",
".pap",
".atex",
".avfx",
".scd"
};
private DateTime? _invisibleSinceUtc; private DateTime? _invisibleSinceUtc;
private DateTime? _visibilityEvictionDueAtUtc; private DateTime? _visibilityEvictionDueAtUtc;
private DateTime _nextActorLookupUtc = DateTime.MinValue;
private static readonly TimeSpan ActorLookupInterval = TimeSpan.FromSeconds(1);
private static readonly SemaphoreSlim ActorInitializationLimiter = new(1, 1);
private readonly object _actorInitializationGate = new();
private ActorObjectService.ActorDescriptor? _pendingActorDescriptor;
private bool _actorInitializationInProgress;
private bool _frameworkUpdateSubscribed;
public DateTime? InvisibleSinceUtc => _invisibleSinceUtc; public DateTime? InvisibleSinceUtc => _invisibleSinceUtc;
public DateTime? VisibilityEvictionDueAtUtc => _visibilityEvictionDueAtUtc; public DateTime? VisibilityEvictionDueAtUtc => _visibilityEvictionDueAtUtc;
@@ -126,6 +150,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
public long LastAppliedApproximateVRAMBytes { get; set; } = -1; public long LastAppliedApproximateVRAMBytes { get; set; } = -1;
public long LastAppliedApproximateEffectiveVRAMBytes { get; set; } = -1; public long LastAppliedApproximateEffectiveVRAMBytes { get; set; } = -1;
public CharacterData? LastReceivedCharacterData { get; private set; } public CharacterData? LastReceivedCharacterData { get; private set; }
public bool PendingModReapply => _pendingModReapply;
public bool ModApplyDeferred => _lastModApplyDeferred;
public int MissingCriticalMods => _lastMissingCriticalMods;
public int MissingNonCriticalMods => _lastMissingNonCriticalMods;
public int MissingForbiddenMods => _lastMissingForbiddenMods;
public DateTime? LastDataReceivedAt => _lastDataReceivedAt; public DateTime? LastDataReceivedAt => _lastDataReceivedAt;
public DateTime? LastApplyAttemptAt => _lastApplyAttemptAt; public DateTime? LastApplyAttemptAt => _lastApplyAttemptAt;
public DateTime? LastSuccessfulApplyAt => _lastSuccessfulApplyAt; public DateTime? LastSuccessfulApplyAt => _lastSuccessfulApplyAt;
@@ -146,6 +175,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
FileDownloadManager transferManager, FileDownloadManager transferManager,
PluginWarningNotificationService pluginWarningNotificationManager, PluginWarningNotificationService pluginWarningNotificationManager,
DalamudUtilService dalamudUtil, DalamudUtilService dalamudUtil,
ActorObjectService actorObjectService,
IHostApplicationLifetime lifetime, IHostApplicationLifetime lifetime,
FileCacheManager fileDbManager, FileCacheManager fileDbManager,
PlayerPerformanceService playerPerformanceService, PlayerPerformanceService playerPerformanceService,
@@ -153,7 +183,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
ServerConfigurationManager serverConfigManager, ServerConfigurationManager serverConfigManager,
TextureDownscaleService textureDownscaleService, TextureDownscaleService textureDownscaleService,
PairStateCache pairStateCache, PairStateCache pairStateCache,
PairPerformanceMetricsCache performanceMetricsCache) : base(logger, mediator) PairPerformanceMetricsCache performanceMetricsCache,
PenumbraTempCollectionJanitor tempCollectionJanitor) : base(logger, mediator)
{ {
_pairManager = pairManager; _pairManager = pairManager;
Ident = ident; Ident = ident;
@@ -162,6 +193,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
_downloadManager = transferManager; _downloadManager = transferManager;
_pluginWarningNotificationManager = pluginWarningNotificationManager; _pluginWarningNotificationManager = pluginWarningNotificationManager;
_dalamudUtil = dalamudUtil; _dalamudUtil = dalamudUtil;
_actorObjectService = actorObjectService;
_lifetime = lifetime; _lifetime = lifetime;
_fileDbManager = fileDbManager; _fileDbManager = fileDbManager;
_playerPerformanceService = playerPerformanceService; _playerPerformanceService = playerPerformanceService;
@@ -170,7 +202,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
_textureDownscaleService = textureDownscaleService; _textureDownscaleService = textureDownscaleService;
_pairStateCache = pairStateCache; _pairStateCache = pairStateCache;
_performanceMetricsCache = performanceMetricsCache; _performanceMetricsCache = performanceMetricsCache;
LastAppliedDataBytes = -1; _tempCollectionJanitor = tempCollectionJanitor;
} }
public void Initialize() public void Initialize()
@@ -185,6 +217,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
return; return;
} }
ActorObjectService.ActorDescriptor? trackedDescriptor = null;
lock (_initializationGate) lock (_initializationGate)
{ {
if (Initialized) if (Initialized)
@@ -198,7 +231,12 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
_forceApplyMods = true; _forceApplyMods = true;
} }
Mediator.Subscribe<FrameworkUpdateMessage>(this, _ => FrameworkUpdate()); var useFrameworkUpdate = !_actorObjectService.HooksActive;
if (useFrameworkUpdate)
{
Mediator.Subscribe<FrameworkUpdateMessage>(this, _ => FrameworkUpdate());
_frameworkUpdateSubscribed = true;
}
Mediator.Subscribe<ZoneSwitchStartMessage>(this, _ => Mediator.Subscribe<ZoneSwitchStartMessage>(this, _ =>
{ {
_downloadCancellationTokenSource?.CancelDispose(); _downloadCancellationTokenSource?.CancelDispose();
@@ -234,17 +272,49 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
Mediator.Subscribe<CutsceneEndMessage>(this, _ => EnableSync()); Mediator.Subscribe<CutsceneEndMessage>(this, _ => EnableSync());
Mediator.Subscribe<GposeStartMessage>(this, _ => DisableSync()); Mediator.Subscribe<GposeStartMessage>(this, _ => DisableSync());
Mediator.Subscribe<GposeEndMessage>(this, _ => EnableSync()); Mediator.Subscribe<GposeEndMessage>(this, _ => EnableSync());
Mediator.Subscribe<DownloadFinishedMessage>(this, msg => Mediator.Subscribe<ActorTrackedMessage>(this, msg => HandleActorTracked(msg.Descriptor));
{ Mediator.Subscribe<ActorUntrackedMessage>(this, msg => HandleActorUntracked(msg.Descriptor));
if (_charaHandler is null || !ReferenceEquals(msg.DownloadId, _charaHandler)) Mediator.Subscribe<DownloadFinishedMessage>(this, msg =>
{ {
return; if (_charaHandler is null || !ReferenceEquals(msg.DownloadId, _charaHandler))
{
return;
}
if (_pendingModReapply && IsVisible)
{
if (LastReceivedCharacterData is not null)
{
Logger.LogDebug("Downloads finished for {handler}, reapplying pending mod data", GetLogIdentifier());
ApplyLastReceivedData(forced: true);
return;
}
if (_cachedData is not null)
{
Logger.LogDebug("Downloads finished for {handler}, reapplying pending mod data from cache", GetLogIdentifier());
ApplyCharacterData(Guid.NewGuid(), _cachedData, forceApplyCustomization: true);
return;
}
}
TryApplyQueuedData();
});
if (!useFrameworkUpdate
&& _actorObjectService.TryGetActorByHash(Ident, out var descriptor)
&& descriptor.Address != nint.Zero)
{
trackedDescriptor = descriptor;
} }
TryApplyQueuedData();
});
Initialized = true; Initialized = true;
} }
if (trackedDescriptor.HasValue)
{
HandleActorTracked(trackedDescriptor.Value);
}
} }
private IReadOnlyList<PairConnection> GetCurrentPairs() private IReadOnlyList<PairConnection> GetCurrentPairs()
@@ -355,6 +425,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
{ {
_penumbraCollection = created; _penumbraCollection = created;
_pairStateCache.StoreTemporaryCollection(Ident, created); _pairStateCache.StoreTemporaryCollection(Ident, created);
_tempCollectionJanitor.Register(created);
} }
return _penumbraCollection; return _penumbraCollection;
@@ -387,6 +458,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
_needsCollectionRebuild = true; _needsCollectionRebuild = true;
_forceFullReapply = true; _forceFullReapply = true;
_forceApplyMods = true; _forceApplyMods = true;
_tempCollectionJanitor.Unregister(toRelease);
} }
if (!releaseFromPenumbra || toRelease == Guid.Empty || !_ipcManager.Penumbra.APIAvailable) if (!releaseFromPenumbra || toRelease == Guid.Empty || !_ipcManager.Penumbra.APIAvailable)
@@ -486,7 +558,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
return; return;
} }
var shouldForce = forced || HasMissingCachedFiles(LastReceivedCharacterData); var hasMissingCachedFiles = HasMissingCachedFiles(LastReceivedCharacterData);
var missingResolved = _lastMissingCachedFiles && !hasMissingCachedFiles;
_lastMissingCachedFiles = hasMissingCachedFiles;
var shouldForce = forced || missingResolved;
if (IsPaused()) if (IsPaused())
{ {
@@ -629,7 +704,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
{ {
if (!string.IsNullOrEmpty(replacement.FileSwapPath)) if (!string.IsNullOrEmpty(replacement.FileSwapPath))
{ {
if (!File.Exists(replacement.FileSwapPath)) if (Path.IsPathRooted(replacement.FileSwapPath) && !File.Exists(replacement.FileSwapPath))
{ {
Logger.LogTrace("Missing file swap path {Path} detected for {Handler}", replacement.FileSwapPath, GetLogIdentifier()); Logger.LogTrace("Missing file swap path {Path} detected for {Handler}", replacement.FileSwapPath, GetLogIdentifier());
return true; return true;
@@ -737,6 +812,67 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
return true; return true;
} }
private bool IsForbiddenHash(string hash)
=> _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, hash, StringComparison.Ordinal));
private static bool IsNonPriorityModPath(string? gamePath)
{
if (string.IsNullOrEmpty(gamePath))
{
return false;
}
var extension = Path.GetExtension(gamePath);
return !string.IsNullOrEmpty(extension) && NonPriorityModExtensions.Contains(extension);
}
private static bool IsCriticalModReplacement(FileReplacementData replacement)
{
foreach (var gamePath in replacement.GamePaths)
{
if (!IsNonPriorityModPath(gamePath))
{
return true;
}
}
return false;
}
private void CountMissingReplacements(IEnumerable<FileReplacementData> missing, out int critical, out int nonCritical, out int forbidden)
{
critical = 0;
nonCritical = 0;
forbidden = 0;
foreach (var replacement in missing)
{
if (IsForbiddenHash(replacement.Hash))
{
forbidden++;
}
if (IsCriticalModReplacement(replacement))
{
critical++;
}
else
{
nonCritical++;
}
}
}
private static void RemoveModApplyChanges(Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData)
{
foreach (var changes in updatedData.Values)
{
changes.Remove(PlayerChanges.ModFiles);
changes.Remove(PlayerChanges.ModManip);
changes.Remove(PlayerChanges.ForcedRedraw);
}
}
private bool CanApplyNow() private bool CanApplyNow()
{ {
return !_dalamudUtil.IsInCombat return !_dalamudUtil.IsInCombat
@@ -760,6 +896,16 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
_lastBlockingConditions = Array.Empty<string>(); _lastBlockingConditions = Array.Empty<string>();
} }
private void DeferApplication(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization, UserData user, string reason,
string failureKey, LogLevel logLevel, string logMessage, params object?[] logArgs)
{
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, reason)));
Logger.Log(logLevel, logMessage, logArgs);
RecordFailure(reason, failureKey);
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
SetUploading(false);
}
public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = false) public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = false)
{ {
_lastApplyAttemptAt = DateTime.UtcNow; _lastApplyAttemptAt = DateTime.UtcNow;
@@ -777,72 +923,48 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
if (_dalamudUtil.IsInCombat) if (_dalamudUtil.IsInCombat)
{ {
const string reason = "Cannot apply character data: you are in combat, deferring application"; const string reason = "Cannot apply character data: you are in combat, deferring application";
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "Combat", LogLevel.Debug,
reason))); "[BASE-{appBase}] Received data but player is in combat", applicationBase);
Logger.LogDebug("[BASE-{appBase}] Received data but player is in combat", applicationBase);
RecordFailure(reason, "Combat");
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
SetUploading(false);
return; return;
} }
if (_dalamudUtil.IsPerforming) if (_dalamudUtil.IsPerforming)
{ {
const string reason = "Cannot apply character data: you are performing music, deferring application"; const string reason = "Cannot apply character data: you are performing music, deferring application";
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "Performance", LogLevel.Debug,
reason))); "[BASE-{appBase}] Received data but player is performing", applicationBase);
Logger.LogDebug("[BASE-{appBase}] Received data but player is performing", applicationBase);
RecordFailure(reason, "Performance");
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
SetUploading(false);
return; return;
} }
if (_dalamudUtil.IsInInstance) if (_dalamudUtil.IsInInstance)
{ {
const string reason = "Cannot apply character data: you are in an instance, deferring application"; const string reason = "Cannot apply character data: you are in an instance, deferring application";
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "Instance", LogLevel.Debug,
reason))); "[BASE-{appBase}] Received data but player is in instance", applicationBase);
Logger.LogDebug("[BASE-{appBase}] Received data but player is in instance", applicationBase);
RecordFailure(reason, "Instance");
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
SetUploading(false);
return; return;
} }
if (_dalamudUtil.IsInCutscene) if (_dalamudUtil.IsInCutscene)
{ {
const string reason = "Cannot apply character data: you are in a cutscene, deferring application"; const string reason = "Cannot apply character data: you are in a cutscene, deferring application";
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "Cutscene", LogLevel.Debug,
reason))); "[BASE-{appBase}] Received data but player is in a cutscene", applicationBase);
Logger.LogDebug("[BASE-{appBase}] Received data but player is in a cutscene", applicationBase);
RecordFailure(reason, "Cutscene");
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
SetUploading(false);
return; return;
} }
if (_dalamudUtil.IsInGpose) if (_dalamudUtil.IsInGpose)
{ {
const string reason = "Cannot apply character data: you are in GPose, deferring application"; const string reason = "Cannot apply character data: you are in GPose, deferring application";
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "GPose", LogLevel.Debug,
reason))); "[BASE-{appBase}] Received data but player is in GPose", applicationBase);
Logger.LogDebug("[BASE-{appBase}] Received data but player is in GPose", applicationBase);
RecordFailure(reason, "GPose");
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
SetUploading(false);
return; return;
} }
if (!_ipcManager.Penumbra.APIAvailable || !_ipcManager.Glamourer.APIAvailable) if (!_ipcManager.Penumbra.APIAvailable || !_ipcManager.Glamourer.APIAvailable)
{ {
const string reason = "Cannot apply character data: Penumbra or Glamourer is not available, deferring application"; const string reason = "Cannot apply character data: Penumbra or Glamourer is not available, deferring application";
Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, DeferApplication(applicationBase, characterData, forceApplyCustomization, user, reason, "PluginUnavailable", LogLevel.Information,
reason))); "[BASE-{appbase}] Application of data for {player} while Penumbra/Glamourer unavailable, returning", applicationBase, GetLogIdentifier());
Logger.LogInformation("[BASE-{appbase}] Application of data for {player} while Penumbra/Glamourer unavailable, returning", applicationBase, GetLogIdentifier());
RecordFailure(reason, "PluginUnavailable");
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
SetUploading(false);
return; return;
} }
@@ -885,13 +1007,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
_forceApplyMods = false; _forceApplyMods = false;
} }
_explicitRedrawQueued = false;
if (_redrawOnNextApplication && charaDataToUpdate.TryGetValue(ObjectKind.Player, out var player)) if (_redrawOnNextApplication && charaDataToUpdate.TryGetValue(ObjectKind.Player, out var player))
{ {
player.Add(PlayerChanges.ForcedRedraw); player.Add(PlayerChanges.ForcedRedraw);
_redrawOnNextApplication = false; _redrawOnNextApplication = false;
_explicitRedrawQueued = true;
} }
if (charaDataToUpdate.TryGetValue(ObjectKind.Player, out var playerChanges)) if (charaDataToUpdate.TryGetValue(ObjectKind.Player, out var playerChanges))
@@ -1085,7 +1204,14 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
Logger.LogDebug("[{applicationId}] Applying Customization Data for {handler}", applicationId, handler); Logger.LogDebug("[{applicationId}] Applying Customization Data for {handler}", applicationId, handler);
await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handler, applicationId, 30000, token).ConfigureAwait(false); await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handler, applicationId, 30000, token).ConfigureAwait(false);
if (handler.Address != nint.Zero)
{
await _actorObjectService.WaitForFullyLoadedAsync(handler.Address, token).ConfigureAwait(false);
}
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var tasks = new List<Task>();
bool needsRedraw = false;
foreach (var change in changes.Value.OrderBy(p => (int)p)) foreach (var change in changes.Value.OrderBy(p => (int)p))
{ {
Logger.LogDebug("[{applicationId}] Processing {change} for {handler}", applicationId, change, handler); Logger.LogDebug("[{applicationId}] Processing {change} for {handler}", applicationId, change, handler);
@@ -1094,45 +1220,39 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
case PlayerChanges.Customize: case PlayerChanges.Customize:
if (charaData.CustomizePlusData.TryGetValue(changes.Key, out var customizePlusData)) if (charaData.CustomizePlusData.TryGetValue(changes.Key, out var customizePlusData))
{ {
_customizeIds[changes.Key] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(handler.Address, customizePlusData).ConfigureAwait(false); tasks.Add(ApplyCustomizeAsync(handler.Address, customizePlusData, changes.Key));
} }
else if (_customizeIds.TryGetValue(changes.Key, out var customizeId)) else if (_customizeIds.TryGetValue(changes.Key, out var customizeId))
{ {
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); tasks.Add(RevertCustomizeAsync(customizeId, changes.Key));
_customizeIds.Remove(changes.Key);
} }
break; break;
case PlayerChanges.Heels: case PlayerChanges.Heels:
await _ipcManager.Heels.SetOffsetForPlayerAsync(handler.Address, charaData.HeelsData).ConfigureAwait(false); tasks.Add(_ipcManager.Heels.SetOffsetForPlayerAsync(handler.Address, charaData.HeelsData));
break; break;
case PlayerChanges.Honorific: case PlayerChanges.Honorific:
await _ipcManager.Honorific.SetTitleAsync(handler.Address, charaData.HonorificData).ConfigureAwait(false); tasks.Add(_ipcManager.Honorific.SetTitleAsync(handler.Address, charaData.HonorificData));
break; break;
case PlayerChanges.Glamourer: case PlayerChanges.Glamourer:
if (charaData.GlamourerData.TryGetValue(changes.Key, out var glamourerData)) if (charaData.GlamourerData.TryGetValue(changes.Key, out var glamourerData))
{ {
await _ipcManager.Glamourer.ApplyAllAsync(Logger, handler, glamourerData, applicationId, token).ConfigureAwait(false); tasks.Add(_ipcManager.Glamourer.ApplyAllAsync(Logger, handler, glamourerData, applicationId, token));
} }
break; break;
case PlayerChanges.Moodles: case PlayerChanges.Moodles:
await _ipcManager.Moodles.SetStatusAsync(handler.Address, charaData.MoodlesData).ConfigureAwait(false); tasks.Add(_ipcManager.Moodles.SetStatusAsync(handler.Address, charaData.MoodlesData));
break; break;
case PlayerChanges.PetNames: case PlayerChanges.PetNames:
await _ipcManager.PetNames.SetPlayerData(handler.Address, charaData.PetNamesData).ConfigureAwait(false); tasks.Add(_ipcManager.PetNames.SetPlayerData(handler.Address, charaData.PetNamesData));
break; break;
case PlayerChanges.ForcedRedraw: case PlayerChanges.ForcedRedraw:
if (!ShouldPerformForcedRedraw(changes.Key, changes.Value, charaData)) needsRedraw = true;
{
Logger.LogTrace("[{applicationId}] Skipping forced redraw for {handler}", applicationId, handler);
break;
}
await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false);
break; break;
default: default:
@@ -1140,6 +1260,16 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
} }
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
} }
if (tasks.Count > 0)
{
await Task.WhenAll(tasks).ConfigureAwait(false);
}
if (needsRedraw)
{
await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false);
}
} }
finally finally
{ {
@@ -1147,44 +1277,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
} }
} }
private bool ShouldPerformForcedRedraw(ObjectKind objectKind, ICollection<PlayerChanges> changeSet, CharacterData newData)
{
if (objectKind != ObjectKind.Player)
{
return true;
}
var hasModFiles = changeSet.Contains(PlayerChanges.ModFiles);
var hasManip = changeSet.Contains(PlayerChanges.ModManip);
var modsChanged = hasModFiles && PlayerModFilesChanged(newData, _cachedData);
var manipChanged = hasManip && !string.Equals(_cachedData?.ManipulationData, newData.ManipulationData, StringComparison.Ordinal);
if (modsChanged)
{
_explicitRedrawQueued = false;
return true;
}
if (manipChanged)
{
_explicitRedrawQueued = false;
return true;
}
if (_explicitRedrawQueued)
{
_explicitRedrawQueued = false;
return true;
}
if ((hasModFiles || hasManip) && (_forceFullReapply || _needsCollectionRebuild))
{
_explicitRedrawQueued = false;
return true;
}
return false;
}
private static Dictionary<ObjectKind, HashSet<PlayerChanges>> BuildFullChangeSet(CharacterData characterData) private static Dictionary<ObjectKind, HashSet<PlayerChanges>> BuildFullChangeSet(CharacterData characterData)
{ {
@@ -1339,6 +1431,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
bool skipDownscaleForPair = ShouldSkipDownscale(); bool skipDownscaleForPair = ShouldSkipDownscale();
var user = GetPrimaryUserData(); var user = GetPrimaryUserData();
Dictionary<(string GamePath, string? Hash), string> moddedPaths; Dictionary<(string GamePath, string? Hash), string> moddedPaths;
List<FileReplacementData> missingReplacements = [];
if (updateModdedPaths) if (updateModdedPaths)
{ {
@@ -1350,6 +1443,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
{ {
int attempts = 0; int attempts = 0;
List<FileReplacementData> toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); List<FileReplacementData> toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken);
missingReplacements = toDownloadReplacements;
while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested) while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested)
{ {
@@ -1399,6 +1493,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
} }
toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken);
missingReplacements = toDownloadReplacements;
if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal)))) if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal))))
{ {
@@ -1422,6 +1517,54 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
: []; : [];
} }
var wantsModApply = updateModdedPaths || updateManip;
var pendingModReapply = false;
var deferModApply = false;
if (wantsModApply && missingReplacements.Count > 0)
{
CountMissingReplacements(missingReplacements, out var missingCritical, out var missingNonCritical, out var missingForbidden);
_lastMissingCriticalMods = missingCritical;
_lastMissingNonCriticalMods = missingNonCritical;
_lastMissingForbiddenMods = missingForbidden;
var hasCriticalMissing = missingCritical > 0;
var hasNonCriticalMissing = missingNonCritical > 0;
var hasDownloadableMissing = missingReplacements.Any(replacement => !IsForbiddenHash(replacement.Hash));
var hasDownloadableCriticalMissing = hasCriticalMissing
&& missingReplacements.Any(replacement => !IsForbiddenHash(replacement.Hash) && IsCriticalModReplacement(replacement));
pendingModReapply = hasDownloadableMissing;
_lastModApplyDeferred = false;
if (hasDownloadableCriticalMissing)
{
deferModApply = true;
_lastModApplyDeferred = true;
Logger.LogDebug("[BASE-{appBase}] Critical mod files missing for {handler}, deferring mod apply ({count} missing)",
applicationBase, GetLogIdentifier(), missingReplacements.Count);
}
else if (hasNonCriticalMissing && hasDownloadableMissing)
{
Logger.LogDebug("[BASE-{appBase}] Non-critical mod files missing for {handler}, applying partial mods and reapplying after downloads ({count} missing)",
applicationBase, GetLogIdentifier(), missingReplacements.Count);
}
}
else
{
_lastMissingCriticalMods = 0;
_lastMissingNonCriticalMods = 0;
_lastMissingForbiddenMods = 0;
_lastModApplyDeferred = false;
}
if (deferModApply)
{
updateModdedPaths = false;
updateManip = false;
RemoveModApplyChanges(updatedData);
}
downloadToken.ThrowIfCancellationRequested(); downloadToken.ThrowIfCancellationRequested();
var handlerForApply = _charaHandler; var handlerForApply = _charaHandler;
@@ -1454,7 +1597,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
_applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource(); _applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource();
var token = _applicationCancellationTokenSource.Token; var token = _applicationCancellationTokenSource.Token;
_applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, token); _applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, wantsModApply, pendingModReapply, token);
} }
finally finally
{ {
@@ -1463,7 +1606,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
} }
private async Task ApplyCharacterDataAsync(Guid applicationBase, GameObjectHandler handlerForApply, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData, bool updateModdedPaths, bool updateManip, private async Task ApplyCharacterDataAsync(Guid applicationBase, GameObjectHandler handlerForApply, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData, bool updateModdedPaths, bool updateManip,
Dictionary<(string GamePath, string? Hash), string> moddedPaths, CancellationToken token) Dictionary<(string GamePath, string? Hash), string> moddedPaths, bool wantsModApply, bool pendingModReapply, CancellationToken token)
{ {
try try
{ {
@@ -1472,6 +1615,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
Logger.LogDebug("[{applicationId}] Waiting for initial draw for for {handler}", _applicationId, handlerForApply); Logger.LogDebug("[{applicationId}] Waiting for initial draw for for {handler}", _applicationId, handlerForApply);
await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handlerForApply, _applicationId, 30000, token).ConfigureAwait(false); await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handlerForApply, _applicationId, 30000, token).ConfigureAwait(false);
if (handlerForApply.Address != nint.Zero)
{
await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false);
}
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
@@ -1538,7 +1685,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
_cachedData = charaData; _cachedData = charaData;
_pairStateCache.Store(Ident, charaData); _pairStateCache.Store(Ident, charaData);
_forceFullReapply = false; if (wantsModApply)
{
_pendingModReapply = pendingModReapply;
}
_forceFullReapply = _pendingModReapply;
_needsCollectionRebuild = false; _needsCollectionRebuild = false;
if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0)
{ {
@@ -1584,8 +1735,15 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
private void FrameworkUpdate() private void FrameworkUpdate()
{ {
if (string.IsNullOrEmpty(PlayerName)) if (string.IsNullOrEmpty(PlayerName) && _charaHandler is null)
{ {
var now = DateTime.UtcNow;
if (now < _nextActorLookupUtc)
{
return;
}
_nextActorLookupUtc = now + ActorLookupInterval;
var pc = _dalamudUtil.FindPlayerByNameHash(Ident); var pc = _dalamudUtil.FindPlayerByNameHash(Ident);
if (pc == default((string, nint))) return; if (pc == default((string, nint))) return;
Logger.LogDebug("One-Time Initializing {handler}", GetLogIdentifier()); Logger.LogDebug("One-Time Initializing {handler}", GetLogIdentifier());
@@ -1595,6 +1753,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
$"Initializing User For Character {pc.Name}"))); $"Initializing User For Character {pc.Name}")));
} }
TryHandleVisibilityUpdate();
}
private void TryHandleVisibilityUpdate()
{
if (_charaHandler?.Address != nint.Zero && !IsVisible && !_pauseRequested) if (_charaHandler?.Address != nint.Zero && !IsVisible && !_pauseRequested)
{ {
Guid appData = Guid.NewGuid(); Guid appData = Guid.NewGuid();
@@ -1641,16 +1804,24 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
} }
else if (_charaHandler?.Address == nint.Zero && IsVisible) else if (_charaHandler?.Address == nint.Zero && IsVisible)
{ {
IsVisible = false; HandleVisibilityLoss(logChange: true);
_charaHandler.Invalidate();
_downloadCancellationTokenSource?.CancelDispose();
_downloadCancellationTokenSource = null;
Logger.LogTrace("{handler} visibility changed, now: {visi}", GetLogIdentifier(), IsVisible);
} }
TryApplyQueuedData(); TryApplyQueuedData();
} }
private void HandleVisibilityLoss(bool logChange)
{
IsVisible = false;
_charaHandler?.Invalidate();
_downloadCancellationTokenSource?.CancelDispose();
_downloadCancellationTokenSource = null;
if (logChange)
{
Logger.LogTrace("{handler} visibility changed, now: {visi}", GetLogIdentifier(), IsVisible);
}
}
private void Initialize(string name) private void Initialize(string name)
{ {
PlayerName = name; PlayerName = name;
@@ -1977,7 +2148,164 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
} }
_dataReceivedInDowntime = null; _dataReceivedInDowntime = null;
ApplyCharacterData(pending.ApplicationId, _ = Task.Run(() =>
pending.CharacterData, pending.Forced); {
try
{
ApplyCharacterData(pending.ApplicationId, pending.CharacterData, pending.Forced);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed applying queued data for {handler}", GetLogIdentifier());
}
});
}
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
{
if (!TryResolveDescriptorHash(descriptor, out var hashedCid))
return;
if (!string.Equals(hashedCid, Ident, StringComparison.Ordinal))
return;
if (descriptor.Address == nint.Zero)
return;
RefreshTrackedHandler(descriptor);
QueueActorInitialization(descriptor);
}
private void QueueActorInitialization(ActorObjectService.ActorDescriptor descriptor)
{
lock (_actorInitializationGate)
{
_pendingActorDescriptor = descriptor;
if (_actorInitializationInProgress)
{
return;
}
_actorInitializationInProgress = true;
}
_ = Task.Run(InitializeFromTrackedAsync);
}
private async Task InitializeFromTrackedAsync()
{
try
{
await ActorInitializationLimiter.WaitAsync().ConfigureAwait(false);
while (true)
{
ActorObjectService.ActorDescriptor? descriptor;
lock (_actorInitializationGate)
{
descriptor = _pendingActorDescriptor;
_pendingActorDescriptor = null;
}
if (!descriptor.HasValue)
{
break;
}
if (_frameworkUpdateSubscribed && _actorObjectService.HooksActive)
{
Mediator.Unsubscribe<FrameworkUpdateMessage>(this);
_frameworkUpdateSubscribed = false;
}
if (string.IsNullOrEmpty(PlayerName) || _charaHandler is null)
{
Logger.LogDebug("Actor tracked for {handler}, initializing from hook", GetLogIdentifier());
Initialize(descriptor.Value.Name);
Mediator.Publish(new EventMessage(new Event(PlayerName, GetPrimaryUserData(), nameof(PairHandlerAdapter), EventSeverity.Informational,
$"Initializing User For Character {descriptor.Value.Name}")));
}
RefreshTrackedHandler(descriptor.Value);
TryHandleVisibilityUpdate();
}
}
finally
{
ActorInitializationLimiter.Release();
lock (_actorInitializationGate)
{
_actorInitializationInProgress = false;
if (_pendingActorDescriptor.HasValue)
{
_actorInitializationInProgress = true;
_ = Task.Run(InitializeFromTrackedAsync);
}
}
}
}
private void RefreshTrackedHandler(ActorObjectService.ActorDescriptor descriptor)
{
if (_charaHandler is null)
return;
if (descriptor.Address == nint.Zero)
return;
if (_charaHandler.Address == descriptor.Address)
return;
_charaHandler.Refresh();
}
private void HandleActorUntracked(ActorObjectService.ActorDescriptor descriptor)
{
if (!TryResolveDescriptorHash(descriptor, out var hashedCid))
{
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
return;
if (descriptor.Address != _charaHandler.Address)
return;
}
else if (!string.Equals(hashedCid, Ident, StringComparison.Ordinal))
{
return;
}
if (_charaHandler is null || _charaHandler.Address == nint.Zero)
return;
if (descriptor.Address != _charaHandler.Address)
return;
HandleVisibilityLoss(logChange: false);
}
private bool TryResolveDescriptorHash(ActorObjectService.ActorDescriptor descriptor, out string hashedCid)
{
hashedCid = descriptor.HashedContentId ?? string.Empty;
if (!string.IsNullOrEmpty(hashedCid))
return true;
if (descriptor.ObjectKind != DalamudObjectKind.Player || descriptor.Address == nint.Zero)
return false;
hashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(descriptor.Address);
return !string.IsNullOrEmpty(hashedCid);
}
private async Task ApplyCustomizeAsync(nint address, string customizeData, ObjectKind kind)
{
_customizeIds[kind] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(address, customizeData).ConfigureAwait(false);
}
private async Task RevertCustomizeAsync(Guid? customizeId, ObjectKind kind)
{
if (!customizeId.HasValue)
return;
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId.Value).ConfigureAwait(false);
_customizeIds.Remove(kind);
} }
} }

View File

@@ -2,6 +2,7 @@ using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc; using LightlessSync.Interop.Ipc;
using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Factories;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.Services.PairProcessing; using LightlessSync.Services.PairProcessing;
using LightlessSync.Services.ServerConfiguration; using LightlessSync.Services.ServerConfiguration;
@@ -30,6 +31,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
private readonly TextureDownscaleService _textureDownscaleService; private readonly TextureDownscaleService _textureDownscaleService;
private readonly PairStateCache _pairStateCache; private readonly PairStateCache _pairStateCache;
private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache; private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache;
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
public PairHandlerAdapterFactory( public PairHandlerAdapterFactory(
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
@@ -47,7 +49,8 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
ServerConfigurationManager serverConfigManager, ServerConfigurationManager serverConfigManager,
TextureDownscaleService textureDownscaleService, TextureDownscaleService textureDownscaleService,
PairStateCache pairStateCache, PairStateCache pairStateCache,
PairPerformanceMetricsCache pairPerformanceMetricsCache) PairPerformanceMetricsCache pairPerformanceMetricsCache,
PenumbraTempCollectionJanitor tempCollectionJanitor)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_mediator = mediator; _mediator = mediator;
@@ -65,12 +68,14 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
_textureDownscaleService = textureDownscaleService; _textureDownscaleService = textureDownscaleService;
_pairStateCache = pairStateCache; _pairStateCache = pairStateCache;
_pairPerformanceMetricsCache = pairPerformanceMetricsCache; _pairPerformanceMetricsCache = pairPerformanceMetricsCache;
_tempCollectionJanitor = tempCollectionJanitor;
} }
public IPairHandlerAdapter Create(string ident) public IPairHandlerAdapter Create(string ident)
{ {
var downloadManager = _fileDownloadManagerFactory.Create(); var downloadManager = _fileDownloadManagerFactory.Create();
var dalamudUtilService = _serviceProvider.GetRequiredService<DalamudUtilService>(); var dalamudUtilService = _serviceProvider.GetRequiredService<DalamudUtilService>();
var actorObjectService = _serviceProvider.GetRequiredService<ActorObjectService>();
return new PairHandlerAdapter( return new PairHandlerAdapter(
_loggerFactory.CreateLogger<PairHandlerAdapter>(), _loggerFactory.CreateLogger<PairHandlerAdapter>(),
_mediator, _mediator,
@@ -81,6 +86,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
downloadManager, downloadManager,
_pluginWarningNotificationManager, _pluginWarningNotificationManager,
dalamudUtilService, dalamudUtilService,
actorObjectService,
_lifetime, _lifetime,
_fileCacheManager, _fileCacheManager,
_playerPerformanceService, _playerPerformanceService,
@@ -88,6 +94,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
_serverConfigManager, _serverConfigManager,
_textureDownscaleService, _textureDownscaleService,
_pairStateCache, _pairStateCache,
_pairPerformanceMetricsCache); _pairPerformanceMetricsCache,
_tempCollectionJanitor);
} }
} }

View File

@@ -40,6 +40,7 @@ using System.Reflection;
using OtterTex; using OtterTex;
using LightlessSync.Services.LightFinder; using LightlessSync.Services.LightFinder;
using LightlessSync.Services.PairProcessing; using LightlessSync.Services.PairProcessing;
using LightlessSync.UI.Models;
namespace LightlessSync; namespace LightlessSync;
@@ -51,7 +52,7 @@ public sealed class Plugin : IDalamudPlugin
IFramework framework, IObjectTable objectTable, IClientState clientState, ICondition condition, IChatGui chatGui, IFramework framework, IObjectTable objectTable, IClientState clientState, ICondition condition, IChatGui chatGui,
IGameGui gameGui, IDtrBar dtrBar, IPluginLog pluginLog, ITargetManager targetManager, INotificationManager notificationManager, IGameGui gameGui, IDtrBar dtrBar, IPluginLog pluginLog, ITargetManager targetManager, INotificationManager notificationManager,
ITextureProvider textureProvider, IContextMenu contextMenu, IGameInteropProvider gameInteropProvider, IGameConfig gameConfig, 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); NativeDll.Initialize(pluginInterface.AssemblyLocation.DirectoryName);
if (!Directory.Exists(pluginInterface.ConfigDirectory.FullName)) if (!Directory.Exists(pluginInterface.ConfigDirectory.FullName))
@@ -105,6 +106,7 @@ public sealed class Plugin : IDalamudPlugin
services.AddSingleton<FileDialogManager>(); services.AddSingleton<FileDialogManager>();
services.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", string.Empty, useEmbedded: true)); services.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", string.Empty, useEmbedded: true));
services.AddSingleton(gameGui); services.AddSingleton(gameGui);
services.AddSingleton(gameInteropProvider);
services.AddSingleton(addonLifecycle); services.AddSingleton(addonLifecycle);
services.AddSingleton<IUiBuilder>(pluginInterface.UiBuilder); services.AddSingleton<IUiBuilder>(pluginInterface.UiBuilder);
@@ -115,6 +117,7 @@ public sealed class Plugin : IDalamudPlugin
services.AddSingleton<ProfileTagService>(); services.AddSingleton<ProfileTagService>();
services.AddSingleton<ApiController>(); services.AddSingleton<ApiController>();
services.AddSingleton<PerformanceCollectorService>(); services.AddSingleton<PerformanceCollectorService>();
services.AddSingleton<NameplateUpdateHookService>();
services.AddSingleton<HubFactory>(); services.AddSingleton<HubFactory>();
services.AddSingleton<FileUploadManager>(); services.AddSingleton<FileUploadManager>();
services.AddSingleton<FileTransferOrchestrator>(); services.AddSingleton<FileTransferOrchestrator>();
@@ -133,8 +136,10 @@ public sealed class Plugin : IDalamudPlugin
services.AddSingleton<TagHandler>(); services.AddSingleton<TagHandler>();
services.AddSingleton<PairRequestService>(); services.AddSingleton<PairRequestService>();
services.AddSingleton<ZoneChatService>(); services.AddSingleton<ZoneChatService>();
services.AddSingleton<ChatEmoteService>();
services.AddSingleton<IdDisplayHandler>(); services.AddSingleton<IdDisplayHandler>();
services.AddSingleton<PlayerPerformanceService>(); services.AddSingleton<PlayerPerformanceService>();
services.AddSingleton<PenumbraTempCollectionJanitor>();
services.AddSingleton<TextureMetadataHelper>(sp => services.AddSingleton<TextureMetadataHelper>(sp =>
new TextureMetadataHelper(sp.GetRequiredService<ILogger<TextureMetadataHelper>>(), gameData)); new TextureMetadataHelper(sp.GetRequiredService<ILogger<TextureMetadataHelper>>(), gameData));
@@ -201,6 +206,7 @@ public sealed class Plugin : IDalamudPlugin
gameInteropProvider, gameInteropProvider,
objectTable, objectTable,
clientState, clientState,
condition,
sp.GetRequiredService<LightlessMediator>())); sp.GetRequiredService<LightlessMediator>()));
services.AddSingleton(sp => new DalamudUtilService( services.AddSingleton(sp => new DalamudUtilService(
@@ -213,6 +219,7 @@ public sealed class Plugin : IDalamudPlugin
gameData, gameData,
targetManager, targetManager,
gameConfig, gameConfig,
playerState,
sp.GetRequiredService<ActorObjectService>(), sp.GetRequiredService<ActorObjectService>(),
sp.GetRequiredService<BlockedCharacterHandler>(), sp.GetRequiredService<BlockedCharacterHandler>(),
sp.GetRequiredService<LightlessMediator>(), sp.GetRequiredService<LightlessMediator>(),
@@ -267,6 +274,7 @@ public sealed class Plugin : IDalamudPlugin
sp.GetRequiredService<ILogger<LightFinderPlateHandler>>(), sp.GetRequiredService<ILogger<LightFinderPlateHandler>>(),
addonLifecycle, addonLifecycle,
gameGui, gameGui,
clientState,
sp.GetRequiredService<LightlessConfigService>(), sp.GetRequiredService<LightlessConfigService>(),
sp.GetRequiredService<LightlessMediator>(), sp.GetRequiredService<LightlessMediator>(),
objectTable, objectTable,
@@ -274,12 +282,22 @@ public sealed class Plugin : IDalamudPlugin
pluginInterface, pluginInterface,
sp.GetRequiredService<PictomancyService>())); sp.GetRequiredService<PictomancyService>()));
services.AddSingleton(sp => new LightFinderNativePlateHandler(
sp.GetRequiredService<ILogger<LightFinderNativePlateHandler>>(),
clientState,
sp.GetRequiredService<LightlessConfigService>(),
sp.GetRequiredService<LightlessMediator>(),
objectTable,
sp.GetRequiredService<PairUiService>(),
sp.GetRequiredService<NameplateUpdateHookService>()));
services.AddSingleton(sp => new LightFinderScannerService( services.AddSingleton(sp => new LightFinderScannerService(
sp.GetRequiredService<ILogger<LightFinderScannerService>>(), sp.GetRequiredService<ILogger<LightFinderScannerService>>(),
framework, framework,
sp.GetRequiredService<LightFinderService>(), sp.GetRequiredService<LightFinderService>(),
sp.GetRequiredService<LightlessMediator>(), sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<LightFinderPlateHandler>(), sp.GetRequiredService<LightFinderPlateHandler>(),
sp.GetRequiredService<LightFinderNativePlateHandler>(),
sp.GetRequiredService<ActorObjectService>())); sp.GetRequiredService<ActorObjectService>()));
services.AddSingleton(sp => new ContextMenuService( services.AddSingleton(sp => new ContextMenuService(
@@ -297,7 +315,10 @@ public sealed class Plugin : IDalamudPlugin
sp.GetRequiredService<LightFinderScannerService>(), sp.GetRequiredService<LightFinderScannerService>(),
sp.GetRequiredService<LightFinderService>(), sp.GetRequiredService<LightFinderService>(),
sp.GetRequiredService<LightlessProfileManager>(), sp.GetRequiredService<LightlessProfileManager>(),
sp.GetRequiredService<LightlessMediator>())); sp.GetRequiredService<LightlessMediator>(),
chatGui,
sp.GetRequiredService<NotificationService>())
);
// IPC callers / manager // IPC callers / manager
services.AddSingleton(sp => new IpcCallerPenumbra( services.AddSingleton(sp => new IpcCallerPenumbra(
@@ -458,7 +479,8 @@ public sealed class Plugin : IDalamudPlugin
sp.GetRequiredService<LightlessConfigService>(), sp.GetRequiredService<LightlessConfigService>(),
sp.GetRequiredService<UiSharedService>(), sp.GetRequiredService<UiSharedService>(),
sp.GetRequiredService<ApiController>(), sp.GetRequiredService<ApiController>(),
sp.GetRequiredService<LightFinderScannerService>())); sp.GetRequiredService<LightFinderScannerService>(),
sp.GetRequiredService<LightFinderPlateHandler>()));
services.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>(sp => new SyncshellFinderUI( services.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>(sp => new SyncshellFinderUI(
sp.GetRequiredService<ILogger<SyncshellFinderUI>>(), sp.GetRequiredService<ILogger<SyncshellFinderUI>>(),
@@ -527,9 +549,9 @@ public sealed class Plugin : IDalamudPlugin
clientState, clientState,
gameGui, gameGui,
objectTable, objectTable,
gameInteropProvider,
sp.GetRequiredService<LightlessMediator>(), sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<PairUiService>())); sp.GetRequiredService<PairUiService>(),
sp.GetRequiredService<NameplateUpdateHookService>()));
// Hosted services // Hosted services
services.AddHostedService(sp => sp.GetRequiredService<ConfigurationSaveService>()); services.AddHostedService(sp => sp.GetRequiredService<ConfigurationSaveService>());
@@ -548,6 +570,7 @@ public sealed class Plugin : IDalamudPlugin
services.AddHostedService(sp => sp.GetRequiredService<ContextMenuService>()); services.AddHostedService(sp => sp.GetRequiredService<ContextMenuService>());
services.AddHostedService(sp => sp.GetRequiredService<LightFinderService>()); services.AddHostedService(sp => sp.GetRequiredService<LightFinderService>());
services.AddHostedService(sp => sp.GetRequiredService<LightFinderPlateHandler>()); services.AddHostedService(sp => sp.GetRequiredService<LightFinderPlateHandler>());
services.AddHostedService(sp => sp.GetRequiredService<LightFinderNativePlateHandler>());
}).Build(); }).Build();
_ = _host.StartAsync(); _ = _host.StartAsync();

View File

@@ -1,5 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Conditions;
using Dalamud.Hooking; using Dalamud.Hooking;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using FFXIVClientStructs.Interop; using FFXIVClientStructs.Interop;
@@ -9,6 +9,8 @@ using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind;
using IPlayerCharacter = Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind; using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
@@ -31,13 +33,17 @@ public sealed class ActorObjectService : IHostedService, IDisposable
private readonly IFramework _framework; private readonly IFramework _framework;
private readonly IGameInteropProvider _interop; private readonly IGameInteropProvider _interop;
private readonly IObjectTable _objectTable; private readonly IObjectTable _objectTable;
private readonly IClientState _clientState;
private readonly ICondition _condition;
private readonly LightlessMediator _mediator; private readonly LightlessMediator _mediator;
private readonly ConcurrentDictionary<nint, ActorDescriptor> _activePlayers = new(); private readonly ConcurrentDictionary<nint, ActorDescriptor> _activePlayers = new();
private readonly ConcurrentDictionary<nint, ActorDescriptor> _gposePlayers = new();
private readonly ConcurrentDictionary<string, ActorDescriptor> _actorsByHash = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, ActorDescriptor> _actorsByHash = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ConcurrentDictionary<nint, ActorDescriptor>> _actorsByName = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, ConcurrentDictionary<nint, ActorDescriptor>> _actorsByName = new(StringComparer.Ordinal);
private readonly OwnedObjectTracker _ownedTracker = new(); private readonly ConcurrentDictionary<nint, byte> _pendingHashResolutions = new();
private ActorSnapshot _snapshot = ActorSnapshot.Empty; private ActorSnapshot _snapshot = ActorSnapshot.Empty;
private GposeSnapshot _gposeSnapshot = GposeSnapshot.Empty;
private Hook<Character.Delegates.OnInitialize>? _onInitializeHook; private Hook<Character.Delegates.OnInitialize>? _onInitializeHook;
private Hook<Character.Delegates.Terminate>? _onTerminateHook; private Hook<Character.Delegates.Terminate>? _onTerminateHook;
@@ -55,21 +61,29 @@ public sealed class ActorObjectService : IHostedService, IDisposable
IGameInteropProvider interop, IGameInteropProvider interop,
IObjectTable objectTable, IObjectTable objectTable,
IClientState clientState, IClientState clientState,
ICondition condition,
LightlessMediator mediator) LightlessMediator mediator)
{ {
_logger = logger; _logger = logger;
_framework = framework; _framework = framework;
_interop = interop; _interop = interop;
_objectTable = objectTable; _objectTable = objectTable;
_clientState = clientState;
_condition = condition;
_mediator = mediator; _mediator = mediator;
} }
private bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51];
private ActorSnapshot Snapshot => Volatile.Read(ref _snapshot); private ActorSnapshot Snapshot => Volatile.Read(ref _snapshot);
private GposeSnapshot CurrentGposeSnapshot => Volatile.Read(ref _gposeSnapshot);
public IReadOnlyList<nint> PlayerAddresses => Snapshot.PlayerAddresses; public IReadOnlyList<nint> PlayerAddresses => Snapshot.PlayerAddresses;
public IEnumerable<ActorDescriptor> PlayerDescriptors => _activePlayers.Values; public IEnumerable<ActorDescriptor> ObjectDescriptors => _activePlayers.Values;
public IReadOnlyList<ActorDescriptor> PlayerCharacterDescriptors => Snapshot.PlayerDescriptors; public IReadOnlyList<ActorDescriptor> PlayerDescriptors => Snapshot.PlayerDescriptors;
public IReadOnlyList<ActorDescriptor> OwnedDescriptors => Snapshot.OwnedDescriptors;
public IReadOnlyList<ActorDescriptor> GposeDescriptors => CurrentGposeSnapshot.GposeDescriptors;
public bool TryGetActorByHash(string hash, out ActorDescriptor descriptor) => _actorsByHash.TryGetValue(hash, out descriptor); public bool TryGetActorByHash(string hash, out ActorDescriptor descriptor) => _actorsByHash.TryGetValue(hash, out descriptor);
public bool TryGetValidatedActorByHash(string hash, out ActorDescriptor descriptor) public bool TryGetValidatedActorByHash(string hash, out ActorDescriptor descriptor)
@@ -113,6 +127,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return false; return false;
} }
public bool HooksActive => _hooksActive; public bool HooksActive => _hooksActive;
public bool HasPendingHashResolutions => !_pendingHashResolutions.IsEmpty;
public IReadOnlyList<nint> RenderedPlayerAddresses => Snapshot.OwnedObjects.RenderedPlayers; public IReadOnlyList<nint> RenderedPlayerAddresses => Snapshot.OwnedObjects.RenderedPlayers;
public IReadOnlyList<nint> RenderedCompanionAddresses => Snapshot.OwnedObjects.RenderedCompanions; public IReadOnlyList<nint> RenderedCompanionAddresses => Snapshot.OwnedObjects.RenderedCompanions;
public IReadOnlyList<nint> OwnedObjectAddresses => Snapshot.OwnedObjects.OwnedAddresses; public IReadOnlyList<nint> OwnedObjectAddresses => Snapshot.OwnedObjects.OwnedAddresses;
@@ -136,15 +151,16 @@ public sealed class ActorObjectService : IHostedService, IDisposable
public bool TryGetOwnedObjectByIndex(ushort objectIndex, out LightlessObjectKind ownedKind) public bool TryGetOwnedObjectByIndex(ushort objectIndex, out LightlessObjectKind ownedKind)
{ {
ownedKind = default; ownedKind = default;
var ownedSnapshot = OwnedObjects; var ownedDescriptors = OwnedDescriptors;
foreach (var (address, kind) in ownedSnapshot) for (var i = 0; i < ownedDescriptors.Count; i++)
{ {
if (!TryGetDescriptor(address, out var descriptor)) var descriptor = ownedDescriptors[i];
if (descriptor.ObjectIndex != objectIndex)
continue; continue;
if (descriptor.ObjectIndex == objectIndex) if (descriptor.OwnedKind is { } resolvedKind)
{ {
ownedKind = kind; ownedKind = resolvedKind;
return true; return true;
} }
} }
@@ -207,7 +223,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var isLoaded = await _framework.RunOnFrameworkThread(() => IsObjectFullyLoaded(address)).ConfigureAwait(false); var isLoaded = await _framework.RunOnFrameworkThread(() => IsObjectFullyLoaded(address)).ConfigureAwait(false);
if (isLoaded) if (!IsZoning && isLoaded)
return; return;
await Task.Delay(100, cancellationToken).ConfigureAwait(false); await Task.Delay(100, cancellationToken).ConfigureAwait(false);
@@ -297,10 +313,12 @@ public sealed class ActorObjectService : IHostedService, IDisposable
{ {
DisposeHooks(); DisposeHooks();
_activePlayers.Clear(); _activePlayers.Clear();
_gposePlayers.Clear();
_actorsByHash.Clear(); _actorsByHash.Clear();
_actorsByName.Clear(); _actorsByName.Clear();
_ownedTracker.Reset(); _pendingHashResolutions.Clear();
Volatile.Write(ref _snapshot, ActorSnapshot.Empty); Volatile.Write(ref _snapshot, ActorSnapshot.Empty);
Volatile.Write(ref _gposeSnapshot, GposeSnapshot.Empty);
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -336,7 +354,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable
_onCompanionTerminateHook.Enable(); _onCompanionTerminateHook.Enable();
_hooksActive = true; _hooksActive = true;
_logger.LogDebug("ActorObjectService hooks enabled."); _logger.LogTrace("ActorObjectService hooks enabled.");
} }
private Task WarmupExistingActors() private Task WarmupExistingActors()
@@ -350,36 +368,21 @@ public sealed class ActorObjectService : IHostedService, IDisposable
private unsafe void OnCharacterInitialized(Character* chara) private unsafe void OnCharacterInitialized(Character* chara)
{ {
try ExecuteOriginal(() => _onInitializeHook!.Original(chara), "Error invoking original character initialize.");
{ QueueTrack((GameObject*)chara);
_onInitializeHook!.Original(chara);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking original character initialize.");
}
QueueFrameworkUpdate(() => TrackGameObject((GameObject*)chara));
} }
private unsafe void OnCharacterTerminated(Character* chara) private unsafe void OnCharacterTerminated(Character* chara)
{ {
var address = (nint)chara; var address = (nint)chara;
QueueFrameworkUpdate(() => UntrackGameObject(address)); QueueUntrack(address);
try ExecuteOriginal(() => _onTerminateHook!.Original(chara), "Error invoking original character terminate.");
{
_onTerminateHook!.Original(chara);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking original character terminate.");
}
} }
private unsafe GameObject* OnCharacterDisposed(Character* chara, byte freeMemory) private unsafe GameObject* OnCharacterDisposed(Character* chara, byte freeMemory)
{ {
var address = (nint)chara; var address = (nint)chara;
QueueFrameworkUpdate(() => UntrackGameObject(address)); QueueUntrack(address);
try try
{ {
return _onDestructorHook!.Original(chara, freeMemory); return _onDestructorHook!.Original(chara, freeMemory);
@@ -416,7 +419,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable
if (_logger.IsEnabled(LogLevel.Debug)) if (_logger.IsEnabled(LogLevel.Debug))
{ {
_logger.LogDebug("Actor tracked: {Name} addr={Address:X} idx={Index} owned={OwnedKind} local={Local} gpose={Gpose}", _logger.LogTrace("Actor tracked: {Name} addr={Address:X} idx={Index} owned={OwnedKind} local={Local} gpose={Gpose}",
descriptor.Name, descriptor.Name,
descriptor.Address, descriptor.Address,
descriptor.ObjectIndex, descriptor.ObjectIndex,
@@ -478,50 +481,196 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return (isLocalPlayer ? LightlessObjectKind.Player : null, entityId); return (isLocalPlayer ? LightlessObjectKind.Player : null, entityId);
} }
if (isLocalPlayer) var ownerId = ResolveOwnerId(gameObject);
{ var localPlayerAddress = _objectTable.LocalPlayer?.Address ?? nint.Zero;
var entityId = ((Character*)gameObject)->EntityId; if (localPlayerAddress == nint.Zero)
return (LightlessObjectKind.Player, entityId); return (null, ownerId);
}
if (_objectTable.LocalPlayer is not { } localPlayer) var localEntityId = ((Character*)localPlayerAddress)->EntityId;
return (null, 0); if (localEntityId == 0)
return (null, ownerId);
var ownerId = gameObject->OwnerId; if (objectKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
if (ownerId == 0)
{ {
var character = (Character*)gameObject; var expectedMinionOrMount = GetMinionOrMountAddress(localPlayerAddress, localEntityId);
if (character != null) if (expectedMinionOrMount != nint.Zero && (nint)gameObject == expectedMinionOrMount)
{ {
ownerId = character->CompanionOwnerId; var resolvedOwner = ownerId != 0 ? ownerId : localEntityId;
if (ownerId == 0) return (LightlessObjectKind.MinionOrMount, resolvedOwner);
{
var parent = character->GetParentCharacter();
if (parent != null)
{
ownerId = parent->EntityId;
}
}
} }
} }
if (ownerId == 0 || ownerId != localPlayer.EntityId) if (objectKind != DalamudObjectKind.BattleNpc)
return (null, ownerId); return (null, ownerId);
var ownedKind = objectKind switch if (ownerId != localEntityId)
{ return (null, ownerId);
DalamudObjectKind.MountType => LightlessObjectKind.MinionOrMount,
DalamudObjectKind.Companion => LightlessObjectKind.MinionOrMount,
DalamudObjectKind.BattleNpc => gameObject->BattleNpcSubKind switch
{
BattleNpcSubKind.Buddy => LightlessObjectKind.Companion,
BattleNpcSubKind.Pet => LightlessObjectKind.Pet,
_ => (LightlessObjectKind?)null,
},
_ => (LightlessObjectKind?)null,
};
return (ownedKind, ownerId); var expectedPet = GetPetAddress(localPlayerAddress, localEntityId);
if (expectedPet != nint.Zero && (nint)gameObject == expectedPet)
return (LightlessObjectKind.Pet, ownerId);
var expectedCompanion = GetCompanionAddress(localPlayerAddress, localEntityId);
if (expectedCompanion != nint.Zero && (nint)gameObject == expectedCompanion)
return (LightlessObjectKind.Companion, ownerId);
return (null, ownerId);
}
private unsafe nint GetMinionOrMountAddress(nint localPlayerAddress, uint ownerEntityId)
{
if (localPlayerAddress == nint.Zero)
return nint.Zero;
var playerObject = (GameObject*)localPlayerAddress;
var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1);
if (candidateAddress != nint.Zero)
{
var candidate = (GameObject*)candidateAddress;
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
{
if (ownerEntityId == 0 || ResolveOwnerId(candidate) == ownerEntityId)
return candidateAddress;
}
}
if (ownerEntityId == 0)
return candidateAddress;
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
continue;
if (obj.ObjectKind is not (DalamudObjectKind.MountType or DalamudObjectKind.Companion))
continue;
var candidate = (GameObject*)obj.Address;
if (ResolveOwnerId(candidate) == ownerEntityId)
return obj.Address;
}
return candidateAddress;
}
private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId)
{
if (localPlayerAddress == nint.Zero || ownerEntityId == 0)
return nint.Zero;
var manager = CharacterManager.Instance();
if (manager != null)
{
var candidate = (nint)manager->LookupPetByOwnerObject((BattleChara*)localPlayerAddress);
if (candidate != nint.Zero)
{
var candidateObj = (GameObject*)candidate;
if (IsPetMatch(candidateObj, ownerEntityId))
return candidate;
}
}
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
continue;
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
continue;
var candidate = (GameObject*)obj.Address;
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
continue;
if (ResolveOwnerId(candidate) == ownerEntityId)
return obj.Address;
}
return nint.Zero;
}
private unsafe nint GetCompanionAddress(nint localPlayerAddress, uint ownerEntityId)
{
if (localPlayerAddress == nint.Zero || ownerEntityId == 0)
return nint.Zero;
var manager = CharacterManager.Instance();
if (manager != null)
{
var candidate = (nint)manager->LookupBuddyByOwnerObject((BattleChara*)localPlayerAddress);
if (candidate != nint.Zero)
{
var candidateObj = (GameObject*)candidate;
if (IsCompanionMatch(candidateObj, ownerEntityId))
return candidate;
}
}
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
continue;
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
continue;
var candidate = (GameObject*)obj.Address;
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Buddy)
continue;
if (ResolveOwnerId(candidate) == ownerEntityId)
return obj.Address;
}
return nint.Zero;
}
private static unsafe bool IsPetMatch(GameObject* candidate, uint ownerEntityId)
{
if (candidate == null)
return false;
if ((DalamudObjectKind)candidate->ObjectKind != DalamudObjectKind.BattleNpc)
return false;
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
return false;
return ResolveOwnerId(candidate) == ownerEntityId;
}
private static unsafe bool IsCompanionMatch(GameObject* candidate, uint ownerEntityId)
{
if (candidate == null)
return false;
if ((DalamudObjectKind)candidate->ObjectKind != DalamudObjectKind.BattleNpc)
return false;
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Buddy)
return false;
return ResolveOwnerId(candidate) == ownerEntityId;
}
private static unsafe uint ResolveOwnerId(GameObject* gameObject)
{
if (gameObject == null)
return 0;
if (gameObject->OwnerId != 0)
return gameObject->OwnerId;
var character = (Character*)gameObject;
if (character == null)
return 0;
if (character->CompanionOwnerId != 0)
return character->CompanionOwnerId;
var parent = character->GetParentCharacter();
return parent != null ? parent->EntityId : 0;
} }
private void UntrackGameObject(nint address) private void UntrackGameObject(nint address)
@@ -534,7 +683,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable
RemoveDescriptor(descriptor); RemoveDescriptor(descriptor);
if (_logger.IsEnabled(LogLevel.Debug)) if (_logger.IsEnabled(LogLevel.Debug))
{ {
_logger.LogDebug("Actor untracked: {Name} addr={Address:X} idx={Index} owned={OwnedKind}", _logger.LogTrace("Actor untracked: {Name} addr={Address:X} idx={Index} owned={OwnedKind}",
descriptor.Name, descriptor.Name,
descriptor.Address, descriptor.Address,
descriptor.ObjectIndex, descriptor.ObjectIndex,
@@ -558,10 +707,14 @@ public sealed class ActorObjectService : IHostedService, IDisposable
if (!seen.Add(address)) if (!seen.Add(address))
continue; continue;
if (_activePlayers.ContainsKey(address)) var gameObject = (GameObject*)address;
if (_activePlayers.TryGetValue(address, out var existing))
{
RefreshDescriptorIfNeeded(existing, gameObject);
continue; continue;
}
TrackGameObject((GameObject*)address); TrackGameObject(gameObject);
} }
var stale = _activePlayers.Keys.Where(addr => !seen.Contains(addr)).ToList(); var stale = _activePlayers.Keys.Where(addr => !seen.Contains(addr)).ToList();
@@ -574,6 +727,47 @@ public sealed class ActorObjectService : IHostedService, IDisposable
{ {
_nextRefreshAllowed = DateTime.UtcNow + SnapshotRefreshInterval; _nextRefreshAllowed = DateTime.UtcNow + SnapshotRefreshInterval;
} }
if (_clientState.IsGPosing)
{
RefreshGposeActorsInternal();
}
else if (!_gposePlayers.IsEmpty)
{
_gposePlayers.Clear();
PublishGposeSnapshot();
}
}
private unsafe void RefreshDescriptorIfNeeded(ActorDescriptor existing, GameObject* gameObject)
{
if (gameObject == null)
return;
if (existing.ObjectKind != DalamudObjectKind.Player || !string.IsNullOrEmpty(existing.HashedContentId))
return;
var objectKind = (DalamudObjectKind)gameObject->ObjectKind;
if (!IsSupportedObjectKind(objectKind))
return;
if (BuildDescriptor(gameObject, objectKind) is not { } updated)
return;
if (string.IsNullOrEmpty(updated.HashedContentId))
return;
ReplaceDescriptor(existing, updated);
_mediator.Publish(new ActorTrackedMessage(updated));
}
private void ReplaceDescriptor(ActorDescriptor existing, ActorDescriptor updated)
{
RemoveDescriptorFromIndexes(existing);
_activePlayers[updated.Address] = updated;
IndexDescriptor(updated);
UpdatePendingHashResolutions(updated);
PublishSnapshot();
} }
private void IndexDescriptor(ActorDescriptor descriptor) private void IndexDescriptor(ActorDescriptor descriptor)
@@ -605,30 +799,15 @@ public sealed class ActorObjectService : IHostedService, IDisposable
private unsafe void OnCompanionInitialized(Companion* companion) private unsafe void OnCompanionInitialized(Companion* companion)
{ {
try ExecuteOriginal(() => _onCompanionInitializeHook!.Original(companion), "Error invoking original companion initialize.");
{ QueueTrack((GameObject*)companion);
_onCompanionInitializeHook!.Original(companion);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking original companion initialize.");
}
QueueFrameworkUpdate(() => TrackGameObject((GameObject*)companion));
} }
private unsafe void OnCompanionTerminated(Companion* companion) private unsafe void OnCompanionTerminated(Companion* companion)
{ {
var address = (nint)companion; var address = (nint)companion;
QueueFrameworkUpdate(() => UntrackGameObject(address)); QueueUntrack(address);
try ExecuteOriginal(() => _onCompanionTerminateHook!.Original(companion), "Error invoking original companion terminate.");
{
_onCompanionTerminateHook!.Original(companion);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking original companion terminate.");
}
} }
private void RemoveDescriptorFromIndexes(ActorDescriptor descriptor) private void RemoveDescriptorFromIndexes(ActorDescriptor descriptor)
@@ -654,29 +833,122 @@ public sealed class ActorObjectService : IHostedService, IDisposable
{ {
_activePlayers[descriptor.Address] = descriptor; _activePlayers[descriptor.Address] = descriptor;
IndexDescriptor(descriptor); IndexDescriptor(descriptor);
_ownedTracker.OnDescriptorAdded(descriptor); UpdatePendingHashResolutions(descriptor);
PublishSnapshot(); PublishSnapshot();
} }
private void RemoveDescriptor(ActorDescriptor descriptor) private void RemoveDescriptor(ActorDescriptor descriptor)
{ {
RemoveDescriptorFromIndexes(descriptor); RemoveDescriptorFromIndexes(descriptor);
_ownedTracker.OnDescriptorRemoved(descriptor); _pendingHashResolutions.TryRemove(descriptor.Address, out _);
PublishSnapshot(); PublishSnapshot();
} }
private void UpdatePendingHashResolutions(ActorDescriptor descriptor)
{
if (descriptor.ObjectKind != DalamudObjectKind.Player)
{
_pendingHashResolutions.TryRemove(descriptor.Address, out _);
return;
}
if (string.IsNullOrEmpty(descriptor.HashedContentId))
{
_pendingHashResolutions[descriptor.Address] = 1;
return;
}
_pendingHashResolutions.TryRemove(descriptor.Address, out _);
}
private void PublishSnapshot() private void PublishSnapshot()
{ {
var playerDescriptors = _activePlayers.Values var descriptors = _activePlayers.Values.ToArray();
.Where(descriptor => descriptor.ObjectKind == DalamudObjectKind.Player) var playerCount = 0;
.ToArray(); var ownedCount = 0;
var playerAddresses = new nint[playerDescriptors.Length]; var companionCount = 0;
for (var i = 0; i < playerDescriptors.Length; i++)
playerAddresses[i] = playerDescriptors[i].Address;
var ownedSnapshot = _ownedTracker.CreateSnapshot(); foreach (var descriptor in descriptors)
{
if (descriptor.ObjectKind == DalamudObjectKind.Player)
playerCount++;
if (descriptor.OwnedKind is not null)
ownedCount++;
if (descriptor.ObjectKind == DalamudObjectKind.Companion)
companionCount++;
}
var playerDescriptors = new ActorDescriptor[playerCount];
var ownedDescriptors = new ActorDescriptor[ownedCount];
var playerAddresses = new nint[playerCount];
var renderedCompanions = new nint[companionCount];
var ownedAddresses = new nint[ownedCount];
var ownedMap = new Dictionary<nint, LightlessObjectKind>(ownedCount);
nint localPlayer = nint.Zero;
nint localPet = nint.Zero;
nint localMinionOrMount = nint.Zero;
nint localCompanion = nint.Zero;
var playerIndex = 0;
var ownedIndex = 0;
var companionIndex = 0;
foreach (var descriptor in descriptors)
{
if (descriptor.ObjectKind == DalamudObjectKind.Player)
{
playerDescriptors[playerIndex] = descriptor;
playerAddresses[playerIndex] = descriptor.Address;
playerIndex++;
}
if (descriptor.ObjectKind == DalamudObjectKind.Companion)
{
renderedCompanions[companionIndex] = descriptor.Address;
companionIndex++;
}
if (descriptor.OwnedKind is not { } ownedKind)
{
continue;
}
ownedDescriptors[ownedIndex] = descriptor;
ownedAddresses[ownedIndex] = descriptor.Address;
ownedMap[descriptor.Address] = ownedKind;
switch (ownedKind)
{
case LightlessObjectKind.Player:
localPlayer = descriptor.Address;
break;
case LightlessObjectKind.Pet:
localPet = descriptor.Address;
break;
case LightlessObjectKind.MinionOrMount:
localMinionOrMount = descriptor.Address;
break;
case LightlessObjectKind.Companion:
localCompanion = descriptor.Address;
break;
}
ownedIndex++;
}
var ownedSnapshot = new OwnedObjectSnapshot(
playerAddresses,
renderedCompanions,
ownedAddresses,
ownedMap,
localPlayer,
localPet,
localMinionOrMount,
localCompanion);
var nextGeneration = Snapshot.Generation + 1; var nextGeneration = Snapshot.Generation + 1;
var snapshot = new ActorSnapshot(playerDescriptors, playerAddresses, ownedSnapshot, nextGeneration); var snapshot = new ActorSnapshot(playerDescriptors, ownedDescriptors, playerAddresses, ownedSnapshot, nextGeneration);
Volatile.Write(ref _snapshot, snapshot); Volatile.Write(ref _snapshot, snapshot);
} }
@@ -694,6 +966,24 @@ public sealed class ActorObjectService : IHostedService, IDisposable
_ = _framework.RunOnFrameworkThread(action); _ = _framework.RunOnFrameworkThread(action);
} }
private void ExecuteOriginal(Action action, string errorMessage)
{
try
{
action();
}
catch (Exception ex)
{
_logger.LogError(ex, errorMessage);
}
}
private unsafe void QueueTrack(GameObject* gameObject)
=> QueueFrameworkUpdate(() => TrackGameObject(gameObject));
private void QueueUntrack(nint address)
=> QueueFrameworkUpdate(() => UntrackGameObject(address));
private void DisposeHooks() private void DisposeHooks()
{ {
var hadHooks = _hooksActive var hadHooks = _hooksActive
@@ -725,7 +1015,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable
if (hadHooks) if (hadHooks)
{ {
_logger.LogDebug("ActorObjectService hooks disabled."); _logger.LogTrace("ActorObjectService hooks disabled.");
} }
} }
@@ -770,6 +1060,89 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return results; return results;
} }
private unsafe void RefreshGposeActorsInternal()
{
var addresses = EnumerateGposeCharacterAddresses();
HashSet<nint> seen = new(addresses.Count);
foreach (var address in addresses)
{
if (address == nint.Zero)
continue;
if (!seen.Add(address))
continue;
if (_gposePlayers.ContainsKey(address))
continue;
TrackGposeObject((GameObject*)address);
}
var stale = _gposePlayers.Keys.Where(addr => !seen.Contains(addr)).ToList();
foreach (var staleAddress in stale)
{
UntrackGposeObject(staleAddress);
}
PublishGposeSnapshot();
}
private unsafe void TrackGposeObject(GameObject* gameObject)
{
if (gameObject == null)
return;
var objectKind = (DalamudObjectKind)gameObject->ObjectKind;
if (objectKind != DalamudObjectKind.Player)
return;
if (BuildDescriptor(gameObject, objectKind) is not { } descriptor)
return;
if (!descriptor.IsInGpose)
return;
_gposePlayers[descriptor.Address] = descriptor;
}
private void UntrackGposeObject(nint address)
{
if (address == nint.Zero)
return;
_gposePlayers.TryRemove(address, out _);
}
private void PublishGposeSnapshot()
{
var gposeDescriptors = _gposePlayers.Values.ToArray();
var gposeAddresses = new nint[gposeDescriptors.Length];
for (var i = 0; i < gposeDescriptors.Length; i++)
gposeAddresses[i] = gposeDescriptors[i].Address;
var nextGeneration = CurrentGposeSnapshot.Generation + 1;
var snapshot = new GposeSnapshot(gposeDescriptors, gposeAddresses, nextGeneration);
Volatile.Write(ref _gposeSnapshot, snapshot);
}
private List<nint> EnumerateGposeCharacterAddresses()
{
var results = new List<nint>(16);
foreach (var obj in _objectTable)
{
if (obj.ObjectKind != DalamudObjectKind.Player)
continue;
if (obj.ObjectIndex < 200)
continue;
results.Add(obj.Address);
}
return results;
}
private static unsafe bool IsObjectFullyLoaded(nint address) private static unsafe bool IsObjectFullyLoaded(nint address)
{ {
if (address == nint.Zero) if (address == nint.Zero)
@@ -783,13 +1156,10 @@ public sealed class ActorObjectService : IHostedService, IDisposable
if (drawObject == null) if (drawObject == null)
return false; return false;
if ((gameObject->RenderFlags & VisibilityFlags.Nameplate) != VisibilityFlags.None) if ((ulong)gameObject->RenderFlags == 2048)
return false; return false;
var characterBase = (CharacterBase*)drawObject; var characterBase = (CharacterBase*)drawObject;
if (characterBase == null)
return false;
if (characterBase->HasModelInSlotLoaded != 0) if (characterBase->HasModelInSlotLoaded != 0)
return false; return false;
@@ -799,109 +1169,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return true; return true;
} }
private sealed class OwnedObjectTracker
{
private readonly HashSet<nint> _renderedPlayers = new();
private readonly HashSet<nint> _renderedCompanions = new();
private readonly Dictionary<nint, LightlessObjectKind> _ownedObjects = new();
private nint _localPlayerAddress = nint.Zero;
private nint _localPetAddress = nint.Zero;
private nint _localMinionMountAddress = nint.Zero;
private nint _localCompanionAddress = nint.Zero;
public void OnDescriptorAdded(ActorDescriptor descriptor)
{
if (descriptor.ObjectKind == DalamudObjectKind.Player)
{
_renderedPlayers.Add(descriptor.Address);
if (descriptor.IsLocalPlayer)
_localPlayerAddress = descriptor.Address;
}
else if (descriptor.ObjectKind == DalamudObjectKind.Companion)
{
_renderedCompanions.Add(descriptor.Address);
}
if (descriptor.OwnedKind is { } ownedKind)
{
_ownedObjects[descriptor.Address] = ownedKind;
switch (ownedKind)
{
case LightlessObjectKind.Player:
_localPlayerAddress = descriptor.Address;
break;
case LightlessObjectKind.Pet:
_localPetAddress = descriptor.Address;
break;
case LightlessObjectKind.MinionOrMount:
_localMinionMountAddress = descriptor.Address;
break;
case LightlessObjectKind.Companion:
_localCompanionAddress = descriptor.Address;
break;
}
}
}
public void OnDescriptorRemoved(ActorDescriptor descriptor)
{
if (descriptor.ObjectKind == DalamudObjectKind.Player)
{
_renderedPlayers.Remove(descriptor.Address);
if (descriptor.IsLocalPlayer && _localPlayerAddress == descriptor.Address)
_localPlayerAddress = nint.Zero;
}
else if (descriptor.ObjectKind == DalamudObjectKind.Companion)
{
_renderedCompanions.Remove(descriptor.Address);
if (_localCompanionAddress == descriptor.Address)
_localCompanionAddress = nint.Zero;
}
if (descriptor.OwnedKind is { } ownedKind)
{
_ownedObjects.Remove(descriptor.Address);
switch (ownedKind)
{
case LightlessObjectKind.Player when _localPlayerAddress == descriptor.Address:
_localPlayerAddress = nint.Zero;
break;
case LightlessObjectKind.Pet when _localPetAddress == descriptor.Address:
_localPetAddress = nint.Zero;
break;
case LightlessObjectKind.MinionOrMount when _localMinionMountAddress == descriptor.Address:
_localMinionMountAddress = nint.Zero;
break;
case LightlessObjectKind.Companion when _localCompanionAddress == descriptor.Address:
_localCompanionAddress = nint.Zero;
break;
}
}
}
public OwnedObjectSnapshot CreateSnapshot()
=> new(
_renderedPlayers.ToArray(),
_renderedCompanions.ToArray(),
_ownedObjects.Keys.ToArray(),
new Dictionary<nint, LightlessObjectKind>(_ownedObjects),
_localPlayerAddress,
_localPetAddress,
_localMinionMountAddress,
_localCompanionAddress);
public void Reset()
{
_renderedPlayers.Clear();
_renderedCompanions.Clear();
_ownedObjects.Clear();
_localPlayerAddress = nint.Zero;
_localPetAddress = nint.Zero;
_localMinionMountAddress = nint.Zero;
_localCompanionAddress = nint.Zero;
}
}
private sealed record OwnedObjectSnapshot( private sealed record OwnedObjectSnapshot(
IReadOnlyList<nint> RenderedPlayers, IReadOnlyList<nint> RenderedPlayers,
IReadOnlyList<nint> RenderedCompanions, IReadOnlyList<nint> RenderedCompanions,
@@ -925,14 +1192,27 @@ public sealed class ActorObjectService : IHostedService, IDisposable
private sealed record ActorSnapshot( private sealed record ActorSnapshot(
IReadOnlyList<ActorDescriptor> PlayerDescriptors, IReadOnlyList<ActorDescriptor> PlayerDescriptors,
IReadOnlyList<ActorDescriptor> OwnedDescriptors,
IReadOnlyList<nint> PlayerAddresses, IReadOnlyList<nint> PlayerAddresses,
OwnedObjectSnapshot OwnedObjects, OwnedObjectSnapshot OwnedObjects,
int Generation) int Generation)
{ {
public static ActorSnapshot Empty { get; } = new( public static ActorSnapshot Empty { get; } = new(
Array.Empty<ActorDescriptor>(),
Array.Empty<ActorDescriptor>(), Array.Empty<ActorDescriptor>(),
Array.Empty<nint>(), Array.Empty<nint>(),
OwnedObjectSnapshot.Empty, OwnedObjectSnapshot.Empty,
0); 0);
} }
private sealed record GposeSnapshot(
IReadOnlyList<ActorDescriptor> GposeDescriptors,
IReadOnlyList<nint> GposeAddresses,
int Generation)
{
public static GposeSnapshot Empty { get; } = new(
Array.Empty<ActorDescriptor>(),
Array.Empty<nint>(),
0);
}
} }

View File

@@ -1,13 +1,16 @@
using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.SubKinds;
using K4os.Compression.LZ4.Legacy; using K4os.Compression.LZ4.Legacy;
using LightlessSync.API.Data; using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto.CharaData; using LightlessSync.API.Dto.CharaData;
using LightlessSync.FileCache; using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services.CharaData; using LightlessSync.Services.CharaData;
using LightlessSync.Services.CharaData.Models; using LightlessSync.Services.CharaData.Models;
using LightlessSync.Services.Mediator;
using LightlessSync.UI.Models;
using LightlessSync.Utils; using LightlessSync.Utils;
using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.Files;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -24,10 +27,11 @@ public sealed class CharaDataFileHandler : IDisposable
private readonly ILogger<CharaDataFileHandler> _logger; private readonly ILogger<CharaDataFileHandler> _logger;
private readonly LightlessCharaFileDataFactory _lightlessCharaFileDataFactory; private readonly LightlessCharaFileDataFactory _lightlessCharaFileDataFactory;
private readonly PlayerDataFactory _playerDataFactory; private readonly PlayerDataFactory _playerDataFactory;
private readonly NotificationService _notificationService;
private int _globalFileCounter = 0; private int _globalFileCounter = 0;
public CharaDataFileHandler(ILogger<CharaDataFileHandler> logger, FileDownloadManagerFactory fileDownloadManagerFactory, FileUploadManager fileUploadManager, FileCacheManager fileCacheManager, 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(); _fileDownloadManager = fileDownloadManagerFactory.Create();
_logger = logger; _logger = logger;
@@ -36,6 +40,7 @@ public sealed class CharaDataFileHandler : IDisposable
_dalamudUtilService = dalamudUtilService; _dalamudUtilService = dalamudUtilService;
_gameObjectHandlerFactory = gameObjectHandlerFactory; _gameObjectHandlerFactory = gameObjectHandlerFactory;
_playerDataFactory = playerDataFactory; _playerDataFactory = playerDataFactory;
_notificationService = notificationService;
_lightlessCharaFileDataFactory = new(fileCacheManager); _lightlessCharaFileDataFactory = new(fileCacheManager);
} }
@@ -248,54 +253,161 @@ public sealed class CharaDataFileHandler : IDisposable
} }
internal async Task SaveCharaFileAsync(string description, string filePath) 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 tempFilePath = filePath + ".tmp";
var overallStopwatch = System.Diagnostics.Stopwatch.StartNew();
try try
{ {
var data = await CreatePlayerData().ConfigureAwait(false);
if (data == null) return;
var lightlessCharaFileData = _lightlessCharaFileDataFactory.Create(description, data); var lightlessCharaFileData = _lightlessCharaFileDataFactory.Create(description, data);
LightlessCharaFileHeader output = new(LightlessCharaFileHeader.CurrentVersion, lightlessCharaFileData); 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 lz4 = new LZ4Stream(fs, LZ4StreamMode.Compress, LZ4StreamFlags.HighCompression);
using var writer = new BinaryWriter(lz4); using var writer = new BinaryWriter(lz4);
output.WriteToStream(writer); 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) foreach (var item in output.CharaFileData.Files)
{ {
fileIndex++;
var fileStopwatch = System.Diagnostics.Stopwatch.StartNew();
var file = _fileCacheManager.GetFileCacheByHash(item.Hash)!; 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:"); _logger.LogDebug("\tAssociated GamePaths:");
foreach (var path in item.GamePaths) foreach (var path in item.GamePaths)
{ {
_logger.LogDebug("\t{path}", path); _logger.LogDebug("\t{path}", path);
} }
var fsRead = File.OpenRead(file.ResolvedFilepath); using var fsRead = File.OpenRead(file.ResolvedFilepath);
await using (fsRead.ConfigureAwait(false)) 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); _logger.LogWarning("Expected to read {expected} bytes but got {actual} bytes from {file}", item.Length, bytesRead, file.ResolvedFilepath);
byte[] buffer = new byte[item.Length]; }
br.Read(buffer, 0, item.Length);
writer.Write(buffer); 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(); writer.Flush();
await lz4.FlushAsync().ConfigureAwait(false); lz4.Flush();
await fs.FlushAsync().ConfigureAwait(false); fs.Flush();
fs.Close(); fs.Close();
flushStopwatch.Stop();
_logger.LogInformation("Flush operations took {elapsed}ms", flushStopwatch.ElapsedMilliseconds);
var moveStopwatch = System.Diagnostics.Stopwatch.StartNew();
File.Move(tempFilePath, filePath, true); 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) catch (Exception ex)
{ {
_logger.LogError(ex, "Failure Saving Lightless Chara File, deleting output"); overallStopwatch.Stop();
File.Delete(tempFilePath); _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) internal async Task<List<string>> UploadFiles(List<string> fileList, ValueProgress<string> uploadProgress, CancellationToken token)
{ {
return await _fileUploadManager.UploadFiles(fileList, uploadProgress, token).ConfigureAwait(false); return await _fileUploadManager.UploadFiles(fileList, uploadProgress, token).ConfigureAwait(false);

View File

@@ -450,7 +450,7 @@ public class CharaDataGposeTogetherManager : DisposableMediatorSubscriberBase
}; };
} }
var loc = await _dalamudUtil.GetMapDataAsync().ConfigureAwait(false); var loc = _dalamudUtil.GetMapData();
worldData.LocationInfo = loc; worldData.LocationInfo = loc;
if (_forceResendWorldData || worldData != _lastWorldData) if (_forceResendWorldData || worldData != _lastWorldData)

View File

@@ -254,7 +254,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
Logger.LogTrace("Attaching World data {data}", worldData); 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); Logger.LogTrace("World data serialized: {data}", worldData);

View File

@@ -186,8 +186,8 @@ public sealed class CharaDataNearbyManager : DisposableMediatorSubscriberBase
var previousPoses = _nearbyData.Keys.ToList(); var previousPoses = _nearbyData.Keys.ToList();
_nearbyData.Clear(); _nearbyData.Clear();
var ownLocation = await _dalamudUtilService.RunOnFrameworkThread(() => _dalamudUtilService.GetMapData()).ConfigureAwait(false); var ownLocation = _dalamudUtilService.GetMapData();
var player = await _dalamudUtilService.RunOnFrameworkThread(() => _dalamudUtilService.GetPlayerCharacter()).ConfigureAwait(false); var player = await _dalamudUtilService.GetPlayerCharacterAsync().ConfigureAwait(false);
var currentServer = player.CurrentWorld; var currentServer = player.CurrentWorld;
var playerPos = player.Position; var playerPos = player.Position;

View File

@@ -1,3 +1,4 @@
using FFXIVClientStructs.FFXIV.Client.Graphics.Render;
using LightlessSync.API.Data; using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Enum;
using LightlessSync.FileCache; using LightlessSync.FileCache;
@@ -98,11 +99,13 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
_analysisCts = null; _analysisCts = null;
if (print) PrintAnalysis(); if (print) PrintAnalysis();
} }
public void Dispose() public void Dispose()
{ {
_analysisCts.CancelDispose(); _analysisCts.CancelDispose();
_baseAnalysisCts.Dispose(); _baseAnalysisCts.Dispose();
} }
public async Task UpdateFileEntriesAsync(IEnumerable<string> filePaths, CancellationToken token) public async Task UpdateFileEntriesAsync(IEnumerable<string> filePaths, CancellationToken token)
{ {
var normalized = new HashSet<string>( var normalized = new HashSet<string>(
@@ -125,6 +128,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
} }
} }
} }
private async Task BaseAnalysis(CharacterData charaData, CancellationToken token) private async Task BaseAnalysis(CharacterData charaData, CancellationToken token)
{ {
if (string.Equals(charaData.DataHash.Value, _lastDataHash, StringComparison.Ordinal)) return; if (string.Equals(charaData.DataHash.Value, _lastDataHash, StringComparison.Ordinal)) return;
@@ -136,29 +140,47 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var fileCacheEntries = (await _fileCacheManager.GetAllFileCachesByHashAsync(fileEntry.Hash, ignoreCacheEntries: true, validate: false, token).ConfigureAwait(false)).ToList(); var fileCacheEntries = (await _fileCacheManager
if (fileCacheEntries.Count == 0) continue; .GetAllFileCachesByHashAsync(fileEntry.Hash, ignoreCacheEntries: true, validate: false, token)
var filePath = fileCacheEntries[0].ResolvedFilepath; .ConfigureAwait(false))
FileInfo fi = new(filePath); .ToList();
string ext = "unk?";
try if (fileCacheEntries.Count == 0)
{ continue;
ext = fi.Extension[1..];
} var resolved = fileCacheEntries[0].ResolvedFilepath;
catch (Exception ex)
{ var extWithDot = Path.GetExtension(resolved);
Logger.LogWarning(ex, "Could not identify extension for {path}", filePath); var ext = string.IsNullOrEmpty(extWithDot) ? "unk?" : extWithDot.TrimStart('.');
}
var tris = await _xivDataAnalyzer.GetTrianglesByHash(fileEntry.Hash).ConfigureAwait(false); var tris = await _xivDataAnalyzer.GetTrianglesByHash(fileEntry.Hash).ConfigureAwait(false);
foreach (var entry in fileCacheEntries)
var distinctFilePaths = fileCacheEntries
.Select(c => c.ResolvedFilepath)
.Where(p => !string.IsNullOrWhiteSpace(p))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
long orig = 0, comp = 0;
var first = fileCacheEntries[0];
if (first.Size > 0) orig = first.Size.Value;
if (first.CompressedSize > 0) comp = first.CompressedSize.Value;
if (_fileCacheManager.TryGetSizeInfo(fileEntry.Hash, out var cached))
{ {
data[fileEntry.Hash] = new FileDataEntry(fileEntry.Hash, ext, if (orig <= 0 && cached.Original > 0) orig = cached.Original;
[.. fileEntry.GamePaths], if (comp <= 0 && cached.Compressed > 0) comp = cached.Compressed;
[.. fileCacheEntries.Select(c => c.ResolvedFilepath).Distinct(StringComparer.Ordinal)],
entry.Size > 0 ? entry.Size.Value : 0,
entry.CompressedSize > 0 ? entry.CompressedSize.Value : 0,
tris);
} }
data[fileEntry.Hash] = new FileDataEntry(
fileEntry.Hash,
ext,
[.. fileEntry.GamePaths],
distinctFilePaths,
orig,
comp,
tris,
fileCacheEntries);
} }
LastAnalysis[obj.Key] = data; LastAnalysis[obj.Key] = data;
} }
@@ -167,6 +189,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
Mediator.Publish(new CharacterDataAnalyzedMessage()); Mediator.Publish(new CharacterDataAnalyzedMessage());
_lastDataHash = charaData.DataHash.Value; _lastDataHash = charaData.DataHash.Value;
} }
private void RecalculateSummary() private void RecalculateSummary()
{ {
var builder = ImmutableDictionary.CreateBuilder<ObjectKind, CharacterAnalysisObjectSummary>(); var builder = ImmutableDictionary.CreateBuilder<ObjectKind, CharacterAnalysisObjectSummary>();
@@ -192,6 +215,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
_latestSummary = new CharacterAnalysisSummary(builder.ToImmutable()); _latestSummary = new CharacterAnalysisSummary(builder.ToImmutable());
} }
private void PrintAnalysis() private void PrintAnalysis()
{ {
if (LastAnalysis.Count == 0) return; if (LastAnalysis.Count == 0) return;
@@ -235,42 +259,79 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
UiSharedService.ByteToString(LastAnalysis.Values.Sum(c => c.Values.Sum(v => v.CompressedSize)))); UiSharedService.ByteToString(LastAnalysis.Values.Sum(c => c.Values.Sum(v => v.CompressedSize))));
Logger.LogInformation("IMPORTANT NOTES:\n\r- For Lightless up- and downloads only the compressed size is relevant.\n\r- An unusually high total files count beyond 200 and up will also increase your download time to others significantly."); Logger.LogInformation("IMPORTANT NOTES:\n\r- For Lightless up- and downloads only the compressed size is relevant.\n\r- An unusually high total files count beyond 200 and up will also increase your download time to others significantly.");
} }
internal sealed record FileDataEntry(string Hash, string FileType, List<string> GamePaths, List<string> FilePaths, long OriginalSize, long CompressedSize, long Triangles)
{
public bool IsComputed => OriginalSize > 0 && CompressedSize > 0;
public async Task ComputeSizes(FileCacheManager fileCacheManager, CancellationToken token)
{
var compressedsize = await fileCacheManager.GetCompressedFileData(Hash, token).ConfigureAwait(false);
var normalSize = new FileInfo(FilePaths[0]).Length;
var entries = await fileCacheManager.GetAllFileCachesByHashAsync(Hash, ignoreCacheEntries: true, validate: false, token).ConfigureAwait(false);
foreach (var entry in entries)
{
entry.Size = normalSize;
entry.CompressedSize = compressedsize.Item2.LongLength;
}
OriginalSize = normalSize;
CompressedSize = compressedsize.Item2.LongLength;
RefreshFormat();
}
public long OriginalSize { get; private set; } = OriginalSize;
public long CompressedSize { get; private set; } = CompressedSize;
public long Triangles { get; private set; } = Triangles;
public Lazy<string> Format => _format ??= CreateFormatValue();
internal sealed class FileDataEntry
{
public string Hash { get; }
public string FileType { get; }
public List<string> GamePaths { get; }
public List<string> FilePaths { get; }
public long OriginalSize { get; private set; }
public long CompressedSize { get; private set; }
public long Triangles { get; private set; }
public IReadOnlyList<FileCacheEntity> CacheEntries { get; }
public bool IsComputed => OriginalSize > 0 && CompressedSize > 0;
public FileDataEntry(
string hash,
string fileType,
List<string> gamePaths,
List<string> filePaths,
long originalSize,
long compressedSize,
long triangles,
IReadOnlyList<FileCacheEntity> cacheEntries)
{
Hash = hash;
FileType = fileType;
GamePaths = gamePaths;
FilePaths = filePaths;
OriginalSize = originalSize;
CompressedSize = compressedSize;
Triangles = triangles;
CacheEntries = cacheEntries;
}
public async Task ComputeSizes(FileCacheManager fileCacheManager, CancellationToken token, bool force = false)
{
if (!force && IsComputed)
return;
if (FilePaths.Count == 0 || string.IsNullOrWhiteSpace(FilePaths[0]))
return;
var path = FilePaths[0];
if (!File.Exists(path))
return;
var original = new FileInfo(path).Length;
var compressedLen = await fileCacheManager.GetCompressedSizeAsync(Hash, token).ConfigureAwait(false);
fileCacheManager.SetSizeInfo(Hash, original, compressedLen);
FileCacheManager.ApplySizesToEntries(CacheEntries, original, compressedLen);
OriginalSize = original;
CompressedSize = compressedLen;
if (string.Equals(FileType, "tex", StringComparison.OrdinalIgnoreCase))
RefreshFormat();
}
public Lazy<string> Format => _format ??= CreateFormatValue();
private Lazy<string>? _format; private Lazy<string>? _format;
public void RefreshFormat() public void RefreshFormat() => _format = CreateFormatValue();
{
_format = CreateFormatValue();
}
private Lazy<string> CreateFormatValue() private Lazy<string> CreateFormatValue()
=> new(() => => new(() =>
{ {
if (!string.Equals(FileType, "tex", StringComparison.Ordinal)) if (!string.Equals(FileType, "tex", StringComparison.OrdinalIgnoreCase))
{
return string.Empty; return string.Empty;
}
try try
{ {

View File

@@ -0,0 +1,275 @@
using Dalamud.Interface.Textures.TextureWraps;
using LightlessSync.UI;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Text.Json;
namespace LightlessSync.Services.Chat;
public sealed class ChatEmoteService : IDisposable
{
private const string GlobalEmoteSetUrl = "https://7tv.io/v3/emote-sets/global";
private readonly ILogger<ChatEmoteService> _logger;
private readonly HttpClient _httpClient;
private readonly UiSharedService _uiSharedService;
private readonly ConcurrentDictionary<string, EmoteEntry> _emotes = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _downloadGate = new(3, 3);
private readonly object _loadLock = new();
private Task? _loadTask;
public ChatEmoteService(ILogger<ChatEmoteService> logger, HttpClient httpClient, UiSharedService uiSharedService)
{
_logger = logger;
_httpClient = httpClient;
_uiSharedService = uiSharedService;
}
public void EnsureGlobalEmotesLoaded()
{
lock (_loadLock)
{
if (_loadTask is not null && !_loadTask.IsCompleted)
{
return;
}
if (_emotes.Count > 0)
{
return;
}
_loadTask = Task.Run(LoadGlobalEmotesAsync);
}
}
public IReadOnlyList<string> GetEmoteNames()
{
EnsureGlobalEmotesLoaded();
var names = _emotes.Keys.ToArray();
Array.Sort(names, StringComparer.OrdinalIgnoreCase);
return names;
}
public bool TryGetEmote(string code, out IDalamudTextureWrap? texture)
{
texture = null;
EnsureGlobalEmotesLoaded();
if (!_emotes.TryGetValue(code, out var entry))
{
return false;
}
if (entry.Texture is not null)
{
texture = entry.Texture;
return true;
}
entry.EnsureLoading(QueueEmoteDownload);
return true;
}
public void Dispose()
{
foreach (var entry in _emotes.Values)
{
entry.Texture?.Dispose();
}
_downloadGate.Dispose();
}
private async Task LoadGlobalEmotesAsync()
{
try
{
using var stream = await _httpClient.GetStreamAsync(GlobalEmoteSetUrl).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
if (!document.RootElement.TryGetProperty("emotes", out var emotes))
{
_logger.LogWarning("7TV emote set response missing emotes array");
return;
}
foreach (var emoteElement in emotes.EnumerateArray())
{
if (!emoteElement.TryGetProperty("name", out var nameElement))
{
continue;
}
var name = nameElement.GetString();
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
var url = TryBuildEmoteUrl(emoteElement);
if (string.IsNullOrWhiteSpace(url))
{
continue;
}
_emotes.TryAdd(name, new EmoteEntry(url));
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load 7TV emote set");
}
}
private static string? TryBuildEmoteUrl(JsonElement emoteElement)
{
if (!emoteElement.TryGetProperty("data", out var dataElement))
{
return null;
}
if (!dataElement.TryGetProperty("host", out var hostElement))
{
return null;
}
if (!hostElement.TryGetProperty("url", out var urlElement))
{
return null;
}
var baseUrl = urlElement.GetString();
if (string.IsNullOrWhiteSpace(baseUrl))
{
return null;
}
if (baseUrl.StartsWith("//", StringComparison.Ordinal))
{
baseUrl = "https:" + baseUrl;
}
if (!hostElement.TryGetProperty("files", out var filesElement))
{
return null;
}
var fileName = PickBestStaticFile(filesElement);
if (string.IsNullOrWhiteSpace(fileName))
{
return null;
}
return baseUrl.TrimEnd('/') + "/" + fileName;
}
private static string? PickBestStaticFile(JsonElement filesElement)
{
string? png1x = null;
string? webp1x = null;
string? pngFallback = null;
string? webpFallback = null;
foreach (var file in filesElement.EnumerateArray())
{
if (file.TryGetProperty("static", out var staticElement) && staticElement.ValueKind == JsonValueKind.False)
{
continue;
}
if (!file.TryGetProperty("name", out var nameElement))
{
continue;
}
var name = nameElement.GetString();
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
if (name.Equals("1x.png", StringComparison.OrdinalIgnoreCase))
{
png1x = name;
}
else if (name.Equals("1x.webp", StringComparison.OrdinalIgnoreCase))
{
webp1x = name;
}
else if (name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && pngFallback is null)
{
pngFallback = name;
}
else if (name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) && webpFallback is null)
{
webpFallback = name;
}
}
return png1x ?? webp1x ?? pngFallback ?? webpFallback;
}
private void QueueEmoteDownload(EmoteEntry entry)
{
_ = Task.Run(async () =>
{
await _downloadGate.WaitAsync().ConfigureAwait(false);
try
{
var data = await _httpClient.GetByteArrayAsync(entry.Url).ConfigureAwait(false);
var texture = _uiSharedService.LoadImage(data);
entry.SetTexture(texture);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to load 7TV emote {Url}", entry.Url);
entry.MarkFailed();
}
finally
{
_downloadGate.Release();
}
});
}
private sealed class EmoteEntry
{
private int _loadingState;
public EmoteEntry(string url)
{
Url = url;
}
public string Url { get; }
public IDalamudTextureWrap? Texture { get; private set; }
public void EnsureLoading(Action<EmoteEntry> queueDownload)
{
if (Texture is not null)
{
return;
}
if (Interlocked.CompareExchange(ref _loadingState, 1, 0) != 0)
{
return;
}
queueDownload(this);
}
public void SetTexture(IDalamudTextureWrap texture)
{
Texture = texture;
Interlocked.Exchange(ref _loadingState, 0);
}
public void MarkFailed()
{
Interlocked.Exchange(ref _loadingState, 0);
}
}
}

View File

@@ -1,6 +1,8 @@
using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.Chat;
using LightlessSync.API.Data.Extensions;
using LightlessSync.Services.ActorTracking; using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.WebAPI; using LightlessSync.WebAPI;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -24,6 +26,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
private readonly ActorObjectService _actorObjectService; private readonly ActorObjectService _actorObjectService;
private readonly PairUiService _pairUiService; private readonly PairUiService _pairUiService;
private readonly ChatConfigService _chatConfigService; private readonly ChatConfigService _chatConfigService;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly Lock _sync = new(); private readonly Lock _sync = new();
@@ -36,6 +39,9 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
private readonly Dictionary<string, bool> _lastPresenceStates = new(StringComparer.Ordinal); private readonly Dictionary<string, bool> _lastPresenceStates = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _selfTokens = new(StringComparer.Ordinal); private readonly Dictionary<string, string> _selfTokens = new(StringComparer.Ordinal);
private readonly List<PendingSelfMessage> _pendingSelfMessages = new(); private readonly List<PendingSelfMessage> _pendingSelfMessages = new();
private readonly Dictionary<string, List<ChatMessageEntry>> _messageHistoryCache = new(StringComparer.Ordinal);
private List<ChatChannelSnapshot>? _cachedChannelSnapshots;
private bool _channelsSnapshotDirty = true;
private bool _isLoggedIn; private bool _isLoggedIn;
private bool _isConnected; private bool _isConnected;
@@ -51,7 +57,8 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
ApiController apiController, ApiController apiController,
DalamudUtilService dalamudUtilService, DalamudUtilService dalamudUtilService,
ActorObjectService actorObjectService, ActorObjectService actorObjectService,
PairUiService pairUiService) PairUiService pairUiService,
ServerConfigurationManager serverConfigurationManager)
: base(logger, mediator) : base(logger, mediator)
{ {
_apiController = apiController; _apiController = apiController;
@@ -59,6 +66,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
_actorObjectService = actorObjectService; _actorObjectService = actorObjectService;
_pairUiService = pairUiService; _pairUiService = pairUiService;
_chatConfigService = chatConfigService; _chatConfigService = chatConfigService;
_serverConfigurationManager = serverConfigurationManager;
_isLoggedIn = _dalamudUtilService.IsLoggedIn; _isLoggedIn = _dalamudUtilService.IsLoggedIn;
_isConnected = _apiController.IsConnected; _isConnected = _apiController.IsConnected;
@@ -69,6 +77,11 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
{ {
using (_sync.EnterScope()) using (_sync.EnterScope())
{ {
if (!_channelsSnapshotDirty && _cachedChannelSnapshots is not null)
{
return _cachedChannelSnapshots;
}
var snapshots = new List<ChatChannelSnapshot>(_channelOrder.Count); var snapshots = new List<ChatChannelSnapshot>(_channelOrder.Count);
foreach (var key in _channelOrder) foreach (var key in _channelOrder)
{ {
@@ -98,6 +111,8 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
state.Messages.ToList())); state.Messages.ToList()));
} }
_cachedChannelSnapshots = snapshots;
_channelsSnapshotDirty = false;
return snapshots; return snapshots;
} }
} }
@@ -135,6 +150,8 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
state.UnreadCount = 0; state.UnreadCount = 0;
_lastReadCounts[key] = state.Messages.Count; _lastReadCounts[key] = state.Messages.Count;
} }
MarkChannelsSnapshotDirtyLocked();
} }
} }
@@ -186,6 +203,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
if (!wasEnabled) if (!wasEnabled)
{ {
_chatEnabled = true; _chatEnabled = true;
MarkChannelsSnapshotDirtyLocked();
} }
} }
@@ -231,6 +249,8 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
state.IsAvailable = false; state.IsAvailable = false;
state.StatusText = "Chat services disabled"; state.StatusText = "Chat services disabled";
} }
MarkChannelsSnapshotDirtyLocked();
} }
UnregisterChatHandler(); UnregisterChatHandler();
@@ -556,7 +576,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
try try
{ {
var location = await _dalamudUtilService.GetMapDataAsync().ConfigureAwait(false); var location = _dalamudUtilService.GetMapData();
var territoryId = (ushort)location.TerritoryId; var territoryId = (ushort)location.TerritoryId;
var worldId = (ushort)location.ServerId; var worldId = (ushort)location.ServerId;
@@ -682,7 +702,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
{ {
try try
{ {
var worldId = (ushort)await _dalamudUtilService.GetWorldIdAsync().ConfigureAwait(false); var worldId = (ushort)_dalamudUtilService.GetWorldId();
return definition.Descriptor with { WorldId = worldId }; return definition.Descriptor with { WorldId = worldId };
} }
catch (Exception ex) catch (Exception ex)
@@ -717,7 +737,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
_zoneDefinitions[key] = new ZoneChannelDefinition(key, info.DisplayName ?? key, descriptor, territories); _zoneDefinitions[key] = new ZoneChannelDefinition(key, info.DisplayName ?? key, descriptor, territories);
} }
var territoryData = _dalamudUtilService.TerritoryData.Value; var territoryData = _dalamudUtilService.TerritoryDataEnglish.Value;
foreach (var kvp in territoryData) foreach (var kvp in territoryData)
{ {
foreach (var variant in EnumerateTerritoryKeys(kvp.Value)) foreach (var variant in EnumerateTerritoryKeys(kvp.Value))
@@ -761,6 +781,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
using (_sync.EnterScope()) using (_sync.EnterScope())
{ {
var remainingGroups = new HashSet<string>(_groupDefinitions.Keys, StringComparer.OrdinalIgnoreCase); var remainingGroups = new HashSet<string>(_groupDefinitions.Keys, StringComparer.OrdinalIgnoreCase);
var allowRemoval = _isConnected;
foreach (var info in infoList) foreach (var info in infoList)
{ {
@@ -776,18 +797,19 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
var key = BuildChannelKey(descriptor); var key = BuildChannelKey(descriptor);
if (!_channels.TryGetValue(key, out var state)) if (!_channels.TryGetValue(key, out var state))
{ {
state = new ChatChannelState(key, ChatChannelType.Group, info.DisplayName ?? groupId, descriptor); state = new ChatChannelState(key, ChatChannelType.Group, info.DisplayName ?? groupId, descriptor);
state.IsConnected = _chatEnabled && _isConnected; var restoredCount = RestoreCachedMessagesLocked(state);
state.IsAvailable = _chatEnabled && _isConnected; state.IsConnected = _chatEnabled && _isConnected;
state.StatusText = !_chatEnabled state.IsAvailable = _chatEnabled && _isConnected;
? "Chat services disabled" state.StatusText = !_chatEnabled
: (_isConnected ? null : "Disconnected from chat server"); ? "Chat services disabled"
_channels[key] = state; : (_isConnected ? null : "Disconnected from chat server");
_lastReadCounts[key] = 0; _channels[key] = state;
if (_chatEnabled) _lastReadCounts[key] = restoredCount > 0 ? state.Messages.Count : 0;
{ if (_chatEnabled)
descriptorsToJoin.Add(descriptor); {
} descriptorsToJoin.Add(descriptor);
}
} }
else else
{ {
@@ -801,26 +823,30 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
} }
} }
foreach (var removedGroupId in remainingGroups) if (allowRemoval)
{ {
if (_groupDefinitions.TryGetValue(removedGroupId, out var definition)) foreach (var removedGroupId in remainingGroups)
{ {
var key = BuildChannelKey(definition.Descriptor); if (_groupDefinitions.TryGetValue(removedGroupId, out var definition))
if (_channels.TryGetValue(key, out var state))
{ {
descriptorsToLeave.Add(state.Descriptor); var key = BuildChannelKey(definition.Descriptor);
_channels.Remove(key); if (_channels.TryGetValue(key, out var state))
_lastReadCounts.Remove(key);
_lastPresenceStates.Remove(BuildPresenceKey(state.Descriptor));
_selfTokens.Remove(key);
_pendingSelfMessages.RemoveAll(p => string.Equals(p.ChannelKey, key, StringComparison.Ordinal));
if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal))
{ {
_activeChannelKey = null; CacheMessagesLocked(state);
descriptorsToLeave.Add(state.Descriptor);
_channels.Remove(key);
_lastReadCounts.Remove(key);
_lastPresenceStates.Remove(BuildPresenceKey(state.Descriptor));
_selfTokens.Remove(key);
_pendingSelfMessages.RemoveAll(p => string.Equals(p.ChannelKey, key, StringComparison.Ordinal));
if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal))
{
_activeChannelKey = null;
}
} }
}
_groupDefinitions.Remove(removedGroupId); _groupDefinitions.Remove(removedGroupId);
}
} }
} }
@@ -853,6 +879,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
var infos = new List<GroupChatChannelInfoDto>(groups.Count); var infos = new List<GroupChatChannelInfoDto>(groups.Count);
foreach (var group in groups) foreach (var group in groups)
{ {
// basically prune the channel if it's disabled
if (group.GroupPermissions.IsDisableChat())
{
continue;
}
var descriptor = new ChatChannelDescriptor var descriptor = new ChatChannelDescriptor
{ {
Type = ChatChannelType.Group, Type = ChatChannelType.Group,
@@ -992,13 +1024,14 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
descriptor.Type, descriptor.Type,
displayName, displayName,
descriptor.Type == ChatChannelType.Zone ? (_lastZoneDescriptor ?? descriptor) : descriptor); descriptor.Type == ChatChannelType.Zone ? (_lastZoneDescriptor ?? descriptor) : descriptor);
var restoredCount = RestoreCachedMessagesLocked(state);
state.IsConnected = _isConnected; state.IsConnected = _isConnected;
state.IsAvailable = descriptor.Type == ChatChannelType.Group && _isConnected; state.IsAvailable = descriptor.Type == ChatChannelType.Group && _isConnected;
state.StatusText = descriptor.Type == ChatChannelType.Zone ? ZoneUnavailableMessage : (_isConnected ? null : "Disconnected from chat server"); state.StatusText = descriptor.Type == ChatChannelType.Zone ? ZoneUnavailableMessage : (_isConnected ? null : "Disconnected from chat server");
_channels[key] = state; _channels[key] = state;
_lastReadCounts[key] = 0; _lastReadCounts[key] = restoredCount > 0 ? state.Messages.Count : 0;
publishChannelList = true; publishChannelList = true;
} }
@@ -1023,6 +1056,8 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
state.UnreadCount = Math.Min(Math.Max(unreadFromHistory, incrementalUnread), MaxUnreadCount); state.UnreadCount = Math.Min(Math.Max(unreadFromHistory, incrementalUnread), MaxUnreadCount);
state.HasUnread = state.UnreadCount > 0; state.HasUnread = state.UnreadCount > 0;
} }
MarkChannelsSnapshotDirtyLocked();
} }
Mediator.Publish(new ChatChannelMessageAdded(key, message)); Mediator.Publish(new ChatChannelMessageAdded(key, message));
@@ -1126,7 +1161,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
{ {
try try
{ {
return _dalamudUtilService.GetPlayerNameAsync().ConfigureAwait(false).GetAwaiter().GetResult(); return _dalamudUtilService.GetPlayerName();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1136,6 +1171,15 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
if (dto.Sender.Kind == ChatSenderKind.IdentifiedUser && dto.Sender.User is not null) if (dto.Sender.Kind == ChatSenderKind.IdentifiedUser && dto.Sender.User is not null)
{ {
if (dto.Channel.Type != ChatChannelType.Group || _chatConfigService.Current.ShowNotesInSyncshellChat)
{
var note = _serverConfigurationManager.GetNoteForUid(dto.Sender.User.UID);
if (!string.IsNullOrWhiteSpace(note))
{
return note;
}
}
return dto.Sender.User.AliasOrUID; return dto.Sender.User.AliasOrUID;
} }
@@ -1204,9 +1248,25 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
{ {
_activeChannelKey = _channelOrder.Count > 0 ? _channelOrder[0] : null; _activeChannelKey = _channelOrder.Count > 0 ? _channelOrder[0] : null;
} }
MarkChannelsSnapshotDirtyLocked();
} }
private void PublishChannelListChanged() => Mediator.Publish(new ChatChannelsUpdated()); private void MarkChannelsSnapshotDirty()
{
using (_sync.EnterScope())
{
_channelsSnapshotDirty = true;
}
}
private void MarkChannelsSnapshotDirtyLocked() => _channelsSnapshotDirty = true;
private void PublishChannelListChanged()
{
MarkChannelsSnapshotDirty();
Mediator.Publish(new ChatChannelsUpdated());
}
private static IEnumerable<string> EnumerateTerritoryKeys(string? value) private static IEnumerable<string> EnumerateTerritoryKeys(string? value)
{ {
@@ -1249,11 +1309,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
if (!_channels.TryGetValue(ZoneChannelKey, out var state)) if (!_channels.TryGetValue(ZoneChannelKey, out var state))
{ {
state = new ChatChannelState(ZoneChannelKey, ChatChannelType.Zone, "Zone Chat", new ChatChannelDescriptor { Type = ChatChannelType.Zone }); state = new ChatChannelState(ZoneChannelKey, ChatChannelType.Zone, "Zone Chat", new ChatChannelDescriptor { Type = ChatChannelType.Zone });
var restoredCount = RestoreCachedMessagesLocked(state);
state.IsConnected = _chatEnabled && _isConnected; state.IsConnected = _chatEnabled && _isConnected;
state.IsAvailable = false; state.IsAvailable = false;
state.StatusText = _chatEnabled ? ZoneUnavailableMessage : "Chat services disabled"; state.StatusText = _chatEnabled ? ZoneUnavailableMessage : "Chat services disabled";
_channels[ZoneChannelKey] = state; _channels[ZoneChannelKey] = state;
_lastReadCounts[ZoneChannelKey] = 0; _lastReadCounts[ZoneChannelKey] = restoredCount > 0 ? state.Messages.Count : 0;
UpdateChannelOrderLocked(); UpdateChannelOrderLocked();
} }
@@ -1262,6 +1323,11 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
private void RemoveZoneStateLocked() private void RemoveZoneStateLocked()
{ {
if (_channels.TryGetValue(ZoneChannelKey, out var existing))
{
CacheMessagesLocked(existing);
}
if (_channels.Remove(ZoneChannelKey)) if (_channels.Remove(ZoneChannelKey))
{ {
_lastReadCounts.Remove(ZoneChannelKey); _lastReadCounts.Remove(ZoneChannelKey);
@@ -1276,6 +1342,28 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
} }
} }
private void CacheMessagesLocked(ChatChannelState state)
{
if (state.Messages.Count == 0)
{
return;
}
_messageHistoryCache[state.Key] = new List<ChatMessageEntry>(state.Messages);
}
private int RestoreCachedMessagesLocked(ChatChannelState state)
{
if (_messageHistoryCache.TryGetValue(state.Key, out var cached) && cached.Count > 0)
{
state.Messages.AddRange(cached);
_messageHistoryCache.Remove(state.Key);
return cached.Count;
}
return 0;
}
private sealed class ChatChannelState private sealed class ChatChannelState
{ {
public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor) public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor)

View File

@@ -4,21 +4,22 @@ using Dalamud.Plugin;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models; using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.Services.LightFinder;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.UI;
using LightlessSync.UI.Services;
using LightlessSync.Utils; using LightlessSync.Utils;
using LightlessSync.WebAPI; using LightlessSync.WebAPI;
using Lumina.Excel.Sheets; using Lumina.Excel.Sheets;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using LightlessSync.UI;
using LightlessSync.Services.LightFinder;
namespace LightlessSync.Services; namespace LightlessSync.Services;
internal class ContextMenuService : IHostedService internal class ContextMenuService : IHostedService
{ {
private readonly IContextMenu _contextMenu; private readonly IContextMenu _contextMenu;
private readonly IChatGui _chatGui;
private readonly IDalamudPluginInterface _pluginInterface; private readonly IDalamudPluginInterface _pluginInterface;
private readonly IDataManager _gameData; private readonly IDataManager _gameData;
private readonly ILogger<ContextMenuService> _logger; private readonly ILogger<ContextMenuService> _logger;
@@ -29,6 +30,7 @@ internal class ContextMenuService : IHostedService
private readonly ApiController _apiController; private readonly ApiController _apiController;
private readonly IObjectTable _objectTable; private readonly IObjectTable _objectTable;
private readonly LightlessConfigService _configService; private readonly LightlessConfigService _configService;
private readonly NotificationService _lightlessNotification;
private readonly LightFinderScannerService _broadcastScannerService; private readonly LightFinderScannerService _broadcastScannerService;
private readonly LightFinderService _broadcastService; private readonly LightFinderService _broadcastService;
private readonly LightlessProfileManager _lightlessProfileManager; private readonly LightlessProfileManager _lightlessProfileManager;
@@ -43,7 +45,7 @@ internal class ContextMenuService : IHostedService
ILogger<ContextMenuService> logger, ILogger<ContextMenuService> logger,
DalamudUtilService dalamudUtil, DalamudUtilService dalamudUtil,
ApiController apiController, ApiController apiController,
IObjectTable objectTable, IObjectTable objectTable,
LightlessConfigService configService, LightlessConfigService configService,
PairRequestService pairRequestService, PairRequestService pairRequestService,
PairUiService pairUiService, PairUiService pairUiService,
@@ -51,7 +53,9 @@ internal class ContextMenuService : IHostedService
LightFinderScannerService broadcastScannerService, LightFinderScannerService broadcastScannerService,
LightFinderService broadcastService, LightFinderService broadcastService,
LightlessProfileManager lightlessProfileManager, LightlessProfileManager lightlessProfileManager,
LightlessMediator mediator) LightlessMediator mediator,
IChatGui chatGui,
NotificationService lightlessNotification)
{ {
_contextMenu = contextMenu; _contextMenu = contextMenu;
_pluginInterface = pluginInterface; _pluginInterface = pluginInterface;
@@ -68,6 +72,8 @@ internal class ContextMenuService : IHostedService
_broadcastService = broadcastService; _broadcastService = broadcastService;
_lightlessProfileManager = lightlessProfileManager; _lightlessProfileManager = lightlessProfileManager;
_mediator = mediator; _mediator = mediator;
_chatGui = chatGui;
_lightlessNotification = lightlessNotification;
} }
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
@@ -99,6 +105,12 @@ internal class ContextMenuService : IHostedService
if (!_pluginInterface.UiBuilder.ShouldModifyUi) if (!_pluginInterface.UiBuilder.ShouldModifyUi)
return; return;
if (!_configService.Current.EnableRightClickMenus)
{
_logger.LogTrace("Right-click menus are disabled in configuration.");
return;
}
if (args.AddonName != null) if (args.AddonName != null)
{ {
var addonName = args.AddonName; var addonName = args.AddonName;
@@ -129,7 +141,6 @@ internal class ContextMenuService : IHostedService
var snapshot = _pairUiService.GetSnapshot(); var snapshot = _pairUiService.GetSnapshot();
var pair = snapshot.PairsByUid.Values.FirstOrDefault(p => var pair = snapshot.PairsByUid.Values.FirstOrDefault(p =>
p.IsVisible &&
p.PlayerCharacterId != uint.MaxValue && p.PlayerCharacterId != uint.MaxValue &&
p.PlayerCharacterId == target.TargetObjectId); p.PlayerCharacterId == target.TargetObjectId);
@@ -199,6 +210,18 @@ internal class ContextMenuService : IHostedService
.Where(p => p.IsVisible && p.PlayerCharacterId != uint.MaxValue) .Where(p => p.IsVisible && p.PlayerCharacterId != uint.MaxValue)
.Select(p => (ulong)p.PlayerCharacterId)]; .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) private async Task HandleSelection(IMenuArgs args)
{ {
if (args.Target is not MenuTargetDefault target) if (args.Target is not MenuTargetDefault target)
@@ -218,7 +241,7 @@ internal class ContextMenuService : IHostedService
return; return;
} }
var senderCid = (await _dalamudUtil.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256(); var senderCid = _dalamudUtil.GetCID().ToString().GetHash256();
var receiverCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address); var receiverCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address);
_logger.LogInformation("Sending pair request: sender {SenderCid}, receiver {ReceiverCid}", senderCid, receiverCid); _logger.LogInformation("Sending pair request: sender {SenderCid}, receiver {ReceiverCid}", senderCid, receiverCid);
@@ -227,6 +250,9 @@ internal class ContextMenuService : IHostedService
{ {
_pairRequestService.RemoveRequest(receiverCid); _pairRequestService.RemoveRequest(receiverCid);
} }
// Notify in chat when NotificationService is disabled
NotifyInChat($"Pair request sent to {target.TargetName}@{world.Name}.", NotificationType.Info);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -1,5 +1,4 @@
using Dalamud.Game.ClientState.Conditions; using Dalamud.Game.ClientState.Conditions;
using Dalamud.Game.ClientState.Objects;
using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
@@ -24,6 +23,7 @@ using Microsoft.Extensions.Logging;
using System.Numerics; using System.Numerics;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text; using System.Text;
using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject; using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags; using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags;
@@ -37,6 +37,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private readonly ICondition _condition; private readonly ICondition _condition;
private readonly IDataManager _gameData; private readonly IDataManager _gameData;
private readonly IGameConfig _gameConfig; private readonly IGameConfig _gameConfig;
private readonly IPlayerState _playerState;
private readonly BlockedCharacterHandler _blockedCharacterHandler; private readonly BlockedCharacterHandler _blockedCharacterHandler;
private readonly IFramework _framework; private readonly IFramework _framework;
private readonly IGameGui _gameGui; private readonly IGameGui _gameGui;
@@ -60,7 +61,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private Lazy<ulong> _cid; private Lazy<ulong> _cid;
public DalamudUtilService(ILogger<DalamudUtilService> logger, IClientState clientState, IObjectTable objectTable, IFramework framework, 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, ActorObjectService actorObjectService, BlockedCharacterHandler blockedCharacterHandler, LightlessMediator mediator, PerformanceCollectorService performanceCollector,
LightlessConfigService configService, PlayerPerformanceConfigService playerPerformanceConfigService, Lazy<PairFactory> pairFactory) LightlessConfigService configService, PlayerPerformanceConfigService playerPerformanceConfigService, Lazy<PairFactory> pairFactory)
{ {
@@ -72,6 +73,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_condition = condition; _condition = condition;
_gameData = gameData; _gameData = gameData;
_gameConfig = gameConfig; _gameConfig = gameConfig;
_playerState = playerState;
_actorObjectService = actorObjectService; _actorObjectService = actorObjectService;
_targetManager = targetManager; _targetManager = targetManager;
_blockedCharacterHandler = blockedCharacterHandler; _blockedCharacterHandler = blockedCharacterHandler;
@@ -80,53 +82,26 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_configService = configService; _configService = configService;
_playerPerformanceConfigService = playerPerformanceConfigService; _playerPerformanceConfigService = playerPerformanceConfigService;
_pairFactory = pairFactory; _pairFactory = pairFactory;
var clientLanguage = _clientState.ClientLanguage;
WorldData = new(() => WorldData = new(() =>
{ {
return gameData.GetExcelSheet<Lumina.Excel.Sheets.World>(Dalamud.Game.ClientLanguage.English)! return gameData.GetExcelSheet<Lumina.Excel.Sheets.World>(clientLanguage)!
.Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0]))) .Where(w => !w.Name.IsEmpty && w.DataCenter.RowId != 0 && (w.IsPublic || char.IsUpper(w.Name.ToString()[0])))
.ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString()); .ToDictionary(w => (ushort)w.RowId, w => w.Name.ToString());
}); });
JobData = new(() => JobData = new(() =>
{ {
return gameData.GetExcelSheet<ClassJob>(Dalamud.Game.ClientLanguage.English)! return gameData.GetExcelSheet<ClassJob>(clientLanguage)!
.ToDictionary(k => k.RowId, k => k.NameEnglish.ToString()); .ToDictionary(k => k.RowId, k => k.Name.ToString());
}); });
TerritoryData = new(() => 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>(Dalamud.Game.ClientLanguage.English)! return _gameData.GetExcelSheet<TerritoryType>()!
.Where(w => w.RowId != 0) .Where(w => w.RowId != 0 && !string.IsNullOrEmpty(w.ContentFinderCondition.ValueNullable?.Name.ToString()))
.ToDictionary(w => w.RowId, w => .ToDictionary(w => w.RowId, w => w.ContentFinderCondition.Value.Name.ToString());
{
StringBuilder sb = new();
sb.Append(w.PlaceNameRegion.Value.Name);
if (w.PlaceName.ValueNullable != null)
{
sb.Append(" - ");
sb.Append(w.PlaceName.Value.Name);
}
return sb.ToString();
});
});
MapData = new(() =>
{
return gameData.GetExcelSheet<Map>(Dalamud.Game.ClientLanguage.English)!
.Where(w => w.RowId != 0)
.ToDictionary(w => w.RowId, w =>
{
StringBuilder sb = new();
sb.Append(w.PlaceNameRegion.Value.Name);
if (w.PlaceName.ValueNullable != null)
{
sb.Append(" - ");
sb.Append(w.PlaceName.Value.Name);
}
if (w.PlaceNameSub.ValueNullable != null && !string.IsNullOrEmpty(w.PlaceNameSub.Value.Name.ToString()))
{
sb.Append(" - ");
sb.Append(w.PlaceNameSub.Value.Name);
}
return (w, sb.ToString());
});
}); });
mediator.Subscribe<TargetPairMessage>(this, (msg) => mediator.Subscribe<TargetPairMessage>(this, (msg) =>
{ {
@@ -158,6 +133,71 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private Lazy<ulong> RebuildCID() => new(GetCID); private Lazy<ulong> RebuildCID() => new(GetCID);
public bool IsWine { get; init; } public bool IsWine { get; init; }
private Dictionary<uint, string> BuildTerritoryData(Dalamud.Game.ClientLanguage language)
{
var placeNames = _gameData.GetExcelSheet<PlaceName>(language)!;
return _gameData.GetExcelSheet<TerritoryType>(language)!
.Where(w => w.RowId != 0)
.ToDictionary(w => w.RowId, w =>
{
var regionName = GetPlaceName(placeNames, w.PlaceNameRegion.RowId);
var placeName = GetPlaceName(placeNames, w.PlaceName.RowId);
return BuildPlaceName(regionName, placeName, string.Empty);
});
}
private Dictionary<uint, (Map Map, string MapName)> BuildMapData(Dalamud.Game.ClientLanguage language)
{
var placeNames = _gameData.GetExcelSheet<PlaceName>(language)!;
return _gameData.GetExcelSheet<Map>(language)!
.Where(w => w.RowId != 0)
.ToDictionary(w => w.RowId, w =>
{
var regionName = GetPlaceName(placeNames, w.PlaceNameRegion.RowId);
var placeName = GetPlaceName(placeNames, w.PlaceName.RowId);
var subPlaceName = GetPlaceName(placeNames, w.PlaceNameSub.RowId);
var displayName = BuildPlaceName(regionName, placeName, subPlaceName);
return (w, displayName);
});
}
private static string GetPlaceName(Lumina.Excel.ExcelSheet<PlaceName> placeNames, uint rowId)
{
if (rowId == 0)
{
return string.Empty;
}
return placeNames.GetRow(rowId).Name.ToString();
}
private static string BuildPlaceName(string regionName, string placeName, string subPlaceName)
{
StringBuilder sb = new();
if (!string.IsNullOrWhiteSpace(regionName))
{
sb.Append(regionName);
}
if (!string.IsNullOrWhiteSpace(placeName))
{
if (sb.Length > 0)
{
sb.Append(" - ");
}
sb.Append(placeName);
}
if (!string.IsNullOrWhiteSpace(subPlaceName))
{
if (sb.Length > 0)
{
sb.Append(" - ");
}
sb.Append(subPlaceName);
}
return sb.ToString();
}
private bool ResolvePairAddress(Pair pair, out Pair resolvedPair, out nint address) private bool ResolvePairAddress(Pair pair, out Pair resolvedPair, out nint address)
{ {
resolvedPair = _pairFactory.Value.Create(pair.UniqueIdent) ?? pair; resolvedPair = _pairFactory.Value.Create(pair.UniqueIdent) ?? pair;
@@ -233,6 +273,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public bool IsAnythingDrawing { get; private set; } = false; public bool IsAnythingDrawing { get; private set; } = false;
public bool IsInCutscene { get; private set; } = false; public bool IsInCutscene { get; private set; } = false;
public bool IsInGpose { get; private set; } = false; public bool IsInGpose { get; private set; } = false;
public bool IsGameUiHidden => _gameGui.GameUiHidden;
public bool IsLoggedIn { get; private set; } public bool IsLoggedIn { get; private set; }
public bool IsOnFrameworkThread => _framework.IsInFrameworkUpdateThread; public bool IsOnFrameworkThread => _framework.IsInFrameworkUpdateThread;
public bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51]; public bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51];
@@ -245,7 +286,9 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public Lazy<Dictionary<uint, string>> JobData { get; private set; } public Lazy<Dictionary<uint, string>> JobData { get; private set; }
public Lazy<Dictionary<ushort, string>> WorldData { get; private set; } public Lazy<Dictionary<ushort, string>> WorldData { get; private set; }
public Lazy<Dictionary<uint, string>> TerritoryData { get; private set; } 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, (Map Map, string MapName)>> MapData { get; private set; }
public Lazy<Dictionary<uint, string>> ContentFinderData { get; private set; }
public bool IsLodEnabled { get; private set; } public bool IsLodEnabled { get; private set; }
public LightlessMediator Mediator { get; } public LightlessMediator Mediator { get; }
@@ -264,7 +307,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return false; return false;
} }
if (!TerritoryData.Value.TryGetValue(territoryId, out var name) || string.IsNullOrWhiteSpace(name)) if (!TerritoryDataEnglish.Value.TryGetValue(territoryId, out var name) || string.IsNullOrWhiteSpace(name))
{ {
return false; return false;
} }
@@ -327,8 +370,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public IEnumerable<ICharacter?> GetGposeCharactersFromObjectTable() public IEnumerable<ICharacter?> GetGposeCharactersFromObjectTable()
{ {
foreach (var actor in _actorObjectService.PlayerDescriptors foreach (var actor in _objectTable
.Where(a => a.ObjectKind == DalamudObjectKind.Player && a.ObjectIndex > 200)) .Where(a => a.ObjectIndex > 200 && a.ObjectKind == DalamudObjectKind.Player))
{ {
var character = _objectTable.CreateObjectReference(actor.Address) as ICharacter; var character = _objectTable.CreateObjectReference(actor.Address) as ICharacter;
if (character != null) if (character != null)
@@ -339,7 +382,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public bool GetIsPlayerPresent() public bool GetIsPlayerPresent()
{ {
EnsureIsOnFramework(); EnsureIsOnFramework();
return _objectTable.LocalPlayer != null && _objectTable.LocalPlayer.IsValid(); return _objectTable.LocalPlayer != null && _objectTable.LocalPlayer.IsValid() && _playerState.IsLoaded;
} }
public async Task<bool> GetIsPlayerPresentAsync() public async Task<bool> GetIsPlayerPresentAsync()
@@ -355,7 +398,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
var playerAddress = playerPointer.Value; var playerAddress = playerPointer.Value;
var ownerEntityId = ((Character*)playerAddress)->EntityId; var ownerEntityId = ((Character*)playerAddress)->EntityId;
if (ownerEntityId == 0) return IntPtr.Zero; var candidateAddress = _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1);
if (ownerEntityId == 0) return candidateAddress;
if (playerAddress == _actorObjectService.LocalPlayerAddress) if (playerAddress == _actorObjectService.LocalPlayerAddress)
{ {
@@ -366,6 +410,17 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
} }
} }
if (candidateAddress != nint.Zero)
{
var candidate = (GameObject*)candidateAddress;
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
if ((candidateKind == DalamudObjectKind.MountType || candidateKind == DalamudObjectKind.Companion)
&& ResolveOwnerId(candidate) == ownerEntityId)
{
return candidateAddress;
}
}
var ownedObject = FindOwnedObject(ownerEntityId, playerAddress, static kind => var ownedObject = FindOwnedObject(ownerEntityId, playerAddress, static kind =>
kind == DalamudObjectKind.MountType || kind == DalamudObjectKind.Companion); kind == DalamudObjectKind.MountType || kind == DalamudObjectKind.Companion);
if (ownedObject != nint.Zero) if (ownedObject != nint.Zero)
@@ -373,7 +428,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return ownedObject; return ownedObject;
} }
return _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1); return candidateAddress;
} }
public async Task<IntPtr> GetMinionOrMountAsync(IntPtr? playerPointer = null) public async Task<IntPtr> GetMinionOrMountAsync(IntPtr? playerPointer = null)
@@ -388,7 +443,22 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
var mgr = CharacterManager.Instance(); var mgr = CharacterManager.Instance();
playerPointer ??= GetPlayerPtr(); playerPointer ??= GetPlayerPtr();
if (playerPointer == IntPtr.Zero || (IntPtr)mgr == IntPtr.Zero) return IntPtr.Zero; if (playerPointer == IntPtr.Zero || (IntPtr)mgr == IntPtr.Zero) return IntPtr.Zero;
return (IntPtr)mgr->LookupPetByOwnerObject((BattleChara*)playerPointer);
var ownerAddress = playerPointer.Value;
var ownerEntityId = ((Character*)ownerAddress)->EntityId;
if (ownerEntityId == 0) return IntPtr.Zero;
var candidate = (IntPtr)mgr->LookupPetByOwnerObject((BattleChara*)ownerAddress);
if (candidate != IntPtr.Zero)
{
var candidateObj = (GameObject*)candidate;
if (IsPetMatch(candidateObj, ownerEntityId))
{
return candidate;
}
}
return FindOwnedPet(ownerEntityId, ownerAddress);
} }
public async Task<IntPtr> GetPetAsync(IntPtr? playerPointer = null) public async Task<IntPtr> GetPetAsync(IntPtr? playerPointer = null)
@@ -425,6 +495,60 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return nint.Zero; return nint.Zero;
} }
private unsafe nint FindOwnedPet(uint ownerEntityId, nint ownerAddress)
{
if (ownerEntityId == 0)
{
return nint.Zero;
}
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == ownerAddress)
{
continue;
}
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
{
continue;
}
var candidate = (GameObject*)obj.Address;
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
{
continue;
}
if (ResolveOwnerId(candidate) == ownerEntityId)
{
return obj.Address;
}
}
return nint.Zero;
}
private static unsafe bool IsPetMatch(GameObject* candidate, uint ownerEntityId)
{
if (candidate == null)
{
return false;
}
if ((DalamudObjectKind)candidate->ObjectKind != DalamudObjectKind.BattleNpc)
{
return false;
}
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
{
return false;
}
return ResolveOwnerId(candidate) == ownerEntityId;
}
private static unsafe uint ResolveOwnerId(GameObject* gameObject) private static unsafe uint ResolveOwnerId(GameObject* gameObject)
{ {
if (gameObject == null) if (gameObject == null)
@@ -472,30 +596,17 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public string GetPlayerName() public string GetPlayerName()
{ {
EnsureIsOnFramework(); return _playerState.CharacterName;
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);
} }
public unsafe ulong GetCID() public unsafe ulong GetCID()
{ {
EnsureIsOnFramework(); return _playerState.ContentId;
var playerChar = GetPlayerCharacter();
return ((BattleChara*)playerChar.Address)->Character.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) public static unsafe bool TryGetHashedCID(IPlayerCharacter? playerCharacter, out string hashedCid)
@@ -534,54 +645,100 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public uint GetHomeWorldId() public uint GetHomeWorldId()
{ {
EnsureIsOnFramework(); return _playerState.HomeWorld.RowId;
return _objectTable.LocalPlayer?.HomeWorld.RowId ?? 0;
} }
public uint GetWorldId() public uint GetWorldId()
{ {
EnsureIsOnFramework(); return _playerState.CurrentWorld.RowId;
return _objectTable.LocalPlayer!.CurrentWorld.RowId;
} }
public unsafe LocationInfo GetMapData() public unsafe LocationInfo GetMapData()
{ {
EnsureIsOnFramework();
var agentMap = AgentMap.Instance();
var houseMan = HousingManager.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, if (houseMan->IsInside())
MapId = mapId, {
TerritoryId = territoryId, location.TerritoryId = HousingManager.GetOriginalHouseTerritoryTypeId();
DivisionId = divisionId, var house = houseMan->GetCurrentIndoorHouseId();
WardId = wardId, location.WardId = house.WardIndex + 1u;
HouseId = houseId, location.HouseId = house.IsApartment ? 100 : house.PlotIndex + 1u;
RoomId = roomId 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) public unsafe void SetMarkerAndOpenMap(Vector3 position, Map map)
@@ -593,21 +750,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
agentMap->SetFlagMapMarker(map.TerritoryType.RowId, map.RowId, position); 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) public unsafe bool IsGameObjectPresent(IntPtr key)
{ {
return _objectTable.Any(f => f.Address == key); return _objectTable.Any(f => f.Address == key);
@@ -780,7 +922,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
bool isDrawingChanged = false; bool isDrawingChanged = false;
if ((nint)drawObj != IntPtr.Zero) if ((nint)drawObj != IntPtr.Zero)
{ {
isDrawing = (gameObj->RenderFlags & VisibilityFlags.Nameplate) != VisibilityFlags.None; isDrawing = gameObj->RenderFlags == (VisibilityFlags)0b100000000000;
if (!isDrawing) if (!isDrawing)
{ {
isDrawing = ((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0; isDrawing = ((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0;
@@ -846,9 +988,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_performanceCollector.LogPerformance(this, $"TrackedActorsToState", _performanceCollector.LogPerformance(this, $"TrackedActorsToState",
() => () =>
{ {
_actorObjectService.RefreshTrackedActors(); if (!_actorObjectService.HooksActive || !isNormalFrameworkUpdate || _actorObjectService.HasPendingHashResolutions)
{
_actorObjectService.RefreshTrackedActors();
}
var playerDescriptors = _actorObjectService.PlayerCharacterDescriptors; var playerDescriptors = _actorObjectService.PlayerDescriptors;
for (var i = 0; i < playerDescriptors.Count; i++) for (var i = 0; i < playerDescriptors.Count; i++)
{ {
var actor = playerDescriptors[i]; var actor = playerDescriptors[i];

View File

@@ -0,0 +1,863 @@
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.System.Framework;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator;
using LightlessSync.UI;
using LightlessSync.UI.Services;
using LightlessSync.Utils;
using LightlessSync.UtilsEnum.Enum;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Collections.Immutable;
using Task = System.Threading.Tasks.Task;
namespace LightlessSync.Services.LightFinder;
/// <summary>
/// Native nameplate handler that injects LightFinder labels via the signature hook path.
/// </summary>
public unsafe class LightFinderNativePlateHandler : DisposableMediatorSubscriberBase, IHostedService
{
private const uint NameplateNodeIdBase = 0x7D99D500;
private const string DefaultLabelText = "LightFinder";
private readonly ILogger<LightFinderNativePlateHandler> _logger;
private readonly IClientState _clientState;
private readonly IObjectTable _objectTable;
private readonly LightlessConfigService _configService;
private readonly PairUiService _pairUiService;
private readonly NameplateUpdateHookService _nameplateUpdateHookService;
private readonly int[] _cachedNameplateTextWidths = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateTextHeights = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateContainerHeights = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateTextOffsets = new int[AddonNamePlate.NumNamePlateObjects];
private readonly string?[] _lastLabelByIndex = new string?[AddonNamePlate.NumNamePlateObjects];
private ImmutableHashSet<string> _activeBroadcastingCids = [];
private LightfinderLabelRenderer _lastRenderer;
private uint _lastSignatureUpdateFrame;
private bool _isUpdating;
private string _lastLabelContent = DefaultLabelText;
public LightFinderNativePlateHandler(
ILogger<LightFinderNativePlateHandler> logger,
IClientState clientState,
LightlessConfigService configService,
LightlessMediator mediator,
IObjectTable objectTable,
PairUiService pairUiService,
NameplateUpdateHookService nameplateUpdateHookService) : base(logger, mediator)
{
_logger = logger;
_clientState = clientState;
_configService = configService;
_objectTable = objectTable;
_pairUiService = pairUiService;
_nameplateUpdateHookService = nameplateUpdateHookService;
_lastRenderer = _configService.Current.LightfinderLabelRenderer;
Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
}
private bool IsSignatureMode => _configService.Current.LightfinderLabelRenderer == LightfinderLabelRenderer.SignatureHook;
/// <summary>
/// Starts listening for nameplate updates from the hook service.
/// </summary>
public Task StartAsync(CancellationToken cancellationToken)
{
_nameplateUpdateHookService.NameplateUpdated += OnNameplateUpdated;
return Task.CompletedTask;
}
/// <summary>
/// Stops listening for nameplate updates and tears down any constructed nodes.
/// </summary>
public Task StopAsync(CancellationToken cancellationToken)
{
_nameplateUpdateHookService.NameplateUpdated -= OnNameplateUpdated;
UnsubscribeAll();
TryDestroyNameplateNodes();
return Task.CompletedTask;
}
/// <summary>
/// Triggered by the sig hook to refresh native nameplate labels.
/// </summary>
private void HandleNameplateUpdate(RaptureAtkModule* raptureAtkModule)
{
if (_isUpdating)
return;
_isUpdating = true;
try
{
RefreshRendererState();
if (!IsSignatureMode)
return;
if (raptureAtkModule == null)
return;
var namePlateAddon = GetNamePlateAddon(raptureAtkModule);
if (namePlateAddon == null)
return;
if (_clientState.IsGPosing)
{
HideAllNameplateNodes(namePlateAddon);
return;
}
var fw = Framework.Instance();
if (fw == null)
return;
var frame = fw->FrameCounter;
if (_lastSignatureUpdateFrame == frame)
return;
_lastSignatureUpdateFrame = frame;
UpdateNameplateNodes(namePlateAddon);
}
finally
{
_isUpdating = false;
}
}
/// <summary>
/// Hook callback from the nameplate update signature.
/// </summary>
private void OnNameplateUpdated(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo,
NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
{
HandleNameplateUpdate(raptureAtkModule);
}
/// <summary>
/// Updates the active broadcasting CID set and requests a nameplate redraw.
/// </summary>
public void UpdateBroadcastingCids(IEnumerable<string> cids)
{
var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal);
if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet))
return;
_activeBroadcastingCids = newSet;
if (_logger.IsEnabled(LogLevel.Trace))
_logger.LogTrace("Active broadcast IDs (native): {Cids}", string.Join(',', _activeBroadcastingCids));
RequestNameplateRedraw();
}
/// <summary>
/// Sync renderer state with config and clear/remove native nodes if needed.
/// </summary>
private void RefreshRendererState()
{
var renderer = _configService.Current.LightfinderLabelRenderer;
if (renderer == _lastRenderer)
return;
_lastRenderer = renderer;
if (renderer == LightfinderLabelRenderer.SignatureHook)
{
ClearNameplateCaches();
RequestNameplateRedraw();
}
else
{
TryDestroyNameplateNodes();
ClearNameplateCaches();
}
}
/// <summary>
/// Requests a full nameplate update through the native addon.
/// </summary>
private void RequestNameplateRedraw()
{
if (!IsSignatureMode)
return;
var raptureAtkModule = GetRaptureAtkModule();
if (raptureAtkModule == null)
return;
var namePlateAddon = GetNamePlateAddon(raptureAtkModule);
if (namePlateAddon == null)
return;
namePlateAddon->DoFullUpdate = 1;
}
private HashSet<ulong> VisibleUserIds
=> [.. _pairUiService.GetSnapshot().PairsByUid.Values
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)];
/// <summary>
/// Creates/updates LightFinder label nodes for active broadcasts.
/// </summary>
private void UpdateNameplateNodes(AddonNamePlate* namePlateAddon)
{
if (namePlateAddon == null)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh.");
return;
}
if (!IsNameplateAddonVisible(namePlateAddon))
return;
if (!IsSignatureMode)
{
HideAllNameplateNodes(namePlateAddon);
return;
}
if (_activeBroadcastingCids.Count == 0)
{
HideAllNameplateNodes(namePlateAddon);
return;
}
var framework = Framework.Instance();
if (framework == null)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Framework instance unavailable during nameplate update, skipping.");
return;
}
var uiModule = framework->GetUIModule();
if (uiModule == null)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("UI module unavailable during nameplate update, skipping.");
return;
}
var ui3DModule = uiModule->GetUI3DModule();
if (ui3DModule == null)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("UI3D module unavailable during nameplate update, skipping.");
return;
}
var vec = ui3DModule->NamePlateObjectInfoPointers;
if (vec.IsEmpty)
return;
var config = _configService.Current;
var visibleUserIdsSnapshot = VisibleUserIds;
var labelColor = UIColors.Get("Lightfinder");
var edgeColor = UIColors.Get("LightfinderEdge");
var scaleMultiplier = Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f);
var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f;
var effectiveScale = baseScale * scaleMultiplier;
var labelContent = config.LightfinderLabelUseIcon
? LightFinderPlateHandler.NormalizeIconGlyph(config.LightfinderLabelIconGlyph)
: DefaultLabelText;
if (!config.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal)))
labelContent = DefaultLabelText;
if (!string.Equals(_lastLabelContent, labelContent, StringComparison.Ordinal))
{
_lastLabelContent = labelContent;
Array.Fill(_lastLabelByIndex, null);
}
var desiredFontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed;
var baseFontSize = config.LightfinderLabelUseIcon ? 36f : 24f;
var desiredFontSize = (byte)Math.Clamp((int)Math.Round(baseFontSize * scaleMultiplier), 1, 255);
var desiredFlags = config.LightfinderLabelUseIcon
? TextFlags.Edge | TextFlags.Glare | TextFlags.AutoAdjustNodeSize
: TextFlags.Edge | TextFlags.Glare;
var desiredLineSpacing = (byte)Math.Clamp((int)Math.Round(24 * scaleMultiplier), 0, byte.MaxValue);
var defaultNodeWidth = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
var defaultNodeHeight = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale);
var safeCount = Math.Min(ui3DModule->NamePlateObjectInfoCount, vec.Length);
var visibleIndices = new bool[AddonNamePlate.NumNamePlateObjects];
for (int i = 0; i < safeCount; ++i)
{
var objectInfoPtr = vec[i];
if (objectInfoPtr == null)
continue;
var objectInfo = objectInfoPtr.Value;
if (objectInfo == null || objectInfo->GameObject == null)
continue;
var nameplateIndex = objectInfo->NamePlateIndex;
if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects)
continue;
var gameObject = objectInfo->GameObject;
if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player)
continue;
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
if (cid == null || !_activeBroadcastingCids.Contains(cid))
continue;
var local = _objectTable.LocalPlayer;
if (!config.LightfinderLabelShowOwn && local != null &&
objectInfo->GameObject->GetGameObjectId() == local.GameObjectId)
continue;
var hidePaired = !config.LightfinderLabelShowPaired;
var goId = (ulong)gameObject->GetGameObjectId();
if (hidePaired && visibleUserIdsSnapshot.Contains(goId))
continue;
var nameplateObject = namePlateAddon->NamePlateObjectArray[nameplateIndex];
var root = nameplateObject.RootComponentNode;
var nameContainer = nameplateObject.NameContainer;
var nameText = nameplateObject.NameText;
var marker = nameplateObject.MarkerIcon;
if (root == null || root->Component == null || nameContainer == null || nameText == null)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Nameplate {Index} missing required nodes during update, skipping.", nameplateIndex);
continue;
}
var nodeId = GetNameplateNodeId(nameplateIndex);
var pNode = EnsureNameplateTextNode(nameContainer, root, nodeId, out var nodeCreated);
if (pNode == null)
continue;
bool isVisible =
((marker != null) && marker->AtkResNode.IsVisible()) ||
(nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) ||
config.LightfinderLabelShowHidden;
if (!isVisible)
continue;
if (!pNode->AtkResNode.IsVisible())
pNode->AtkResNode.ToggleVisibility(enable: true);
visibleIndices[nameplateIndex] = true;
if (nodeCreated)
pNode->AtkResNode.SetUseDepthBasedPriority(enable: true);
var scaleMatches = NearlyEqual(pNode->AtkResNode.ScaleX, effectiveScale) &&
NearlyEqual(pNode->AtkResNode.ScaleY, effectiveScale);
if (!scaleMatches)
pNode->AtkResNode.SetScale(effectiveScale, effectiveScale);
var fontTypeChanged = pNode->FontType != desiredFontType;
if (fontTypeChanged)
pNode->FontType = desiredFontType;
var fontSizeChanged = pNode->FontSize != desiredFontSize;
if (fontSizeChanged)
pNode->FontSize = desiredFontSize;
var needsTextUpdate = nodeCreated ||
!string.Equals(_lastLabelByIndex[nameplateIndex], labelContent, StringComparison.Ordinal);
if (needsTextUpdate)
{
pNode->SetText(labelContent);
_lastLabelByIndex[nameplateIndex] = labelContent;
}
var flagsChanged = pNode->TextFlags != desiredFlags;
var nodeWidth = (int)pNode->AtkResNode.GetWidth();
if (nodeWidth <= 0)
nodeWidth = defaultNodeWidth;
var nodeHeight = defaultNodeHeight;
AlignmentType alignment;
var textScaleY = nameText->AtkResNode.ScaleY;
if (textScaleY <= 0f)
textScaleY = 1f;
var blockHeight = Math.Abs((int)nameplateObject.TextH);
if (blockHeight > 0)
{
_cachedNameplateTextHeights[nameplateIndex] = blockHeight;
}
else
{
blockHeight = _cachedNameplateTextHeights[nameplateIndex];
}
if (blockHeight <= 0)
{
blockHeight = GetScaledTextHeight(nameText);
if (blockHeight <= 0)
blockHeight = nodeHeight;
_cachedNameplateTextHeights[nameplateIndex] = blockHeight;
}
var containerHeight = (int)nameContainer->Height;
if (containerHeight > 0)
{
_cachedNameplateContainerHeights[nameplateIndex] = containerHeight;
}
else
{
containerHeight = _cachedNameplateContainerHeights[nameplateIndex];
}
if (containerHeight <= 0)
{
containerHeight = blockHeight + (int)Math.Round(8 * textScaleY);
if (containerHeight <= blockHeight)
containerHeight = blockHeight + 1;
_cachedNameplateContainerHeights[nameplateIndex] = containerHeight;
}
var blockTop = containerHeight - blockHeight;
if (blockTop < 0)
blockTop = 0;
var verticalPadding = (int)Math.Round(4 * effectiveScale);
var positionY = blockTop - verticalPadding - nodeHeight;
var textWidth = Math.Abs((int)nameplateObject.TextW);
if (textWidth <= 0)
{
textWidth = GetScaledTextWidth(nameText);
if (textWidth <= 0)
textWidth = nodeWidth;
}
if (textWidth > 0)
{
_cachedNameplateTextWidths[nameplateIndex] = textWidth;
}
var textOffset = (int)Math.Round(nameText->AtkResNode.X);
var hasValidOffset = false;
if (Math.Abs((int)nameplateObject.TextW) > 0 || textOffset != 0)
{
_cachedNameplateTextOffsets[nameplateIndex] = textOffset;
hasValidOffset = true;
}
else if (_cachedNameplateTextOffsets[nameplateIndex] != int.MinValue)
{
hasValidOffset = true;
}
int positionX;
if (!config.LightfinderLabelUseIcon)
{
var needsWidthRefresh = nodeCreated || needsTextUpdate || !scaleMatches || fontTypeChanged || fontSizeChanged || flagsChanged;
if (flagsChanged)
pNode->TextFlags = desiredFlags;
if (needsWidthRefresh)
{
if (pNode->AtkResNode.Width != 0)
pNode->AtkResNode.Width = 0;
nodeWidth = (int)pNode->AtkResNode.GetWidth();
if (nodeWidth <= 0)
nodeWidth = defaultNodeWidth;
}
if (pNode->AtkResNode.Width != (ushort)nodeWidth)
pNode->AtkResNode.Width = (ushort)nodeWidth;
}
else
{
var needsWidthRefresh = nodeCreated || needsTextUpdate || !scaleMatches || fontTypeChanged || fontSizeChanged || flagsChanged;
if (flagsChanged)
pNode->TextFlags = desiredFlags;
if (needsWidthRefresh && pNode->AtkResNode.Width != 0)
pNode->AtkResNode.Width = 0;
nodeWidth = pNode->AtkResNode.GetWidth();
}
if (config.LightfinderAutoAlign && nameContainer != null && hasValidOffset)
{
var nameplateWidth = (int)nameContainer->Width;
int leftPos = nameplateWidth / 8;
int rightPos = nameplateWidth - nodeWidth - (nameplateWidth / 8);
int centrePos = (nameplateWidth - nodeWidth) / 2;
int staticMargin = 24;
int calcMargin = (int)(nameplateWidth * 0.08f);
switch (config.LabelAlignment)
{
case LabelAlignment.Left:
positionX = config.LightfinderLabelUseIcon ? leftPos + staticMargin : leftPos;
alignment = AlignmentType.BottomLeft;
break;
case LabelAlignment.Right:
positionX = config.LightfinderLabelUseIcon ? rightPos - staticMargin : nameplateWidth - nodeWidth + calcMargin;
alignment = AlignmentType.BottomRight;
break;
default:
positionX = config.LightfinderLabelUseIcon ? centrePos : centrePos + calcMargin;
alignment = AlignmentType.Bottom;
break;
}
}
else
{
positionX = 58 + config.LightfinderLabelOffsetX;
alignment = AlignmentType.Bottom;
}
positionY += config.LightfinderLabelOffsetY;
alignment = (AlignmentType)Math.Clamp((int)alignment, 0, 8);
if (pNode->AtkResNode.Color.A != 255)
pNode->AtkResNode.Color.A = 255;
var textR = (byte)(labelColor.X * 255);
var textG = (byte)(labelColor.Y * 255);
var textB = (byte)(labelColor.Z * 255);
var textA = (byte)(labelColor.W * 255);
if (pNode->TextColor.R != textR || pNode->TextColor.G != textG ||
pNode->TextColor.B != textB || pNode->TextColor.A != textA)
{
pNode->TextColor.R = textR;
pNode->TextColor.G = textG;
pNode->TextColor.B = textB;
pNode->TextColor.A = textA;
}
var edgeR = (byte)(edgeColor.X * 255);
var edgeG = (byte)(edgeColor.Y * 255);
var edgeB = (byte)(edgeColor.Z * 255);
var edgeA = (byte)(edgeColor.W * 255);
if (pNode->EdgeColor.R != edgeR || pNode->EdgeColor.G != edgeG ||
pNode->EdgeColor.B != edgeB || pNode->EdgeColor.A != edgeA)
{
pNode->EdgeColor.R = edgeR;
pNode->EdgeColor.G = edgeG;
pNode->EdgeColor.B = edgeB;
pNode->EdgeColor.A = edgeA;
}
var desiredAlignment = config.LightfinderLabelUseIcon ? alignment : AlignmentType.Bottom;
if (pNode->AlignmentType != desiredAlignment)
pNode->AlignmentType = desiredAlignment;
var desiredX = (short)Math.Clamp(positionX, short.MinValue, short.MaxValue);
var desiredY = (short)Math.Clamp(positionY, short.MinValue, short.MaxValue);
if (!NearlyEqual(pNode->AtkResNode.X, desiredX) || !NearlyEqual(pNode->AtkResNode.Y, desiredY))
pNode->AtkResNode.SetPositionShort(desiredX, desiredY);
if (pNode->LineSpacing != desiredLineSpacing)
pNode->LineSpacing = desiredLineSpacing;
if (pNode->CharSpacing != 1)
pNode->CharSpacing = 1;
}
HideUnmarkedNodes(namePlateAddon, visibleIndices);
}
/// <summary>
/// Resolve the current RaptureAtkModule for native UI access.
/// </summary>
private static RaptureAtkModule* GetRaptureAtkModule()
{
var framework = Framework.Instance();
if (framework == null)
return null;
var uiModule = framework->GetUIModule();
if (uiModule == null)
return null;
return uiModule->GetRaptureAtkModule();
}
/// <summary>
/// Resolve the NamePlate addon from the given RaptureAtkModule.
/// </summary>
private static AddonNamePlate* GetNamePlateAddon(RaptureAtkModule* raptureAtkModule)
{
if (raptureAtkModule == null)
return null;
var addon = raptureAtkModule->RaptureAtkUnitManager.GetAddonByName("NamePlate");
return addon != null ? (AddonNamePlate*)addon : null;
}
private static uint GetNameplateNodeId(int index)
=> NameplateNodeIdBase + (uint)index;
/// <summary>
/// Checks if the NamePlate addon is visible and safe to touch.
/// </summary>
private static bool IsNameplateAddonVisible(AddonNamePlate* namePlateAddon)
{
if (namePlateAddon == null)
return false;
var root = namePlateAddon->AtkUnitBase.RootNode;
return root != null && root->IsVisible();
}
/// <summary>
/// Finds a LightFinder text node by ID in the name container.
/// </summary>
private static AtkTextNode* FindNameplateTextNode(AtkResNode* nameContainer, uint nodeId)
{
if (nameContainer == null)
return null;
var child = nameContainer->ChildNode;
while (child != null)
{
if (child->NodeId == nodeId &&
child->Type == NodeType.Text &&
child->ParentNode == nameContainer)
return (AtkTextNode*)child;
child = child->PrevSiblingNode;
}
return null;
}
/// <summary>
/// Ensures a LightFinder text node exists for the given nameplate index.
/// </summary>
private static AtkTextNode* EnsureNameplateTextNode(AtkResNode* nameContainer, AtkComponentNode* root, uint nodeId, out bool created)
{
created = false;
if (nameContainer == null || root == null || root->Component == null)
return null;
var existing = FindNameplateTextNode(nameContainer, nodeId);
if (existing != null)
return existing;
if (nameContainer->ChildNode == null)
return null;
var newNode = AtkNodeHelpers.CreateOrphanTextNode(nodeId, TextFlags.Edge | TextFlags.Glare);
if (newNode == null)
return null;
var lastChild = nameContainer->ChildNode;
while (lastChild->PrevSiblingNode != null)
lastChild = lastChild->PrevSiblingNode;
newNode->AtkResNode.NextSiblingNode = lastChild;
newNode->AtkResNode.ParentNode = nameContainer;
lastChild->PrevSiblingNode = (AtkResNode*)newNode;
root->Component->UldManager.UpdateDrawNodeList();
newNode->AtkResNode.SetUseDepthBasedPriority(true);
created = true;
return newNode;
}
/// <summary>
/// Hides all native LightFinder nodes on the nameplate addon.
/// </summary>
private static void HideAllNameplateNodes(AddonNamePlate* namePlateAddon)
{
if (namePlateAddon == null)
return;
if (!IsNameplateAddonVisible(namePlateAddon))
return;
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
{
HideNameplateTextNode(namePlateAddon->NamePlateObjectArray[i], GetNameplateNodeId(i));
}
}
/// <summary>
/// Hides all LightFinder nodes not marked as visible this frame.
/// </summary>
private static void HideUnmarkedNodes(AddonNamePlate* namePlateAddon, bool[] visibleIndices)
{
if (namePlateAddon == null)
return;
if (!IsNameplateAddonVisible(namePlateAddon))
return;
var visibleLength = visibleIndices.Length;
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
{
if (i < visibleLength && visibleIndices[i])
continue;
HideNameplateTextNode(namePlateAddon->NamePlateObjectArray[i], GetNameplateNodeId(i));
}
}
/// <summary>
/// Hides the LightFinder text node for a single nameplate object.
/// </summary>
private static void HideNameplateTextNode(AddonNamePlate.NamePlateObject nameplateObject, uint nodeId)
{
var nameContainer = nameplateObject.NameContainer;
if (nameContainer == null)
return;
var node = FindNameplateTextNode(nameContainer, nodeId);
if (!IsValidNameplateTextNode(node, nameContainer))
return;
node->AtkResNode.ToggleVisibility(false);
}
/// <summary>
/// Attempts to destroy all constructed LightFinder nodes safely.
/// </summary>
private void TryDestroyNameplateNodes()
{
var raptureAtkModule = GetRaptureAtkModule();
if (raptureAtkModule == null)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Unable to destroy nameplate nodes because the RaptureAtkModule is not available.");
return;
}
var namePlateAddon = GetNamePlateAddon(raptureAtkModule);
if (namePlateAddon == null)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Unable to destroy nameplate nodes because the NamePlate addon is not available.");
return;
}
DestroyNameplateNodes(namePlateAddon);
}
/// <summary>
/// Removes all constructed LightFinder nodes from the given nameplate addon.
/// </summary>
private void DestroyNameplateNodes(AddonNamePlate* namePlateAddon)
{
if (namePlateAddon == null)
return;
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
{
var nameplateObject = namePlateAddon->NamePlateObjectArray[i];
var root = nameplateObject.RootComponentNode;
var nameContainer = nameplateObject.NameContainer;
if (root == null || root->Component == null || nameContainer == null)
continue;
var nodeId = GetNameplateNodeId(i);
var textNode = FindNameplateTextNode(nameContainer, nodeId);
if (!IsValidNameplateTextNode(textNode, nameContainer))
continue;
try
{
var resNode = &textNode->AtkResNode;
if (resNode->PrevSiblingNode != null)
resNode->PrevSiblingNode->NextSiblingNode = resNode->NextSiblingNode;
if (resNode->NextSiblingNode != null)
resNode->NextSiblingNode->PrevSiblingNode = resNode->PrevSiblingNode;
root->Component->UldManager.UpdateDrawNodeList();
resNode->Destroy(true);
}
catch (Exception e)
{
_logger.LogError(e, "Unknown error while removing text node 0x{Node:X} for nameplate {Index} on component node 0x{Component:X}", (IntPtr)textNode, i, (IntPtr)root);
}
}
ClearNameplateCaches();
}
/// <summary>
/// Validates that a node is a LightFinder text node owned by the container.
/// </summary>
private static bool IsValidNameplateTextNode(AtkTextNode* node, AtkResNode* nameContainer)
{
if (node == null || nameContainer == null)
return false;
var resNode = &node->AtkResNode;
return resNode->Type == NodeType.Text && resNode->ParentNode == nameContainer;
}
/// <summary>
/// Float comparison helper for UI values.
/// </summary>
private static bool NearlyEqual(float a, float b, float epsilon = 0.001f)
=> Math.Abs(a - b) <= epsilon;
private static int GetScaledTextHeight(AtkTextNode* node)
{
if (node == null)
return 0;
var resNode = &node->AtkResNode;
var rawHeight = (int)resNode->GetHeight();
if (rawHeight <= 0 && node->LineSpacing > 0)
rawHeight = node->LineSpacing;
if (rawHeight <= 0)
rawHeight = AtkNodeHelpers.DefaultTextNodeHeight;
var scale = resNode->ScaleY;
if (scale <= 0f)
scale = 1f;
var computed = (int)Math.Round(rawHeight * scale);
return Math.Max(1, computed);
}
private static int GetScaledTextWidth(AtkTextNode* node)
{
if (node == null)
return 0;
var resNode = &node->AtkResNode;
var rawWidth = (int)resNode->GetWidth();
if (rawWidth <= 0)
rawWidth = AtkNodeHelpers.DefaultTextNodeWidth;
var scale = resNode->ScaleX;
if (scale <= 0f)
scale = 1f;
var computed = (int)Math.Round(rawWidth * scale);
return Math.Max(1, computed);
}
/// <summary>
/// Clears cached text sizing and label state for nameplates.
/// </summary>
public void ClearNameplateCaches()
{
Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
Array.Fill(_lastLabelByIndex, null);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,11 +15,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
private readonly LightFinderService _broadcastService; private readonly LightFinderService _broadcastService;
private readonly LightFinderPlateHandler _lightFinderPlateHandler; private readonly LightFinderPlateHandler _lightFinderPlateHandler;
private readonly LightFinderNativePlateHandler _lightFinderNativePlateHandler;
private readonly ConcurrentDictionary<string, BroadcastEntry> _broadcastCache = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, BroadcastEntry> _broadcastCache = new(StringComparer.Ordinal);
private readonly Queue<string> _lookupQueue = new(); private readonly Queue<string> _lookupQueue = new();
private readonly HashSet<string> _lookupQueuedCids = []; private readonly HashSet<string> _lookupQueuedCids = [];
private readonly HashSet<string> _syncshellCids = []; private readonly HashSet<string> _syncshellCids = [];
private volatile bool _pendingLocalBroadcast;
private TimeSpan? _pendingLocalTtl;
private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4); private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4);
private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1); private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1);
@@ -42,12 +45,14 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
LightFinderService broadcastService, LightFinderService broadcastService,
LightlessMediator mediator, LightlessMediator mediator,
LightFinderPlateHandler lightFinderPlateHandler, LightFinderPlateHandler lightFinderPlateHandler,
LightFinderNativePlateHandler lightFinderNativePlateHandler,
ActorObjectService actorTracker) : base(logger, mediator) ActorObjectService actorTracker) : base(logger, mediator)
{ {
_logger = logger; _logger = logger;
_actorTracker = actorTracker; _actorTracker = actorTracker;
_broadcastService = broadcastService; _broadcastService = broadcastService;
_lightFinderPlateHandler = lightFinderPlateHandler; _lightFinderPlateHandler = lightFinderPlateHandler;
_lightFinderNativePlateHandler = lightFinderNativePlateHandler;
_logger = logger; _logger = logger;
_framework = framework; _framework = framework;
@@ -69,6 +74,8 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
if (!_broadcastService.IsBroadcasting) if (!_broadcastService.IsBroadcasting)
return; return;
TryPrimeLocalBroadcastCache();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
foreach (var address in _actorTracker.PlayerAddresses) foreach (var address in _actorTracker.PlayerAddresses)
@@ -129,6 +136,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
.ToList(); .ToList();
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids); _lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
_lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids);
UpdateSyncshellBroadcasts(); UpdateSyncshellBroadcasts();
} }
@@ -140,18 +148,58 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
_lookupQueue.Clear(); _lookupQueue.Clear();
_lookupQueuedCids.Clear(); _lookupQueuedCids.Clear();
_syncshellCids.Clear(); _syncshellCids.Clear();
_pendingLocalBroadcast = false;
_pendingLocalTtl = null;
_lightFinderPlateHandler.UpdateBroadcastingCids([]); _lightFinderPlateHandler.UpdateBroadcastingCids([]);
_lightFinderNativePlateHandler.UpdateBroadcastingCids([]);
return;
} }
_pendingLocalBroadcast = true;
_pendingLocalTtl = msg.Ttl;
TryPrimeLocalBroadcastCache();
}
private void TryPrimeLocalBroadcastCache()
{
if (!_pendingLocalBroadcast)
return;
if (!TryGetLocalHashedCid(out var localCid))
return;
var ttl = _pendingLocalTtl ?? _maxAllowedTtl;
var expiry = DateTime.UtcNow + ttl;
_broadcastCache.AddOrUpdate(localCid,
new BroadcastEntry(true, expiry, null),
(_, old) => new BroadcastEntry(true, expiry, old.GID));
_pendingLocalBroadcast = false;
_pendingLocalTtl = null;
var now = DateTime.UtcNow;
var activeCids = _broadcastCache
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now)
.Select(e => e.Key)
.ToList();
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
_lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids);
} }
private void UpdateSyncshellBroadcasts() private void UpdateSyncshellBroadcasts()
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var newSet = _broadcastCache var nearbyCids = GetNearbyHashedCids(out _);
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID)) var newSet = nearbyCids.Count == 0
.Select(e => e.Key) ? new HashSet<string>(StringComparer.Ordinal)
.ToHashSet(StringComparer.Ordinal); : _broadcastCache
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID))
.Where(e => nearbyCids.Contains(e.Key))
.Select(e => e.Key)
.ToHashSet(StringComparer.Ordinal);
if (!_syncshellCids.SetEquals(newSet)) if (!_syncshellCids.SetEquals(newSet))
{ {
@@ -163,12 +211,17 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
} }
} }
public List<BroadcastStatusInfoDto> GetActiveSyncshellBroadcasts() public List<BroadcastStatusInfoDto> GetActiveSyncshellBroadcasts(bool excludeLocal = false)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var nearbyCids = GetNearbyHashedCids(out var localCid);
if (nearbyCids.Count == 0)
return [];
return [.. _broadcastCache return [.. _broadcastCache
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID)) .Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID))
.Where(e => nearbyCids.Contains(e.Key))
.Where(e => !excludeLocal || !string.Equals(e.Key, localCid, StringComparison.Ordinal))
.Select(e => new BroadcastStatusInfoDto .Select(e => new BroadcastStatusInfoDto
{ {
HashedCID = e.Key, HashedCID = e.Key,
@@ -178,6 +231,47 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase
})]; })];
} }
public bool TryGetLocalHashedCid(out string hashedCid)
{
hashedCid = string.Empty;
var descriptors = _actorTracker.PlayerDescriptors;
if (descriptors.Count == 0)
return false;
foreach (var descriptor in descriptors)
{
if (!descriptor.IsLocalPlayer || string.IsNullOrWhiteSpace(descriptor.HashedContentId))
continue;
hashedCid = descriptor.HashedContentId;
return true;
}
return false;
}
private HashSet<string> GetNearbyHashedCids(out string? localCid)
{
localCid = null;
var descriptors = _actorTracker.PlayerDescriptors;
if (descriptors.Count == 0)
return new HashSet<string>(StringComparer.Ordinal);
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var descriptor in descriptors)
{
if (string.IsNullOrWhiteSpace(descriptor.HashedContentId))
continue;
if (descriptor.IsLocalPlayer)
localCid = descriptor.HashedContentId;
set.Add(descriptor.HashedContentId);
}
return set;
}
private async Task ExpiredBroadcastCleanupLoop() private async Task ExpiredBroadcastCleanupLoop()
{ {
var token = _cleanupCts.Token; var token = _cleanupCts.Token;

View File

@@ -67,7 +67,7 @@ public class LightFinderService : IHostedService, IMediatorSubscriber
{ {
try try
{ {
var cid = await _dalamudUtil.GetCIDAsync().ConfigureAwait(false); var cid = _dalamudUtil.GetCID();
return cid.ToString().GetHash256(); return cid.ToString().GetHash256();
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -2,10 +2,8 @@ using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.NativeWrapper; using Dalamud.Game.NativeWrapper;
using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Hooking;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using Dalamud.Utility; using Dalamud.Utility;
using Dalamud.Utility.Signatures;
using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI; using FFXIVClientStructs.FFXIV.Component.GUI;
@@ -24,27 +22,22 @@ namespace LightlessSync.Services;
/// </summary> /// </summary>
public unsafe class NameplateService : DisposableMediatorSubscriberBase public unsafe class NameplateService : DisposableMediatorSubscriberBase
{ {
private delegate nint UpdateNameplateDelegate(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex);
// Glyceri, Thanks :bow:
[Signature("40 53 55 57 41 56 48 81 EC ?? ?? ?? ?? 48 8B 84 24", DetourName = nameof(UpdateNameplateDetour))]
private readonly Hook<UpdateNameplateDelegate>? _nameplateHook = null;
private readonly ILogger<NameplateService> _logger; private readonly ILogger<NameplateService> _logger;
private readonly LightlessConfigService _configService; private readonly LightlessConfigService _configService;
private readonly IClientState _clientState; private readonly IClientState _clientState;
private readonly IGameGui _gameGui; private readonly IGameGui _gameGui;
private readonly IObjectTable _objectTable; private readonly IObjectTable _objectTable;
private readonly PairUiService _pairUiService; private readonly PairUiService _pairUiService;
private readonly NameplateUpdateHookService _nameplateUpdateHookService;
public NameplateService(ILogger<NameplateService> logger, public NameplateService(ILogger<NameplateService> logger,
LightlessConfigService configService, LightlessConfigService configService,
IClientState clientState, IClientState clientState,
IGameGui gameGui, IGameGui gameGui,
IObjectTable objectTable, IObjectTable objectTable,
IGameInteropProvider interop,
LightlessMediator lightlessMediator, LightlessMediator lightlessMediator,
PairUiService pairUiService) : base(logger, lightlessMediator) PairUiService pairUiService,
NameplateUpdateHookService nameplateUpdateHookService) : base(logger, lightlessMediator)
{ {
_logger = logger; _logger = logger;
_configService = configService; _configService = configService;
@@ -52,21 +45,18 @@ public unsafe class NameplateService : DisposableMediatorSubscriberBase
_gameGui = gameGui; _gameGui = gameGui;
_objectTable = objectTable; _objectTable = objectTable;
_pairUiService = pairUiService; _pairUiService = pairUiService;
_nameplateUpdateHookService = nameplateUpdateHookService;
interop.InitializeFromAttributes(this); _nameplateUpdateHookService.NameplateUpdated += OnNameplateUpdated;
_nameplateHook?.Enable();
Refresh(); Refresh();
Mediator.Subscribe<VisibilityChange>(this, (_) => Refresh()); Mediator.Subscribe<VisibilityChange>(this, (_) => Refresh());
} }
/// <summary> /// <summary>
/// Detour for the game's internal nameplate update function. /// Nameplate update handler, triggered by the signature hook service.
/// This will be called whenever the client updates any nameplate.
///
/// We hook into it to apply our own nameplate coloring logic via <see cref="SetNameplate"/>,
/// </summary> /// </summary>
private nint UpdateNameplateDetour(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex) private void OnNameplateUpdated(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
{ {
try try
{ {
@@ -74,10 +64,8 @@ public unsafe class NameplateService : DisposableMediatorSubscriberBase
} }
catch (Exception e) catch (Exception e)
{ {
_logger.LogError(e, "Error in NameplateService UpdateNameplateDetour"); _logger.LogError(e, "Error in NameplateService OnNameplateUpdated");
} }
return _nameplateHook!.Original(raptureAtkModule, namePlateInfo, numArray, stringArray, battleChara, numArrayIndex, stringArrayIndex);
} }
/// <summary> /// <summary>
@@ -246,7 +234,7 @@ public unsafe class NameplateService : DisposableMediatorSubscriberBase
{ {
if (disposing) if (disposing)
{ {
_nameplateHook?.Dispose(); _nameplateUpdateHookService.NameplateUpdated -= OnNameplateUpdated;
} }
base.Dispose(disposing); base.Dispose(disposing);

View File

@@ -0,0 +1,57 @@
using Dalamud.Hooking;
using Dalamud.Plugin.Services;
using Dalamud.Utility.Signatures;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Services;
public unsafe sealed class NameplateUpdateHookService : IDisposable
{
private delegate nint UpdateNameplateDelegate(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo,
NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex);
public delegate void NameplateUpdatedHandler(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo,
NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex);
// Glyceri, Thanks :bow:
[Signature("40 53 55 57 41 56 48 81 EC ?? ?? ?? ?? 48 8B 84 24", DetourName = nameof(UpdateNameplateDetour))]
private readonly Hook<UpdateNameplateDelegate>? _nameplateHook = null;
private readonly ILogger<NameplateUpdateHookService> _logger;
public NameplateUpdateHookService(ILogger<NameplateUpdateHookService> logger, IGameInteropProvider interop)
{
_logger = logger;
interop.InitializeFromAttributes(this);
_nameplateHook?.Enable();
}
public event NameplateUpdatedHandler? NameplateUpdated;
/// <summary>
/// Detour for the game's internal nameplate update function.
/// This will be called whenever the client updates any nameplate.
/// </summary>
private nint UpdateNameplateDetour(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo,
NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
{
try
{
NameplateUpdated?.Invoke(raptureAtkModule, namePlateInfo, numArray, stringArray, battleChara, numArrayIndex, stringArrayIndex);
}
catch (Exception e)
{
_logger.LogError(e, "Error in NameplateUpdateHookService UpdateNameplateDetour");
}
return _nameplateHook!.Original(raptureAtkModule, namePlateInfo, numArray, stringArray, battleChara, numArrayIndex, stringArrayIndex);
}
public void Dispose()
{
_nameplateHook?.Dispose();
}
}

View File

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

View File

@@ -101,9 +101,9 @@ public class ServerConfigurationManager
} }
hasMulti = false; hasMulti = false;
var charaName = _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult(); var charaName = _dalamudUtil.GetPlayerName();
var worldId = _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult(); var worldId = _dalamudUtil.GetHomeWorldId();
var cid = _dalamudUtil.GetCIDAsync().GetAwaiter().GetResult(); var cid = _dalamudUtil.GetCID();
var auth = currentServer.Authentications.FindAll(f => string.Equals(f.CharacterName, charaName) && f.WorldId == worldId); var auth = currentServer.Authentications.FindAll(f => string.Equals(f.CharacterName, charaName) && f.WorldId == worldId);
if (auth.Count >= 2) if (auth.Count >= 2)
@@ -148,9 +148,9 @@ public class ServerConfigurationManager
} }
hasMulti = false; hasMulti = false;
var charaName = _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult(); var charaName = _dalamudUtil.GetPlayerName();
var worldId = _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult(); var worldId = _dalamudUtil.GetHomeWorldId();
var cid = _dalamudUtil.GetCIDAsync().GetAwaiter().GetResult(); var cid = _dalamudUtil.GetCID();
if (!currentServer.Authentications.Any() && currentServer.SecretKeys.Any()) if (!currentServer.Authentications.Any() && currentServer.SecretKeys.Any())
{ {
currentServer.Authentications.Add(new Authentication() currentServer.Authentications.Add(new Authentication()
@@ -268,16 +268,16 @@ public class ServerConfigurationManager
{ {
if (serverSelectionIndex == -1) serverSelectionIndex = CurrentServerIndex; if (serverSelectionIndex == -1) serverSelectionIndex = CurrentServerIndex;
var server = GetServerByIndex(serverSelectionIndex); var server = GetServerByIndex(serverSelectionIndex);
if (server.Authentications.Exists(c => string.Equals(c.CharacterName, _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult(), StringComparison.Ordinal) if (server.Authentications.Exists(c => string.Equals(c.CharacterName, _dalamudUtil.GetPlayerName(), StringComparison.Ordinal)
&& c.WorldId == _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult())) && c.WorldId == _dalamudUtil.GetHomeWorldId()))
return; return;
server.Authentications.Add(new Authentication() server.Authentications.Add(new Authentication()
{ {
CharacterName = _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult(), CharacterName = _dalamudUtil.GetPlayerName(),
WorldId = _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult(), WorldId = _dalamudUtil.GetHomeWorldId(),
SecretKeyIdx = !server.UseOAuth2 ? server.SecretKeys.Last().Key : -1, SecretKeyIdx = !server.UseOAuth2 ? server.SecretKeys.Last().Key : -1,
LastSeenCID = _dalamudUtil.GetCIDAsync().GetAwaiter().GetResult() LastSeenCID = _dalamudUtil.GetCID()
}); });
Save(); Save();
} }

View File

@@ -394,6 +394,21 @@ public sealed class TextureMetadataHelper
if (string.IsNullOrEmpty(fileNameWithExtension) && string.IsNullOrEmpty(fileNameWithoutExtension)) if (string.IsNullOrEmpty(fileNameWithExtension) && string.IsNullOrEmpty(fileNameWithoutExtension))
return TextureMapKind.Unknown; return TextureMapKind.Unknown;
if (normalized.Contains("/eye/eyelids_shadow.tex", StringComparison.Ordinal))
return TextureMapKind.Normal;
if (normalized.Contains("/ui/map/", StringComparison.Ordinal) && !string.IsNullOrEmpty(fileNameWithoutExtension))
{
if (fileNameWithoutExtension.EndsWith("m_m", StringComparison.Ordinal)
|| fileNameWithoutExtension.EndsWith("m_s", StringComparison.Ordinal))
return TextureMapKind.Mask;
if (fileNameWithoutExtension.EndsWith("_m", StringComparison.Ordinal)
|| fileNameWithoutExtension.EndsWith("_s", StringComparison.Ordinal)
|| fileNameWithoutExtension.EndsWith("d", StringComparison.Ordinal))
return TextureMapKind.Diffuse;
}
foreach (var (kind, token) in MapTokens) foreach (var (kind, token) in MapTokens)
{ {
if (!string.IsNullOrEmpty(fileNameWithExtension) && if (!string.IsNullOrEmpty(fileNameWithExtension) &&
@@ -563,7 +578,16 @@ public sealed class TextureMetadataHelper
var normalized = format.ToUpperInvariant(); var normalized = format.ToUpperInvariant();
return normalized.Contains("A8", StringComparison.Ordinal) return normalized.Contains("A8", StringComparison.Ordinal)
|| normalized.Contains("A1", StringComparison.Ordinal)
|| normalized.Contains("A4", StringComparison.Ordinal)
|| normalized.Contains("A16", StringComparison.Ordinal)
|| normalized.Contains("A32", StringComparison.Ordinal)
|| normalized.Contains("ARGB", StringComparison.Ordinal) || normalized.Contains("ARGB", StringComparison.Ordinal)
|| normalized.Contains("RGBA", StringComparison.Ordinal)
|| normalized.Contains("BGRA", StringComparison.Ordinal)
|| normalized.Contains("DXT3", StringComparison.Ordinal)
|| normalized.Contains("DXT5", StringComparison.Ordinal)
|| normalized.Contains("BC2", StringComparison.Ordinal)
|| normalized.Contains("BC3", StringComparison.Ordinal) || normalized.Contains("BC3", StringComparison.Ordinal)
|| normalized.Contains("BC7", StringComparison.Ordinal); || normalized.Contains("BC7", StringComparison.Ordinal);
} }

View File

@@ -133,6 +133,26 @@ public class DrawUserPair
UiSharedService.AttachToolTip("This reapplies the last received character data to this character"); UiSharedService.AttachToolTip("This reapplies the last received character data to this character");
} }
var isPaused = _pair.UserPair!.OwnPermissions.IsPaused();
if (!isPaused)
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Pause, "Toggle Pause State", _menuWidth, true))
{
_ = _apiController.PauseAsync(_pair.UserData);
ImGui.CloseCurrentPopup();
}
UiSharedService.AttachToolTip("Pauses syncing with this user.");
}
else
{
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Play, "Toggle Unpause State", _menuWidth, true))
{
_ = _apiController.UnpauseAsync(_pair.UserData);
ImGui.CloseCurrentPopup();
}
UiSharedService.AttachToolTip("Resumes syncing with this user.");
}
if (_uiSharedService.IconTextButton(FontAwesomeIcon.PlayCircle, "Cycle pause state", _menuWidth, true)) if (_uiSharedService.IconTextButton(FontAwesomeIcon.PlayCircle, "Cycle pause state", _menuWidth, true))
{ {
_ = _apiController.CyclePauseAsync(_pair); _ = _apiController.CyclePauseAsync(_pair);
@@ -737,14 +757,19 @@ public class DrawUserPair
} }
UiSharedService.AttachToolTip("Hold CTRL and click to remove user " + (_pair.UserData.AliasOrUID) + " from Syncshell"); UiSharedService.AttachToolTip("Hold CTRL and click to remove user " + (_pair.UserData.AliasOrUID) + " from Syncshell");
if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserSlash, "Ban User", _menuWidth, true)) var banEnabled = UiSharedService.CtrlPressed();
var banLabel = banEnabled ? "Ban user" : "Ban user (Hold CTRL)";
if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserSlash, banLabel, _menuWidth, true) && banEnabled)
{ {
_mediator.Publish(new OpenBanUserPopupMessage(_pair, group)); _mediator.Publish(new OpenBanUserPopupMessage(_pair, group));
ImGui.CloseCurrentPopup(); ImGui.CloseCurrentPopup();
} }
UiSharedService.AttachToolTip("Ban user from this Syncshell"); UiSharedService.AttachToolTip("Hold CTRL to ban user " + (_pair.UserData.AliasOrUID) + " from this Syncshell");
ImGui.Separator(); if (showOwnerActions)
{
ImGui.Separator();
}
} }
if (showOwnerActions) if (showOwnerActions)

View File

@@ -14,6 +14,7 @@ using LightlessSync.Services.TextureCompression;
using LightlessSync.Utils; using LightlessSync.Utils;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using OtterTex; using OtterTex;
using System.Buffers.Binary;
using System.Globalization; using System.Globalization;
using System.Numerics; using System.Numerics;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
@@ -49,6 +50,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
private readonly Dictionary<string, TextureCompressionTarget> _textureSelections = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, TextureCompressionTarget> _textureSelections = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _selectedTextureKeys = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _selectedTextureKeys = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, TexturePreviewState> _texturePreviews = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, TexturePreviewState> _texturePreviews = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, TextureResolutionInfo?> _textureResolutionCache = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<ObjectKind, TextureWorkspaceTab> _textureWorkspaceTabs = new(); private readonly Dictionary<ObjectKind, TextureWorkspaceTab> _textureWorkspaceTabs = new();
private readonly List<string> _storedPathsToRemove = []; private readonly List<string> _storedPathsToRemove = [];
private readonly Dictionary<string, string> _filePathResolve = []; private readonly Dictionary<string, string> _filePathResolve = [];
@@ -88,6 +90,9 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
private bool _showAlreadyAddedTransients = false; private bool _showAlreadyAddedTransients = false;
private bool _acknowledgeReview = false; private bool _acknowledgeReview = false;
private Task<TextureRowBuildResult>? _textureRowsBuildTask;
private CancellationTokenSource? _textureRowsBuildCts;
private ObjectKind _selectedObjectTab; private ObjectKind _selectedObjectTab;
private TextureUsageCategory? _textureCategoryFilter = null; private TextureUsageCategory? _textureCategoryFilter = null;
@@ -204,9 +209,9 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
return; return;
} }
_cachedAnalysis = _characterAnalyzer.LastAnalysis.DeepClone(); _cachedAnalysis = CloneAnalysis(_characterAnalyzer.LastAnalysis);
_hasUpdate = false; _hasUpdate = false;
_textureRowsDirty = true; InvalidateTextureRows();
} }
private void DrawContentTabs() private void DrawContentTabs()
@@ -750,7 +755,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
_selectedTextureKeys.Clear(); _selectedTextureKeys.Clear();
_textureSelections.Clear(); _textureSelections.Clear();
ResetTextureFilters(); ResetTextureFilters();
_textureRowsDirty = true; InvalidateTextureRows();
_conversionFailed = false; _conversionFailed = false;
} }
@@ -762,6 +767,8 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
preview.Texture?.Dispose(); preview.Texture?.Dispose();
} }
_texturePreviews.Clear(); _texturePreviews.Clear();
_textureRowsBuildCts?.Cancel();
_textureRowsBuildCts?.Dispose();
_conversionProgress.ProgressChanged -= ConversionProgress_ProgressChanged; _conversionProgress.ProgressChanged -= ConversionProgress_ProgressChanged;
} }
@@ -775,18 +782,108 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
private void EnsureTextureRows() private void EnsureTextureRows()
{ {
if (!_textureRowsDirty || _cachedAnalysis == null) if (_cachedAnalysis == null)
{ {
return; return;
} }
if (_textureRowsDirty && _textureRowsBuildTask == null)
{
_textureRowsBuildCts?.Dispose();
_textureRowsBuildCts = new();
var snapshot = _cachedAnalysis;
_textureRowsBuildTask = Task.Run(() => BuildTextureRows(snapshot, _textureRowsBuildCts.Token), _textureRowsBuildCts.Token);
}
if (_textureRowsBuildTask == null || !_textureRowsBuildTask.IsCompleted)
{
return;
}
var completedTask = _textureRowsBuildTask;
_textureRowsBuildTask = null;
_textureRowsBuildCts?.Dispose();
_textureRowsBuildCts = null;
if (completedTask.IsCanceled)
{
return;
}
if (completedTask.IsFaulted)
{
_logger.LogWarning(completedTask.Exception, "Failed to build texture rows.");
_textureRowsDirty = false;
return;
}
ApplyTextureRowBuild(completedTask.Result);
_textureRowsDirty = false;
}
private void ApplyTextureRowBuild(TextureRowBuildResult result)
{
_textureRows.Clear(); _textureRows.Clear();
_textureRows.AddRange(result.Rows);
foreach (var row in _textureRows)
{
if (row.IsAlreadyCompressed)
{
_selectedTextureKeys.Remove(row.Key);
_textureSelections.Remove(row.Key);
}
}
_selectedTextureKeys.RemoveWhere(key => !result.ValidKeys.Contains(key));
foreach (var key in _texturePreviews.Keys.ToArray())
{
if (!result.ValidKeys.Contains(key) && _texturePreviews.TryGetValue(key, out var preview))
{
preview.Texture?.Dispose();
_texturePreviews.Remove(key);
}
}
foreach (var key in _textureResolutionCache.Keys.ToArray())
{
if (!result.ValidKeys.Contains(key))
{
_textureResolutionCache.Remove(key);
}
}
foreach (var key in _textureSelections.Keys.ToArray())
{
if (!result.ValidKeys.Contains(key))
{
_textureSelections.Remove(key);
continue;
}
_textureSelections[key] = _textureCompressionService.NormalizeTarget(_textureSelections[key]);
}
if (!string.IsNullOrEmpty(_selectedTextureKey) && !result.ValidKeys.Contains(_selectedTextureKey))
{
_selectedTextureKey = string.Empty;
}
}
private TextureRowBuildResult BuildTextureRows(
Dictionary<ObjectKind, Dictionary<string, CharacterAnalyzer.FileDataEntry>> analysis,
CancellationToken token)
{
var rows = new List<TextureRow>();
HashSet<string> validKeys = new(StringComparer.OrdinalIgnoreCase); HashSet<string> validKeys = new(StringComparer.OrdinalIgnoreCase);
foreach (var (objectKind, entries) in _cachedAnalysis) foreach (var (objectKind, entries) in analysis)
{ {
foreach (var entry in entries.Values) foreach (var entry in entries.Values)
{ {
token.ThrowIfCancellationRequested();
if (!string.Equals(entry.FileType, "tex", StringComparison.Ordinal)) if (!string.Equals(entry.FileType, "tex", StringComparison.Ordinal))
{ {
continue; continue;
@@ -828,17 +925,11 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
suggestion?.Reason); suggestion?.Reason);
validKeys.Add(row.Key); validKeys.Add(row.Key);
_textureRows.Add(row); rows.Add(row);
if (row.IsAlreadyCompressed)
{
_selectedTextureKeys.Remove(row.Key);
_textureSelections.Remove(row.Key);
}
} }
} }
_textureRows.Sort((a, b) => rows.Sort((a, b) =>
{ {
var comp = a.ObjectKind.CompareTo(b.ObjectKind); var comp = a.ObjectKind.CompareTo(b.ObjectKind);
if (comp != 0) if (comp != 0)
@@ -851,34 +942,14 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
return string.Compare(a.DisplayName, b.DisplayName, StringComparison.OrdinalIgnoreCase); return string.Compare(a.DisplayName, b.DisplayName, StringComparison.OrdinalIgnoreCase);
}); });
_selectedTextureKeys.RemoveWhere(key => !validKeys.Contains(key)); return new TextureRowBuildResult(rows, validKeys);
}
foreach (var key in _texturePreviews.Keys.ToArray()) private void InvalidateTextureRows()
{ {
if (!validKeys.Contains(key) && _texturePreviews.TryGetValue(key, out var preview)) _textureRowsDirty = true;
{ _textureRowsBuildCts?.Cancel();
preview.Texture?.Dispose(); _textureResolutionCache.Clear();
_texturePreviews.Remove(key);
}
}
foreach (var key in _textureSelections.Keys.ToArray())
{
if (!validKeys.Contains(key))
{
_textureSelections.Remove(key);
continue;
}
_textureSelections[key] = _textureCompressionService.NormalizeTarget(_textureSelections[key]);
}
if (!string.IsNullOrEmpty(_selectedTextureKey) && !validKeys.Contains(_selectedTextureKey))
{
_selectedTextureKey = string.Empty;
}
_textureRowsDirty = false;
} }
private static string MakeTextureKey(ObjectKind objectKind, string primaryFilePath) => private static string MakeTextureKey(ObjectKind objectKind, string primaryFilePath) =>
@@ -893,6 +964,35 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
_textureSearch = string.Empty; _textureSearch = string.Empty;
} }
private static Dictionary<ObjectKind, Dictionary<string, CharacterAnalyzer.FileDataEntry>> CloneAnalysis(
Dictionary<ObjectKind, Dictionary<string, CharacterAnalyzer.FileDataEntry>> source)
{
var clone = new Dictionary<ObjectKind, Dictionary<string, CharacterAnalyzer.FileDataEntry>>(source.Count);
foreach (var (objectKind, entries) in source)
{
var entryClone = new Dictionary<string, CharacterAnalyzer.FileDataEntry>(entries.Count, entries.Comparer);
foreach (var (hash, entry) in entries)
{
entryClone[hash] = new CharacterAnalyzer.FileDataEntry(
hash: hash,
fileType: entry.FileType,
gamePaths: entry.GamePaths?.ToList() ?? [],
filePaths: entry.FilePaths?.ToList() ?? [],
originalSize: entry.OriginalSize,
compressedSize: entry.CompressedSize,
triangles: entry.Triangles,
cacheEntries: entry.CacheEntries
);
}
clone[objectKind] = entryClone;
}
return clone;
}
private void DrawAnalysisOverview(int totalFiles, long totalActualSize, long totalCompressedSize, long totalTriangles, string breakdownTooltip) private void DrawAnalysisOverview(int totalFiles, long totalActualSize, long totalCompressedSize, long totalTriangles, string breakdownTooltip)
{ {
var scale = ImGuiHelpers.GlobalScale; var scale = ImGuiHelpers.GlobalScale;
@@ -1091,6 +1191,10 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
public bool IsAlreadyCompressed => CurrentTarget.HasValue; public bool IsAlreadyCompressed => CurrentTarget.HasValue;
} }
private sealed record TextureRowBuildResult(
List<TextureRow> Rows,
HashSet<string> ValidKeys);
private sealed class TexturePreviewState private sealed class TexturePreviewState
{ {
public Task? LoadTask { get; set; } public Task? LoadTask { get; set; }
@@ -1099,6 +1203,22 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
public DateTime LastAccessUtc { get; set; } = DateTime.UtcNow; public DateTime LastAccessUtc { get; set; } = DateTime.UtcNow;
} }
private readonly struct TextureResolutionInfo
{
public TextureResolutionInfo(ushort width, ushort height, ushort depth, ushort mipLevels)
{
Width = width;
Height = height;
Depth = depth;
MipLevels = mipLevels;
}
public ushort Width { get; }
public ushort Height { get; }
public ushort Depth { get; }
public ushort MipLevels { get; }
}
private void DrawTextureWorkspace(ObjectKind objectKind, IReadOnlyList<IGrouping<string, CharacterAnalyzer.FileDataEntry>> otherFileGroups) private void DrawTextureWorkspace(ObjectKind objectKind, IReadOnlyList<IGrouping<string, CharacterAnalyzer.FileDataEntry>> otherFileGroups)
{ {
if (!_textureWorkspaceTabs.ContainsKey(objectKind)) if (!_textureWorkspaceTabs.ContainsKey(objectKind))
@@ -1143,6 +1263,11 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
private void DrawTextureTabContent(ObjectKind objectKind) private void DrawTextureTabContent(ObjectKind objectKind)
{ {
var scale = ImGuiHelpers.GlobalScale; var scale = ImGuiHelpers.GlobalScale;
if (_textureRowsBuildTask != null && !_textureRowsBuildTask.IsCompleted && _textureRows.Count == 0)
{
UiSharedService.ColorText("Building texture list.", ImGuiColors.DalamudGrey);
return;
}
var objectRows = _textureRows.Where(row => row.ObjectKind == objectKind).ToList(); var objectRows = _textureRows.Where(row => row.ObjectKind == objectKind).ToList();
var hasAnyTextureRows = objectRows.Count > 0; var hasAnyTextureRows = objectRows.Count > 0;
var availableCategories = objectRows.Select(row => row.Category) var availableCategories = objectRows.Select(row => row.Category)
@@ -1404,6 +1529,24 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
{ {
ResetTextureFilters(); ResetTextureFilters();
} }
ImGuiHelpers.ScaledDummy(6);
ImGui.Separator();
ImGuiHelpers.ScaledDummy(4);
UiSharedService.ColorText("Texture row colors", UIColors.Get("LightlessPurple"));
DrawTextureRowLegendItem("Selected", UIColors.Get("LightlessYellow"), "This row is selected in the texture table.");
DrawTextureRowLegendItem("Already compressed", UIColors.Get("LightlessGreenDefault"), "Texture is already stored in a compressed format.");
DrawTextureRowLegendItem("Missing analysis data", UIColors.Get("DimRed"), "File size data has not been computed yet.");
}
private static void DrawTextureRowLegendItem(string label, Vector4 color, string description)
{
var scale = ImGuiHelpers.GlobalScale;
var swatchSize = new Vector2(12f * scale, 12f * scale);
ImGui.ColorButton($"##textureRowLegend{label}", color, ImGuiColorEditFlags.NoTooltip | ImGuiColorEditFlags.NoDragDrop, swatchSize);
ImGui.SameLine(0f, 6f * scale);
var wrapPos = ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X;
UiSharedService.TextWrapped($"{label}: {description}", wrapPos);
} }
private static void DrawEnumFilterCombo<T>( private static void DrawEnumFilterCombo<T>(
@@ -1810,7 +1953,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
ImGui.SameLine(); ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Sync, "Refresh", 130f * scale)) if (_uiSharedService.IconTextButton(FontAwesomeIcon.Sync, "Refresh", 130f * scale))
{ {
_textureRowsDirty = true; InvalidateTextureRows();
} }
TextureRow? lastSelected = null; TextureRow? lastSelected = null;
@@ -1976,7 +2119,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
{ {
_selectedTextureKeys.Clear(); _selectedTextureKeys.Clear();
_textureSelections.Clear(); _textureSelections.Clear();
_textureRowsDirty = true; InvalidateTextureRows();
} }
} }
@@ -2197,6 +2340,68 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
} }
} }
private TextureResolutionInfo? GetTextureResolution(TextureRow row)
{
if (_textureResolutionCache.TryGetValue(row.Key, out var cached))
{
return cached;
}
var info = TryReadTextureResolution(row.PrimaryFilePath, out var resolved)
? resolved
: (TextureResolutionInfo?)null;
_textureResolutionCache[row.Key] = info;
return info;
}
private static bool TryReadTextureResolution(string path, out TextureResolutionInfo info)
{
info = default;
try
{
Span<byte> buffer = stackalloc byte[16];
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
var read = stream.Read(buffer);
if (read < buffer.Length)
{
return false;
}
var width = BinaryPrimitives.ReadUInt16LittleEndian(buffer[8..10]);
var height = BinaryPrimitives.ReadUInt16LittleEndian(buffer[10..12]);
var depth = BinaryPrimitives.ReadUInt16LittleEndian(buffer[12..14]);
var mipLevels = BinaryPrimitives.ReadUInt16LittleEndian(buffer[14..16]);
if (width == 0 || height == 0)
{
return false;
}
if (depth == 0)
{
depth = 1;
}
if (mipLevels == 0)
{
mipLevels = 1;
}
info = new TextureResolutionInfo(width, height, depth, mipLevels);
return true;
}
catch
{
return false;
}
}
private static string FormatTextureResolution(TextureResolutionInfo info)
=> info.Depth > 1
? $"{info.Width} x {info.Height} x {info.Depth}"
: $"{info.Width} x {info.Height}";
private void DrawTextureRow(TextureRow row, IReadOnlyList<TextureCompressionTarget> targets, int index) private void DrawTextureRow(TextureRow row, IReadOnlyList<TextureCompressionTarget> targets, int index)
{ {
var key = row.Key; var key = row.Key;
@@ -2465,6 +2670,9 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase
MetaRow(FontAwesomeIcon.LayerGroup, "Map Type", row.MapKind.ToString()); MetaRow(FontAwesomeIcon.LayerGroup, "Map Type", row.MapKind.ToString());
MetaRow(FontAwesomeIcon.Fingerprint, "Hash", row.Hash, UIColors.Get("LightlessBlue")); MetaRow(FontAwesomeIcon.Fingerprint, "Hash", row.Hash, UIColors.Get("LightlessBlue"));
MetaRow(FontAwesomeIcon.InfoCircle, "Current Format", row.Format); MetaRow(FontAwesomeIcon.InfoCircle, "Current Format", row.Format);
var resolution = GetTextureResolution(row);
var resolutionLabel = resolution.HasValue ? FormatTextureResolution(resolution.Value) : "Unknown";
MetaRow(FontAwesomeIcon.Images, "Resolution", resolutionLabel);
var selectedLabel = hasSelectedInfo ? selectedInfo!.Title : selectedTarget.ToString(); var selectedLabel = hasSelectedInfo ? selectedInfo!.Title : selectedTarget.ToString();
var selectionColor = hasSelectedInfo ? UIColors.Get("LightlessYellow") : UIColors.Get("LightlessGreen"); var selectionColor = hasSelectedInfo ? UIColors.Get("LightlessYellow") : UIColors.Get("LightlessGreen");

View File

@@ -164,9 +164,25 @@ public class DownloadUi : WindowMediatorSubscriberBase
const float rounding = 6f; const float rounding = 6f;
var shadowOffset = new Vector2(2, 2); var shadowOffset = new Vector2(2, 2);
foreach (var transfer in _currentDownloads.ToList()) List<KeyValuePair<GameObjectHandler, Dictionary<string, FileDownloadStatus>>> transfers;
try
{
transfers = _currentDownloads.ToList();
}
catch (ArgumentException)
{
return;
}
foreach (var transfer in transfers)
{ {
var transferKey = transfer.Key; var transferKey = transfer.Key;
// Skip if no valid game object
if (transferKey.GetGameObject() == null)
continue;
var rawPos = _dalamudUtilService.WorldToScreen(transferKey.GetGameObject()); var rawPos = _dalamudUtilService.WorldToScreen(transferKey.GetGameObject());
// If RawPos is zero, remove it from smoothed dictionary // If RawPos is zero, remove it from smoothed dictionary

View File

@@ -46,10 +46,12 @@ public sealed class DtrEntry : IDisposable, IHostedService
private string? _lightfinderText; private string? _lightfinderText;
private string? _lightfinderTooltip; private string? _lightfinderTooltip;
private Colors _lightfinderColors; private Colors _lightfinderColors;
private readonly object _localHashedCidLock = new();
private string? _localHashedCid; private string? _localHashedCid;
private DateTime _localHashedCidFetchedAt = DateTime.MinValue; private DateTime _localHashedCidFetchedAt = DateTime.MinValue;
private DateTime _localHashedCidNextErrorLog = DateTime.MinValue; private DateTime _localHashedCidNextErrorLog = DateTime.MinValue;
private DateTime _pairRequestNextErrorLog = DateTime.MinValue; private DateTime _pairRequestNextErrorLog = DateTime.MinValue;
private int _localHashedCidRefreshActive;
public DtrEntry( public DtrEntry(
ILogger<DtrEntry> logger, ILogger<DtrEntry> logger,
@@ -339,29 +341,61 @@ public sealed class DtrEntry : IDisposable, IHostedService
private string? GetLocalHashedCid() private string? GetLocalHashedCid()
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
if (_localHashedCid is not null && now - _localHashedCidFetchedAt < _localHashedCidCacheDuration) lock (_localHashedCidLock)
return _localHashedCid;
try
{ {
var cid = _dalamudUtilService.GetCIDAsync().GetAwaiter().GetResult(); if (_localHashedCid is not null && now - _localHashedCidFetchedAt < _localHashedCidCacheDuration)
var hashedCid = cid.ToString().GetHash256();
_localHashedCid = hashedCid;
_localHashedCidFetchedAt = now;
return hashedCid;
}
catch (Exception ex)
{
if (now >= _localHashedCidNextErrorLog)
{ {
_logger.LogDebug(ex, "Failed to refresh local hashed CID for Lightfinder DTR entry."); return _localHashedCid;
_localHashedCidNextErrorLog = now + _localHashedCidErrorCooldown;
} }
_localHashedCid = null;
_localHashedCidFetchedAt = now;
return null;
} }
QueueLocalHashedCidRefresh();
lock (_localHashedCidLock)
{
return _localHashedCid;
}
}
private void QueueLocalHashedCidRefresh()
{
if (Interlocked.Exchange(ref _localHashedCidRefreshActive, 1) != 0)
{
return;
}
_ = Task.Run(async () =>
{
try
{
var cid = _dalamudUtilService.GetCID();
var hashedCid = cid.ToString().GetHash256();
lock (_localHashedCidLock)
{
_localHashedCid = hashedCid;
_localHashedCidFetchedAt = DateTime.UtcNow;
}
}
catch (Exception ex)
{
var now = DateTime.UtcNow;
lock (_localHashedCidLock)
{
if (now >= _localHashedCidNextErrorLog)
{
_logger.LogDebug(ex, "Failed to refresh local hashed CID for Lightfinder DTR entry.");
_localHashedCidNextErrorLog = now + _localHashedCidErrorCooldown;
}
_localHashedCid = null;
_localHashedCidFetchedAt = now;
}
}
finally
{
Interlocked.Exchange(ref _localHashedCidRefreshActive, 0);
}
});
} }
private List<string> GetNearbyBroadcasts() private List<string> GetNearbyBroadcasts()

View File

@@ -23,6 +23,7 @@ namespace LightlessSync.UI
private readonly LightFinderService _broadcastService; private readonly LightFinderService _broadcastService;
private readonly UiSharedService _uiSharedService; private readonly UiSharedService _uiSharedService;
private readonly LightFinderScannerService _broadcastScannerService; private readonly LightFinderScannerService _broadcastScannerService;
private readonly LightFinderPlateHandler _lightFinderPlateHandler;
private IReadOnlyList<GroupFullInfoDto> _allSyncshells = Array.Empty<GroupFullInfoDto>(); private IReadOnlyList<GroupFullInfoDto> _allSyncshells = Array.Empty<GroupFullInfoDto>();
private string _userUid = string.Empty; private string _userUid = string.Empty;
@@ -38,7 +39,8 @@ namespace LightlessSync.UI
UiSharedService uiShared, UiSharedService uiShared,
ApiController apiController, ApiController apiController,
LightFinderScannerService broadcastScannerService LightFinderScannerService broadcastScannerService
) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService) ,
LightFinderPlateHandler lightFinderPlateHandler) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService)
{ {
_broadcastService = broadcastService; _broadcastService = broadcastService;
_uiSharedService = uiShared; _uiSharedService = uiShared;
@@ -50,6 +52,7 @@ namespace LightlessSync.UI
WindowBuilder.For(this) WindowBuilder.For(this)
.SetSizeConstraints(new Vector2(600, 465), new Vector2(750, 525)) .SetSizeConstraints(new Vector2(600, 465), new Vector2(750, 525))
.Apply(); .Apply();
_lightFinderPlateHandler = lightFinderPlateHandler;
} }
private void RebuildSyncshellDropdownOptions() private void RebuildSyncshellDropdownOptions()
@@ -380,9 +383,47 @@ namespace LightlessSync.UI
#if DEBUG #if DEBUG
if (ImGui.BeginTabItem("Debug")) if (ImGui.BeginTabItem("Debug"))
{ {
if (ImGui.CollapsingHeader("LightFinder Plates", ImGuiTreeNodeFlags.DefaultOpen))
{
var h = _lightFinderPlateHandler;
var enabled = h.DebugEnabled;
if (ImGui.Checkbox("Enable LightFinder debug", ref enabled))
h.DebugEnabled = enabled;
if (h.DebugEnabled)
{
ImGui.Indent();
var disableOcc = h.DebugDisableOcclusion;
if (ImGui.Checkbox("Disable occlusion (force draw)", ref disableOcc))
h.DebugDisableOcclusion = disableOcc;
var drawUiRects = h.DebugDrawUiRects;
if (ImGui.Checkbox("Draw UI rects", ref drawUiRects))
h.DebugDrawUiRects = drawUiRects;
var drawLabelRects = h.DebugDrawLabelRects;
if (ImGui.Checkbox("Draw label rects", ref drawLabelRects))
h.DebugDrawLabelRects = drawLabelRects;
ImGui.Separator();
ImGui.TextUnformatted($"Labels last frame: {h.DebugLabelCountLastFrame}");
ImGui.TextUnformatted($"UI rects last frame: {h.DebugUiRectCountLastFrame}");
ImGui.TextUnformatted($"Occluded last frame: {h.DebugOccludedCountLastFrame}");
ImGui.TextUnformatted($"Last NamePlate frame: {h.DebugLastNameplateFrame}");
ImGui.Unindent();
}
}
ImGui.Separator();
ImGui.Text("Broadcast Cache"); ImGui.Text("Broadcast Cache");
if (ImGui.BeginTable("##BroadcastCacheTable", 4, ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY, new Vector2(-1, 225f))) if (ImGui.BeginTable("##BroadcastCacheTable", 4,
ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.ScrollY,
new Vector2(-1, 225f)))
{ {
ImGui.TableSetupColumn("CID", ImGuiTableColumnFlags.WidthStretch); ImGui.TableSetupColumn("CID", ImGuiTableColumnFlags.WidthStretch);
ImGui.TableSetupColumn("IsBroadcasting", ImGuiTableColumnFlags.WidthStretch); ImGui.TableSetupColumn("IsBroadcasting", ImGuiTableColumnFlags.WidthStretch);

File diff suppressed because it is too large Load Diff

View File

@@ -222,7 +222,9 @@ public class AnimatedHeader
if (ImGui.IsItemHovered() && !string.IsNullOrEmpty(button.Tooltip)) if (ImGui.IsItemHovered() && !string.IsNullOrEmpty(button.Tooltip))
{ {
ImGui.PushFont(UiBuilder.DefaultFont);
ImGui.SetTooltip(button.Tooltip); ImGui.SetTooltip(button.Tooltip);
ImGui.PopFont();
} }
currentX -= buttonSize.X + spacing; currentX -= buttonSize.X + spacing;

View File

@@ -297,6 +297,25 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
var ownerTab = ImRaii.TabItem("Owner Settings"); var ownerTab = ImRaii.TabItem("Owner Settings");
if (ownerTab) if (ownerTab)
{ {
bool isChatDisabled = perm.IsDisableChat();
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Syncshell Chat");
_uiSharedService.BooleanToColoredIcon(!isChatDisabled);
ImGui.SameLine(230);
using (ImRaii.PushColor(ImGuiCol.Text, isChatDisabled ? UIColors.Get("PairBlue") : UIColors.Get("DimRed")))
{
if (_uiSharedService.IconTextButton(
isChatDisabled ? FontAwesomeIcon.Comment : FontAwesomeIcon.Ban,
isChatDisabled ? "Enable syncshell chat" : "Disable syncshell chat"))
{
perm.SetDisableChat(!isChatDisabled);
_ = _apiController.GroupChangeGroupPermissionState(new(GroupFullInfo.Group, perm));
}
}
UiSharedService.AttachToolTip("Disables syncshell chat for all members.");
ImGuiHelpers.ScaledDummy(6f);
ImGui.AlignTextToFramePadding(); ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("New Password"); ImGui.TextUnformatted("New Password");
var availableWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X; var availableWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X;

View File

@@ -140,19 +140,10 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
return; return;
} }
string? myHashedCid = null; var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts().ToList() ?? [];
try _broadcastScannerService.TryGetLocalHashedCid(out var localHashedCid);
{
var cid = _dalamudUtilService.GetCID();
myHashedCid = cid.ToString().GetHash256();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to get CID, not excluding own broadcast.");
}
var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts().Where(b => !string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal)).ToList() ?? [];
var cardData = new List<(GroupJoinDto Shell, string BroadcasterName)>(); var cardData = new List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)>();
foreach (var shell in _nearbySyncshells) foreach (var shell in _nearbySyncshells)
{ {
@@ -185,9 +176,15 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
broadcasterName = !string.IsNullOrEmpty(worldName) broadcasterName = !string.IsNullOrEmpty(worldName)
? $"{name} ({worldName})" ? $"{name} ({worldName})"
: name; : name;
var isSelfBroadcast = !string.IsNullOrEmpty(localHashedCid)
&& string.Equals(broadcast.HashedCID, localHashedCid, StringComparison.Ordinal);
cardData.Add((shell, broadcasterName, isSelfBroadcast));
continue;
} }
cardData.Add((shell, broadcasterName)); cardData.Add((shell, broadcasterName, false));
} }
if (cardData.Count == 0) if (cardData.Count == 0)
@@ -210,7 +207,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
DrawConfirmation(); DrawConfirmation();
} }
private void DrawSyncshellList(List<(GroupJoinDto Shell, string BroadcasterName)> listData) private void DrawSyncshellList(List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)> listData)
{ {
const int shellsPerPage = 3; const int shellsPerPage = 3;
var totalPages = (int)Math.Ceiling(listData.Count / (float)shellsPerPage); var totalPages = (int)Math.Ceiling(listData.Count / (float)shellsPerPage);
@@ -227,7 +224,10 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
for (int index = firstIndex; index < lastExclusive; index++) for (int index = firstIndex; index < lastExclusive; index++)
{ {
var (shell, broadcasterName) = listData[index]; var (shell, broadcasterName, isSelfBroadcast) = listData[index];
var broadcasterLabel = string.IsNullOrEmpty(broadcasterName)
? (isSelfBroadcast ? "You" : string.Empty)
: (isSelfBroadcast ? $"{broadcasterName} (You)" : broadcasterName);
ImGui.PushID(shell.Group.GID); ImGui.PushID(shell.Group.GID);
float rowHeight = 74f * ImGuiHelpers.GlobalScale; float rowHeight = 74f * ImGuiHelpers.GlobalScale;
@@ -239,7 +239,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
var style = ImGui.GetStyle(); var style = ImGui.GetStyle();
float startX = ImGui.GetCursorPosX(); float startX = ImGui.GetCursorPosX();
float regionW = ImGui.GetContentRegionAvail().X; float regionW = ImGui.GetContentRegionAvail().X;
float rightTxtW = ImGui.CalcTextSize(broadcasterName).X; float rightTxtW = ImGui.CalcTextSize(broadcasterLabel).X;
_uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple"));
if (ImGui.IsItemHovered()) if (ImGui.IsItemHovered())
@@ -252,7 +252,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X; float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X;
ImGui.SameLine(); ImGui.SameLine();
ImGui.SetCursorPosX(rightX); ImGui.SetCursorPosX(rightX);
ImGui.TextUnformatted(broadcasterName); ImGui.TextUnformatted(broadcasterLabel);
if (ImGui.IsItemHovered()) if (ImGui.IsItemHovered())
ImGui.SetTooltip("Broadcaster of the syncshell."); ImGui.SetTooltip("Broadcaster of the syncshell.");
@@ -291,7 +291,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
float joinX = rowStartLocal.X + (tagsWidth > 0 ? tagsWidth + style.ItemSpacing.X : 0f); float joinX = rowStartLocal.X + (tagsWidth > 0 ? tagsWidth + style.ItemSpacing.X : 0f);
ImGui.SetCursorPos(new Vector2(joinX, btnBaselineY)); ImGui.SetCursorPos(new Vector2(joinX, btnBaselineY));
DrawJoinButton(shell); DrawJoinButton(shell, isSelfBroadcast);
float btnHeight = ImGui.GetFrameHeightWithSpacing(); float btnHeight = ImGui.GetFrameHeightWithSpacing();
float rowHeightUsed = MathF.Max(tagsHeight, btnHeight); float rowHeightUsed = MathF.Max(tagsHeight, btnHeight);
@@ -311,7 +311,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
DrawPagination(totalPages); DrawPagination(totalPages);
} }
private void DrawSyncshellGrid(List<(GroupJoinDto Shell, string BroadcasterName)> cardData) private void DrawSyncshellGrid(List<(GroupJoinDto Shell, string BroadcasterName, bool IsSelfBroadcast)> cardData)
{ {
const int shellsPerPage = 4; const int shellsPerPage = 4;
var totalPages = (int)Math.Ceiling(cardData.Count / (float)shellsPerPage); var totalPages = (int)Math.Ceiling(cardData.Count / (float)shellsPerPage);
@@ -336,7 +336,10 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
for (int index = firstIndex; index < lastExclusive; index++) for (int index = firstIndex; index < lastExclusive; index++)
{ {
var localIndex = index - firstIndex; var localIndex = index - firstIndex;
var (shell, broadcasterName) = cardData[index]; var (shell, broadcasterName, isSelfBroadcast) = cardData[index];
var broadcasterLabel = string.IsNullOrEmpty(broadcasterName)
? (isSelfBroadcast ? "You" : string.Empty)
: (isSelfBroadcast ? $"{broadcasterName} (You)" : broadcasterName);
if (localIndex % 2 != 0) if (localIndex % 2 != 0)
ImGui.SameLine(); ImGui.SameLine();
@@ -373,17 +376,17 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
float maxBroadcasterWidth = regionRightX - minBroadcasterX; float maxBroadcasterWidth = regionRightX - minBroadcasterX;
string broadcasterToShow = broadcasterName; string broadcasterToShow = broadcasterLabel;
if (!string.IsNullOrEmpty(broadcasterName) && maxBroadcasterWidth > 0f) if (!string.IsNullOrEmpty(broadcasterLabel) && maxBroadcasterWidth > 0f)
{ {
float bcFullWidth = ImGui.CalcTextSize(broadcasterName).X; float bcFullWidth = ImGui.CalcTextSize(broadcasterLabel).X;
string toolTip; string toolTip;
if (bcFullWidth > maxBroadcasterWidth) if (bcFullWidth > maxBroadcasterWidth)
{ {
broadcasterToShow = TruncateTextToWidth(broadcasterName, maxBroadcasterWidth); broadcasterToShow = TruncateTextToWidth(broadcasterLabel, maxBroadcasterWidth);
toolTip = broadcasterName + Environment.NewLine + Environment.NewLine + "Broadcaster of the syncshell."; toolTip = broadcasterLabel + Environment.NewLine + Environment.NewLine + "Broadcaster of the syncshell.";
} }
else else
{ {
@@ -443,7 +446,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
if (remainingY > 0) if (remainingY > 0)
ImGui.Dummy(new Vector2(0, remainingY)); ImGui.Dummy(new Vector2(0, remainingY));
DrawJoinButton(shell); DrawJoinButton(shell, isSelfBroadcast);
ImGui.EndChild(); ImGui.EndChild();
ImGui.EndGroup(); ImGui.EndGroup();
@@ -489,7 +492,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
} }
} }
private void DrawJoinButton(dynamic shell) private void DrawJoinButton(GroupJoinDto shell, bool isSelfBroadcast)
{ {
const string visibleLabel = "Join"; const string visibleLabel = "Join";
var label = $"{visibleLabel}##{shell.Group.GID}"; var label = $"{visibleLabel}##{shell.Group.GID}";
@@ -517,7 +520,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
buttonSize = new Vector2(-1, 0); buttonSize = new Vector2(-1, 0);
} }
if (!isAlreadyMember && !isRecentlyJoined) if (!isAlreadyMember && !isRecentlyJoined && !isSelfBroadcast)
{ {
ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")); ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen"));
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f)); ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f));
@@ -567,7 +570,9 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
ImGui.Button(label, buttonSize); ImGui.Button(label, buttonSize);
} }
UiSharedService.AttachToolTip("Already a member or owner of this Syncshell."); UiSharedService.AttachToolTip(isSelfBroadcast
? "This is your own Syncshell."
: "Already a member or owner of this Syncshell.");
} }
ImGui.PopStyleColor(3); ImGui.PopStyleColor(3);

View File

@@ -440,7 +440,7 @@ public class TopTabMenu
try try
{ {
var myCidHash = (await _dalamudUtilService.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256(); var myCidHash = _dalamudUtilService.GetCID().ToString().GetHash256();
await _apiController.TryPairWithContentId(request.HashedCid).ConfigureAwait(false); await _apiController.TryPairWithContentId(request.HashedCid).ConfigureAwait(false);
_pairRequestService.RemoveRequest(request.HashedCid); _pairRequestService.RemoveRequest(request.HashedCid);
@@ -781,7 +781,8 @@ public class TopTabMenu
{ {
var buttonX = (availableWidth - (spacingX)) / 2f; 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))); _lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
} }
@@ -795,27 +796,28 @@ 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() private string GetSyncshellFinderLabel()
{ {
if (!_lightFinderService.IsBroadcasting) if (!_lightFinderService.IsBroadcasting)
return "Syncshell Finder"; return "Syncshell Finder";
string? myHashedCid = null;
try
{
var cid = _dalamudUtilService.GetCID();
myHashedCid = cid.ToString().GetHash256();
}
catch (Exception)
{
// Couldnt get own CID, log and return default table
}
var nearbyCount = _lightFinderScannerService var nearbyCount = _lightFinderScannerService
.GetActiveSyncshellBroadcasts() .GetActiveSyncshellBroadcasts(excludeLocal: true)
.Where(b => .Where(b => !string.IsNullOrEmpty(b.GID))
!string.IsNullOrEmpty(b.GID) &&
!string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal))
.Select(b => b.GID!) .Select(b => b.GID!)
.Distinct(StringComparer.Ordinal) .Distinct(StringComparer.Ordinal)
.Count(); .Count();

View File

@@ -947,13 +947,16 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
} }
} }
if (_discordOAuthCheck != null && _discordOAuthCheck.IsCompleted) if (_discordOAuthCheck != null && _discordOAuthCheck.IsCompleted && _discordOAuthCheck.Result != null)
{ {
if (IconTextButton(FontAwesomeIcon.ArrowRight, "Authenticate with Server")) if (_discordOAuthGetCode == null)
{ {
_discordOAuthGetCode = _serverConfigurationManager.GetDiscordOAuthToken(_discordOAuthCheck.Result!, selectedServer.ServerUri, _discordOAuthGetCts.Token); if (IconTextButton(FontAwesomeIcon.ArrowRight, "Authenticate with Server"))
{
_discordOAuthGetCode = _serverConfigurationManager.GetDiscordOAuthToken(_discordOAuthCheck.Result, selectedServer.ServerUri, _discordOAuthGetCts.Token);
}
} }
else if (_discordOAuthGetCode != null && !_discordOAuthGetCode.IsCompleted) else if (!_discordOAuthGetCode.IsCompleted)
{ {
TextWrapped("A browser window has been opened, follow it to authenticate. Click the button below if you accidentally closed the window and need to restart the authentication."); TextWrapped("A browser window has been opened, follow it to authenticate. Click the button below if you accidentally closed the window and need to restart the authentication.");
if (IconTextButton(FontAwesomeIcon.Ban, "Cancel Authentication")) if (IconTextButton(FontAwesomeIcon.Ban, "Cancel Authentication"))
@@ -962,7 +965,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase
_discordOAuthGetCode = null; _discordOAuthGetCode = null;
} }
} }
else if (_discordOAuthGetCode != null && _discordOAuthGetCode.IsCompleted) else
{ {
TextWrapped("Discord OAuth is completed, status: "); TextWrapped("Discord OAuth is completed, status: ");
ImGui.SameLine(); ImGui.SameLine();

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,4 @@
using System; namespace LightlessSync.UtilsEnum.Enum
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace LightlessSync.UtilsEnum.Enum
{ {
public enum LabelAlignment public enum LabelAlignment
{ {

View File

@@ -0,0 +1,8 @@
namespace LightlessSync.UtilsEnum.Enum
{
public enum LightfinderLabelRenderer
{
Pictomancy,
SignatureHook,
}
}

View File

@@ -60,16 +60,6 @@ public static class VariousExtensions
CharacterData? oldData, ILogger logger, IPairPerformanceSubject cachedPlayer, bool forceApplyCustomization, bool forceApplyMods) CharacterData? oldData, ILogger logger, IPairPerformanceSubject cachedPlayer, bool forceApplyCustomization, bool forceApplyMods)
{ {
oldData ??= new(); oldData ??= new();
static bool FileReplacementsEquivalent(ICollection<FileReplacementData> left, ICollection<FileReplacementData> right)
{
if (left.Count != right.Count)
{
return false;
}
var comparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer.Instance;
return !left.Except(right, comparer).Any() && !right.Except(left, comparer).Any();
}
var charaDataToUpdate = new Dictionary<ObjectKind, HashSet<PlayerChanges>>(); var charaDataToUpdate = new Dictionary<ObjectKind, HashSet<PlayerChanges>>();
foreach (ObjectKind objectKind in Enum.GetValues<ObjectKind>()) foreach (ObjectKind objectKind in Enum.GetValues<ObjectKind>())
@@ -105,7 +95,7 @@ public static class VariousExtensions
{ {
var oldList = oldData.FileReplacements[objectKind]; var oldList = oldData.FileReplacements[objectKind];
var newList = newData.FileReplacements[objectKind]; var newList = newData.FileReplacements[objectKind];
var listsAreEqual = FileReplacementsEquivalent(oldList, newList); var listsAreEqual = oldList.SequenceEqual(newList, PlayerData.Data.FileReplacementDataComparer.Instance);
if (!listsAreEqual || forceApplyMods) if (!listsAreEqual || forceApplyMods)
{ {
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (FileReplacements not equal) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModFiles); logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (FileReplacements not equal) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModFiles);
@@ -128,9 +118,9 @@ public static class VariousExtensions
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList(); .OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var newTail = newFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/tail/", StringComparison.OrdinalIgnoreCase))) var newTail = newFileReplacements.Where(g => g.GamePaths.Any(p => p.Contains("/tail/", StringComparison.OrdinalIgnoreCase)))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList(); .OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var existingTransients = existingFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl", StringComparison.OrdinalIgnoreCase) && !g.EndsWith("tex", StringComparison.OrdinalIgnoreCase) && !g.EndsWith("mtrl", StringComparison.OrdinalIgnoreCase))) var existingTransients = existingFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl") && !g.EndsWith("tex") && !g.EndsWith("mtrl")))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList(); .OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
var newTransients = newFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl", StringComparison.OrdinalIgnoreCase) && !g.EndsWith("tex", StringComparison.OrdinalIgnoreCase) && !g.EndsWith("mtrl", StringComparison.OrdinalIgnoreCase))) var newTransients = newFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl") && !g.EndsWith("tex") && !g.EndsWith("mtrl")))
.OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList(); .OrderBy(g => string.IsNullOrEmpty(g.Hash) ? g.FileSwapPath : g.Hash, StringComparer.OrdinalIgnoreCase).ToList();
logger.LogTrace("[BASE-{appbase}] ExistingFace: {of}, NewFace: {fc}; ExistingHair: {eh}, NewHair: {nh}; ExistingTail: {et}, NewTail: {nt}; ExistingTransient: {etr}, NewTransient: {ntr}", applicationBase, logger.LogTrace("[BASE-{appbase}] ExistingFace: {of}, NewFace: {fc}; ExistingHair: {eh}, NewHair: {nh}; ExistingTail: {et}, NewTail: {nt}; ExistingTransient: {etr}, NewTransient: {ntr}", applicationBase,
@@ -177,8 +167,7 @@ public static class VariousExtensions
if (objectKind != ObjectKind.Player) continue; if (objectKind != ObjectKind.Player) continue;
bool manipDataDifferent = !string.Equals(oldData.ManipulationData, newData.ManipulationData, StringComparison.Ordinal); bool manipDataDifferent = !string.Equals(oldData.ManipulationData, newData.ManipulationData, StringComparison.Ordinal);
var hasManipulationData = !string.IsNullOrEmpty(newData.ManipulationData); if (manipDataDifferent || forceApplyMods)
if (manipDataDifferent || (forceApplyMods && hasManipulationData))
{ {
logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff manip data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModManip); logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (Diff manip data) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModManip);
charaDataToUpdate[objectKind].Add(PlayerChanges.ModManip); charaDataToUpdate[objectKind].Add(PlayerChanges.ModManip);

File diff suppressed because it is too large Load Diff

View File

@@ -18,56 +18,72 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
private readonly LightlessConfigService _lightlessConfig; private readonly LightlessConfigService _lightlessConfig;
private readonly object _semaphoreModificationLock = new(); private readonly object _semaphoreModificationLock = new();
private readonly TokenProvider _tokenProvider; private readonly TokenProvider _tokenProvider;
private int _availableDownloadSlots; private int _availableDownloadSlots;
private SemaphoreSlim _downloadSemaphore; private SemaphoreSlim _downloadSemaphore;
private int CurrentlyUsedDownloadSlots => _availableDownloadSlots - _downloadSemaphore.CurrentCount; private int CurrentlyUsedDownloadSlots => _availableDownloadSlots - _downloadSemaphore.CurrentCount;
public FileTransferOrchestrator(ILogger<FileTransferOrchestrator> logger, LightlessConfigService lightlessConfig, public FileTransferOrchestrator(
LightlessMediator mediator, TokenProvider tokenProvider, HttpClient httpClient) : base(logger, mediator) ILogger<FileTransferOrchestrator> logger,
LightlessConfigService lightlessConfig,
LightlessMediator mediator,
TokenProvider tokenProvider,
HttpClient httpClient) : base(logger, mediator)
{ {
_lightlessConfig = lightlessConfig; _lightlessConfig = lightlessConfig;
_tokenProvider = tokenProvider; _tokenProvider = tokenProvider;
_httpClient = httpClient; _httpClient = httpClient;
var ver = Assembly.GetExecutingAssembly().GetName().Version; 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; _availableDownloadSlots = Math.Max(1, lightlessConfig.Current.ParallelDownloads);
_downloadSemaphore = new(_availableDownloadSlots, _availableDownloadSlots); _downloadSemaphore = new SemaphoreSlim(_availableDownloadSlots, _availableDownloadSlots);
Mediator.Subscribe<ConnectedMessage>(this, (msg) => Mediator.Subscribe<ConnectedMessage>(this, msg => FilesCdnUri = msg.Connection.ServerInfo.FileServerAddress);
{ Mediator.Subscribe<DisconnectedMessage>(this, _ => FilesCdnUri = null);
FilesCdnUri = msg.Connection.ServerInfo.FileServerAddress; Mediator.Subscribe<DownloadReadyMessage>(this, msg => _downloadReady[msg.RequestId] = true);
});
Mediator.Subscribe<DisconnectedMessage>(this, (msg) =>
{
FilesCdnUri = null;
});
Mediator.Subscribe<DownloadReadyMessage>(this, (msg) =>
{
_downloadReady[msg.RequestId] = true;
});
} }
/// <summary>
/// Files CDN Uri from server
/// </summary>
public Uri? FilesCdnUri { private set; get; } public Uri? FilesCdnUri { private set; get; }
/// <summary>
/// Forbidden file transfers given by server
/// </summary>
public List<FileTransfer> ForbiddenTransfers { get; } = []; public List<FileTransfer> ForbiddenTransfers { get; } = [];
/// <summary>
/// Is the FileTransferOrchestrator initialized
/// </summary>
public bool IsInitialized => FilesCdnUri != null; public bool IsInitialized => FilesCdnUri != null;
public void ClearDownloadRequest(Guid guid) /// <summary>
{ /// Configured parallel downloads in settings (ParallelDownloads)
_downloadReady.Remove(guid, out _); /// </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) public bool IsDownloadReady(Guid guid)
{ => _downloadReady.TryGetValue(guid, out bool isReady) && isReady;
if (_downloadReady.TryGetValue(guid, out bool isReady) && isReady)
{
return true;
}
return false;
}
/// <summary>
/// Release a download slot after download is complete
/// </summary>
public void ReleaseDownloadSlot() public void ReleaseDownloadSlot()
{ {
try try
@@ -81,60 +97,26 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
} }
} }
public async Task<HttpResponseMessage> SendRequestAsync(HttpMethod method, Uri uri, /// <summary>
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead, /// Wait for an available download slot asyncronously
bool withToken = true) /// </summary>
{ /// <param name="token">Cancellation Token</param>
return await SendRequestInternalAsync(() => new HttpRequestMessage(method, uri), /// <returns>Task of the slot</returns>
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);
}
public async Task WaitForDownloadSlotAsync(CancellationToken token) public async Task WaitForDownloadSlotAsync(CancellationToken token)
{ {
lock (_semaphoreModificationLock) 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; _availableDownloadSlots = desired;
_downloadSemaphore = new(_availableDownloadSlots, _availableDownloadSlots);
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()); Mediator.Publish(new DownloadLimitChangedMessage());
} }
/// <summary>
/// Download limit per slot in bytes
/// </summary>
/// <returns>Bytes of the download limit</returns>
public long DownloadLimitPerSlot() public long DownloadLimitPerSlot()
{ {
var limit = _lightlessConfig.Current.DownloadSpeedLimitInBytes; var limit = _lightlessConfig.Current.DownloadSpeedLimitInBytes;
if (limit <= 0) return 0; if (limit <= 0) return 0;
limit = _lightlessConfig.Current.DownloadSpeedType switch limit = _lightlessConfig.Current.DownloadSpeedType switch
{ {
LightlessConfiguration.Models.DownloadSpeeds.Bps => limit, LightlessConfiguration.Models.DownloadSpeeds.Bps => limit,
@@ -153,22 +140,113 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
LightlessConfiguration.Models.DownloadSpeeds.MBps => limit * 1024 * 1024, LightlessConfiguration.Models.DownloadSpeeds.MBps => limit * 1024 * 1024,
_ => limit, _ => limit,
}; };
var currentUsedDlSlots = CurrentlyUsedDownloadSlots;
var avaialble = _availableDownloadSlots; var usedSlots = CurrentlyUsedDownloadSlots;
var currentCount = _downloadSemaphore.CurrentCount; var divided = limit / (usedSlots <= 0 ? 1 : usedSlots);
var dividedLimit = limit / (currentUsedDlSlots == 0 ? 1 : currentUsedDlSlots);
if (dividedLimit < 0) if (divided < 0)
{ {
Logger.LogWarning("Calculated Bandwidth Limit is negative, returning Infinity: {value}, CurrentlyUsedDownloadSlots is {currentSlots}, " + Logger.LogWarning(
"DownloadSpeedLimit is {limit}, available slots: {avail}, current count: {count}", dividedLimit, currentUsedDlSlots, limit, avaialble, currentCount); "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 long.MaxValue;
} }
return Math.Clamp(dividedLimit, 1, long.MaxValue);
return Math.Clamp(divided, 1, long.MaxValue);
} }
private async Task<HttpResponseMessage> SendRequestInternalAsync(Func<HttpRequestMessage> requestFactory, /// <summary>
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead, /// sends an HTTP request without content serialization
bool withToken = true, bool allowRetry = true) /// </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; const int maxAttempts = 2;
var attempt = 0; var attempt = 0;
@@ -184,8 +262,11 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); 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); var content = await ((JsonContent)requestMessage.Content).ReadAsStringAsync().ConfigureAwait(false);
Logger.LogDebug("Sending {method} to {uri} (Content: {content})", requestMessage.Method, requestMessage.RequestUri, content); Logger.LogDebug("Sending {method} to {uri} (Content: {content})", requestMessage.Method, requestMessage.RequestUri, content);
} }
@@ -196,9 +277,10 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
try try
{ {
if (ct != null) // send request
return await _httpClient.SendAsync(requestMessage, httpCompletionOption, ct.Value).ConfigureAwait(false); return ct != null
return await _httpClient.SendAsync(requestMessage, httpCompletionOption).ConfigureAwait(false); ? await _httpClient.SendAsync(requestMessage, httpCompletionOption, ct.Value).ConfigureAwait(false)
: await _httpClient.SendAsync(requestMessage, httpCompletionOption).ConfigureAwait(false);
} }
catch (TaskCanceledException) catch (TaskCanceledException)
{ {
@@ -208,14 +290,11 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
{ {
Logger.LogWarning(ex, "Transient error during SendRequestInternal for {uri}, retrying attempt {attempt}/{maxAttempts}", Logger.LogWarning(ex, "Transient error during SendRequestInternal for {uri}, retrying attempt {attempt}/{maxAttempts}",
requestMessage.RequestUri, attempt, maxAttempts); requestMessage.RequestUri, attempt, maxAttempts);
if (ct.HasValue) if (ct.HasValue)
{
await Task.Delay(TimeSpan.FromMilliseconds(200), ct.Value).ConfigureAwait(false); await Task.Delay(TimeSpan.FromMilliseconds(200), ct.Value).ConfigureAwait(false);
}
else else
{
await Task.Delay(TimeSpan.FromMilliseconds(200)).ConfigureAwait(false); await Task.Delay(TimeSpan.FromMilliseconds(200)).ConfigureAwait(false);
}
} }
catch (Exception ex) 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) private static bool IsTransientNetworkException(Exception ex)
{ {
var current = ex; var current = ex;
@@ -232,12 +316,13 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
{ {
if (current is SocketException socketEx) 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; current = current.InnerException;
} }
return false; return false;
} }
} }

View File

@@ -193,7 +193,7 @@ public partial class ApiController
CensusDataDto? censusDto = null; CensusDataDto? censusDto = null;
if (_serverManager.SendCensusData && _lastCensus != 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); censusDto = new((ushort)world, _lastCensus.RaceId, _lastCensus.TribeId, _lastCensus.Gender);
Logger.LogDebug("Attaching Census Data: {data}", censusDto); Logger.LogDebug("Attaching Census Data: {data}", censusDto);
} }

View File

@@ -418,7 +418,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
public Task CyclePauseAsync(PairUniqueIdentifier ident) public Task CyclePauseAsync(PairUniqueIdentifier ident)
{ {
var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
var token = timeoutCts.Token; var token = timeoutCts.Token;
@@ -430,20 +430,19 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
return; return;
} }
var originalPermissions = entry.SelfPermissions; var targetPermissions = entry.SelfPermissions;
var targetPermissions = originalPermissions; targetPermissions.SetPaused(paused: true);
targetPermissions.SetPaused(!originalPermissions.IsPaused());
await UserSetPairPermissions(new UserPermissionsDto(entry.User, targetPermissions)).ConfigureAwait(false); await UserSetPairPermissions(new UserPermissionsDto(entry.User, targetPermissions)).ConfigureAwait(false);
var applied = false; var pauseApplied = false;
while (!token.IsCancellationRequested) while (!token.IsCancellationRequested)
{ {
if (_pairCoordinator.Ledger.TryGetEntry(ident, out var updated) && updated is not null) if (_pairCoordinator.Ledger.TryGetEntry(ident, out var updated) && updated is not null)
{ {
if (updated.SelfPermissions == targetPermissions) if (updated.SelfPermissions == targetPermissions)
{ {
applied = true; pauseApplied = true;
entry = updated; entry = updated;
break; break;
} }
@@ -453,13 +452,16 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
Logger.LogTrace("Waiting for permissions change for {uid}", ident.UserId); Logger.LogTrace("Waiting for permissions change for {uid}", ident.UserId);
} }
if (!applied) if (!pauseApplied)
{ {
Logger.LogWarning("CyclePauseAsync timed out waiting for pause acknowledgement for {uid}", ident.UserId); Logger.LogWarning("CyclePauseAsync timed out waiting for pause acknowledgement for {uid}", ident.UserId);
return; return;
} }
Logger.LogDebug("CyclePauseAsync toggled paused for {uid} to {state}", ident.UserId, targetPermissions.IsPaused()); targetPermissions.SetPaused(paused: false);
await UserSetPairPermissions(new UserPermissionsDto(entry.User, targetPermissions)).ConfigureAwait(false);
Logger.LogDebug("CyclePauseAsync completed pause cycle for {uid}", ident.UserId);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -479,16 +481,26 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
} }
public async Task PauseAsync(UserData userData) public async Task PauseAsync(UserData userData)
{
await SetPausedStateAsync(userData, paused: true).ConfigureAwait(false);
}
public async Task UnpauseAsync(UserData userData)
{
await SetPausedStateAsync(userData, paused: false).ConfigureAwait(false);
}
private async Task SetPausedStateAsync(UserData userData, bool paused)
{ {
var pairIdent = new PairUniqueIdentifier(userData.UID); var pairIdent = new PairUniqueIdentifier(userData.UID);
if (!_pairCoordinator.Ledger.TryGetEntry(pairIdent, out var entry) || entry is null) if (!_pairCoordinator.Ledger.TryGetEntry(pairIdent, out var entry) || entry is null)
{ {
Logger.LogWarning("PauseAsync: pair {uid} not found in ledger", userData.UID); Logger.LogWarning("SetPausedStateAsync: pair {uid} not found in ledger", userData.UID);
return; return;
} }
var permissions = entry.SelfPermissions; var permissions = entry.SelfPermissions;
permissions.SetPaused(paused: true); permissions.SetPaused(paused);
await UserSetPairPermissions(new UserPermissionsDto(userData, permissions)).ConfigureAwait(false); await UserSetPairPermissions(new UserPermissionsDto(userData, permissions)).ConfigureAwait(false);
} }
@@ -532,8 +544,8 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
private void DalamudUtilOnLogIn() private void DalamudUtilOnLogIn()
{ {
var charaName = _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult(); var charaName = _dalamudUtil.GetPlayerName();
var worldId = _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult(); var worldId = _dalamudUtil.GetHomeWorldId();
var auth = _serverManager.CurrentServer.Authentications.Find(f => string.Equals(f.CharacterName, charaName, StringComparison.Ordinal) && f.WorldId == worldId); var auth = _serverManager.CurrentServer.Authentications.Find(f => string.Equals(f.CharacterName, charaName, StringComparison.Ordinal) && f.WorldId == worldId);
if (auth?.AutoLogin ?? false) if (auth?.AutoLogin ?? false)
{ {
@@ -641,7 +653,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
CensusDataDto? dto = null; CensusDataDto? dto = null;
if (_serverManager.SendCensusData && _lastCensus != 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); dto = new((ushort)world, _lastCensus.RaceId, _lastCensus.TribeId, _lastCensus.Gender);
Logger.LogDebug("Attaching Census Data: {data}", dto); Logger.LogDebug("Attaching Census Data: {data}", dto);
} }

View File

@@ -72,7 +72,7 @@ public sealed class TokenProvider : IDisposable, IMediatorSubscriber
result = await _httpClient.PostAsync(tokenUri, new FormUrlEncodedContent( result = await _httpClient.PostAsync(tokenUri, new FormUrlEncodedContent(
[ [
new KeyValuePair<string, string>("auth", auth), 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); ]), ct).ConfigureAwait(false);
} }
else else
@@ -152,7 +152,7 @@ public sealed class TokenProvider : IDisposable, IMediatorSubscriber
JwtIdentifier jwtIdentifier; JwtIdentifier jwtIdentifier;
try try
{ {
var playerIdentifier = await _dalamudUtil.GetPlayerNameHashedAsync().ConfigureAwait(false); var playerIdentifier = _dalamudUtil.GetPlayerNameHashed();
if (string.IsNullOrEmpty(playerIdentifier)) if (string.IsNullOrEmpty(playerIdentifier))
{ {