Refactored many parts, added settings for detection

This commit is contained in:
cake
2026-01-03 14:58:54 +01:00
parent e16ddb0a1d
commit e41a7149c5
7 changed files with 481 additions and 280 deletions

View File

@@ -6,6 +6,7 @@ using FFXIVClientStructs.Havok.Common.Serialize.Util;
using LightlessSync.FileCache;
using LightlessSync.Interop.GameModel;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers;
using Microsoft.Extensions.Logging;
using System.Runtime.InteropServices;
@@ -13,7 +14,7 @@ using System.Text.RegularExpressions;
namespace LightlessSync.Services;
public sealed class XivDataAnalyzer
public sealed partial class XivDataAnalyzer
{
private readonly ILogger<XivDataAnalyzer> _logger;
private readonly FileCacheManager _fileCacheManager;
@@ -126,9 +127,12 @@ public sealed class XivDataAnalyzer
return (output.Count != 0 && output.Values.All(v => v.Count > 0)) ? output : null;
}
public unsafe Dictionary<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;
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
@@ -138,47 +142,49 @@ public sealed class XivDataAnalyzer
using var fs = File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new BinaryReader(fs);
// most of this is from vfxeditor
reader.ReadInt32(); // ignore
reader.ReadInt32(); // ignore
reader.ReadInt16(); // num animations
reader.ReadInt16(); // modelid
// PAP header (mostly from vfxeditor)
_ = reader.ReadInt32(); // ignore
_ = reader.ReadInt32(); // ignore
_ = reader.ReadInt16(); // num animations
_ = reader.ReadInt16(); // modelid
var type = reader.ReadByte(); // type
if (type != 0)
return null; // not human
reader.ReadByte(); // variant
reader.ReadInt32(); // ignore
_ = reader.ReadByte(); // variant
_ = reader.ReadInt32(); // ignore
var havokPosition = reader.ReadInt32();
var footerPosition = reader.ReadInt32();
// sanity checks
if (havokPosition <= 0 || footerPosition <= havokPosition || footerPosition > fs.Length)
return null;
var havokDataSize = footerPosition - havokPosition;
reader.BaseStream.Position = havokPosition;
var havokDataSizeLong = (long)footerPosition - havokPosition;
if (havokDataSizeLong <= 8 || havokDataSizeLong > int.MaxValue)
return null;
var havokDataSize = (int)havokDataSizeLong;
reader.BaseStream.Position = havokPosition;
var havokData = reader.ReadBytes(havokDataSize);
if (havokData.Length <= 8)
return null;
var output = new Dictionary<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 tempHavokDataPathAnsi = IntPtr.Zero;
IntPtr tempHavokDataPathAnsi = IntPtr.Zero;
try
{
using (var tempFs = new FileStream(tempHavokDataPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.DeleteOnClose))
{
tempFs.Write(havokData, 0, havokData.Length);
tempFs.Flush(true);
}
File.WriteAllBytes(tempHavokDataPath, havokData);
if (!File.Exists(tempHavokDataPath))
{
_logger.LogTrace("Temporary havok file was deleted before it could be loaded: {path}", tempHavokDataPath);
_logger.LogTrace("Temporary havok file did not exist when attempting to load: {path}", tempHavokDataPath);
return null;
}
@@ -228,26 +234,21 @@ public sealed class XivDataAnalyzer
if (boneTransform.Length <= 0)
continue;
if (!output.TryGetValue(skeletonKey, out var list))
if (!tempSets.TryGetValue(skeletonKey, out var set))
{
list = new List<ushort>(boneTransform.Length);
output[skeletonKey] = list;
set = new HashSet<ushort>();
tempSets[skeletonKey] = set;
}
for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++)
{
list.Add((ushort)boneTransform[boneIdx]);
var v = boneTransform[boneIdx];
if (v < 0) continue;
set.Add((ushort)v);
}
}
}
}
foreach (var key in output.Keys.ToList())
{
output[key] = [.. output[key]
.Distinct()
.Order()];
}
}
catch (Exception ex)
{
@@ -270,20 +271,30 @@ public sealed class XivDataAnalyzer
}
}
if (tempSets.Count == 0)
return null;
var output = new Dictionary<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.Save();
if (persistToConfig)
_configService.Save();
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)
{
@@ -314,6 +325,159 @@ public sealed class XivDataAnalyzer
return string.Empty;
}
public static bool ContainsIndexCompat(HashSet<ushort> available, ushort idx, bool papLikelyOneBased)
{
if (available.Contains(idx))
return true;
if (papLikelyOneBased && idx > 0 && available.Contains((ushort)(idx - 1)))
return true;
return false;
}
public static bool IsPapCompatible(
IReadOnlyDictionary<string, HashSet<ushort>> localBoneSets,
IReadOnlyDictionary<string, List<ushort>> papBoneIndices,
AnimationValidationMode mode,
out string reason)
{
if (mode == AnimationValidationMode.Unsafe)
{
reason = string.Empty;
return true;
}
// Group PAP bindings by canonical skeleton key (with raw as fallback)
var groups = papBoneIndices
.Select(kvp => new
{
Raw = kvp.Key,
Key = CanonicalizeSkeletonKey(kvp.Key),
Indices = kvp.Value
})
.Where(x => x.Indices is { Count: > 0 })
.GroupBy(
x => string.IsNullOrEmpty(x.Key) ? x.Raw : x.Key,
StringComparer.OrdinalIgnoreCase)
.ToList();
if (groups.Count == 0)
{
reason = "No bindings found in the PAP";
return false;
}
// Determine relevant groups based on mode
var relevantGroups = groups.AsEnumerable();
if (mode == AnimationValidationMode.Safest)
{
relevantGroups = groups.Where(g => localBoneSets.ContainsKey(g.Key));
if (!relevantGroups.Any())
{
var papKeys = string.Join(", ", groups.Select(g => g.Key).Distinct(StringComparer.OrdinalIgnoreCase));
var localKeys = string.Join(", ", localBoneSets.Keys.Order(StringComparer.OrdinalIgnoreCase));
reason = $"No matching skeleton bucket between PAP [{papKeys}] and local [{localKeys}].";
return false;
}
}
foreach (var g in relevantGroups)
{
// Each group may have multiple variants (different raw names mapping to same canonical key)
bool anyVariantOk = false;
foreach (var variant in g)
{
// Check this variant against local skeleton(s)
var min = variant.Indices.Min();
var papLikelyOneBased = min == 1 && !variant.Indices.Contains(0);
bool variantOk;
if (mode == AnimationValidationMode.Safest)
{
var available = localBoneSets[g.Key];
variantOk = true;
foreach (var idx in variant.Indices)
{
if (!ContainsIndexCompat(available, idx, papLikelyOneBased))
{
variantOk = false;
break;
}
}
}
else
{
// Safe mode: any local skeleton matching this canonical key
variantOk = false;
foreach (var available in localBoneSets.Values)
{
bool ok = true;
foreach (var idx in variant.Indices)
{
if (!ContainsIndexCompat(available, idx, papLikelyOneBased))
{
ok = false;
break;
}
}
if (ok)
{
variantOk = true;
break;
}
}
}
if (variantOk)
{
anyVariantOk = true;
break;
}
}
if (!anyVariantOk)
{
// No variant was compatible for this skeleton key
var first = g.First();
ushort? missing = null;
HashSet<ushort> best;
if (mode == AnimationValidationMode.Safest && localBoneSets.TryGetValue(g.Key, out var exact))
best = exact;
else
best = localBoneSets.Values.OrderByDescending(s => s.Count).First();
var min = first.Indices.Min();
var papLikelyOneBased = min == 1 && !first.Indices.Contains(0);
foreach (var idx in first.Indices)
{
if (!ContainsIndexCompat(best, idx, papLikelyOneBased))
{
missing = idx;
break;
}
}
reason = missing.HasValue
? $"Skeleton '{g.Key}' missing bone index {missing.Value}. (raw '{first.Raw}')"
: $"Skeleton '{g.Key}' missing required bone indices. (raw '{first.Raw}')";
return false;
}
}
reason = string.Empty;
return true;
}
public void DumpLocalSkeletonIndices(GameObjectHandler handler, string? filter = null)
{
var skels = GetSkeletonBoneIndices(handler);
@@ -408,4 +572,23 @@ public sealed class XivDataAnalyzer
return 0;
}
}
// Regexes for canonicalizing skeleton keys
private static readonly Regex _bucketPathRegex =
BucketRegex();
private static readonly Regex _bucketSklRegex =
SklRegex();
private static readonly Regex _bucketLooseRegex =
LooseBucketRegex();
[GeneratedRegex(@"(?i)(?:^|/)(?<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();
}