From aa7f0bace9a55da8fdcd08d7acb427081735f153 Mon Sep 17 00:00:00 2001 From: ackwell Date: Sun, 7 Jan 2024 19:49:13 +1100 Subject: [PATCH] Wire up hair EST resolution --- .../Import/Models/Export/ModelExporter.cs | 9 +- Penumbra/Import/Models/ModelManager.cs | 89 ++++++++++++++----- .../ModEditWindow.Models.MdlTab.cs | 42 ++++++--- Penumbra/UI/AdvancedWindow/ModEditWindow.cs | 2 +- 4 files changed, 104 insertions(+), 38 deletions(-) diff --git a/Penumbra/Import/Models/Export/ModelExporter.cs b/Penumbra/Import/Models/Export/ModelExporter.cs index 2060c323..07b37eeb 100644 --- a/Penumbra/Import/Models/Export/ModelExporter.cs +++ b/Penumbra/Import/Models/Export/ModelExporter.cs @@ -22,7 +22,7 @@ public class ModelExporter } /// Export a model in preparation for usage in a glTF file. If provided, skeleton will be used to skin the resulting meshes where appropriate. - public static Model Export(MdlFile mdl, XivSkeleton? xivSkeleton) + public static Model Export(MdlFile mdl, IEnumerable? xivSkeleton) { var gltfSkeleton = xivSkeleton != null ? ConvertSkeleton(xivSkeleton) : null; var meshes = ConvertMeshes(mdl, gltfSkeleton); @@ -50,12 +50,15 @@ public class ModelExporter } /// Convert XIV skeleton data into a glTF-compatible node tree, with mappings. - private static GltfSkeleton? ConvertSkeleton(XivSkeleton skeleton) + private static GltfSkeleton? ConvertSkeleton(IEnumerable skeletons) { NodeBuilder? root = null; var names = new Dictionary(); var joints = new List(); - foreach (var bone in skeleton.Bones) + + // Flatten out the bones across all the recieved skeletons, but retain a reference to the parent skeleton for lookups. + var iterator = skeletons.SelectMany(skeleton => skeleton.Bones.Select(bone => (skeleton, bone))); + foreach (var (skeleton, bone) in iterator) { if (names.ContainsKey(bone.Name)) continue; diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index dd796a42..a9e1b32d 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -1,14 +1,19 @@ using Dalamud.Plugin.Services; +using OtterGui; using OtterGui.Tasks; +using Penumbra.Collections.Manager; +using Penumbra.GameData; using Penumbra.GameData.Data; using Penumbra.GameData.Enums; using Penumbra.GameData.Files; +using Penumbra.GameData.Structs; using Penumbra.Import.Models.Export; +using Penumbra.Meta.Manipulations; using SharpGLTF.Scenes; namespace Penumbra.Import.Models; -public sealed class ModelManager(IFramework framework, GamePathParser _parser) : SingleTaskQueue, IDisposable +public sealed class ModelManager(IFramework framework, ActiveCollections collections, GamePathParser parser) : SingleTaskQueue, IDisposable { private readonly IFramework _framework = framework; @@ -24,31 +29,55 @@ public sealed class ModelManager(IFramework framework, GamePathParser _parser) : _tasks.Clear(); } - public Task ExportToGltf(MdlFile mdl, SklbFile? sklb, string outputPath) - => Enqueue(new ExportToGltfAction(this, mdl, sklb, outputPath)); + public Task ExportToGltf(MdlFile mdl, IEnumerable? sklbs, string outputPath) + => Enqueue(new ExportToGltfAction(this, mdl, sklbs, outputPath)); - /// Try to find the .sklb path for a .mdl file. - /// .mdl file to look up the skeleton for. - public string? ResolveSklbForMdl(string mdlPath) + /// Try to find the .sklb paths for a .mdl file. + /// .mdl file to look up the skeletons for. + public string[]? ResolveSklbsForMdl(string mdlPath, EstManipulation[] estManipulations) { - var info = _parser.GetFileInfo(mdlPath); + var info = parser.GetFileInfo(mdlPath); if (info.FileType is not FileType.Model) return null; + var baseSkeleton = GamePaths.Skeleton.Sklb.Path(info.GenderRace, "base", 1); + return info.ObjectType switch { - ObjectType.Equipment => GamePaths.Skeleton.Sklb.Path(info.GenderRace, "base", 1), - ObjectType.Accessory => GamePaths.Skeleton.Sklb.Path(info.GenderRace, "base", 1), - ObjectType.Character when info.BodySlot is BodySlot.Body or BodySlot.Tail => GamePaths.Skeleton.Sklb.Path(info.GenderRace, "base", - 1), + ObjectType.Equipment => [baseSkeleton], + ObjectType.Accessory => [baseSkeleton], + ObjectType.Character when info.BodySlot is BodySlot.Body or BodySlot.Tail => [baseSkeleton], + ObjectType.Character when info.BodySlot is BodySlot.Hair + => [baseSkeleton, ResolveHairSkeleton(info, estManipulations)], ObjectType.Character => throw new Exception($"Currently unsupported human model type \"{info.BodySlot}\"."), - ObjectType.DemiHuman => GamePaths.DemiHuman.Sklb.Path(info.PrimaryId), - ObjectType.Monster => GamePaths.Monster.Sklb.Path(info.PrimaryId), - ObjectType.Weapon => GamePaths.Weapon.Sklb.Path(info.PrimaryId), + ObjectType.DemiHuman => [GamePaths.DemiHuman.Sklb.Path(info.PrimaryId)], + ObjectType.Monster => [GamePaths.Monster.Sklb.Path(info.PrimaryId)], + ObjectType.Weapon => [GamePaths.Weapon.Sklb.Path(info.PrimaryId)], _ => null, }; } + private string ResolveHairSkeleton(GameObjectInfo info, EstManipulation[] estManipulations) + { + // TODO: might be able to genericse this over esttype based on incoming info + var (gender, race) = info.GenderRace.Split(); + var modEst = estManipulations + .FirstOrNull(est => + est.Gender == gender + && est.Race == race + && est.Slot == EstManipulation.EstType.Hair + && est.SetId == info.PrimaryId + ); + + // Try to use an entry from the current mod, falling back to the current collection, and finally an unmodified value. + var targetId = modEst?.Entry + ?? collections.Current.MetaCache?.GetEstEntry(EstManipulation.EstType.Hair, info.GenderRace, info.PrimaryId) + ?? info.PrimaryId; + + // TODO: i'm not conviced ToSuffix is correct - check! + return GamePaths.Skeleton.Sklb.Path(info.GenderRace, info.BodySlot.ToSuffix(), targetId); + } + private Task Enqueue(IAction action) { if (_disposed) @@ -75,16 +104,16 @@ public sealed class ModelManager(IFramework framework, GamePathParser _parser) : return task; } - private class ExportToGltfAction(ModelManager manager, MdlFile mdl, SklbFile? sklb, string outputPath) + private class ExportToGltfAction(ModelManager manager, MdlFile mdl, IEnumerable? sklbs, string outputPath) : IAction { public void Execute(CancellationToken cancel) { - Penumbra.Log.Debug("Reading skeleton."); - var xivSkeleton = BuildSkeleton(cancel); + Penumbra.Log.Debug("Reading skeletons."); + var xivSkeletons = BuildSkeletons(cancel); Penumbra.Log.Debug("Converting model."); - var model = ModelExporter.Export(mdl, xivSkeleton); + var model = ModelExporter.Export(mdl, xivSkeletons); Penumbra.Log.Debug("Building scene."); var scene = new SceneBuilder(); @@ -96,16 +125,28 @@ public sealed class ModelManager(IFramework framework, GamePathParser _parser) : } /// Attempt to read out the pertinent information from a .sklb. - private XivSkeleton? BuildSkeleton(CancellationToken cancel) + private IEnumerable? BuildSkeletons(CancellationToken cancel) { - if (sklb == null) + if (sklbs == null) return null; - var xmlTask = manager._framework.RunOnFrameworkThread(() => HavokConverter.HkxToXml(sklb.Skeleton)); - xmlTask.Wait(cancel); - var xml = xmlTask.Result; + // The havok methods we're relying on for this conversion are a bit + // finicky at the best of times, and can outright cause a CTD if they + // get upset. Running each conversion on its own tick seems to make + // this consistently non-crashy across my testing. + Task CreateHavokTask((SklbFile Sklb, int Index) pair) => + manager._framework.RunOnTick( + () => HavokConverter.HkxToXml(pair.Sklb.Skeleton), + delayTicks: pair.Index + ); - return SkeletonConverter.FromXml(xml); + var havokTasks = sklbs + .WithIndex() + .Select(CreateHavokTask) + .ToArray(); + Task.WaitAll(havokTasks, cancel); + + return havokTasks.Select(task => SkeletonConverter.FromXml(task.Result)); } public bool Equals(IAction? other) diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs index b8573780..d4e75487 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -1,7 +1,7 @@ using OtterGui; using Penumbra.GameData; using Penumbra.GameData.Files; -using Penumbra.Mods; +using Penumbra.Meta.Manipulations; using Penumbra.String.Classes; namespace Penumbra.UI.AdvancedWindow; @@ -21,15 +21,14 @@ public partial class ModEditWindow public bool PendingIo { get; private set; } public string? IoException { get; private set; } - public MdlTab(ModEditWindow edit, byte[] bytes, string path, IMod? mod) + public MdlTab(ModEditWindow edit, byte[] bytes, string path) { _edit = edit; Mdl = new MdlFile(bytes); _attributes = CreateAttributes(Mdl); - if (mod != null) - FindGamePaths(path, mod); + FindGamePaths(path); } /// @@ -42,9 +41,13 @@ public partial class ModEditWindow /// Find the list of game paths that may correspond to this model. /// Resolved path to a .mdl. - /// Mod within which the .mdl is resolved. - private void FindGamePaths(string path, IMod mod) + private void FindGamePaths(string path) { + // If there's no current mod (somehow), there's nothing to resolve the model within. + var mod = _edit._editor.Mod; + if (mod == null) + return; + if (!Path.IsPathRooted(path) && Utf8GamePath.FromString(path, out var p)) { GamePaths = [p]; @@ -71,15 +74,34 @@ public partial class ModEditWindow }); } + private EstManipulation[] GetCurrentEstManipulations() + { + var mod = _edit._editor.Mod; + var option = _edit._editor.Option; + if (mod == null || option == null) + return []; + + // Filter then prepend the current option to ensure it's chosen first. + return mod.AllSubMods + .Where(subMod => subMod != option) + .Prepend(option) + .SelectMany(subMod => subMod.Manipulations) + .Where(manipulation => manipulation.ManipulationType == MetaManipulation.Type.Est) + .Select(manipulation => manipulation.Est) + .ToArray(); + } + /// Export model to an interchange format. /// Disk path to save the resulting file to. public void Export(string outputPath, Utf8GamePath mdlPath) { - SklbFile? sklb = null; + IEnumerable? sklbs = null; try { - var sklbPath = _edit._models.ResolveSklbForMdl(mdlPath.ToString()); - sklb = sklbPath != null ? ReadSklb(sklbPath) : null; + var sklbPaths = _edit._models.ResolveSklbsForMdl(mdlPath.ToString(), GetCurrentEstManipulations()); + sklbs = sklbPaths != null + ? sklbPaths.Select(ReadSklb).ToArray() + : null; } catch (Exception exception) { @@ -88,7 +110,7 @@ public partial class ModEditWindow } PendingIo = true; - _edit._models.ExportToGltf(Mdl, sklb, outputPath) + _edit._models.ExportToGltf(Mdl, sklbs, outputPath) .ContinueWith(task => { IoException = task.Exception?.ToString(); diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs index 167adafe..8d3e32f9 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs @@ -600,7 +600,7 @@ public partial class ModEditWindow : Window, IDisposable (bytes, path, writable) => new MtrlTab(this, new MtrlFile(bytes), path, writable)); _modelTab = new FileEditor(this, gameData, config, _editor.Compactor, _fileDialog, "Models", ".mdl", () => PopulateIsOnPlayer(_editor.Files.Mdl, ResourceType.Mdl), DrawModelPanel, () => _mod?.ModPath.FullName ?? string.Empty, - (bytes, path, _) => new MdlTab(this, bytes, path, _mod)); + (bytes, path, _) => new MdlTab(this, bytes, path)); _shaderPackageTab = new FileEditor(this, gameData, config, _editor.Compactor, _fileDialog, "Shaders", ".shpk", () => PopulateIsOnPlayer(_editor.Files.Shpk, ResourceType.Shpk), DrawShaderPackagePanel, () => _mod?.ModPath.FullName ?? string.Empty,