Testing PAP handling changes.

This commit is contained in:
cake
2026-01-02 03:56:59 +01:00
parent 7e61954541
commit d6fe09ba8e
7 changed files with 708 additions and 169 deletions

View File

@@ -21,6 +21,7 @@ public class PlayerDataFactory
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,
@@ -121,7 +122,6 @@ public class PlayerDataFactory
_logger.LogDebug("Building character data for {obj}", playerRelatedObject);
var logDebug = _logger.IsEnabled(LogLevel.Debug);
// 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)
@@ -135,7 +135,6 @@ public class PlayerDataFactory
DateTime start = DateTime.UtcNow;
// penumbra call, it's currently broken
Dictionary<string, HashSet<string>>? resolvedPaths;
resolvedPaths = (await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false));
@@ -144,8 +143,7 @@ public class PlayerDataFactory
ct.ThrowIfCancellationRequested();
fragment.FileReplacements =
new HashSet<FileReplacement>(resolvedPaths.Select(c => new FileReplacement([.. c.Value], c.Key)), FileReplacementComparer.Instance)
.Where(p => p.HasFileReplacement).ToHashSet();
[.. new HashSet<FileReplacement>(resolvedPaths.Select(c => new FileReplacement([.. c.Value], c.Key)), FileReplacementComparer.Instance).Where(p => p.HasFileReplacement)];
fragment.FileReplacements.RemoveWhere(c => c.GamePaths.Any(g => !CacheMonitor.AllowedFileExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase))));
ct.ThrowIfCancellationRequested();
@@ -169,8 +167,6 @@ public class PlayerDataFactory
await _transientResourceManager.WaitForRecording(ct).ConfigureAwait(false);
// 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))
@@ -189,10 +185,8 @@ public class PlayerDataFactory
_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());
_transientResourceManager.ClearTransientPaths(objectKind, [.. fragment.FileReplacements.SelectMany(c => c.GamePaths)]);
// get all remaining paths and resolve them
var transientPaths = ManageSemiTransientData(objectKind);
var resolvedTransientPaths = await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
@@ -213,12 +207,10 @@ public class PlayerDataFactory
}
}
// 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
@@ -270,13 +262,17 @@ public class PlayerDataFactory
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);
boneIndices = await _dalamudUtil
.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject))
.ConfigureAwait(false);
}
}
@@ -284,9 +280,16 @@ public class PlayerDataFactory
{
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, (fragment as CharacterDataFragmentPlayer)!, ct)
.ConfigureAwait(false);
}
}
catch (OperationCanceledException e)
@@ -305,74 +308,174 @@ public class PlayerDataFactory
return fragment;
}
private async Task VerifyPlayerAnimationBones(Dictionary<string, List<ushort>>? boneIndices, CharacterDataFragmentPlayer fragment, CancellationToken ct)
private async Task VerifyPlayerAnimationBones(
Dictionary<string, List<ushort>>? playerBoneIndices,
CharacterDataFragmentPlayer fragment,
CancellationToken ct)
{
if (boneIndices == null) return;
if (playerBoneIndices == null || playerBoneIndices.Count == 0)
return;
var playerBoneSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
foreach (var (rawLocalKey, indices) in playerBoneIndices)
{
if (indices == null || indices.Count == 0)
continue;
var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawLocalKey);
if (string.IsNullOrEmpty(key))
continue;
if (!playerBoneSets.TryGetValue(key, out var set))
playerBoneSets[key] = set = new HashSet<ushort>();
foreach (var idx in indices)
set.Add(idx);
}
if (playerBoneSets.Count == 0)
return;
if (_logger.IsEnabled(LogLevel.Debug))
{
foreach (var kvp in boneIndices)
foreach (var kvp in playerBoneSets)
{
_logger.LogDebug("Found {skellyname} ({idx} bone indices) on player: {bones}", kvp.Key, kvp.Value.Any() ? kvp.Value.Max() : 0, string.Join(',', kvp.Value));
_logger.LogDebug(
"Found local skeleton bucket '{bucket}' ({count} indices, max {max})",
kvp.Key,
kvp.Value.Count,
kvp.Value.Count > 0 ? kvp.Value.Max() : 0);
}
}
var maxPlayerBoneIndex = boneIndices.SelectMany(kvp => kvp.Value).DefaultIfEmpty().Max();
if (maxPlayerBoneIndex <= 0) return;
var papFiles = fragment.FileReplacements
.Where(f => !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase))
.ToList();
if (papFiles.Count == 0)
return;
var papGroupsByHash = papFiles
.Where(f => !string.IsNullOrEmpty(f.Hash))
.GroupBy(f => f.Hash, StringComparer.OrdinalIgnoreCase)
.ToList();
int noValidationFailed = 0;
foreach (var file in fragment.FileReplacements.Where(f => !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)).ToList())
static ushort MaxIndex(List<ushort> list)
{
if (list == null || list.Count == 0) return 0;
ushort max = 0;
for (int i = 0; i < list.Count; i++)
if (list[i] > max) max = list[i];
return max;
}
static bool ShouldIgnorePap(Dictionary<string, List<ushort>> pap)
{
foreach (var kv in pap)
{
if (kv.Value == null || kv.Value.Count == 0)
continue;
if (MaxIndex(kv.Value) > 105)
return false;
}
return true;
}
foreach (var group in papGroupsByHash)
{
ct.ThrowIfCancellationRequested();
var skeletonIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetBoneIndicesFromPap(file.Hash)).ConfigureAwait(false);
bool validationFailed = false;
if (skeletonIndices != null)
var hash = group.Key;
Dictionary<string, List<ushort>>? papSkeletonIndices;
await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
try
{
// 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);
papSkeletonIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash), ct)
.ConfigureAwait(false);
}
finally
{
_papParseLimiter.Release();
}
if (papSkeletonIndices == null || papSkeletonIndices.Count == 0)
continue;
if (ShouldIgnorePap(papSkeletonIndices))
{
_logger.LogTrace("All indices of PAP hash {hash} are <= 105, ignoring", hash);
continue;
}
bool invalid = false;
string? reason = null;
foreach (var (rawPapName, usedIndices) in papSkeletonIndices)
{
var papKey = XivDataAnalyzer.CanonicalizeSkeletonKey(rawPapName);
if (string.IsNullOrEmpty(papKey))
continue;
if (!playerBoneSets.TryGetValue(papKey, out var available))
{
invalid = true;
reason = $"Missing skeleton bucket '{papKey}' (raw '{rawPapName}') on local player.";
break;
}
_logger.LogDebug("Verifying bone indices for {path}, found {x} skeletons", file.ResolvedPath, skeletonIndices.Count);
foreach (var boneCount in skeletonIndices)
for (int i = 0; i < usedIndices.Count; i++)
{
var maxAnimationIndex = boneCount.Value.DefaultIfEmpty().Max();
if (maxAnimationIndex > maxPlayerBoneIndex)
var idx = usedIndices[i];
if (!available.Contains(idx))
{
_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;
invalid = true;
reason = $"Skeleton '{papKey}' missing bone index {idx} (raw '{rawPapName}').";
break;
}
}
if (invalid)
break;
}
if (validationFailed)
if (!invalid)
continue;
noValidationFailed++;
_logger.LogWarning(
"Animation PAP hash {hash} is not compatible with local skeletons; dropping all mappings for this hash. Reason: {reason}",
hash,
reason);
foreach (var file in group.ToList())
{
noValidationFailed++;
_logger.LogDebug("Removing {file} from sent file replacements and transient data", file.ResolvedPath);
fragment.FileReplacements.Remove(file);
foreach (var gamePath in file.GamePaths)
{
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gamePath);
}
}
foreach (var gamePath in file.GamePaths)
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gamePath);
}
}
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 file groups with bone indices not present on your current skeleton. " +
"Those animation files have been removed from your sent data. Verify that you are using the correct skeleton for those animations " +
"(Check /xllog for more information).",
NotificationType.Warning,
TimeSpan.FromSeconds(10)));
}
}
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(GameObjectHandler handler, HashSet<string> forwardResolve, HashSet<string> reverseResolve)
{
var forwardPaths = forwardResolve.ToArray();