808 lines
32 KiB
C#
808 lines
32 KiB
C#
using Dalamud.Utility;
|
|
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
|
using LightlessSync.API.Data.Enum;
|
|
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 Microsoft.Extensions.Logging;
|
|
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using System.Runtime.ExceptionServices;
|
|
using System.Runtime.InteropServices;
|
|
|
|
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 ConcurrentDictionary<nint, Task<CharacterDataFragment>> _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 async Task<bool> CheckForNullDrawObject(IntPtr playerPointer)
|
|
=> await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
|
|
|
|
private unsafe static bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
|
|
{
|
|
if (playerPointer == IntPtr.Zero)
|
|
return true;
|
|
|
|
if (!IsPointerValid(playerPointer))
|
|
return true;
|
|
|
|
var character = (Character*)playerPointer;
|
|
if (character == null)
|
|
return true;
|
|
|
|
var gameObject = &character->GameObject;
|
|
if (gameObject == null)
|
|
return true;
|
|
|
|
if (!IsPointerValid((IntPtr)gameObject))
|
|
return true;
|
|
|
|
return gameObject->DrawObject == null;
|
|
}
|
|
|
|
private static bool IsPointerValid(IntPtr ptr)
|
|
{
|
|
if (ptr == IntPtr.Zero)
|
|
return false;
|
|
|
|
try
|
|
{
|
|
_ = Marshal.ReadByte(ptr);
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
return 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 var cached) && IsCacheFresh(cached) && !_characterBuildInflight.ContainsKey(key))
|
|
return cached.Fragment;
|
|
|
|
var buildTask = _characterBuildInflight.GetOrAdd(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)
|
|
{
|
|
try
|
|
{
|
|
using var cts = new CancellationTokenSource(_hardBuildTimeout);
|
|
var fragment = await CreateCharacterDataInternal(obj, cts.Token).ConfigureAwait(false);
|
|
|
|
_characterBuildCache[key] = new CacheEntry(fragment, DateTime.UtcNow);
|
|
PruneCharacterCacheIfNeeded();
|
|
|
|
return fragment;
|
|
}
|
|
finally
|
|
{
|
|
_characterBuildInflight.TryRemove(key, out _);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
var resolvedPaths = await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false) ?? 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(obj, 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(
|
|
GameObjectHandler handler,
|
|
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);
|
|
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] = [forwardPathsLower[i]];
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < reversePaths.Length; i++)
|
|
{
|
|
var filePath = reversePathsLower[i];
|
|
var reverseResolvedLower = new string[reverseResolved[i].Length];
|
|
for (var j = 0; j < reverseResolvedLower.Length; j++)
|
|
{
|
|
reverseResolvedLower[j] = reverseResolved[i][j].ToLowerInvariant();
|
|
}
|
|
if (resolvedPaths.TryGetValue(filePath, out var list))
|
|
list.AddRange(reverseResolved[i].Select(c => c.ToLowerInvariant()));
|
|
else
|
|
resolvedPaths[filePath] = [.. reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList()];
|
|
}
|
|
|
|
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
|
|
}
|
|
}
|
|
|
|
var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false);
|
|
|
|
for (int i = 0; i < forwardPaths.Length; i++)
|
|
{
|
|
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;
|
|
}
|
|
}
|