From e16ddb0a1d9e98bd590bd4114724ebb5bf777306 Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 2 Jan 2026 19:29:50 +0100 Subject: [PATCH 1/4] change log to trace --- LightlessSync/Services/XivDataAnalyzer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/Services/XivDataAnalyzer.cs b/LightlessSync/Services/XivDataAnalyzer.cs index f3f7e9d..95700bb 100644 --- a/LightlessSync/Services/XivDataAnalyzer.cs +++ b/LightlessSync/Services/XivDataAnalyzer.cs @@ -178,7 +178,7 @@ public sealed class XivDataAnalyzer if (!File.Exists(tempHavokDataPath)) { - _logger.LogWarning("Temporary havok file was deleted before it could be loaded: {path}", tempHavokDataPath); + _logger.LogTrace("Temporary havok file was deleted before it could be loaded: {path}", tempHavokDataPath); return null; } From e41a7149c5ecd8adb6e65d4b1a5284bad58ae717 Mon Sep 17 00:00:00 2001 From: cake Date: Sat, 3 Jan 2026 14:58:54 +0100 Subject: [PATCH 2/4] Refactored many parts, added settings for detection --- .../Configurations/LightlessConfig.cs | 2 + .../Factories/AnimationValidationMode.cs | 9 + .../PlayerData/Factories/PlayerDataFactory.cs | 160 +++++------ .../PlayerData/Pairs/PairHandlerAdapter.cs | 231 +++++---------- .../Pairs/PairHandlerAdapterFactory.cs | 9 +- LightlessSync/Services/XivDataAnalyzer.cs | 265 +++++++++++++++--- LightlessSync/UI/SettingsUi.cs | 85 +++++- 7 files changed, 481 insertions(+), 280 deletions(-) create mode 100644 LightlessSync/PlayerData/Factories/AnimationValidationMode.cs diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 737f9ee..f38e100 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -4,6 +4,7 @@ using LightlessSync.LightlessConfiguration.Models; using LightlessSync.UI; using LightlessSync.UI.Models; using Microsoft.Extensions.Logging; +using LightlessSync.PlayerData.Factories; namespace LightlessSync.LightlessConfiguration.Configurations; @@ -156,4 +157,5 @@ public class LightlessConfig : ILightlessConfiguration public string? SelectedFinderSyncshell { get; set; } = null; public string LastSeenVersion { get; set; } = string.Empty; public HashSet OrphanableTempCollections { get; set; } = []; + public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Safe; } diff --git a/LightlessSync/PlayerData/Factories/AnimationValidationMode.cs b/LightlessSync/PlayerData/Factories/AnimationValidationMode.cs new file mode 100644 index 0000000..ca73117 --- /dev/null +++ b/LightlessSync/PlayerData/Factories/AnimationValidationMode.cs @@ -0,0 +1,9 @@ +namespace LightlessSync.PlayerData.Factories +{ + public enum AnimationValidationMode + { + Unsafe = 0, + Safe = 1, + Safest = 2, + } +} diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 371a2a9..6dc46ba 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -2,6 +2,7 @@ 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; @@ -20,6 +21,7 @@ public class PlayerDataFactory private readonly IpcManager _ipcManager; private readonly ILogger _logger; private readonly PerformanceCollectorService _performanceCollector; + private readonly LightlessConfigService _configService; private readonly XivDataAnalyzer _modelAnalyzer; private readonly LightlessMediator _lightlessMediator; private readonly TransientResourceManager _transientResourceManager; @@ -45,7 +47,8 @@ public class PlayerDataFactory FileCacheManager fileReplacementFactory, PerformanceCollectorService performanceCollector, XivDataAnalyzer modelAnalyzer, - LightlessMediator lightlessMediator) + LightlessMediator lightlessMediator, + LightlessConfigService configService) { _logger = logger; _dalamudUtil = dalamudUtil; @@ -55,6 +58,7 @@ 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); @@ -338,7 +342,7 @@ public class PlayerDataFactory if (objectKind == ObjectKind.Player) { hasPapFiles = fragment.FileReplacements.Any(f => - !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)); + !f.IsFileSwap && f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase))); if (hasPapFiles) { @@ -353,6 +357,7 @@ public class PlayerDataFactory if (hasPapFiles && boneIndices != null) _modelAnalyzer.DumpLocalSkeletonIndices(playerRelatedObject); #endif + if (hasPapFiles) { await VerifyPlayerAnimationBones(boneIndices, (CharacterDataFragmentPlayer)fragment, ct) @@ -458,77 +463,72 @@ public class PlayerDataFactory return (resolved, clearedReplacements); } + private async Task VerifyPlayerAnimationBones( Dictionary>? playerBoneIndices, CharacterDataFragmentPlayer fragment, CancellationToken ct) { + var mode = _configService.Current.AnimationValidationMode; + + if (mode == AnimationValidationMode.Unsafe) + return; + if (playerBoneIndices == null || playerBoneIndices.Count == 0) return; - var playerBoneSets = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var localBoneSets = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var (rawLocalKey, indices) in playerBoneIndices) { - if (indices == null || indices.Count == 0) + if (indices is not { Count: > 0 }) continue; var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawLocalKey); if (string.IsNullOrEmpty(key)) continue; - if (!playerBoneSets.TryGetValue(key, out var set)) - playerBoneSets[key] = set = []; + if (!localBoneSets.TryGetValue(key, out var set)) + localBoneSets[key] = set = []; foreach (var idx in indices) set.Add(idx); } - if (playerBoneSets.Count == 0) + if (localBoneSets.Count == 0) return; - var papFiles = fragment.FileReplacements - .Where(f => !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)) - .ToList(); + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("SEND local buckets: {b}", + string.Join(", ", localBoneSets.Keys.Order(StringComparer.Ordinal))); - if (papFiles.Count == 0) - return; + 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 papGroupsByHash = papFiles - .Where(f => !string.IsNullOrEmpty(f.Hash)) - .GroupBy(f => f.Hash, StringComparer.OrdinalIgnoreCase) + 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; - static ushort MaxIndex(List 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> 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) + foreach (var g in papGroups) { ct.ThrowIfCancellationRequested(); - var hash = group.Key; - Dictionary>? papSkeletonIndices; + var hash = g.Key; + + Dictionary>? papSkeletonIndices = null; await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false); try @@ -544,41 +544,36 @@ public class PlayerDataFactory if (papSkeletonIndices == null || papSkeletonIndices.Count == 0) continue; - if (ShouldIgnorePap(papSkeletonIndices)) + if (papSkeletonIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105)) continue; - bool invalid = false; - string? reason = null; - - foreach (var (rawPapName, usedIndices) in papSkeletonIndices) + if (_logger.IsEnabled(LogLevel.Debug)) { - 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; - } - - for (int i = 0; i < usedIndices.Count; i++) - { - var idx = usedIndices[i]; - if (!available.Contains(idx)) + var papBuckets = papSkeletonIndices + .Select(kvp => new { - invalid = true; - reason = $"Skeleton '{papKey}' missing bone index {idx} (raw '{rawPapName}')."; - break; - } - } + 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(); - if (invalid) - break; + _logger.LogDebug("SEND pap buckets for hash={hash}: {b}", + hash, + string.Join(" | ", papBuckets)); } - if (!invalid) + if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papSkeletonIndices, mode, out var reason)) continue; noValidationFailed++; @@ -588,27 +583,36 @@ public class PlayerDataFactory hash, reason); - foreach (var file in group.ToList()) - { - fragment.FileReplacements.Remove(file); + 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(); - foreach (var gamePath in file.GamePaths) - _transientResourceManager.RemoveTransientResource(ObjectKind.Player, gamePath); - } + 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 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).", + $"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> GetFileReplacementsFromPaths( GameObjectHandler handler, HashSet forwardResolve, @@ -681,7 +685,7 @@ public class PlayerDataFactory if (resolvedPaths.TryGetValue(filePath, out var list)) list.AddRange(reverse[i].Select(c => c.ToLowerInvariant())); else - resolvedPaths[filePath] = new List(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(); diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index e11f290..cdfeaf0 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -22,7 +22,7 @@ using Microsoft.Extensions.Logging; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer; -using LightlessSync.LightlessConfiguration.Models; +using LightlessSync.LightlessConfiguration; namespace LightlessSync.PlayerData.Pairs; @@ -49,6 +49,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private readonly PairPerformanceMetricsCache _performanceMetricsCache; private readonly XivDataAnalyzer _modelAnalyzer; private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor; + private readonly LightlessConfigService _configService; private readonly PairManager _pairManager; private CancellationTokenSource? _applicationCancellationTokenSource; private Guid _applicationId; @@ -188,7 +189,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa PairStateCache pairStateCache, PairPerformanceMetricsCache performanceMetricsCache, PenumbraTempCollectionJanitor tempCollectionJanitor, - XivDataAnalyzer modelAnalyzer) : base(logger, mediator) + XivDataAnalyzer modelAnalyzer, + LightlessConfigService configService) : base(logger, mediator) { _pairManager = pairManager; Ident = ident; @@ -208,6 +210,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _performanceMetricsCache = performanceMetricsCache; _tempCollectionJanitor = tempCollectionJanitor; _modelAnalyzer = modelAnalyzer; + _configService = configService; } public void Initialize() @@ -1736,45 +1739,45 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _needsCollectionRebuild = false; if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) { - _playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List()); - } - if (LastAppliedDataTris < 0) - { - await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(false); - } + _playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List()); + } + if (LastAppliedDataTris < 0) + { + await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(false); + } - StorePerformanceMetrics(charaData); - _lastSuccessfulApplyAt = DateTime.UtcNow; - ClearFailureState(); - Logger.LogDebug("[{applicationId}] Application finished", _applicationId); - } - catch (OperationCanceledException) - { - Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier()); - _cachedData = charaData; - _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; - RecordFailure("Application cancelled", "Cancellation"); - } - catch (Exception ex) - { - if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException)) + StorePerformanceMetrics(charaData); + _lastSuccessfulApplyAt = DateTime.UtcNow; + ClearFailureState(); + Logger.LogDebug("[{applicationId}] Application finished", _applicationId); + } + catch (OperationCanceledException) { - IsVisible = false; - _forceApplyMods = true; + Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier()); _cachedData = charaData; _pairStateCache.Store(Ident, charaData); _forceFullReapply = true; - Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId); + RecordFailure("Application cancelled", "Cancellation"); } - else + catch (Exception ex) { - Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId); - _forceFullReapply = true; + if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException)) + { + IsVisible = false; + _forceApplyMods = true; + _cachedData = charaData; + _pairStateCache.Store(Ident, charaData); + _forceFullReapply = true; + Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId); + } + else + { + Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId); + _forceFullReapply = true; + } + RecordFailure($"Application failed: {ex.Message}", "Exception"); } - RecordFailure($"Application failed: {ex.Message}", "Exception"); } -} private void FrameworkUpdate() { @@ -2008,12 +2011,27 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (string.IsNullOrEmpty(new FileInfo(fileCache.ResolvedFilepath).Extension)) { hasMigrationChanges = true; - fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, item.GamePaths[0].Split(".")[^1]); + var anyGamePath = item.GamePaths.FirstOrDefault(); + + if (!string.IsNullOrEmpty(anyGamePath)) + { + var ext = Path.GetExtension(anyGamePath); + var extNoDot = ext.StartsWith('.') ? ext[1..] : ext; + + if (!string.IsNullOrEmpty(extNoDot)) + { + hasMigrationChanges = true; + fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, extNoDot); + } + } } foreach (var gamePath in item.GamePaths) { - if (gamePath.EndsWith(".pap", StringComparison.OrdinalIgnoreCase) + var mode = _configService.Current.AnimationValidationMode; + + if (mode != AnimationValidationMode.Unsafe + && gamePath.EndsWith(".pap", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(item.Hash) && _blockedPapHashes.ContainsKey(item.Hash)) { @@ -2346,100 +2364,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return !string.IsNullOrEmpty(hashedCid); } - private static bool ContainsIndexCompat(HashSet available, ushort idx) - { - if (available.Contains(idx)) return true; - - if (idx > 0 && available.Contains((ushort)(idx - 1))) return true; - if (idx < ushort.MaxValue && available.Contains((ushort)(idx + 1))) return true; - - return false; - } - - private static bool IsPapCompatible( - IReadOnlyDictionary> targetBoneSets, - IReadOnlyDictionary> papBoneIndices, - out string reason) - { - var groups = papBoneIndices - .Select(kvp => new - { - Raw = kvp.Key, - Key = XivDataAnalyzer.CanonicalizeSkeletonKey(kvp.Key), - Indices = kvp.Value - }) - .Where(x => !string.IsNullOrEmpty(x.Key) && x.Indices is { Count: > 0 }) - .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (groups.Count == 0) - { - reason = "No bindings found in the PAP"; - return false; - } - - var papKeys = string.Join(", ", groups.Select(g => g.Key).Distinct(StringComparer.OrdinalIgnoreCase)); - var targetKeys = string.Join(", ", targetBoneSets.Keys); - - foreach (var g in groups) - { - var candidates = targetBoneSets - .Where(kvp => string.Equals(kvp.Key, g.Key, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - if (candidates.Count == 0) - { - if (targetBoneSets.Count == 1) - { - candidates = targetBoneSets.ToList(); - } - else - { - reason = $"No matching skeleton bucket between PAP [{papKeys}] and target [{targetKeys}]."; - return false; - } - } - - bool groupOk = false; - string? lastFail = null; - - foreach (var (targetKey, available) in candidates) - { - foreach (var variant in g) - { - bool ok = true; - foreach (var idx in variant.Indices) - { - if (!ContainsIndexCompat(available, idx)) - { - ok = false; - lastFail = $"Target bucket '{targetKey}' missing bone index {idx}. (pap raw '{variant.Raw}')"; - break; - } - } - - if (ok) - { - groupOk = true; - break; - } - } - - if (groupOk) break; - } - - if (!groupOk) - { - reason = lastFail - ?? $"Target skeleton missing required bone indices for PAP bucket '{g.Key}'."; - return false; - } - } - - reason = string.Empty; - return true; - } - private static void SplitPapMappings( Dictionary<(string GamePath, string? Hash), string> moddedPaths, out Dictionary<(string GamePath, string? Hash), string> withoutPap, @@ -2464,7 +2388,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Dictionary<(string GamePath, string? Hash), string> papOnly, CancellationToken token) { - if (papOnly.Count == 0) + var mode = _configService.Current.AnimationValidationMode; + if (mode == AnimationValidationMode.Unsafe || papOnly.Count == 0) return 0; var boneIndices = await _dalamudUtil.RunOnFrameworkThread( @@ -2472,15 +2397,17 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa .ConfigureAwait(false); if (boneIndices == null || boneIndices.Count == 0) - return papOnly.Count; + { + var removedCount = papOnly.Count; + papOnly.Clear(); + return removedCount; + } var localBoneSets = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var (rawKey, list) in boneIndices) { var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawKey); - if (string.IsNullOrEmpty(key) || list is null || list.Count == 0) - continue; + if (string.IsNullOrEmpty(key)) continue; if (!localBoneSets.TryGetValue(key, out var set)) localBoneSets[key] = set = []; @@ -2496,8 +2423,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa token.ThrowIfCancellationRequested(); var papIndices = await _dalamudUtil.RunOnFrameworkThread( - () => _modelAnalyzer.GetBoneIndicesFromPap(hash!)) - .ConfigureAwait(false); + () => _modelAnalyzer.GetBoneIndicesFromPap(hash!)) + .ConfigureAwait(false); if (papIndices == null || papIndices.Count == 0) continue; @@ -2505,26 +2432,22 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105)) continue; - if (!IsPapCompatible(localBoneSets, papIndices, out var reason)) + if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, out var reason)) + continue; + + var keysToRemove = papOnly.Keys.Where(k => string.Equals(k.Hash, hash, StringComparison.OrdinalIgnoreCase)).ToList(); + foreach (var k in keysToRemove) + papOnly.Remove(k); + + removed += keysToRemove.Count; + + if (_blockedPapHashes.TryAdd(hash!, 0)) + Logger.LogWarning("Blocked remote object PAP (hash {hash}) for {handler}: {reason}", hash, GetLogIdentifier(), reason); + + if (charaData.FileReplacements.TryGetValue(ObjectKind.Player, out var list)) { - var keysToRemove = papOnly.Keys.Where(k => string.Equals(k.Hash, hash, StringComparison.OrdinalIgnoreCase)).ToList(); - foreach (var k in keysToRemove) - papOnly.Remove(k); - - removed += keysToRemove.Count; - if (hash == null) - continue; - - if (_blockedPapHashes.TryAdd(hash, 0)) - { - Logger.LogWarning("Blocked remote object PAP (hash {hash}) for {handler}: {reason}", hash, GetLogIdentifier(), reason); - } - - if (charaData.FileReplacements.TryGetValue(ObjectKind.Player, out var list)) - { - list.RemoveAll(r => string.Equals(r.Hash, hash, StringComparison.OrdinalIgnoreCase) - && r.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase))); - } + list.RemoveAll(r => string.Equals(r.Hash, hash, StringComparison.OrdinalIgnoreCase) + && r.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase))); } } diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs index 3e36006..4b4e95d 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs @@ -1,5 +1,6 @@ using LightlessSync.FileCache; using LightlessSync.Interop.Ipc; +using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Factories; using LightlessSync.Services; using LightlessSync.Services.ActorTracking; @@ -32,6 +33,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory private readonly PairStateCache _pairStateCache; private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache; private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor; + private readonly LightlessConfigService _configService; private readonly XivDataAnalyzer _modelAnalyzer; public PairHandlerAdapterFactory( @@ -52,7 +54,8 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory PairStateCache pairStateCache, PairPerformanceMetricsCache pairPerformanceMetricsCache, PenumbraTempCollectionJanitor tempCollectionJanitor, - XivDataAnalyzer modelAnalyzer) + XivDataAnalyzer modelAnalyzer, + LightlessConfigService configService) { _loggerFactory = loggerFactory; _mediator = mediator; @@ -72,6 +75,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory _pairPerformanceMetricsCache = pairPerformanceMetricsCache; _tempCollectionJanitor = tempCollectionJanitor; _modelAnalyzer = modelAnalyzer; + _configService = configService; } public IPairHandlerAdapter Create(string ident) @@ -99,6 +103,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory _pairStateCache, _pairPerformanceMetricsCache, _tempCollectionJanitor, - _modelAnalyzer); + _modelAnalyzer, + _configService); } } diff --git a/LightlessSync/Services/XivDataAnalyzer.cs b/LightlessSync/Services/XivDataAnalyzer.cs index 95700bb..848fda3 100644 --- a/LightlessSync/Services/XivDataAnalyzer.cs +++ b/LightlessSync/Services/XivDataAnalyzer.cs @@ -6,6 +6,7 @@ using FFXIVClientStructs.Havok.Common.Serialize.Util; using LightlessSync.FileCache; using LightlessSync.Interop.GameModel; using LightlessSync.LightlessConfiguration; +using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Handlers; using Microsoft.Extensions.Logging; using System.Runtime.InteropServices; @@ -13,7 +14,7 @@ using System.Text.RegularExpressions; namespace LightlessSync.Services; -public sealed class XivDataAnalyzer +public sealed partial class XivDataAnalyzer { private readonly ILogger _logger; private readonly FileCacheManager _fileCacheManager; @@ -126,9 +127,12 @@ public sealed class XivDataAnalyzer return (output.Count != 0 && output.Values.All(v => v.Count > 0)) ? output : null; } - public unsafe Dictionary>? GetBoneIndicesFromPap(string hash) + public unsafe Dictionary>? GetBoneIndicesFromPap(string hash, bool persistToConfig = true) { - if (_configService.Current.BonesDictionary.TryGetValue(hash, out var cached)) + if (string.IsNullOrWhiteSpace(hash)) + return null; + + if (_configService.Current.BonesDictionary.TryGetValue(hash, out var cached) && cached is not null) return cached; var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash); @@ -138,47 +142,49 @@ public sealed class XivDataAnalyzer using var fs = File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read); using var reader = new BinaryReader(fs); - // most of this is from vfxeditor - reader.ReadInt32(); // ignore - reader.ReadInt32(); // ignore - reader.ReadInt16(); // num animations - reader.ReadInt16(); // modelid + // PAP header (mostly from vfxeditor) + _ = reader.ReadInt32(); // ignore + _ = reader.ReadInt32(); // ignore + _ = reader.ReadInt16(); // num animations + _ = reader.ReadInt16(); // modelid + var type = reader.ReadByte(); // type if (type != 0) return null; // not human - reader.ReadByte(); // variant - reader.ReadInt32(); // ignore + _ = reader.ReadByte(); // variant + _ = reader.ReadInt32(); // ignore + var havokPosition = reader.ReadInt32(); var footerPosition = reader.ReadInt32(); + // sanity checks if (havokPosition <= 0 || footerPosition <= havokPosition || footerPosition > fs.Length) return null; - var havokDataSize = footerPosition - havokPosition; - reader.BaseStream.Position = havokPosition; + var havokDataSizeLong = (long)footerPosition - havokPosition; + if (havokDataSizeLong <= 8 || havokDataSizeLong > int.MaxValue) + return null; + var havokDataSize = (int)havokDataSizeLong; + + reader.BaseStream.Position = havokPosition; var havokData = reader.ReadBytes(havokDataSize); if (havokData.Length <= 8) return null; - var output = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var tempSets = new Dictionary>(StringComparer.OrdinalIgnoreCase); - // write to temp file var tempHavokDataPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx"); - var tempHavokDataPathAnsi = IntPtr.Zero; + IntPtr tempHavokDataPathAnsi = IntPtr.Zero; try { - using (var tempFs = new FileStream(tempHavokDataPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.DeleteOnClose)) - { - tempFs.Write(havokData, 0, havokData.Length); - tempFs.Flush(true); - } + File.WriteAllBytes(tempHavokDataPath, havokData); if (!File.Exists(tempHavokDataPath)) { - _logger.LogTrace("Temporary havok file was deleted before it could be loaded: {path}", tempHavokDataPath); + _logger.LogTrace("Temporary havok file did not exist when attempting to load: {path}", tempHavokDataPath); return null; } @@ -228,26 +234,21 @@ public sealed class XivDataAnalyzer if (boneTransform.Length <= 0) continue; - if (!output.TryGetValue(skeletonKey, out var list)) + if (!tempSets.TryGetValue(skeletonKey, out var set)) { - list = new List(boneTransform.Length); - output[skeletonKey] = list; + set = new HashSet(); + tempSets[skeletonKey] = set; } for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++) { - list.Add((ushort)boneTransform[boneIdx]); + var v = boneTransform[boneIdx]; + if (v < 0) continue; + set.Add((ushort)v); } } } } - - foreach (var key in output.Keys.ToList()) - { - output[key] = [.. output[key] - .Distinct() - .Order()]; - } } catch (Exception ex) { @@ -270,20 +271,30 @@ public sealed class XivDataAnalyzer } } + if (tempSets.Count == 0) + return null; + + var output = new Dictionary>(tempSets.Count, StringComparer.OrdinalIgnoreCase); + foreach (var (key, set) in tempSets) + { + if (set.Count == 0) continue; + + var list = set.ToList(); + list.Sort(); + output[key] = list; + } + + if (output.Count == 0) + return null; + _configService.Current.BonesDictionary[hash] = output; - _configService.Save(); + + if (persistToConfig) + _configService.Save(); return output; } - private static readonly Regex _bucketPathRegex = - new(@"(?i)(?:^|/)(?c\d{4})(?:/|$)", RegexOptions.Compiled); - - private static readonly Regex _bucketSklRegex = - new(@"(?i)\bskl_(?c\d{4})[a-z]\d{4}\b", RegexOptions.Compiled); - - private static readonly Regex _bucketLooseRegex = - new(@"(?i)(?c\d{4})(?!\d)", RegexOptions.Compiled); public static string CanonicalizeSkeletonKey(string? raw) { @@ -314,6 +325,159 @@ public sealed class XivDataAnalyzer return string.Empty; } + public static bool ContainsIndexCompat(HashSet available, ushort idx, bool papLikelyOneBased) + { + if (available.Contains(idx)) + return true; + + if (papLikelyOneBased && idx > 0 && available.Contains((ushort)(idx - 1))) + return true; + + return false; + } + + public static bool IsPapCompatible( + IReadOnlyDictionary> localBoneSets, + IReadOnlyDictionary> papBoneIndices, + AnimationValidationMode mode, + out string reason) + { + if (mode == AnimationValidationMode.Unsafe) + { + reason = string.Empty; + return true; + } + + // Group PAP bindings by canonical skeleton key (with raw as fallback) + var groups = papBoneIndices + .Select(kvp => new + { + Raw = kvp.Key, + Key = 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) + .ToList(); + + if (groups.Count == 0) + { + reason = "No bindings found in the PAP"; + return false; + } + + // Determine relevant groups based on mode + var relevantGroups = groups.AsEnumerable(); + + if (mode == AnimationValidationMode.Safest) + { + relevantGroups = groups.Where(g => localBoneSets.ContainsKey(g.Key)); + + if (!relevantGroups.Any()) + { + var papKeys = string.Join(", ", groups.Select(g => g.Key).Distinct(StringComparer.OrdinalIgnoreCase)); + var localKeys = string.Join(", ", localBoneSets.Keys.Order(StringComparer.OrdinalIgnoreCase)); + reason = $"No matching skeleton bucket between PAP [{papKeys}] and local [{localKeys}]."; + return false; + } + } + + foreach (var g in relevantGroups) + { + // Each group may have multiple variants (different raw names mapping to same canonical key) + bool anyVariantOk = false; + + foreach (var variant in g) + { + // Check this variant against local skeleton(s) + var min = variant.Indices.Min(); + var papLikelyOneBased = min == 1 && !variant.Indices.Contains(0); + + bool variantOk; + + if (mode == AnimationValidationMode.Safest) + { + var available = localBoneSets[g.Key]; + + variantOk = true; + foreach (var idx in variant.Indices) + { + if (!ContainsIndexCompat(available, idx, papLikelyOneBased)) + { + variantOk = false; + break; + } + } + } + else + { + // Safe mode: any local skeleton matching this canonical key + variantOk = false; + + foreach (var available in localBoneSets.Values) + { + bool ok = true; + foreach (var idx in variant.Indices) + { + if (!ContainsIndexCompat(available, idx, papLikelyOneBased)) + { + ok = false; + break; + } + } + + if (ok) + { + variantOk = true; + break; + } + } + } + + if (variantOk) + { + anyVariantOk = true; + break; + } + } + + if (!anyVariantOk) + { + // No variant was compatible for this skeleton key + var first = g.First(); + ushort? missing = null; + + HashSet best; + if (mode == AnimationValidationMode.Safest && localBoneSets.TryGetValue(g.Key, out var exact)) + best = exact; + else + best = localBoneSets.Values.OrderByDescending(s => s.Count).First(); + + var min = first.Indices.Min(); + var papLikelyOneBased = min == 1 && !first.Indices.Contains(0); + + foreach (var idx in first.Indices) + { + if (!ContainsIndexCompat(best, idx, papLikelyOneBased)) + { + missing = idx; + break; + } + } + + reason = missing.HasValue + ? $"Skeleton '{g.Key}' missing bone index {missing.Value}. (raw '{first.Raw}')" + : $"Skeleton '{g.Key}' missing required bone indices. (raw '{first.Raw}')"; + return false; + } + } + + reason = string.Empty; + return true; + } + public void DumpLocalSkeletonIndices(GameObjectHandler handler, string? filter = null) { var skels = GetSkeletonBoneIndices(handler); @@ -408,4 +572,23 @@ public sealed class XivDataAnalyzer return 0; } } + + // Regexes for canonicalizing skeleton keys + private static readonly Regex _bucketPathRegex = + BucketRegex(); + + private static readonly Regex _bucketSklRegex = + SklRegex(); + + private static readonly Regex _bucketLooseRegex = + LooseBucketRegex(); + + [GeneratedRegex(@"(?i)(?:^|/)(?c\d{4})(?:/|$)", RegexOptions.Compiled, "en-NL")] + private static partial Regex BucketRegex(); + + [GeneratedRegex(@"(?i)\bskl_(?c\d{4})[a-z]\d{4}\b", RegexOptions.Compiled, "en-NL")] + private static partial Regex SklRegex(); + + [GeneratedRegex(@"(?i)(?c\d{4})(?!\d)", RegexOptions.Compiled, "en-NL")] + private static partial Regex LooseBucketRegex(); } diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 0efc91a..1ce6753 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -14,6 +14,7 @@ using LightlessSync.Interop.Ipc; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Configurations; using LightlessSync.LightlessConfiguration.Models; +using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; @@ -40,6 +41,7 @@ using System.Globalization; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Numerics; +using System.Runtime.InteropServices; using System.Text; using System.Text.Json; @@ -105,8 +107,8 @@ public class SettingsUi : WindowMediatorSubscriberBase }; private readonly UiSharedService.TabOption[] _transferTabOptions = new UiSharedService.TabOption[2]; private readonly List> _serverTabOptions = new(4); - private readonly string[] _generalTreeNavOrder = new[] - { + private readonly string[] _generalTreeNavOrder = + [ "Import & Export", "Popup & Auto Fill", "Behavior", @@ -116,7 +118,8 @@ public class SettingsUi : WindowMediatorSubscriberBase "Colors", "Server Info Bar", "Nameplate", - }; + "Animation & Bones" + ]; private static readonly HashSet _generalNavSeparatorAfter = new(StringComparer.Ordinal) { "Popup & Auto Fill", @@ -1141,7 +1144,7 @@ public class SettingsUi : WindowMediatorSubscriberBase private async Task?> RunSpeedTest(List servers, CancellationToken token) { - List speedTestResults = new(); + List speedTestResults = []; foreach (var server in servers) { HttpResponseMessage? result = null; @@ -3085,10 +3088,81 @@ public class SettingsUi : WindowMediatorSubscriberBase } ImGui.Separator(); + ImGui.Dummy(new Vector2(10)); + _uiShared.BigText("Animation"); + + using (var animationTree = BeginGeneralTree("Animation & Bones", UIColors.Get("LightlessPurple"))) + { + if (animationTree.Visible) + { + ImGui.TextUnformatted("Animation Options"); + + var modes = new[] + { + AnimationValidationMode.Unsafe, + AnimationValidationMode.Safe, + AnimationValidationMode.Safest, + }; + + var labels = new[] + { + "Unsafe", + "Safe (Bones)", + "Safest (Race + Bones)", + }; + + var tooltips = new[] + { + "No validation. Fastest, but may allow incompatible animations (riskier).", + "Validates bone indices against your current skeleton (recommended).", + "Requires matching skeleton race + bone compatibility (strictest).", + }; + + + var currentMode = _configService.Current.AnimationValidationMode; + int selectedIndex = Array.IndexOf(modes, currentMode); + if (selectedIndex < 0) selectedIndex = 1; + + ImGui.SetNextItemWidth(140 * ImGuiHelpers.GlobalScale); + + bool open = ImGui.BeginCombo("Animation validation", labels[selectedIndex]); + + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(tooltips[selectedIndex]); + + if (open) + { + for (int i = 0; i < modes.Length; i++) + { + bool isSelected = (i == selectedIndex); + + if (ImGui.Selectable(labels[i], isSelected)) + { + selectedIndex = i; + _configService.Current.AnimationValidationMode = modes[i]; + _configService.Save(); + } + + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(tooltips[i]); + + if (isSelected) + ImGui.SetItemDefaultFocus(); + } + + ImGui.EndCombo(); + } + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + animationTree.MarkContentEnd(); + } + } ImGui.EndChild(); ImGui.EndGroup(); + ImGui.Separator(); generalSelune.DrawHighlightOnly(ImGui.GetIO().DeltaTime); } } @@ -3178,6 +3252,7 @@ public class SettingsUi : WindowMediatorSubscriberBase return 1f - (elapsed / GeneralTreeHighlightDuration); } + [StructLayout(LayoutKind.Auto)] private struct GeneralTreeScope : IDisposable { private readonly bool _visible; @@ -3485,7 +3560,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText("Controls whether Lightless reduces index textures that exceed the size limit."); var dimensionOptions = new[] { 512, 1024, 2048, 4096 }; - var optionLabels = dimensionOptions.Select(static value => value.ToString()).ToArray(); + var optionLabels = dimensionOptions.Select(selector: static value => value.ToString()).ToArray(); var currentDimension = textureConfig.TextureDownscaleMaxDimension; var selectedIndex = Array.IndexOf(dimensionOptions, currentDimension); if (selectedIndex < 0) From 02d091eefa3e9168eca4659affcedfe30ebb6a81 Mon Sep 17 00:00:00 2001 From: cake Date: Sat, 3 Jan 2026 15:59:10 +0100 Subject: [PATCH 3/4] Added more loose matching options, fixed some race issues --- .../Configurations/LightlessConfig.cs | 3 + .../PlayerData/Factories/PlayerDataFactory.cs | 14 +- .../PlayerData/Pairs/PairHandlerAdapter.cs | 8 +- LightlessSync/Services/XivDataAnalyzer.cs | 208 +++++++----------- LightlessSync/UI/SettingsUi.cs | 23 +- 5 files changed, 122 insertions(+), 134 deletions(-) diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index f38e100..bf6dde1 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -158,4 +158,7 @@ public class LightlessConfig : ILightlessConfiguration public string LastSeenVersion { get; set; } = string.Empty; public HashSet OrphanableTempCollections { get; set; } = []; public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Safe; + public bool AnimationAllowOneBasedShift { get; set; } = true; + + public bool AnimationAllowNeighborIndexTolerance { get; set; } = false; } diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 6dc46ba..4ef77c9 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -470,6 +470,8 @@ public class PlayerDataFactory CancellationToken ct) { var mode = _configService.Current.AnimationValidationMode; + var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift; + var allownNightIndex = _configService.Current.AnimationAllowNeighborIndexTolerance; if (mode == AnimationValidationMode.Unsafe) return; @@ -528,12 +530,12 @@ public class PlayerDataFactory var hash = g.Key; - Dictionary>? papSkeletonIndices = null; + Dictionary>? papIndices = null; await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false); try { - papSkeletonIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash), ct) + papIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash), ct) .ConfigureAwait(false); } finally @@ -541,15 +543,15 @@ public class PlayerDataFactory _papParseLimiter.Release(); } - if (papSkeletonIndices == null || papSkeletonIndices.Count == 0) + if (papIndices == null || papIndices.Count == 0) continue; - if (papSkeletonIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105)) + if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105)) continue; if (_logger.IsEnabled(LogLevel.Debug)) { - var papBuckets = papSkeletonIndices + var papBuckets = papIndices .Select(kvp => new { Raw = kvp.Key, @@ -573,7 +575,7 @@ public class PlayerDataFactory string.Join(" | ", papBuckets)); } - if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papSkeletonIndices, mode, out var reason)) + if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason)) continue; noValidationFailed++; diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index cdfeaf0..97ba71a 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -93,7 +93,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa ".avfx", ".scd" }; + private readonly ConcurrentDictionary _blockedPapHashes = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _dumpedRemoteSkeletonForHash = new(StringComparer.OrdinalIgnoreCase); + private DateTime? _invisibleSinceUtc; private DateTime? _visibilityEvictionDueAtUtc; private DateTime _nextActorLookupUtc = DateTime.MinValue; @@ -2389,6 +2392,9 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa CancellationToken token) { var mode = _configService.Current.AnimationValidationMode; + var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift; + var allownNightIndex = _configService.Current.AnimationAllowNeighborIndexTolerance; + if (mode == AnimationValidationMode.Unsafe || papOnly.Count == 0) return 0; @@ -2432,7 +2438,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105)) continue; - if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, out var reason)) + if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason)) continue; var keysToRemove = papOnly.Keys.Where(k => string.Equals(k.Hash, hash, StringComparison.OrdinalIgnoreCase)).ToList(); diff --git a/LightlessSync/Services/XivDataAnalyzer.cs b/LightlessSync/Services/XivDataAnalyzer.cs index 848fda3..03b5ee9 100644 --- a/LightlessSync/Services/XivDataAnalyzer.cs +++ b/LightlessSync/Services/XivDataAnalyzer.cs @@ -236,7 +236,7 @@ public sealed partial class XivDataAnalyzer if (!tempSets.TryGetValue(skeletonKey, out var set)) { - set = new HashSet(); + set = []; tempSets[skeletonKey] = set; } @@ -325,13 +325,37 @@ public sealed partial class XivDataAnalyzer return string.Empty; } - public static bool ContainsIndexCompat(HashSet available, ushort idx, bool papLikelyOneBased) + public static bool ContainsIndexCompat( + HashSet available, + ushort idx, + bool papLikelyOneBased, + bool allowOneBasedShift, + bool allowNeighborTolerance) { - if (available.Contains(idx)) - return true; + Span candidates = stackalloc ushort[2]; + int count = 0; - if (papLikelyOneBased && idx > 0 && available.Contains((ushort)(idx - 1))) - return true; + candidates[count++] = idx; + + if (allowOneBasedShift && papLikelyOneBased && idx > 0) + candidates[count++] = (ushort)(idx - 1); + + for (int i = 0; i < count; i++) + { + var c = candidates[i]; + + if (available.Contains(c)) + return true; + + if (allowNeighborTolerance) + { + if (c > 0 && available.Contains((ushort)(c - 1))) + return true; + + if (c < ushort.MaxValue && available.Contains((ushort)(c + 1))) + return true; + } + } return false; } @@ -340,141 +364,73 @@ public sealed partial class XivDataAnalyzer IReadOnlyDictionary> localBoneSets, IReadOnlyDictionary> papBoneIndices, AnimationValidationMode mode, + bool allowOneBasedShift, + bool allowNeighborTolerance, out string reason) { - if (mode == AnimationValidationMode.Unsafe) - { - reason = string.Empty; - return true; - } + reason = string.Empty; - // Group PAP bindings by canonical skeleton key (with raw as fallback) - var groups = papBoneIndices - .Select(kvp => new - { - Raw = kvp.Key, - Key = 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) + if (mode == AnimationValidationMode.Unsafe) + return true; + + var papBuckets = papBoneIndices.Keys + .Select(CanonicalizeSkeletonKey) + .Where(k => !string.IsNullOrEmpty(k)) + .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); - if (groups.Count == 0) + if (papBuckets.Count == 0) { - reason = "No bindings found in the PAP"; + reason = "No skeleton bucket bindings found in the PAP"; return false; } - // Determine relevant groups based on mode - var relevantGroups = groups.AsEnumerable(); - - if (mode == AnimationValidationMode.Safest) + if (mode == AnimationValidationMode.Safe) { - relevantGroups = groups.Where(g => localBoneSets.ContainsKey(g.Key)); + if (papBuckets.Any(b => localBoneSets.ContainsKey(b))) + return true; - if (!relevantGroups.Any()) + reason = $"No matching skeleton bucket between PAP [{string.Join(", ", papBuckets)}] and local [{string.Join(", ", localBoneSets.Keys.Order())}]."; + return false; + } + + foreach (var bucket in papBuckets) + { + if (!localBoneSets.TryGetValue(bucket, out var available)) { - var papKeys = string.Join(", ", groups.Select(g => g.Key).Distinct(StringComparer.OrdinalIgnoreCase)); - var localKeys = string.Join(", ", localBoneSets.Keys.Order(StringComparer.OrdinalIgnoreCase)); - reason = $"No matching skeleton bucket between PAP [{papKeys}] and local [{localKeys}]."; + reason = $"Missing skeleton bucket '{bucket}' on local actor."; return false; } + + var indices = papBoneIndices + .Where(kvp => string.Equals(CanonicalizeSkeletonKey(kvp.Key), bucket, StringComparison.OrdinalIgnoreCase)) + .SelectMany(kvp => kvp.Value ?? Enumerable.Empty()) + .Distinct() + .ToList(); + + if (indices.Count == 0) + continue; + + bool has0 = false, has1 = false; + ushort min = ushort.MaxValue; + foreach (var v in indices) + { + if (v == 0) has0 = true; + if (v == 1) has1 = true; + if (v < min) min = v; + } + bool papLikelyOneBased = allowOneBasedShift && (min == 1) && has1 && !has0; + + foreach (var idx in indices) + { + if (!ContainsIndexCompat(available, idx, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance)) + { + reason = $"No compatible local skeleton for PAP '{bucket}': missing bone index {idx}."; + return false; + } + } } - foreach (var g in relevantGroups) - { - // Each group may have multiple variants (different raw names mapping to same canonical key) - bool anyVariantOk = false; - - foreach (var variant in g) - { - // Check this variant against local skeleton(s) - var min = variant.Indices.Min(); - var papLikelyOneBased = min == 1 && !variant.Indices.Contains(0); - - bool variantOk; - - if (mode == AnimationValidationMode.Safest) - { - var available = localBoneSets[g.Key]; - - variantOk = true; - foreach (var idx in variant.Indices) - { - if (!ContainsIndexCompat(available, idx, papLikelyOneBased)) - { - variantOk = false; - break; - } - } - } - else - { - // Safe mode: any local skeleton matching this canonical key - variantOk = false; - - foreach (var available in localBoneSets.Values) - { - bool ok = true; - foreach (var idx in variant.Indices) - { - if (!ContainsIndexCompat(available, idx, papLikelyOneBased)) - { - ok = false; - break; - } - } - - if (ok) - { - variantOk = true; - break; - } - } - } - - if (variantOk) - { - anyVariantOk = true; - break; - } - } - - if (!anyVariantOk) - { - // No variant was compatible for this skeleton key - var first = g.First(); - ushort? missing = null; - - HashSet best; - if (mode == AnimationValidationMode.Safest && localBoneSets.TryGetValue(g.Key, out var exact)) - best = exact; - else - best = localBoneSets.Values.OrderByDescending(s => s.Count).First(); - - var min = first.Indices.Min(); - var papLikelyOneBased = min == 1 && !first.Indices.Contains(0); - - foreach (var idx in first.Indices) - { - if (!ContainsIndexCompat(best, idx, papLikelyOneBased)) - { - missing = idx; - break; - } - } - - reason = missing.HasValue - ? $"Skeleton '{g.Key}' missing bone index {missing.Value}. (raw '{first.Raw}')" - : $"Skeleton '{g.Key}' missing required bone indices. (raw '{first.Raw}')"; - return false; - } - } - - reason = string.Empty; return true; } @@ -486,7 +442,7 @@ public sealed partial class XivDataAnalyzer _logger.LogTrace("DumpLocalSkeletonIndices: local skeleton indices are null or not found"); return; } - + var keys = skels.Keys .Order(StringComparer.OrdinalIgnoreCase) .ToArray(); diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 1ce6753..3102e13 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -3114,7 +3114,7 @@ public class SettingsUi : WindowMediatorSubscriberBase var tooltips = new[] { "No validation. Fastest, but may allow incompatible animations (riskier).", - "Validates bone indices against your current skeleton (recommended).", + "Validates skeleton race (recommended).", "Requires matching skeleton race + bone compatibility (strictest).", }; @@ -3154,6 +3154,27 @@ public class SettingsUi : WindowMediatorSubscriberBase } UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + + var cfg = _configService.Current; + + bool oneBased = cfg.AnimationAllowOneBasedShift; + if (ImGui.Checkbox("Treat 1-based PAP indices as compatible", ref oneBased)) + { + cfg.AnimationAllowOneBasedShift = oneBased; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Fixes off-by-one PAPs (one bone differance in bones and PAP). Can also increase crashing, toggle off if alot of crashing is happening"); + + bool neighbor = cfg.AnimationAllowNeighborIndexTolerance; + if (ImGui.Checkbox("Allow 1+- bone index tolerance", ref neighbor)) + { + cfg.AnimationAllowNeighborIndexTolerance = neighbor; + _configService.Save(); + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Looser matching on bone matching. Can reduce false blocks happening but also reduces safety and more prone to crashing."); + ImGui.TreePop(); animationTree.MarkContentEnd(); } From 6af61451dca311ad1b3e5f26d917549c9f54dd8e Mon Sep 17 00:00:00 2001 From: cake Date: Sat, 3 Jan 2026 16:32:39 +0100 Subject: [PATCH 4/4] Fixed naming of setting --- LightlessSync/UI/SettingsUi.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 3102e13..d80bb35 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -3107,14 +3107,14 @@ public class SettingsUi : WindowMediatorSubscriberBase var labels = new[] { "Unsafe", - "Safe (Bones)", + "Safe (Race)", "Safest (Race + Bones)", }; var tooltips = new[] { "No validation. Fastest, but may allow incompatible animations (riskier).", - "Validates skeleton race (recommended).", + "Validates skeleton race + modded skeleton check (recommended).", "Requires matching skeleton race + bone compatibility (strictest).", };