Merge branch 'cake-attempts-2.0.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into cake-attempts-2.0.3
This commit is contained in:
@@ -4,6 +4,7 @@ using LightlessSync.LightlessConfiguration.Models;
|
|||||||
using LightlessSync.UI;
|
using LightlessSync.UI;
|
||||||
using LightlessSync.UI.Models;
|
using LightlessSync.UI.Models;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using LightlessSync.PlayerData.Factories;
|
||||||
|
|
||||||
namespace LightlessSync.LightlessConfiguration.Configurations;
|
namespace LightlessSync.LightlessConfiguration.Configurations;
|
||||||
|
|
||||||
@@ -156,4 +157,8 @@ public class LightlessConfig : ILightlessConfiguration
|
|||||||
public string? SelectedFinderSyncshell { get; set; } = null;
|
public string? SelectedFinderSyncshell { get; set; } = null;
|
||||||
public string LastSeenVersion { get; set; } = string.Empty;
|
public string LastSeenVersion { get; set; } = string.Empty;
|
||||||
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
|
public HashSet<Guid> OrphanableTempCollections { get; set; } = [];
|
||||||
|
public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Safe;
|
||||||
|
public bool AnimationAllowOneBasedShift { get; set; } = true;
|
||||||
|
|
||||||
|
public bool AnimationAllowNeighborIndexTolerance { get; set; } = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace LightlessSync.PlayerData.Factories
|
||||||
|
{
|
||||||
|
public enum AnimationValidationMode
|
||||||
|
{
|
||||||
|
Unsafe = 0,
|
||||||
|
Safe = 1,
|
||||||
|
Safest = 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
using LightlessSync.API.Data.Enum;
|
using LightlessSync.API.Data.Enum;
|
||||||
using LightlessSync.FileCache;
|
using LightlessSync.FileCache;
|
||||||
using LightlessSync.Interop.Ipc;
|
using LightlessSync.Interop.Ipc;
|
||||||
|
using LightlessSync.LightlessConfiguration;
|
||||||
using LightlessSync.LightlessConfiguration.Models;
|
using LightlessSync.LightlessConfiguration.Models;
|
||||||
using LightlessSync.PlayerData.Data;
|
using LightlessSync.PlayerData.Data;
|
||||||
using LightlessSync.PlayerData.Handlers;
|
using LightlessSync.PlayerData.Handlers;
|
||||||
@@ -20,6 +21,7 @@ public class PlayerDataFactory
|
|||||||
private readonly IpcManager _ipcManager;
|
private readonly IpcManager _ipcManager;
|
||||||
private readonly ILogger<PlayerDataFactory> _logger;
|
private readonly ILogger<PlayerDataFactory> _logger;
|
||||||
private readonly PerformanceCollectorService _performanceCollector;
|
private readonly PerformanceCollectorService _performanceCollector;
|
||||||
|
private readonly LightlessConfigService _configService;
|
||||||
private readonly XivDataAnalyzer _modelAnalyzer;
|
private readonly XivDataAnalyzer _modelAnalyzer;
|
||||||
private readonly LightlessMediator _lightlessMediator;
|
private readonly LightlessMediator _lightlessMediator;
|
||||||
private readonly TransientResourceManager _transientResourceManager;
|
private readonly TransientResourceManager _transientResourceManager;
|
||||||
@@ -45,7 +47,8 @@ public class PlayerDataFactory
|
|||||||
FileCacheManager fileReplacementFactory,
|
FileCacheManager fileReplacementFactory,
|
||||||
PerformanceCollectorService performanceCollector,
|
PerformanceCollectorService performanceCollector,
|
||||||
XivDataAnalyzer modelAnalyzer,
|
XivDataAnalyzer modelAnalyzer,
|
||||||
LightlessMediator lightlessMediator)
|
LightlessMediator lightlessMediator,
|
||||||
|
LightlessConfigService configService)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_dalamudUtil = dalamudUtil;
|
_dalamudUtil = dalamudUtil;
|
||||||
@@ -55,6 +58,7 @@ public class PlayerDataFactory
|
|||||||
_performanceCollector = performanceCollector;
|
_performanceCollector = performanceCollector;
|
||||||
_modelAnalyzer = modelAnalyzer;
|
_modelAnalyzer = modelAnalyzer;
|
||||||
_lightlessMediator = lightlessMediator;
|
_lightlessMediator = lightlessMediator;
|
||||||
|
_configService = configService;
|
||||||
_logger.LogTrace("Creating {this}", nameof(PlayerDataFactory));
|
_logger.LogTrace("Creating {this}", nameof(PlayerDataFactory));
|
||||||
}
|
}
|
||||||
private sealed record CacheEntry(CharacterDataFragment Fragment, DateTime CreatedUtc);
|
private sealed record CacheEntry(CharacterDataFragment Fragment, DateTime CreatedUtc);
|
||||||
@@ -338,7 +342,7 @@ public class PlayerDataFactory
|
|||||||
if (objectKind == ObjectKind.Player)
|
if (objectKind == ObjectKind.Player)
|
||||||
{
|
{
|
||||||
hasPapFiles = fragment.FileReplacements.Any(f =>
|
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)
|
if (hasPapFiles)
|
||||||
{
|
{
|
||||||
@@ -353,6 +357,7 @@ public class PlayerDataFactory
|
|||||||
if (hasPapFiles && boneIndices != null)
|
if (hasPapFiles && boneIndices != null)
|
||||||
_modelAnalyzer.DumpLocalSkeletonIndices(playerRelatedObject);
|
_modelAnalyzer.DumpLocalSkeletonIndices(playerRelatedObject);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if (hasPapFiles)
|
if (hasPapFiles)
|
||||||
{
|
{
|
||||||
await VerifyPlayerAnimationBones(boneIndices, (CharacterDataFragmentPlayer)fragment, ct)
|
await VerifyPlayerAnimationBones(boneIndices, (CharacterDataFragmentPlayer)fragment, ct)
|
||||||
@@ -458,82 +463,79 @@ public class PlayerDataFactory
|
|||||||
return (resolved, clearedReplacements);
|
return (resolved, clearedReplacements);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async Task VerifyPlayerAnimationBones(
|
private async Task VerifyPlayerAnimationBones(
|
||||||
Dictionary<string, List<ushort>>? playerBoneIndices,
|
Dictionary<string, List<ushort>>? playerBoneIndices,
|
||||||
CharacterDataFragmentPlayer fragment,
|
CharacterDataFragmentPlayer fragment,
|
||||||
CancellationToken ct)
|
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)
|
if (playerBoneIndices == null || playerBoneIndices.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var playerBoneSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
|
var localBoneSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
foreach (var (rawLocalKey, indices) in playerBoneIndices)
|
foreach (var (rawLocalKey, indices) in playerBoneIndices)
|
||||||
{
|
{
|
||||||
if (indices == null || indices.Count == 0)
|
if (indices is not { Count: > 0 })
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawLocalKey);
|
var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawLocalKey);
|
||||||
if (string.IsNullOrEmpty(key))
|
if (string.IsNullOrEmpty(key))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (!playerBoneSets.TryGetValue(key, out var set))
|
if (!localBoneSets.TryGetValue(key, out var set))
|
||||||
playerBoneSets[key] = set = [];
|
localBoneSets[key] = set = [];
|
||||||
|
|
||||||
foreach (var idx in indices)
|
foreach (var idx in indices)
|
||||||
set.Add(idx);
|
set.Add(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playerBoneSets.Count == 0)
|
if (localBoneSets.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var papFiles = fragment.FileReplacements
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
.Where(f => !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase))
|
{
|
||||||
.ToList();
|
_logger.LogDebug("SEND local buckets: {b}",
|
||||||
|
string.Join(", ", localBoneSets.Keys.Order(StringComparer.Ordinal)));
|
||||||
|
|
||||||
if (papFiles.Count == 0)
|
foreach (var kvp in localBoneSets.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
|
||||||
return;
|
{
|
||||||
|
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
|
var papGroups = fragment.FileReplacements
|
||||||
.Where(f => !string.IsNullOrEmpty(f.Hash))
|
.Where(f => !f.IsFileSwap
|
||||||
.GroupBy(f => f.Hash, StringComparer.OrdinalIgnoreCase)
|
&& !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();
|
.ToList();
|
||||||
|
|
||||||
int noValidationFailed = 0;
|
int noValidationFailed = 0;
|
||||||
|
|
||||||
static ushort MaxIndex(List<ushort> list)
|
foreach (var g in papGroups)
|
||||||
{
|
|
||||||
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();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var hash = group.Key;
|
var hash = g.Key;
|
||||||
Dictionary<string, List<ushort>>? papSkeletonIndices;
|
|
||||||
|
Dictionary<string, List<ushort>>? papIndices = null;
|
||||||
|
|
||||||
await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
|
await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
papSkeletonIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash), ct)
|
papIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash), ct)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -541,44 +543,39 @@ public class PlayerDataFactory
|
|||||||
_papParseLimiter.Release();
|
_papParseLimiter.Release();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (papSkeletonIndices == null || papSkeletonIndices.Count == 0)
|
if (papIndices == null || papIndices.Count == 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (ShouldIgnorePap(papSkeletonIndices))
|
if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
bool invalid = false;
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
string? reason = null;
|
|
||||||
|
|
||||||
foreach (var (rawPapName, usedIndices) in papSkeletonIndices)
|
|
||||||
{
|
{
|
||||||
var papKey = XivDataAnalyzer.CanonicalizeSkeletonKey(rawPapName);
|
var papBuckets = papIndices
|
||||||
if (string.IsNullOrEmpty(papKey))
|
.Select(kvp => new
|
||||||
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))
|
|
||||||
{
|
{
|
||||||
invalid = true;
|
Raw = kvp.Key,
|
||||||
reason = $"Skeleton '{papKey}' missing bone index {idx} (raw '{rawPapName}').";
|
Key = XivDataAnalyzer.CanonicalizeSkeletonKey(kvp.Key),
|
||||||
break;
|
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)
|
_logger.LogDebug("SEND pap buckets for hash={hash}: {b}",
|
||||||
break;
|
hash,
|
||||||
|
string.Join(" | ", papBuckets));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!invalid)
|
if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
noValidationFailed++;
|
noValidationFailed++;
|
||||||
@@ -588,27 +585,36 @@ public class PlayerDataFactory
|
|||||||
hash,
|
hash,
|
||||||
reason);
|
reason);
|
||||||
|
|
||||||
foreach (var file in group.ToList())
|
var removedGamePaths = fragment.FileReplacements
|
||||||
{
|
.Where(fr => !fr.IsFileSwap
|
||||||
fragment.FileReplacements.Remove(file);
|
&& 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)
|
fragment.FileReplacements.RemoveWhere(fr =>
|
||||||
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gamePath);
|
!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)
|
if (noValidationFailed > 0)
|
||||||
{
|
{
|
||||||
_lightlessMediator.Publish(new NotificationMessage(
|
_lightlessMediator.Publish(new NotificationMessage(
|
||||||
"Invalid Skeleton Setup",
|
"Invalid Skeleton Setup",
|
||||||
$"Your client is attempting to send {noValidationFailed} animation file groups with bone indices not present on your current skeleton. " +
|
$"Your client is attempting to send {noValidationFailed} animation files that don't match your current skeleton validation mode ({mode}). " +
|
||||||
"Those animation files have been removed from your sent data. Verify that you are using the correct skeleton for those animations " +
|
"Please adjust your skeleton/mods or change the validation mode if this is unexpected. " +
|
||||||
"(Check /xllog for more information).",
|
"Those animation files have been removed from your sent (player) data. (Check /xllog for details).",
|
||||||
NotificationType.Warning,
|
NotificationType.Warning,
|
||||||
TimeSpan.FromSeconds(10)));
|
TimeSpan.FromSeconds(10)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(
|
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(
|
||||||
GameObjectHandler handler,
|
GameObjectHandler handler,
|
||||||
HashSet<string> forwardResolve,
|
HashSet<string> forwardResolve,
|
||||||
@@ -681,7 +687,7 @@ public class PlayerDataFactory
|
|||||||
if (resolvedPaths.TryGetValue(filePath, out var list))
|
if (resolvedPaths.TryGetValue(filePath, out var list))
|
||||||
list.AddRange(reverse[i].Select(c => c.ToLowerInvariant()));
|
list.AddRange(reverse[i].Select(c => c.ToLowerInvariant()));
|
||||||
else
|
else
|
||||||
resolvedPaths[filePath] = new List<string>(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();
|
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
|
||||||
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
|
||||||
using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer;
|
using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer;
|
||||||
using LightlessSync.LightlessConfiguration.Models;
|
using LightlessSync.LightlessConfiguration;
|
||||||
|
|
||||||
namespace LightlessSync.PlayerData.Pairs;
|
namespace LightlessSync.PlayerData.Pairs;
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
private readonly PairPerformanceMetricsCache _performanceMetricsCache;
|
private readonly PairPerformanceMetricsCache _performanceMetricsCache;
|
||||||
private readonly XivDataAnalyzer _modelAnalyzer;
|
private readonly XivDataAnalyzer _modelAnalyzer;
|
||||||
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
|
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
|
||||||
|
private readonly LightlessConfigService _configService;
|
||||||
private readonly PairManager _pairManager;
|
private readonly PairManager _pairManager;
|
||||||
private CancellationTokenSource? _applicationCancellationTokenSource;
|
private CancellationTokenSource? _applicationCancellationTokenSource;
|
||||||
private Guid _applicationId;
|
private Guid _applicationId;
|
||||||
@@ -92,7 +93,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
".avfx",
|
".avfx",
|
||||||
".scd"
|
".scd"
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, byte> _blockedPapHashes = new(StringComparer.OrdinalIgnoreCase);
|
private readonly ConcurrentDictionary<string, byte> _blockedPapHashes = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly ConcurrentDictionary<string, byte> _dumpedRemoteSkeletonForHash = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private DateTime? _invisibleSinceUtc;
|
private DateTime? _invisibleSinceUtc;
|
||||||
private DateTime? _visibilityEvictionDueAtUtc;
|
private DateTime? _visibilityEvictionDueAtUtc;
|
||||||
private DateTime _nextActorLookupUtc = DateTime.MinValue;
|
private DateTime _nextActorLookupUtc = DateTime.MinValue;
|
||||||
@@ -188,7 +192,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
PairStateCache pairStateCache,
|
PairStateCache pairStateCache,
|
||||||
PairPerformanceMetricsCache performanceMetricsCache,
|
PairPerformanceMetricsCache performanceMetricsCache,
|
||||||
PenumbraTempCollectionJanitor tempCollectionJanitor,
|
PenumbraTempCollectionJanitor tempCollectionJanitor,
|
||||||
XivDataAnalyzer modelAnalyzer) : base(logger, mediator)
|
XivDataAnalyzer modelAnalyzer,
|
||||||
|
LightlessConfigService configService) : base(logger, mediator)
|
||||||
{
|
{
|
||||||
_pairManager = pairManager;
|
_pairManager = pairManager;
|
||||||
Ident = ident;
|
Ident = ident;
|
||||||
@@ -208,6 +213,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
_performanceMetricsCache = performanceMetricsCache;
|
_performanceMetricsCache = performanceMetricsCache;
|
||||||
_tempCollectionJanitor = tempCollectionJanitor;
|
_tempCollectionJanitor = tempCollectionJanitor;
|
||||||
_modelAnalyzer = modelAnalyzer;
|
_modelAnalyzer = modelAnalyzer;
|
||||||
|
_configService = configService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Initialize()
|
public void Initialize()
|
||||||
@@ -1736,45 +1742,45 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
_needsCollectionRebuild = false;
|
_needsCollectionRebuild = false;
|
||||||
if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0)
|
if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0)
|
||||||
{
|
{
|
||||||
_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List<DownloadFileTransfer>());
|
_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List<DownloadFileTransfer>());
|
||||||
}
|
}
|
||||||
if (LastAppliedDataTris < 0)
|
if (LastAppliedDataTris < 0)
|
||||||
{
|
{
|
||||||
await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(false);
|
await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
StorePerformanceMetrics(charaData);
|
StorePerformanceMetrics(charaData);
|
||||||
_lastSuccessfulApplyAt = DateTime.UtcNow;
|
_lastSuccessfulApplyAt = DateTime.UtcNow;
|
||||||
ClearFailureState();
|
ClearFailureState();
|
||||||
Logger.LogDebug("[{applicationId}] Application finished", _applicationId);
|
Logger.LogDebug("[{applicationId}] Application finished", _applicationId);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
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))
|
|
||||||
{
|
{
|
||||||
IsVisible = false;
|
Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier());
|
||||||
_forceApplyMods = true;
|
|
||||||
_cachedData = charaData;
|
_cachedData = charaData;
|
||||||
_pairStateCache.Store(Ident, charaData);
|
_pairStateCache.Store(Ident, charaData);
|
||||||
_forceFullReapply = true;
|
_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);
|
if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException))
|
||||||
_forceFullReapply = true;
|
{
|
||||||
|
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()
|
private void FrameworkUpdate()
|
||||||
{
|
{
|
||||||
@@ -2008,12 +2014,27 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
if (string.IsNullOrEmpty(new FileInfo(fileCache.ResolvedFilepath).Extension))
|
if (string.IsNullOrEmpty(new FileInfo(fileCache.ResolvedFilepath).Extension))
|
||||||
{
|
{
|
||||||
hasMigrationChanges = true;
|
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)
|
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)
|
&& !string.IsNullOrEmpty(item.Hash)
|
||||||
&& _blockedPapHashes.ContainsKey(item.Hash))
|
&& _blockedPapHashes.ContainsKey(item.Hash))
|
||||||
{
|
{
|
||||||
@@ -2346,100 +2367,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
return !string.IsNullOrEmpty(hashedCid);
|
return !string.IsNullOrEmpty(hashedCid);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ContainsIndexCompat(HashSet<ushort> 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<string, HashSet<ushort>> targetBoneSets,
|
|
||||||
IReadOnlyDictionary<string, List<ushort>> 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(
|
private static void SplitPapMappings(
|
||||||
Dictionary<(string GamePath, string? Hash), string> moddedPaths,
|
Dictionary<(string GamePath, string? Hash), string> moddedPaths,
|
||||||
out Dictionary<(string GamePath, string? Hash), string> withoutPap,
|
out Dictionary<(string GamePath, string? Hash), string> withoutPap,
|
||||||
@@ -2464,7 +2391,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
Dictionary<(string GamePath, string? Hash), string> papOnly,
|
Dictionary<(string GamePath, string? Hash), string> papOnly,
|
||||||
CancellationToken token)
|
CancellationToken token)
|
||||||
{
|
{
|
||||||
if (papOnly.Count == 0)
|
var mode = _configService.Current.AnimationValidationMode;
|
||||||
|
var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift;
|
||||||
|
var allownNightIndex = _configService.Current.AnimationAllowNeighborIndexTolerance;
|
||||||
|
|
||||||
|
if (mode == AnimationValidationMode.Unsafe || papOnly.Count == 0)
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
var boneIndices = await _dalamudUtil.RunOnFrameworkThread(
|
var boneIndices = await _dalamudUtil.RunOnFrameworkThread(
|
||||||
@@ -2472,15 +2403,17 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
if (boneIndices == null || boneIndices.Count == 0)
|
if (boneIndices == null || boneIndices.Count == 0)
|
||||||
return papOnly.Count;
|
{
|
||||||
|
var removedCount = papOnly.Count;
|
||||||
|
papOnly.Clear();
|
||||||
|
return removedCount;
|
||||||
|
}
|
||||||
|
|
||||||
var localBoneSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
|
var localBoneSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
foreach (var (rawKey, list) in boneIndices)
|
foreach (var (rawKey, list) in boneIndices)
|
||||||
{
|
{
|
||||||
var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawKey);
|
var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawKey);
|
||||||
if (string.IsNullOrEmpty(key) || list is null || list.Count == 0)
|
if (string.IsNullOrEmpty(key)) continue;
|
||||||
continue;
|
|
||||||
|
|
||||||
if (!localBoneSets.TryGetValue(key, out var set))
|
if (!localBoneSets.TryGetValue(key, out var set))
|
||||||
localBoneSets[key] = set = [];
|
localBoneSets[key] = set = [];
|
||||||
@@ -2496,8 +2429,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var papIndices = await _dalamudUtil.RunOnFrameworkThread(
|
var papIndices = await _dalamudUtil.RunOnFrameworkThread(
|
||||||
() => _modelAnalyzer.GetBoneIndicesFromPap(hash!))
|
() => _modelAnalyzer.GetBoneIndicesFromPap(hash!))
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
if (papIndices == null || papIndices.Count == 0)
|
if (papIndices == null || papIndices.Count == 0)
|
||||||
continue;
|
continue;
|
||||||
@@ -2505,26 +2438,22 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
|||||||
if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105))
|
if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (!IsPapCompatible(localBoneSets, papIndices, 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();
|
||||||
|
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();
|
list.RemoveAll(r => string.Equals(r.Hash, hash, StringComparison.OrdinalIgnoreCase)
|
||||||
foreach (var k in keysToRemove)
|
&& r.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
|
||||||
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)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using LightlessSync.FileCache;
|
using LightlessSync.FileCache;
|
||||||
using LightlessSync.Interop.Ipc;
|
using LightlessSync.Interop.Ipc;
|
||||||
|
using LightlessSync.LightlessConfiguration;
|
||||||
using LightlessSync.PlayerData.Factories;
|
using LightlessSync.PlayerData.Factories;
|
||||||
using LightlessSync.Services;
|
using LightlessSync.Services;
|
||||||
using LightlessSync.Services.ActorTracking;
|
using LightlessSync.Services.ActorTracking;
|
||||||
@@ -32,6 +33,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
|||||||
private readonly PairStateCache _pairStateCache;
|
private readonly PairStateCache _pairStateCache;
|
||||||
private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache;
|
private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache;
|
||||||
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
|
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
|
||||||
|
private readonly LightlessConfigService _configService;
|
||||||
private readonly XivDataAnalyzer _modelAnalyzer;
|
private readonly XivDataAnalyzer _modelAnalyzer;
|
||||||
|
|
||||||
public PairHandlerAdapterFactory(
|
public PairHandlerAdapterFactory(
|
||||||
@@ -52,7 +54,8 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
|||||||
PairStateCache pairStateCache,
|
PairStateCache pairStateCache,
|
||||||
PairPerformanceMetricsCache pairPerformanceMetricsCache,
|
PairPerformanceMetricsCache pairPerformanceMetricsCache,
|
||||||
PenumbraTempCollectionJanitor tempCollectionJanitor,
|
PenumbraTempCollectionJanitor tempCollectionJanitor,
|
||||||
XivDataAnalyzer modelAnalyzer)
|
XivDataAnalyzer modelAnalyzer,
|
||||||
|
LightlessConfigService configService)
|
||||||
{
|
{
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
_mediator = mediator;
|
_mediator = mediator;
|
||||||
@@ -72,6 +75,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
|||||||
_pairPerformanceMetricsCache = pairPerformanceMetricsCache;
|
_pairPerformanceMetricsCache = pairPerformanceMetricsCache;
|
||||||
_tempCollectionJanitor = tempCollectionJanitor;
|
_tempCollectionJanitor = tempCollectionJanitor;
|
||||||
_modelAnalyzer = modelAnalyzer;
|
_modelAnalyzer = modelAnalyzer;
|
||||||
|
_configService = configService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IPairHandlerAdapter Create(string ident)
|
public IPairHandlerAdapter Create(string ident)
|
||||||
@@ -99,6 +103,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
|
|||||||
_pairStateCache,
|
_pairStateCache,
|
||||||
_pairPerformanceMetricsCache,
|
_pairPerformanceMetricsCache,
|
||||||
_tempCollectionJanitor,
|
_tempCollectionJanitor,
|
||||||
_modelAnalyzer);
|
_modelAnalyzer,
|
||||||
|
_configService);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using FFXIVClientStructs.Havok.Common.Serialize.Util;
|
|||||||
using LightlessSync.FileCache;
|
using LightlessSync.FileCache;
|
||||||
using LightlessSync.Interop.GameModel;
|
using LightlessSync.Interop.GameModel;
|
||||||
using LightlessSync.LightlessConfiguration;
|
using LightlessSync.LightlessConfiguration;
|
||||||
|
using LightlessSync.PlayerData.Factories;
|
||||||
using LightlessSync.PlayerData.Handlers;
|
using LightlessSync.PlayerData.Handlers;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
@@ -13,7 +14,7 @@ using System.Text.RegularExpressions;
|
|||||||
|
|
||||||
namespace LightlessSync.Services;
|
namespace LightlessSync.Services;
|
||||||
|
|
||||||
public sealed class XivDataAnalyzer
|
public sealed partial class XivDataAnalyzer
|
||||||
{
|
{
|
||||||
private readonly ILogger<XivDataAnalyzer> _logger;
|
private readonly ILogger<XivDataAnalyzer> _logger;
|
||||||
private readonly FileCacheManager _fileCacheManager;
|
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;
|
return (output.Count != 0 && output.Values.All(v => v.Count > 0)) ? output : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public unsafe Dictionary<string, List<ushort>>? GetBoneIndicesFromPap(string hash)
|
public unsafe Dictionary<string, List<ushort>>? 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;
|
return cached;
|
||||||
|
|
||||||
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
|
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 fs = File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
using var reader = new BinaryReader(fs);
|
using var reader = new BinaryReader(fs);
|
||||||
|
|
||||||
// most of this is from vfxeditor
|
// PAP header (mostly from vfxeditor)
|
||||||
reader.ReadInt32(); // ignore
|
_ = reader.ReadInt32(); // ignore
|
||||||
reader.ReadInt32(); // ignore
|
_ = reader.ReadInt32(); // ignore
|
||||||
reader.ReadInt16(); // num animations
|
_ = reader.ReadInt16(); // num animations
|
||||||
reader.ReadInt16(); // modelid
|
_ = reader.ReadInt16(); // modelid
|
||||||
|
|
||||||
var type = reader.ReadByte(); // type
|
var type = reader.ReadByte(); // type
|
||||||
if (type != 0)
|
if (type != 0)
|
||||||
return null; // not human
|
return null; // not human
|
||||||
|
|
||||||
reader.ReadByte(); // variant
|
_ = reader.ReadByte(); // variant
|
||||||
reader.ReadInt32(); // ignore
|
_ = reader.ReadInt32(); // ignore
|
||||||
|
|
||||||
var havokPosition = reader.ReadInt32();
|
var havokPosition = reader.ReadInt32();
|
||||||
var footerPosition = reader.ReadInt32();
|
var footerPosition = reader.ReadInt32();
|
||||||
|
|
||||||
|
// sanity checks
|
||||||
if (havokPosition <= 0 || footerPosition <= havokPosition || footerPosition > fs.Length)
|
if (havokPosition <= 0 || footerPosition <= havokPosition || footerPosition > fs.Length)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var havokDataSize = footerPosition - havokPosition;
|
var havokDataSizeLong = (long)footerPosition - havokPosition;
|
||||||
reader.BaseStream.Position = havokPosition;
|
if (havokDataSizeLong <= 8 || havokDataSizeLong > int.MaxValue)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var havokDataSize = (int)havokDataSizeLong;
|
||||||
|
|
||||||
|
reader.BaseStream.Position = havokPosition;
|
||||||
var havokData = reader.ReadBytes(havokDataSize);
|
var havokData = reader.ReadBytes(havokDataSize);
|
||||||
if (havokData.Length <= 8)
|
if (havokData.Length <= 8)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var output = new Dictionary<string, List<ushort>>(StringComparer.OrdinalIgnoreCase);
|
var tempSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// write to temp file
|
|
||||||
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx");
|
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx");
|
||||||
var tempHavokDataPathAnsi = IntPtr.Zero;
|
IntPtr tempHavokDataPathAnsi = IntPtr.Zero;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using (var tempFs = new FileStream(tempHavokDataPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.DeleteOnClose))
|
File.WriteAllBytes(tempHavokDataPath, havokData);
|
||||||
{
|
|
||||||
tempFs.Write(havokData, 0, havokData.Length);
|
|
||||||
tempFs.Flush(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!File.Exists(tempHavokDataPath))
|
if (!File.Exists(tempHavokDataPath))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,26 +234,21 @@ public sealed class XivDataAnalyzer
|
|||||||
if (boneTransform.Length <= 0)
|
if (boneTransform.Length <= 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (!output.TryGetValue(skeletonKey, out var list))
|
if (!tempSets.TryGetValue(skeletonKey, out var set))
|
||||||
{
|
{
|
||||||
list = new List<ushort>(boneTransform.Length);
|
set = [];
|
||||||
output[skeletonKey] = list;
|
tempSets[skeletonKey] = set;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++)
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -270,20 +271,30 @@ public sealed class XivDataAnalyzer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tempSets.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var output = new Dictionary<string, List<ushort>>(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.Current.BonesDictionary[hash] = output;
|
||||||
_configService.Save();
|
|
||||||
|
if (persistToConfig)
|
||||||
|
_configService.Save();
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly Regex _bucketPathRegex =
|
|
||||||
new(@"(?i)(?:^|/)(?<bucket>c\d{4})(?:/|$)", RegexOptions.Compiled);
|
|
||||||
|
|
||||||
private static readonly Regex _bucketSklRegex =
|
|
||||||
new(@"(?i)\bskl_(?<bucket>c\d{4})[a-z]\d{4}\b", RegexOptions.Compiled);
|
|
||||||
|
|
||||||
private static readonly Regex _bucketLooseRegex =
|
|
||||||
new(@"(?i)(?<![a-z0-9])(?<bucket>c\d{4})(?!\d)", RegexOptions.Compiled);
|
|
||||||
|
|
||||||
public static string CanonicalizeSkeletonKey(string? raw)
|
public static string CanonicalizeSkeletonKey(string? raw)
|
||||||
{
|
{
|
||||||
@@ -314,6 +325,115 @@ public sealed class XivDataAnalyzer
|
|||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool ContainsIndexCompat(
|
||||||
|
HashSet<ushort> available,
|
||||||
|
ushort idx,
|
||||||
|
bool papLikelyOneBased,
|
||||||
|
bool allowOneBasedShift,
|
||||||
|
bool allowNeighborTolerance)
|
||||||
|
{
|
||||||
|
Span<ushort> candidates = stackalloc ushort[2];
|
||||||
|
int count = 0;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsPapCompatible(
|
||||||
|
IReadOnlyDictionary<string, HashSet<ushort>> localBoneSets,
|
||||||
|
IReadOnlyDictionary<string, List<ushort>> papBoneIndices,
|
||||||
|
AnimationValidationMode mode,
|
||||||
|
bool allowOneBasedShift,
|
||||||
|
bool allowNeighborTolerance,
|
||||||
|
out string reason)
|
||||||
|
{
|
||||||
|
reason = string.Empty;
|
||||||
|
|
||||||
|
if (mode == AnimationValidationMode.Unsafe)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var papBuckets = papBoneIndices.Keys
|
||||||
|
.Select(CanonicalizeSkeletonKey)
|
||||||
|
.Where(k => !string.IsNullOrEmpty(k))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (papBuckets.Count == 0)
|
||||||
|
{
|
||||||
|
reason = "No skeleton bucket bindings found in the PAP";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == AnimationValidationMode.Safe)
|
||||||
|
{
|
||||||
|
if (papBuckets.Any(b => localBoneSets.ContainsKey(b)))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
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<ushort>())
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public void DumpLocalSkeletonIndices(GameObjectHandler handler, string? filter = null)
|
public void DumpLocalSkeletonIndices(GameObjectHandler handler, string? filter = null)
|
||||||
{
|
{
|
||||||
var skels = GetSkeletonBoneIndices(handler);
|
var skels = GetSkeletonBoneIndices(handler);
|
||||||
@@ -322,7 +442,7 @@ public sealed class XivDataAnalyzer
|
|||||||
_logger.LogTrace("DumpLocalSkeletonIndices: local skeleton indices are null or not found");
|
_logger.LogTrace("DumpLocalSkeletonIndices: local skeleton indices are null or not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var keys = skels.Keys
|
var keys = skels.Keys
|
||||||
.Order(StringComparer.OrdinalIgnoreCase)
|
.Order(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
@@ -408,4 +528,23 @@ public sealed class XivDataAnalyzer
|
|||||||
return 0;
|
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)(?:^|/)(?<bucket>c\d{4})(?:/|$)", RegexOptions.Compiled, "en-NL")]
|
||||||
|
private static partial Regex BucketRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"(?i)\bskl_(?<bucket>c\d{4})[a-z]\d{4}\b", RegexOptions.Compiled, "en-NL")]
|
||||||
|
private static partial Regex SklRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"(?i)(?<![a-z0-9])(?<bucket>c\d{4})(?!\d)", RegexOptions.Compiled, "en-NL")]
|
||||||
|
private static partial Regex LooseBucketRegex();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ using LightlessSync.Interop.Ipc;
|
|||||||
using LightlessSync.LightlessConfiguration;
|
using LightlessSync.LightlessConfiguration;
|
||||||
using LightlessSync.LightlessConfiguration.Configurations;
|
using LightlessSync.LightlessConfiguration.Configurations;
|
||||||
using LightlessSync.LightlessConfiguration.Models;
|
using LightlessSync.LightlessConfiguration.Models;
|
||||||
|
using LightlessSync.PlayerData.Factories;
|
||||||
using LightlessSync.PlayerData.Handlers;
|
using LightlessSync.PlayerData.Handlers;
|
||||||
using LightlessSync.PlayerData.Pairs;
|
using LightlessSync.PlayerData.Pairs;
|
||||||
using LightlessSync.Services;
|
using LightlessSync.Services;
|
||||||
@@ -40,6 +41,7 @@ using System.Globalization;
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
@@ -105,8 +107,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
};
|
};
|
||||||
private readonly UiSharedService.TabOption<TransferSettingsTab>[] _transferTabOptions = new UiSharedService.TabOption<TransferSettingsTab>[2];
|
private readonly UiSharedService.TabOption<TransferSettingsTab>[] _transferTabOptions = new UiSharedService.TabOption<TransferSettingsTab>[2];
|
||||||
private readonly List<UiSharedService.TabOption<ServerSettingsTab>> _serverTabOptions = new(4);
|
private readonly List<UiSharedService.TabOption<ServerSettingsTab>> _serverTabOptions = new(4);
|
||||||
private readonly string[] _generalTreeNavOrder = new[]
|
private readonly string[] _generalTreeNavOrder =
|
||||||
{
|
[
|
||||||
"Import & Export",
|
"Import & Export",
|
||||||
"Popup & Auto Fill",
|
"Popup & Auto Fill",
|
||||||
"Behavior",
|
"Behavior",
|
||||||
@@ -116,7 +118,8 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
"Colors",
|
"Colors",
|
||||||
"Server Info Bar",
|
"Server Info Bar",
|
||||||
"Nameplate",
|
"Nameplate",
|
||||||
};
|
"Animation & Bones"
|
||||||
|
];
|
||||||
private static readonly HashSet<string> _generalNavSeparatorAfter = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> _generalNavSeparatorAfter = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
"Popup & Auto Fill",
|
"Popup & Auto Fill",
|
||||||
@@ -1141,7 +1144,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
|
|
||||||
private async Task<List<string>?> RunSpeedTest(List<string> servers, CancellationToken token)
|
private async Task<List<string>?> RunSpeedTest(List<string> servers, CancellationToken token)
|
||||||
{
|
{
|
||||||
List<string> speedTestResults = new();
|
List<string> speedTestResults = [];
|
||||||
foreach (var server in servers)
|
foreach (var server in servers)
|
||||||
{
|
{
|
||||||
HttpResponseMessage? result = null;
|
HttpResponseMessage? result = null;
|
||||||
@@ -3085,10 +3088,102 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
ImGui.Separator();
|
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 (Race)",
|
||||||
|
"Safest (Race + Bones)",
|
||||||
|
};
|
||||||
|
|
||||||
|
var tooltips = new[]
|
||||||
|
{
|
||||||
|
"No validation. Fastest, but may allow incompatible animations (riskier).",
|
||||||
|
"Validates skeleton race + modded skeleton check (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);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ImGui.EndChild();
|
ImGui.EndChild();
|
||||||
ImGui.EndGroup();
|
ImGui.EndGroup();
|
||||||
|
|
||||||
|
ImGui.Separator();
|
||||||
generalSelune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
|
generalSelune.DrawHighlightOnly(ImGui.GetIO().DeltaTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3178,6 +3273,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
return 1f - (elapsed / GeneralTreeHighlightDuration);
|
return 1f - (elapsed / GeneralTreeHighlightDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Auto)]
|
||||||
private struct GeneralTreeScope : IDisposable
|
private struct GeneralTreeScope : IDisposable
|
||||||
{
|
{
|
||||||
private readonly bool _visible;
|
private readonly bool _visible;
|
||||||
@@ -3485,7 +3581,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
|||||||
_uiShared.DrawHelpText("Controls whether Lightless reduces index textures that exceed the size limit.");
|
_uiShared.DrawHelpText("Controls whether Lightless reduces index textures that exceed the size limit.");
|
||||||
|
|
||||||
var dimensionOptions = new[] { 512, 1024, 2048, 4096 };
|
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 currentDimension = textureConfig.TextureDownscaleMaxDimension;
|
||||||
var selectedIndex = Array.IndexOf(dimensionOptions, currentDimension);
|
var selectedIndex = Array.IndexOf(dimensionOptions, currentDimension);
|
||||||
if (selectedIndex < 0)
|
if (selectedIndex < 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user