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(); }