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:
defnotken
2026-01-03 17:28:25 -06:00
7 changed files with 472 additions and 283 deletions

View File

@@ -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;
} }

View File

@@ -0,0 +1,9 @@
namespace LightlessSync.PlayerData.Factories
{
public enum AnimationValidationMode
{
Unsafe = 0,
Safe = 1,
Safest = 2,
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)