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
752 lines
30 KiB
C#
752 lines
30 KiB
C#
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;
|
|
|
|
public class PlayerDataFactory
|
|
{
|
|
private readonly DalamudUtilService _dalamudUtil;
|
|
private readonly FileCacheManager _fileCacheManager;
|
|
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);
|
|
|
|
// 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;
|
|
_ipcManager = ipcManager;
|
|
_transientResourceManager = transientResourceManager;
|
|
_fileCacheManager = fileReplacementFactory;
|
|
_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;
|
|
|
|
bool pointerIsZero = true;
|
|
try
|
|
{
|
|
pointerIsZero = playerRelatedObject.Address == IntPtr.Zero;
|
|
try
|
|
{
|
|
pointerIsZero = await CheckForNullDrawObject(playerRelatedObject.Address).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
pointerIsZero = true;
|
|
_logger.LogDebug("NullRef for {object}", playerRelatedObject);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Could not create data for {object}", playerRelatedObject);
|
|
}
|
|
|
|
if (pointerIsZero)
|
|
{
|
|
_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 () => await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false)
|
|
).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
_logger.LogDebug("Cancelled creating Character data for {object}", playerRelatedObject);
|
|
throw;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_logger.LogWarning(e, "Failed to create {object} data", playerRelatedObject);
|
|
}
|
|
|
|
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)
|
|
{
|
|
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 async Task<CharacterDataFragment> BuildAndCacheAsync(GameObjectHandler obj, nint key)
|
|
{
|
|
using var cts = new CancellationTokenSource(_hardBuildTimeout);
|
|
CharacterDataFragment fragment = await CreateCharacterDataInternal(obj, cts.Token).ConfigureAwait(false);
|
|
|
|
_characterBuildCache[key] = new CacheEntry(fragment, DateTime.UtcNow);
|
|
PruneCharacterCacheIfNeeded();
|
|
|
|
return fragment;
|
|
}
|
|
|
|
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();
|
|
|
|
var logDebug = _logger.IsEnabled(LogLevel.Debug);
|
|
var sw = Stopwatch.StartNew();
|
|
|
|
_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)
|
|
{
|
|
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();
|
|
|
|
var staticBuildTask = Task.Run(() => BuildStaticReplacements(resolvedPaths), ct);
|
|
|
|
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))
|
|
{
|
|
_logger.LogDebug("=> {repl}", replacement);
|
|
ct.ThrowIfCancellationRequested();
|
|
}
|
|
}
|
|
|
|
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)
|
|
{
|
|
CharacterDataFragmentPlayer? playerFragment = fragment as CharacterDataFragmentPlayer ?? throw new InvalidOperationException("Failed to cast CharacterDataFragment to Player variant");
|
|
|
|
playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations();
|
|
playerFragment.HonorificData = await getHonorificTitle!.ConfigureAwait(false);
|
|
_logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData);
|
|
|
|
playerFragment.PetNamesData = _ipcManager.PetNames.GetLocalNames();
|
|
_logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);
|
|
|
|
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();
|
|
|
|
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))
|
|
{
|
|
_logger.LogDebug("=> {repl}", replacement);
|
|
fragment.FileReplacements.Add(replacement);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)))
|
|
fragment.FileReplacements.Add(replacement);
|
|
}
|
|
|
|
_transientResourceManager.CleanUpSemiTransientResources(objectKind, [.. fragment.FileReplacements]);
|
|
|
|
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);
|
|
|
|
await Task.Run(() =>
|
|
{
|
|
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.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, (CharacterDataFragmentPlayer)fragment, ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch (OperationCanceledException e)
|
|
{
|
|
_logger.LogDebug(e, "Cancelled during player animation verification");
|
|
throw;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_logger.LogWarning(e, "Failed to verify player animations, continuing without further verification");
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("Building character data for {obj} took {time}ms",
|
|
objectKind, sw.Elapsed.TotalMilliseconds);
|
|
|
|
return fragment;
|
|
}
|
|
|
|
private async Task EnsureObjectPresentAsync(GameObjectHandler handler, CancellationToken ct)
|
|
{
|
|
var remaining = 10000;
|
|
while (remaining > 0)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
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))
|
|
{
|
|
if (_transientResourceManager.AddTransientResource(objectKind, item))
|
|
_logger.LogDebug("Marking static {item} for Pet as transient", item);
|
|
}
|
|
|
|
_logger.LogTrace("Clearing {count} Static Replacements for Pet", staticReplacements.Count);
|
|
clearedReplacements = staticReplacements;
|
|
}
|
|
|
|
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 havokBytes = await Task.Run(() => XivDataAnalyzer.ReadHavokBytesFromPap(papPath), ct)
|
|
.ConfigureAwait(false);
|
|
|
|
if (havokBytes is { Length: > 8 })
|
|
{
|
|
papIndices = await _dalamudUtil.RunOnFrameworkThread(
|
|
() => _modelAnalyzer.ParseHavokBytesOnFrameworkThread(havokBytes, hash, persistToConfig: false))
|
|
.ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_papParseLimiter.Release();
|
|
}
|
|
|
|
if (papIndices == null || papIndices.Count == 0)
|
|
continue;
|
|
|
|
if (_logger.IsEnabled(LogLevel.Debug))
|
|
{
|
|
try
|
|
{
|
|
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 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(
|
|
HashSet<string> forwardResolve,
|
|
HashSet<string> reverseResolve)
|
|
{
|
|
var forwardPaths = forwardResolve.ToArray();
|
|
var reversePaths = reverseResolve.ToArray();
|
|
if (forwardPaths.Length == 0 && reversePaths.Length == 0)
|
|
{
|
|
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 = 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] = [.. reverse[i].Select(c => c.ToLowerInvariant()).ToList()];
|
|
}
|
|
|
|
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
|
|
}
|
|
|
|
private HashSet<string> ManageSemiTransientData(ObjectKind objectKind)
|
|
{
|
|
_transientResourceManager.PersistTransientResources(objectKind);
|
|
|
|
HashSet<string> pathsToResolve = new(StringComparer.Ordinal);
|
|
|
|
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;
|
|
}
|
|
}
|