Refactored many parts, added settings for detection
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user