2.1.0 (#123)
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m9s

# Patchnotes 2.1.0
The changes in this update are more than just "patches". With a new UI, a new feature, and a bunch of bug fixes, improvements and a new member on the dev team, we thought this was more of a minor update.

We would like to introduce @tsubasahane of MareCN to the team! We’re happy to work with them to bring Lightless and its features to the CN client as well as having another talented dev bring features and ideas to us. Speaking of which:

# Location Sharing (Big shout out to @tsubasahane for bringing this feature)

- Are you TIRED of scrambling to find the address of the venue you're in to share with your friends? We are introducing Location Sharing! An optional feature where you can share your location with direct pairs temporarily [30 minutes, 1 hour, 3 hours] minutes or until you turn it off for them. That's up to you! [#125](<#125>)  [#49](<Lightless-Sync/LightlessServer#49>)
- To share your location with a pair, click the three dots beside the pair and choose a duration to share with them. [#125](<#125>)  [#49](<Lightless-Sync/LightlessServer#49>)
- To view the location of someone who's shared with you, simply hover over the globe icon! [#125](<#125>)  [#49](<Lightless-Sync/LightlessServer#49>)

[1]

# Model Optimization (Mesh Decimating)
 - This new option can automatically “simplify” incoming character meshes to help performance by reducing triangle counts. You choose how strong the reduction is (default/recommended is 80%). [#131](<#131>)
 - Decimation only kicks in when a mesh is above a certain triangle threshold, and only for the items that qualify for it and you selected for. [#131](<#131>)
 - Hair meshes is always excluded, since simplifying hair meshes is very prone to breaking.
 - You can find everything under Settings → Performance → Model Optimization. [#131](<#131>)
+ ** IF YOU HAVE USED DECIMATION IN TESTING, PLEASE CLEAR YOUR CACHE  **

[2]

# Animation (PAP) Validation (Safer animations)
 - Lightless now checks your currently animations to see if they work with your local skeleton/bone mod. If an animation matches, it’s included in what gets sent to other players. If it doesn’t, Lightless will skip it and write a warning to your log showing how many were skipped due to skeleton changes. Its defaulted to Unsafe (off). turn it on if you experience crashes from others users. [#131](<#131>)
 - Lightless also does the same kind of check for incoming animation files, to make sure they match the body/skeleton they were sent with. [#131](<#131>)
 - Because these checks can sometimes be a little picky, you can adjust how strict they are in Settings -> General -> Animation & Bones to reduce false positives. [#131](<#131>)

# UI Changes (Thanks to @kyuwu for UI Changes)
- The top part of the main screen has gotten a makeover. You can adjust the colors of the gradiant in the Color settings of Lightless. [#127](<#127>)

[3]

- Settings have gotten some changes as well to make this change more universal, and will use the same color settings. [#127](<#127>)
- The particle effects of the gradient are toggleable in 'Settings -> UI -> Behavior' [#127](<#127>)
- Instead of showing download/upload on bottom of Main UI, it will show VRAM usage and triangles with their optimization options next to it [#138](<#138>)

# LightFinder / ShellFinder
- UI Changes that follow our new design follow the color codes for the Gradient top as the main screen does.  [#127](<#127>)

[4]

Co-authored-by: defnotken <itsdefnotken@gmail.com>
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: cake <admin@cakeandbanana.nl>
Co-authored-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
Co-authored-by: choco <choco@patat.nl>
Co-authored-by: celine <aaa@aaa.aaa>
Co-authored-by: celine <celine@noreply.git.lightless-sync.org>
Co-authored-by: Tsubasahane <wozaiha@gmail.com>
Co-authored-by: cake <cake@noreply.git.lightless-sync.org>
Reviewed-on: #123
This commit was merged in pull request #123.
This commit is contained in:
2026-01-20 19:43:00 +00:00
parent ed7932ab83
commit 72a62b7449
178 changed files with 33356 additions and 4568 deletions

View File

@@ -0,0 +1,9 @@
namespace LightlessSync.PlayerData.Factories
{
public enum AnimationValidationMode
{
Unsafe = 0,
Safe = 1,
Safest = 2,
}
}

View File

@@ -1,6 +1,7 @@
using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ModelDecimation;
using LightlessSync.Services.TextureCompression;
using LightlessSync.WebAPI.Files;
using Microsoft.Extensions.Logging;
@@ -16,7 +17,9 @@ public class FileDownloadManagerFactory
private readonly FileCompactor _fileCompactor;
private readonly LightlessConfigService _configService;
private readonly TextureDownscaleService _textureDownscaleService;
private readonly ModelDecimationService _modelDecimationService;
private readonly TextureMetadataHelper _textureMetadataHelper;
private readonly FileDownloadDeduplicator _downloadDeduplicator;
public FileDownloadManagerFactory(
ILoggerFactory loggerFactory,
@@ -26,7 +29,9 @@ public class FileDownloadManagerFactory
FileCompactor fileCompactor,
LightlessConfigService configService,
TextureDownscaleService textureDownscaleService,
TextureMetadataHelper textureMetadataHelper)
ModelDecimationService modelDecimationService,
TextureMetadataHelper textureMetadataHelper,
FileDownloadDeduplicator downloadDeduplicator)
{
_loggerFactory = loggerFactory;
_lightlessMediator = lightlessMediator;
@@ -35,7 +40,9 @@ public class FileDownloadManagerFactory
_fileCompactor = fileCompactor;
_configService = configService;
_textureDownscaleService = textureDownscaleService;
_modelDecimationService = modelDecimationService;
_textureMetadataHelper = textureMetadataHelper;
_downloadDeduplicator = downloadDeduplicator;
}
public FileDownloadManager Create()
@@ -48,6 +55,8 @@ public class FileDownloadManagerFactory
_fileCompactor,
_configService,
_textureDownscaleService,
_textureMetadataHelper);
_modelDecimationService,
_textureMetadataHelper,
_downloadDeduplicator);
}
}

View File

@@ -1,13 +1,19 @@
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using LightlessSync.API.Data.Enum;
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Data;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.InteropServices;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.PlayerData.Factories;
@@ -18,13 +24,34 @@ public class PlayerDataFactory
private readonly IpcManager _ipcManager;
private readonly ILogger<PlayerDataFactory> _logger;
private readonly PerformanceCollectorService _performanceCollector;
private readonly LightlessConfigService _configService;
private readonly XivDataAnalyzer _modelAnalyzer;
private readonly LightlessMediator _lightlessMediator;
private readonly TransientResourceManager _transientResourceManager;
private static readonly SemaphoreSlim _papParseLimiter = new(1, 1);
public PlayerDataFactory(ILogger<PlayerDataFactory> logger, DalamudUtilService dalamudUtil, IpcManager ipcManager,
TransientResourceManager transientResourceManager, FileCacheManager fileReplacementFactory,
PerformanceCollectorService performanceCollector, XivDataAnalyzer modelAnalyzer, LightlessMediator lightlessMediator)
// Transient resolved entries threshold
private const int _maxTransientResolvedEntries = 1000;
// Character build caches
private readonly TaskRegistry<nint> _characterBuildInflight = new();
private readonly ConcurrentDictionary<nint, CacheEntry> _characterBuildCache = new();
// Time out thresholds
private static readonly TimeSpan _characterCacheTtl = TimeSpan.FromMilliseconds(750);
private static readonly TimeSpan _softReturnIfBusyAfter = TimeSpan.FromMilliseconds(250);
private static readonly TimeSpan _hardBuildTimeout = TimeSpan.FromSeconds(30);
public PlayerDataFactory(
ILogger<PlayerDataFactory> logger,
DalamudUtilService dalamudUtil,
IpcManager ipcManager,
TransientResourceManager transientResourceManager,
FileCacheManager fileReplacementFactory,
PerformanceCollectorService performanceCollector,
XivDataAnalyzer modelAnalyzer,
LightlessMediator lightlessMediator,
LightlessConfigService configService)
{
_logger = logger;
_dalamudUtil = dalamudUtil;
@@ -34,15 +61,15 @@ public class PlayerDataFactory
_performanceCollector = performanceCollector;
_modelAnalyzer = modelAnalyzer;
_lightlessMediator = lightlessMediator;
_configService = configService;
_logger.LogTrace("Creating {this}", nameof(PlayerDataFactory));
}
private sealed record CacheEntry(CharacterDataFragment Fragment, DateTime CreatedUtc);
public async Task<CharacterDataFragment?> BuildCharacterData(GameObjectHandler playerRelatedObject, CancellationToken token)
{
if (!_ipcManager.Initialized)
{
throw new InvalidOperationException("Penumbra or Glamourer is not connected");
}
if (playerRelatedObject == null) return null;
@@ -67,16 +94,17 @@ public class PlayerDataFactory
if (pointerIsZero)
{
_logger.LogTrace("Pointer was zero for {objectKind}", playerRelatedObject.ObjectKind);
_logger.LogTrace("Pointer was zero for {objectKind}; couldn't build character", playerRelatedObject.ObjectKind);
return null;
}
try
{
return await _performanceCollector.LogPerformance(this, $"CreateCharacterData>{playerRelatedObject.ObjectKind}", async () =>
{
return await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false);
}).ConfigureAwait(true);
return await _performanceCollector.LogPerformance(
this,
$"CreateCharacterData>{playerRelatedObject.ObjectKind}",
async () => await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false)
).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -91,115 +119,207 @@ public class PlayerDataFactory
return null;
}
private static readonly int _drawObjectOffset =
(int)Marshal.OffsetOf<GameObject>(nameof(GameObject.DrawObject));
private async Task<bool> CheckForNullDrawObject(IntPtr playerPointer)
=> await _dalamudUtil.RunOnFrameworkThread(() =>
{
nint basePtr = playerPointer;
if (!PtrGuard.LooksLikePtr(basePtr))
return true;
nint drawObjAddr = basePtr + _drawObjectOffset;
if (!PtrGuard.IsReadable(drawObjAddr, (nuint)IntPtr.Size))
return true;
if (!PtrGuard.TryReadIntPtr(drawObjAddr, out var drawObj))
return true;
if (drawObj != 0 && !PtrGuard.LooksLikePtr(drawObj))
return true;
return drawObj == 0;
}).ConfigureAwait(false);
private static bool IsCacheFresh(CacheEntry entry)
=> (DateTime.UtcNow - entry.CreatedUtc) <= _characterCacheTtl;
private Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
=> CreateCharacterDataCoalesced(playerRelatedObject, ct);
private async Task<CharacterDataFragment> CreateCharacterDataCoalesced(GameObjectHandler obj, CancellationToken ct)
{
return await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
var key = obj.Address;
if (_characterBuildCache.TryGetValue(key, out CacheEntry cached) && IsCacheFresh(cached) && !_characterBuildInflight.TryGetExisting(key, out _))
return cached.Fragment;
Task<CharacterDataFragment> buildTask = _characterBuildInflight.GetOrStart(key, () => BuildAndCacheAsync(obj, key));
if (_characterBuildCache.TryGetValue(key, out cached))
{
var completed = await Task.WhenAny(buildTask, Task.Delay(_softReturnIfBusyAfter, ct)).ConfigureAwait(false);
if (completed != buildTask && (DateTime.UtcNow - cached.CreatedUtc) <= TimeSpan.FromSeconds(5))
{
return cached.Fragment;
}
}
return await WithCancellation(buildTask, ct).ConfigureAwait(false);
}
private unsafe bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
private async Task<CharacterDataFragment> BuildAndCacheAsync(GameObjectHandler obj, nint key)
{
if (playerPointer == IntPtr.Zero)
return true;
using var cts = new CancellationTokenSource(_hardBuildTimeout);
CharacterDataFragment fragment = await CreateCharacterDataInternal(obj, cts.Token).ConfigureAwait(false);
var character = (Character*)playerPointer;
_characterBuildCache[key] = new CacheEntry(fragment, DateTime.UtcNow);
PruneCharacterCacheIfNeeded();
if (character == null)
return true;
var gameObject = &character->GameObject;
if (gameObject == null)
return true;
return gameObject->DrawObject == null;
return fragment;
}
private async Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
private void PruneCharacterCacheIfNeeded()
{
if (_characterBuildCache.Count < 2048) return;
var cutoff = DateTime.UtcNow - TimeSpan.FromSeconds(10);
foreach (var kv in _characterBuildCache)
{
if (kv.Value.CreatedUtc < cutoff)
_characterBuildCache.TryRemove(kv.Key, out _);
}
}
private static async Task<T> WithCancellation<T>(Task<T> task, CancellationToken ct)
=> await task.WaitAsync(ct).ConfigureAwait(false);
private async Task<CharacterDataFragment> CreateCharacterDataInternal(GameObjectHandler playerRelatedObject, CancellationToken ct)
{
var objectKind = playerRelatedObject.ObjectKind;
CharacterDataFragment fragment = objectKind == ObjectKind.Player ? new CharacterDataFragmentPlayer() : new();
_logger.LogDebug("Building character data for {obj}", playerRelatedObject);
var logDebug = _logger.IsEnabled(LogLevel.Debug);
var sw = Stopwatch.StartNew();
// wait until chara is not drawing and present so nothing spontaneously explodes
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct).ConfigureAwait(false);
int totalWaitTime = 10000;
while (!await _dalamudUtil.IsObjectPresentAsync(await _dalamudUtil.CreateGameObjectAsync(playerRelatedObject.Address).ConfigureAwait(false)).ConfigureAwait(false) && totalWaitTime > 0)
_logger.LogDebug("Building character data for {obj}", playerRelatedObject);
await EnsureObjectPresentAsync(playerRelatedObject, ct).ConfigureAwait(false);
ct.ThrowIfCancellationRequested();
var waitRecordingTask = _transientResourceManager.WaitForRecording(ct);
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct)
.ConfigureAwait(false);
// get all remaining paths and resolve them
ct.ThrowIfCancellationRequested();
if (await CheckForNullDrawObject(playerRelatedObject.Address).ConfigureAwait(false))
throw new InvalidOperationException("DrawObject became null during build (actor despawned)");
Task<string> getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address);
Task<string?> getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address);
Task<string?>? getMoodlesData = null;
Task<string>? getHeelsOffset = null;
Task<string>? getHonorificTitle = null;
if (objectKind == ObjectKind.Player)
{
_logger.LogTrace("Character is null but it shouldn't be, waiting");
await Task.Delay(50, ct).ConfigureAwait(false);
totalWaitTime -= 50;
getHeelsOffset = _ipcManager.Heels.GetOffsetAsync();
getHonorificTitle = _ipcManager.Honorific.GetTitle();
getMoodlesData = _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address);
}
Guid penumbraRequestId = Guid.Empty;
Stopwatch? penumbraSw = null;
if (logDebug)
{
penumbraRequestId = Guid.NewGuid();
penumbraSw = Stopwatch.StartNew();
_logger.LogDebug("Penumbra GetCharacterData start {id} for {obj}", penumbraRequestId, playerRelatedObject);
}
var resolvedPaths = await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false);
if (logDebug)
{
penumbraSw!.Stop();
_logger.LogDebug("Penumbra GetCharacterData done {id} in {elapsedMs}ms (count={count})",
penumbraRequestId,
penumbraSw.ElapsedMilliseconds,
resolvedPaths?.Count ?? -1);
}
if (resolvedPaths == null)
throw new InvalidOperationException("Penumbra returned null data; couldn't proceed with character");
ct.ThrowIfCancellationRequested();
DateTime start = DateTime.UtcNow;
var staticBuildTask = Task.Run(() => BuildStaticReplacements(resolvedPaths), ct);
// penumbra call, it's currently broken
Dictionary<string, HashSet<string>>? resolvedPaths;
resolvedPaths = (await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false));
if (resolvedPaths == null) throw new InvalidOperationException("Penumbra returned null data");
ct.ThrowIfCancellationRequested();
fragment.FileReplacements =
new HashSet<FileReplacement>(resolvedPaths.Select(c => new FileReplacement([.. c.Value], c.Key)), FileReplacementComparer.Instance)
.Where(p => p.HasFileReplacement).ToHashSet();
fragment.FileReplacements.RemoveWhere(c => c.GamePaths.Any(g => !CacheMonitor.AllowedFileExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase))));
ct.ThrowIfCancellationRequested();
fragment.FileReplacements = await staticBuildTask.ConfigureAwait(false);
if (logDebug)
{
_logger.LogDebug("== Static Replacements ==");
foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement).OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
foreach (var replacement in fragment.FileReplacements
.Where(i => i.HasFileReplacement)
.OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
{
_logger.LogDebug("=> {repl}", replacement);
ct.ThrowIfCancellationRequested();
}
}
else
var staticReplacements = new HashSet<FileReplacement>(fragment.FileReplacements, FileReplacementComparer.Instance);
var transientTask = ResolveTransientReplacementsAsync(
playerRelatedObject,
objectKind,
staticReplacements,
waitRecordingTask,
ct);
fragment.GlamourerString = await getGlamourerData.ConfigureAwait(false);
_logger.LogDebug("Glamourer is now: {data}", fragment.GlamourerString);
var customizeScale = await getCustomizeData.ConfigureAwait(false);
fragment.CustomizePlusScale = customizeScale ?? string.Empty;
_logger.LogDebug("Customize is now: {data}", fragment.CustomizePlusScale);
if (objectKind == ObjectKind.Player)
{
foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement))
{
ct.ThrowIfCancellationRequested();
}
}
CharacterDataFragmentPlayer? playerFragment = fragment as CharacterDataFragmentPlayer ?? throw new InvalidOperationException("Failed to cast CharacterDataFragment to Player variant");
await _transientResourceManager.WaitForRecording(ct).ConfigureAwait(false);
playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations();
playerFragment.HonorificData = await getHonorificTitle!.ConfigureAwait(false);
_logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData);
// if it's pet then it's summoner, if it's summoner we actually want to keep all filereplacements alive at all times
// or we get into redraw city for every change and nothing works properly
if (objectKind == ObjectKind.Pet)
{
foreach (var item in fragment.FileReplacements.Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths))
{
if (_transientResourceManager.AddTransientResource(objectKind, item))
{
_logger.LogDebug("Marking static {item} for Pet as transient", item);
}
}
playerFragment.PetNamesData = _ipcManager.PetNames.GetLocalNames();
_logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);
_logger.LogTrace("Clearing {count} Static Replacements for Pet", fragment.FileReplacements.Count);
fragment.FileReplacements.Clear();
playerFragment.HeelsData = await getHeelsOffset!.ConfigureAwait(false);
_logger.LogDebug("Heels is now: {heels}", playerFragment!.HeelsData);
playerFragment.MoodlesData = (await getMoodlesData!.ConfigureAwait(false)) ?? string.Empty;
_logger.LogDebug("Moodles is now: {moodles}", playerFragment!.MoodlesData);
}
ct.ThrowIfCancellationRequested();
_logger.LogDebug("Handling transient update for {obj}", playerRelatedObject);
// remove all potentially gathered paths from the transient resource manager that are resolved through static resolving
_transientResourceManager.ClearTransientPaths(objectKind, fragment.FileReplacements.SelectMany(c => c.GamePaths).ToList());
// get all remaining paths and resolve them
var transientPaths = ManageSemiTransientData(objectKind);
var resolvedTransientPaths = await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
var (resolvedTransientPaths, clearedForPet) = await transientTask.ConfigureAwait(false);
if (clearedForPet != null)
fragment.FileReplacements.Clear();
if (logDebug)
{
_logger.LogDebug("== Transient Replacements ==");
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)).OrderBy(f => f.ResolvedPath, StringComparer.Ordinal))
foreach (var replacement in resolvedTransientPaths
.Select(c => new FileReplacement([.. c.Value], c.Key))
.OrderBy(f => f.ResolvedPath, StringComparer.Ordinal))
{
_logger.LogDebug("=> {repl}", replacement);
fragment.FileReplacements.Add(replacement);
@@ -208,85 +328,64 @@ public class PlayerDataFactory
else
{
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)))
{
fragment.FileReplacements.Add(replacement);
}
}
// clean up all semi transient resources that don't have any file replacement (aka null resolve)
_transientResourceManager.CleanUpSemiTransientResources(objectKind, [.. fragment.FileReplacements]);
ct.ThrowIfCancellationRequested();
// make sure we only return data that actually has file replacements
fragment.FileReplacements = new HashSet<FileReplacement>(fragment.FileReplacements.Where(v => v.HasFileReplacement).OrderBy(v => v.ResolvedPath, StringComparer.Ordinal), FileReplacementComparer.Instance);
// gather up data from ipc
Task<string> getHeelsOffset = _ipcManager.Heels.GetOffsetAsync();
Task<string> getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address);
Task<string?> getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address);
Task<string> getHonorificTitle = _ipcManager.Honorific.GetTitle();
fragment.GlamourerString = await getGlamourerData.ConfigureAwait(false);
_logger.LogDebug("Glamourer is now: {data}", fragment.GlamourerString);
var customizeScale = await getCustomizeData.ConfigureAwait(false);
fragment.CustomizePlusScale = customizeScale ?? string.Empty;
_logger.LogDebug("Customize is now: {data}", fragment.CustomizePlusScale);
if (objectKind == ObjectKind.Player)
{
var playerFragment = (fragment as CharacterDataFragmentPlayer)!;
playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations();
playerFragment!.HonorificData = await getHonorificTitle.ConfigureAwait(false);
_logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData);
playerFragment!.HeelsData = await getHeelsOffset.ConfigureAwait(false);
_logger.LogDebug("Heels is now: {heels}", playerFragment!.HeelsData);
playerFragment!.MoodlesData = await _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address).ConfigureAwait(false) ?? string.Empty;
_logger.LogDebug("Moodles is now: {moodles}", playerFragment!.MoodlesData);
playerFragment!.PetNamesData = _ipcManager.PetNames.GetLocalNames();
_logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);
}
fragment.FileReplacements = new HashSet<FileReplacement>(
fragment.FileReplacements
.Where(v => v.HasFileReplacement)
.OrderBy(v => v.ResolvedPath, StringComparer.Ordinal),
FileReplacementComparer.Instance);
ct.ThrowIfCancellationRequested();
var toCompute = fragment.FileReplacements.Where(f => !f.IsFileSwap).ToArray();
_logger.LogDebug("Getting Hashes for {amount} Files", toCompute.Length);
var computedPaths = _fileCacheManager.GetFileCachesByPaths(toCompute.Select(c => c.ResolvedPath).ToArray());
foreach (var file in toCompute)
await Task.Run(() =>
{
ct.ThrowIfCancellationRequested();
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty;
}
var computedPaths = _fileCacheManager.GetFileCachesByPaths([.. toCompute.Select(c => c.ResolvedPath)]);
foreach (var file in toCompute)
{
ct.ThrowIfCancellationRequested();
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty;
}
}, ct).ConfigureAwait(false);
var removed = fragment.FileReplacements.RemoveWhere(f => !f.IsFileSwap && string.IsNullOrEmpty(f.Hash));
if (removed > 0)
{
_logger.LogDebug("Removed {amount} of invalid files", removed);
}
ct.ThrowIfCancellationRequested();
Dictionary<string, List<ushort>>? boneIndices = null;
var hasPapFiles = false;
if (objectKind == ObjectKind.Player)
{
hasPapFiles = fragment.FileReplacements.Any(f =>
!f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase));
if (hasPapFiles)
{
boneIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject)).ConfigureAwait(false);
}
}
if (objectKind == ObjectKind.Player)
{
hasPapFiles = fragment.FileReplacements.Any(f =>
!f.IsFileSwap && f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
if (hasPapFiles)
{
boneIndices = await _dalamudUtil
.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject))
.ConfigureAwait(false);
}
try
{
#if DEBUG
if (hasPapFiles && boneIndices != null)
_modelAnalyzer.DumpLocalSkeletonIndices(playerRelatedObject);
#endif
if (hasPapFiles)
{
await VerifyPlayerAnimationBones(boneIndices, (fragment as CharacterDataFragmentPlayer)!, ct).ConfigureAwait(false);
await VerifyPlayerAnimationBones(boneIndices, (CharacterDataFragmentPlayer)fragment, ct)
.ConfigureAwait(false);
}
}
catch (OperationCanceledException e)
@@ -300,171 +399,320 @@ public class PlayerDataFactory
}
}
_logger.LogInformation("Building character data for {obj} took {time}ms", objectKind, TimeSpan.FromTicks(DateTime.UtcNow.Ticks - start.Ticks).TotalMilliseconds);
_logger.LogInformation("Building character data for {obj} took {time}ms",
objectKind, sw.Elapsed.TotalMilliseconds);
return fragment;
}
private async Task VerifyPlayerAnimationBones(Dictionary<string, List<ushort>>? boneIndices, CharacterDataFragmentPlayer fragment, CancellationToken ct)
private async Task EnsureObjectPresentAsync(GameObjectHandler handler, CancellationToken ct)
{
if (boneIndices == null) return;
if (_logger.IsEnabled(LogLevel.Debug))
{
foreach (var kvp in boneIndices)
{
_logger.LogDebug("Found {skellyname} ({idx} bone indices) on player: {bones}", kvp.Key, kvp.Value.Any() ? kvp.Value.Max() : 0, string.Join(',', kvp.Value));
}
}
var maxPlayerBoneIndex = boneIndices.SelectMany(kvp => kvp.Value).DefaultIfEmpty().Max();
if (maxPlayerBoneIndex <= 0) return;
int noValidationFailed = 0;
foreach (var file in fragment.FileReplacements.Where(f => !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)).ToList())
var remaining = 10000;
while (remaining > 0)
{
ct.ThrowIfCancellationRequested();
var skeletonIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetBoneIndicesFromPap(file.Hash)).ConfigureAwait(false);
bool validationFailed = false;
if (skeletonIndices != null)
var obj = await _dalamudUtil.CreateGameObjectAsync(handler.Address).ConfigureAwait(false);
if (await _dalamudUtil.IsObjectPresentAsync(obj).ConfigureAwait(false))
return;
_logger.LogTrace("Character is null but it shouldn't be, waiting");
await Task.Delay(50, ct).ConfigureAwait(false);
remaining -= 50;
}
}
private static HashSet<FileReplacement> BuildStaticReplacements(Dictionary<string, HashSet<string>> resolvedPaths)
{
var set = new HashSet<FileReplacement>(FileReplacementComparer.Instance);
foreach (var kvp in resolvedPaths)
{
var fr = new FileReplacement([.. kvp.Value], kvp.Key);
if (!fr.HasFileReplacement) continue;
var allAllowed = fr.GamePaths.All(g =>
CacheMonitor.AllowedFileExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase)));
if (!allAllowed) continue;
set.Add(fr);
}
return set;
}
private async Task<(IReadOnlyDictionary<string, string[]> ResolvedPaths, HashSet<FileReplacement>? ClearedReplacements)>
ResolveTransientReplacementsAsync(
GameObjectHandler obj,
ObjectKind objectKind,
HashSet<FileReplacement> staticReplacements,
Task waitRecordingTask,
CancellationToken ct)
{
await waitRecordingTask.ConfigureAwait(false);
HashSet<FileReplacement>? clearedReplacements = null;
if (objectKind == ObjectKind.Pet)
{
foreach (var item in staticReplacements.Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths))
{
// 105 is the maximum vanilla skellington spoopy bone index
if (skeletonIndices.All(k => k.Value.Max() <= 105))
{
_logger.LogTrace("All indices of {path} are <= 105, ignoring", file.ResolvedPath);
continue;
}
if (_transientResourceManager.AddTransientResource(objectKind, item))
_logger.LogDebug("Marking static {item} for Pet as transient", item);
}
_logger.LogDebug("Verifying bone indices for {path}, found {x} skeletons", file.ResolvedPath, skeletonIndices.Count);
_logger.LogTrace("Clearing {count} Static Replacements for Pet", staticReplacements.Count);
clearedReplacements = staticReplacements;
}
foreach (var boneCount in skeletonIndices)
ct.ThrowIfCancellationRequested();
_transientResourceManager.ClearTransientPaths(objectKind, [.. staticReplacements.SelectMany(c => c.GamePaths)]);
var transientPaths = ManageSemiTransientData(objectKind);
if (transientPaths.Count == 0)
return (new Dictionary<string, string[]>(StringComparer.Ordinal), clearedReplacements);
var resolved = await GetFileReplacementsFromPaths(transientPaths, new HashSet<string>(StringComparer.Ordinal))
.ConfigureAwait(false);
if (_maxTransientResolvedEntries > 0 && resolved.Count > _maxTransientResolvedEntries)
{
_logger.LogWarning("Transient entries ({resolved}) are above the threshold {max}; Please consider disable some mods (VFX have heavy load) to reduce transient load",
resolved.Count,
_maxTransientResolvedEntries);
}
return (resolved, clearedReplacements);
}
private async Task VerifyPlayerAnimationBones(
Dictionary<string, List<ushort>>? playerBoneIndices,
CharacterDataFragmentPlayer fragment,
CancellationToken ct)
{
var mode = _configService.Current.AnimationValidationMode;
var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift;
var allownNightIndex = _configService.Current.AnimationAllowNeighborIndexTolerance;
if (mode == AnimationValidationMode.Unsafe)
return;
if (playerBoneIndices == null || playerBoneIndices.Count == 0)
return;
var localBoneSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
foreach (var (rawLocalKey, indices) in playerBoneIndices)
{
if (indices is not { Count: > 0 })
continue;
var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawLocalKey);
if (string.IsNullOrEmpty(key))
continue;
if (!localBoneSets.TryGetValue(key, out var set))
localBoneSets[key] = set = [];
foreach (var idx in indices)
set.Add(idx);
}
if (localBoneSets.Count == 0)
return;
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("SEND local buckets: {b}",
string.Join(", ", localBoneSets.Keys.Order(StringComparer.Ordinal)));
foreach (var kvp in localBoneSets.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
{
var min = kvp.Value.Count > 0 ? kvp.Value.Min() : 0;
var max = kvp.Value.Count > 0 ? kvp.Value.Max() : 0;
_logger.LogDebug("Local bucket {bucket}: count={count} min={min} max={max}",
kvp.Key, kvp.Value.Count, min, max);
}
}
var papGroups = fragment.FileReplacements
.Where(f => !f.IsFileSwap
&& !string.IsNullOrEmpty(f.Hash)
&& f.GamePaths is { Count: > 0 }
&& f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
.GroupBy(f => f.Hash!, StringComparer.OrdinalIgnoreCase)
.ToList();
int noValidationFailed = 0;
foreach (var g in papGroups)
{
ct.ThrowIfCancellationRequested();
var hash = g.Key;
var resolvedPath = g.Select(f => f.ResolvedPath).Distinct(StringComparer.OrdinalIgnoreCase);
var papPathSummary = string.Join(", ", resolvedPath);
if (papPathSummary.IsNullOrEmpty())
papPathSummary = "<unknown pap path>";
Dictionary<string, List<ushort>>? papIndices = null;
await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
try
{
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
var papPath = cacheEntity?.ResolvedFilepath;
if (!string.IsNullOrEmpty(papPath) && File.Exists(papPath))
{
var maxAnimationIndex = boneCount.Value.DefaultIfEmpty().Max();
if (maxAnimationIndex > maxPlayerBoneIndex)
var havokBytes = await Task.Run(() => XivDataAnalyzer.ReadHavokBytesFromPap(papPath), ct)
.ConfigureAwait(false);
if (havokBytes is { Length: > 8 })
{
_logger.LogWarning("Found more bone indices on the animation {path} skeleton {skl} (max indice {idx}) than on any player related skeleton (max indice {idx2})",
file.ResolvedPath, boneCount.Key, maxAnimationIndex, maxPlayerBoneIndex);
validationFailed = true;
break;
papIndices = await _dalamudUtil.RunOnFrameworkThread(
() => _modelAnalyzer.ParseHavokBytesOnFrameworkThread(havokBytes, hash, persistToConfig: false))
.ConfigureAwait(false);
}
}
}
if (validationFailed)
finally
{
noValidationFailed++;
_logger.LogDebug("Removing {file} from sent file replacements and transient data", file.ResolvedPath);
fragment.FileReplacements.Remove(file);
foreach (var gamePath in file.GamePaths)
_papParseLimiter.Release();
}
if (papIndices == null || papIndices.Count == 0)
continue;
if (_logger.IsEnabled(LogLevel.Debug))
{
try
{
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gamePath);
var papBuckets = papIndices
.Where(kvp => kvp.Value is { Count: > 0 })
.Select(kvp => new
{
Raw = kvp.Key,
Key = XivDataAnalyzer.CanonicalizeSkeletonKey(kvp.Key),
Indices = kvp.Value
})
.Where(x => x.Indices is { Count: > 0 })
.GroupBy(x => string.IsNullOrEmpty(x.Key) ? x.Raw : x.Key!, StringComparer.OrdinalIgnoreCase)
.Select(grp =>
{
var all = grp.SelectMany(v => v.Indices).ToList();
var min = all.Count > 0 ? all.Min() : 0;
var max = all.Count > 0 ? all.Max() : 0;
var raws = string.Join(',', grp.Select(v => v.Raw).Distinct(StringComparer.OrdinalIgnoreCase));
return $"{grp.Key}(min={min},max={max},raw=[{raws}])";
})
.ToList();
_logger.LogDebug("SEND pap buckets for hash={hash}: {b}",
hash,
string.Join(" | ", papBuckets));
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error logging PAP bucket details for hash={hash}", hash);
}
}
bool isCompatible = false;
string reason = string.Empty;
try
{
isCompatible = XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out reason);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error checking PAP compatibility for hash={hash}, path={path}. Treating as incompatible.", hash, papPathSummary);
reason = $"Exception during compatibility check: {ex.Message}";
isCompatible = false;
}
if (isCompatible)
continue;
noValidationFailed++;
_logger.LogWarning(
"Animation PAP is not compatible with local skeletons; dropping mappings for {papPath}. Reason: {reason}",
papPathSummary,
reason);
var removedGamePaths = fragment.FileReplacements
.Where(fr => !fr.IsFileSwap
&& string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
&& fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
.SelectMany(fr => fr.GamePaths.Where(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
fragment.FileReplacements.RemoveWhere(fr =>
!fr.IsFileSwap
&& string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
&& fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
foreach (var gp in removedGamePaths)
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gp);
}
if (noValidationFailed > 0)
{
_lightlessMediator.Publish(new NotificationMessage("Invalid Skeleton Setup",
$"Your client is attempting to send {noValidationFailed} animation files with invalid bone data. Those animation files have been removed from your sent data. " +
$"Verify that you are using the correct skeleton for those animation files (Check /xllog for more information).",
NotificationType.Warning, TimeSpan.FromSeconds(10)));
_lightlessMediator.Publish(new NotificationMessage(
"Invalid Skeleton Setup",
$"Your client is attempting to send {noValidationFailed} animation files that don't match your current skeleton validation mode ({mode}). " +
"Please adjust your skeleton/mods or change the validation mode if this is unexpected. " +
"Those animation files have been removed from your sent (player) data. (Check /xllog for details).",
NotificationType.Warning,
TimeSpan.FromSeconds(10)));
}
}
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(GameObjectHandler handler, HashSet<string> forwardResolve, HashSet<string> reverseResolve)
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(
HashSet<string> forwardResolve,
HashSet<string> reverseResolve)
{
var forwardPaths = forwardResolve.ToArray();
var reversePaths = reverseResolve.ToArray();
Dictionary<string, List<string>> resolvedPaths = new(StringComparer.Ordinal);
if (handler.ObjectKind != ObjectKind.Player)
if (forwardPaths.Length == 0 && reversePaths.Length == 0)
{
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();
}
return new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase).AsReadOnly();
}
var forwardPathsLower = forwardPaths.Length == 0 ? [] : forwardPaths.Select(p => p.ToLowerInvariant()).ToArray();
var reversePathsLower = reversePaths.Length == 0 ? [] : reversePaths.Select(p => p.ToLowerInvariant()).ToArray();
Dictionary<string, List<string>> resolvedPaths = new(forwardPaths.Length + reversePaths.Length, StringComparer.Ordinal);
var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false);
for (int i = 0; i < forwardPaths.Length; i++)
{
var filePath = forward[i].ToLowerInvariant();
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();
var filePath = reversePathsLower[i];
var reverseResolvedLower = new string[reverse[i].Length];
for (var j = 0; j < reverseResolvedLower.Length; j++)
{
reverseResolvedLower[j] = reverse[i][j].ToLowerInvariant();
}
if (resolvedPaths.TryGetValue(filePath, out var list))
{
list.AddRange(reverse[i].Select(c => c.ToLowerInvariant()));
}
else
{
resolvedPaths[filePath] = new List<string>(reverse[i].Select(c => c.ToLowerInvariant()).ToList());
}
resolvedPaths[filePath] = [.. reverse[i].Select(c => c.ToLowerInvariant()).ToList()];
}
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
@@ -475,11 +723,29 @@ public class PlayerDataFactory
_transientResourceManager.PersistTransientResources(objectKind);
HashSet<string> pathsToResolve = new(StringComparer.Ordinal);
foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind).Where(path => !string.IsNullOrEmpty(path)))
int scanned = 0, skippedEmpty = 0, skippedVfx = 0;
foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind))
{
scanned++;
if (string.IsNullOrEmpty(path))
{
skippedEmpty++;
continue;
}
pathsToResolve.Add(path);
}
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug(
"ManageSemiTransientData({kind}): scanned={scanned}, added={added}, skippedEmpty={skippedEmpty}, skippedVfx={skippedVfx}",
objectKind, scanned, pathsToResolve.Count, skippedEmpty, skippedVfx);
}
return pathsToResolve;
}
}
}