From 635d606112979cf7661938a88afd711e92357cae Mon Sep 17 00:00:00 2001 From: ackwell Date: Thu, 28 Dec 2023 15:51:20 +1100 Subject: [PATCH] Initial skeleton tests --- Penumbra/Import/Models/HavokConverter.cs | 141 +++++++++++++ Penumbra/Import/Models/ModelManager.cs | 187 +++++++++++++++++- .../UI/AdvancedWindow/ModEditWindow.Models.cs | 4 + 3 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 Penumbra/Import/Models/HavokConverter.cs diff --git a/Penumbra/Import/Models/HavokConverter.cs b/Penumbra/Import/Models/HavokConverter.cs new file mode 100644 index 00000000..515c6f97 --- /dev/null +++ b/Penumbra/Import/Models/HavokConverter.cs @@ -0,0 +1,141 @@ +using FFXIVClientStructs.Havok; + +namespace Penumbra.Import.Models; + +// TODO: where should this live? interop i guess, in penum? or game data? +public unsafe class HavokConverter +{ + /// Creates a temporary file and returns its path. + /// Path to a temporary file. + private string CreateTempFile() + { + var s = File.Create(Path.GetTempFileName()); + s.Close(); + return s.Name; + } + + /// Converts a .hkx file to a .xml file. + /// A byte array representing the .hkx file. + /// A string representing the .xml file. + /// Thrown if parsing the .hkx file fails. + /// Thrown if writing the .xml file fails. + public string HkxToXml(byte[] hkx) + { + var tempHkx = CreateTempFile(); + File.WriteAllBytes(tempHkx, hkx); + + var resource = Read(tempHkx); + File.Delete(tempHkx); + + if (resource == null) throw new Exception("HavokReadException"); + + var options = hkSerializeUtil.SaveOptionBits.SerializeIgnoredMembers + | hkSerializeUtil.SaveOptionBits.TextFormat + | hkSerializeUtil.SaveOptionBits.WriteAttributes; + + var file = Write(resource, options); + file.Close(); + + var bytes = File.ReadAllText(file.Name); + File.Delete(file.Name); + + return bytes; + } + + /// Converts a .xml file to a .hkx file. + /// A string representing the .xml file. + /// A byte array representing the .hkx file. + /// Thrown if parsing the .xml file fails. + /// Thrown if writing the .hkx file fails. + public byte[] XmlToHkx(string xml) + { + var tempXml = CreateTempFile(); + File.WriteAllText(tempXml, xml); + + var resource = Read(tempXml); + File.Delete(tempXml); + + if (resource == null) throw new Exception("HavokReadException"); + + var options = hkSerializeUtil.SaveOptionBits.SerializeIgnoredMembers + | hkSerializeUtil.SaveOptionBits.WriteAttributes; + + var file = Write(resource, options); + file.Close(); + + var bytes = File.ReadAllBytes(file.Name); + File.Delete(file.Name); + + return bytes; + } + + /// + /// Parses a serialized file into an hkResource*. + /// The type is guessed automatically by Havok. + /// This pointer might be null - you should check for that. + /// + /// Path to a file on the filesystem. + /// A (potentially null) pointer to an hkResource. + private hkResource* Read(string filePath) + { + var path = Marshal.StringToHGlobalAnsi(filePath); + + var builtinTypeRegistry = hkBuiltinTypeRegistry.Instance(); + + var loadOptions = stackalloc hkSerializeUtil.LoadOptions[1]; + loadOptions->Flags = new() { Storage = (int)hkSerializeUtil.LoadOptionBits.Default }; + loadOptions->ClassNameRegistry = builtinTypeRegistry->GetClassNameRegistry(); + loadOptions->TypeInfoRegistry = builtinTypeRegistry->GetTypeInfoRegistry(); + + // TODO: probably can loadfrombuffer this + var resource = hkSerializeUtil.LoadFromFile((byte*)path, null, loadOptions); + return resource; + } + + /// Serializes an hkResource* to a temporary file. + /// A pointer to the hkResource, opened through Read(). + /// Flags representing how to serialize the file. + /// An opened FileStream of a temporary file. You are expected to read the file and delete it. + /// Thrown if accessing the root level container fails. + /// Thrown if an unknown failure in writing occurs. + private FileStream Write( + hkResource* resource, + hkSerializeUtil.SaveOptionBits optionBits + ) + { + var tempFile = CreateTempFile(); + var path = Marshal.StringToHGlobalAnsi(tempFile); + var oStream = new hkOstream(); + oStream.Ctor((byte*)path); + + var result = stackalloc hkResult[1]; + + var saveOptions = new hkSerializeUtil.SaveOptions() + { + Flags = new() { Storage = (int)optionBits } + }; + + + var builtinTypeRegistry = hkBuiltinTypeRegistry.Instance(); + var classNameRegistry = builtinTypeRegistry->GetClassNameRegistry(); + var typeInfoRegistry = builtinTypeRegistry->GetTypeInfoRegistry(); + + try + { + var name = "hkRootLevelContainer"; + + var resourcePtr = (hkRootLevelContainer*)resource->GetContentsPointer(name, typeInfoRegistry); + if (resourcePtr == null) throw new Exception("HavokWriteException"); + + var hkRootLevelContainerClass = classNameRegistry->GetClassByName(name); + if (hkRootLevelContainerClass == null) throw new Exception("HavokWriteException"); + + hkSerializeUtil.Save(result, resourcePtr, hkRootLevelContainerClass, oStream.StreamWriter.ptr, saveOptions); + } + finally { oStream.Dtor(); } + + if (result->Result == hkResult.hkResultEnum.Failure) throw new Exception("HavokFailureException"); + + return new FileStream(tempFile, FileMode.Open); + } +} diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index 429aad54..c4c46353 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -1,18 +1,26 @@ +using System.Xml; +using Dalamud.Plugin.Services; +using Lumina.Data; +using Lumina.Extensions; +using OtterGui; using OtterGui.Tasks; using Penumbra.GameData.Files; using Penumbra.Import.Modules; using SharpGLTF.Scenes; +using SharpGLTF.Transforms; namespace Penumbra.Import.Models; public sealed class ModelManager : SingleTaskQueue, IDisposable { + private readonly IDataManager _gameData; + private readonly ConcurrentDictionary _tasks = new(); private bool _disposed = false; - public ModelManager() + public ModelManager(IDataManager gameData) { - // + _gameData = gameData; } public void Dispose() @@ -46,6 +54,181 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable public Task ExportToGltf(MdlFile mdl, string path) => Enqueue(new ExportToGltfAction(mdl, path)); + public void SkeletonTest() + { + var sklbPath = "chara/human/c0201/skeleton/base/b0001/skl_c0201b0001.sklb"; + + var something = _gameData.GetFile(sklbPath); + + var fuck = new HavokConverter(); + var killme = fuck.HkxToXml(something.Skeleton); + + var doc = new XmlDocument(); + doc.LoadXml(killme); + + var skels = doc.SelectNodes("/hktagfile/object[@type='hkaSkeleton']") + .Cast() + .Select(element => new Skel(element)) + .ToArray(); + + // todo: look into how this is selecting the skel - only first? + var animSkel = doc.SelectSingleNode("/hktagfile/object[@type='hkaAnimationContainer']") + .SelectNodes("array[@name='skeletons']") + .Cast() + .First(); + var mainSkelId = animSkel.ChildNodes[0].InnerText; + + var mainSkel = skels.First(skel => skel.Id == mainSkelId); + + // this is atrocious + NodeBuilder? root = null; + var boneMap = new Dictionary(); + for (var boneIndex = 0; boneIndex < mainSkel.BoneNames.Length; boneIndex++) + { + var name = mainSkel.BoneNames[boneIndex]; + if (boneMap.ContainsKey(name)) continue; + + var node = new NodeBuilder(name); + + var rp = mainSkel.ReferencePose[boneIndex]; + var transform = new AffineTransform( + new Vector3(rp[8], rp[9], rp[10]), + new Quaternion(rp[4], rp[5], rp[6], rp[7]), + new Vector3([rp[0], rp[1], rp[2]]) + ); + node.SetLocalTransform(transform, false); + + boneMap[name] = node; + + var parentId = mainSkel.ParentIndices[boneIndex]; + if (parentId == -1) + { + root = node; + continue; + } + + var parent = boneMap[mainSkel.BoneNames[parentId]]; + parent.AddNode(node); + } + + var scene = new SceneBuilder(); + scene.AddNode(root); + var model = scene.ToGltf2(); + model.SaveGLTF(@"C:\Users\ackwell\blender\gltf-tests\zoingo.gltf"); + + Penumbra.Log.Information($"zoingo {string.Join(',', mainSkel.ParentIndices)}"); + } + + // this is garbage that should be in gamedata + + private sealed class Garbage : FileResource + { + public byte[] Skeleton; + + public override void LoadFile() + { + var magic = Reader.ReadUInt32(); + if (magic != 0x736B6C62) + throw new InvalidDataException("Invalid sklb magic"); + + // todo do this all properly jfc + var version = Reader.ReadUInt32(); + + var oldHeader = version switch { + 0x31313030 or 0x31313130 or 0x31323030 => true, + 0x31333030 => false, + _ => throw new InvalidDataException($"Unknown version {version}") + }; + + // Skeleton offset directly follows the layer offset. + uint skeletonOffset; + if (oldHeader) + { + Reader.ReadInt16(); + skeletonOffset = Reader.ReadUInt16(); + } + else + { + Reader.ReadUInt32(); + skeletonOffset = Reader.ReadUInt32(); + } + + Reader.Seek(skeletonOffset); + Skeleton = Reader.ReadBytes((int)(Reader.BaseStream.Length - skeletonOffset)); + } + } + + private class Skel + { + public readonly string Id; + + public readonly float[][] ReferencePose; + public readonly int[] ParentIndices; + public readonly string[] BoneNames; + + // TODO: this shouldn't have any reference to the skel xml - i should just make it a bare class that can be repr'd in gamedata or whatever + public Skel(XmlElement el) + { + Id = el.GetAttribute("id"); + + ReferencePose = ReadReferencePose(el); + ParentIndices = ReadParentIndices(el); + BoneNames = ReadBoneNames(el); + } + + private float[][] ReadReferencePose(XmlElement el) + { + return ReadArray( + (XmlElement)el.SelectSingleNode("array[@name='referencePose']"), + ReadVec12 + ); + } + + private float[] ReadVec12(XmlElement el) + { + return el.ChildNodes + .Cast() + .Where(node => node.NodeType != XmlNodeType.Comment) + .Select(node => { + var t = node.InnerText.Trim()[1..]; + // todo: surely there's a less shit way to do this i mean seriously + return BitConverter.ToSingle(BitConverter.GetBytes(int.Parse(t, NumberStyles.HexNumber))); + }) + .ToArray(); + } + + private int[] ReadParentIndices(XmlElement el) + { + // todo: would be neat to genericise array between bare and children + return el.SelectSingleNode("array[@name='parentIndices']") + .InnerText + .Split(new char[] {' ', '\n'}, StringSplitOptions.RemoveEmptyEntries) + .Select(int.Parse) + .ToArray(); + } + + private string[] ReadBoneNames(XmlElement el) + { + return ReadArray( + (XmlElement)el.SelectSingleNode("array[@name='bones']"), + el => el.SelectSingleNode("string[@name='name']").InnerText + ); + } + + private T[] ReadArray(XmlElement el, Func convert) + { + var size = int.Parse(el.GetAttribute("size")); + + var array = new T[size]; + foreach (var (node, index) in el.ChildNodes.Cast().WithIndex()) + { + array[index] = convert(node); + } + + return array; + } + } + private class ExportToGltfAction : IAction { private readonly MdlFile _mdl; diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs index b64b4f40..f0cc34a6 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -41,6 +41,10 @@ public partial class ModEditWindow var task = _models.ExportToGltf(file, "C:\\Users\\ackwell\\blender\\gltf-tests\\bingo.gltf"); task.ContinueWith(_ => _pendingIo = false); } + if (ImGui.Button("zoingo boingo")) + { + _models.SkeletonTest(); + } var ret = false;