diff --git a/Penumbra.GameData b/Penumbra.GameData index a2db1b30..1dad8d07 160000 --- a/Penumbra.GameData +++ b/Penumbra.GameData @@ -1 +1 @@ -Subproject commit a2db1b309c3121e84c75e639e70575af7d936c3e +Subproject commit 1dad8d07047be0851f518cdac2b1c8bc76a7be98 diff --git a/Penumbra/Collections/Manager/CollectionStorage.cs b/Penumbra/Collections/Manager/CollectionStorage.cs index 7c94d705..c43c3817 100644 --- a/Penumbra/Collections/Manager/CollectionStorage.cs +++ b/Penumbra/Collections/Manager/CollectionStorage.cs @@ -17,10 +17,10 @@ public class CollectionStorage : IReadOnlyList, IDisposable private readonly ModStorage _modStorage; /// The empty collection is always available at Index 0. - private readonly List _collections = new() - { + private readonly List _collections = + [ ModCollection.Empty, - }; + ]; public readonly ModCollection DefaultNamed; diff --git a/Penumbra/Communication/ChangedItemClick.cs b/Penumbra/Communication/ChangedItemClick.cs index b11f2306..754570e2 100644 --- a/Penumbra/Communication/ChangedItemClick.cs +++ b/Penumbra/Communication/ChangedItemClick.cs @@ -1,6 +1,5 @@ using OtterGui.Classes; using Penumbra.Api.Enums; -using Penumbra.GameData.Enums; namespace Penumbra.Communication; diff --git a/Penumbra/Import/Models/Export/ModelExporter.cs b/Penumbra/Import/Models/Export/ModelExporter.cs index 2060c323..8271f266 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,14 +50,18 @@ 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 received 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; + if (names.ContainsKey(bone.Name)) + continue; var node = new NodeBuilder(bone.Name); names[bone.Name] = joints.Count; diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index afb92fc0..bae9569f 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -1,16 +1,21 @@ 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.Import.Models.Import; +using Penumbra.Meta.Manipulations; using SharpGLTF.Scenes; using SharpGLTF.Schema2; 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; @@ -26,37 +31,70 @@ 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)); public Task ImportGltf(string inputPath) { var action = new ImportGltfAction(inputPath); return Enqueue(action).ContinueWith(_ => action.Out); } - - /// 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. + /// Modified extra skeleton template parameters. + public string[] ResolveSklbsForMdl(string mdlPath, EstManipulation[] estManipulations) { var info = parser.GetFileInfo(mdlPath); if (info.FileType is not FileType.Model) - return null; + return []; + + 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 when info.EquipSlot.ToSlot() is EquipSlot.Body + => [baseSkeleton, ..ResolveEstSkeleton(EstManipulation.EstType.Body, info, estManipulations)], + ObjectType.Equipment when info.EquipSlot.ToSlot() is EquipSlot.Head + => [baseSkeleton, ..ResolveEstSkeleton(EstManipulation.EstType.Head, info, estManipulations)], + 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, ..ResolveEstSkeleton(EstManipulation.EstType.Hair, info, estManipulations)], + ObjectType.Character when info.BodySlot is BodySlot.Face or BodySlot.Ear + => [baseSkeleton, ..ResolveEstSkeleton(EstManipulation.EstType.Face, 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), - _ => null, + ObjectType.DemiHuman => [GamePaths.DemiHuman.Sklb.Path(info.PrimaryId)], + ObjectType.Monster => [GamePaths.Monster.Sklb.Path(info.PrimaryId)], + ObjectType.Weapon => [GamePaths.Weapon.Sklb.Path(info.PrimaryId)], + _ => [], }; } + private string[] ResolveEstSkeleton(EstManipulation.EstType type, GameObjectInfo info, EstManipulation[] estManipulations) + { + // Try to find an EST entry from the manipulations provided. + var (gender, race) = info.GenderRace.Split(); + var modEst = estManipulations + .FirstOrNull(est => + est.Gender == gender + && est.Race == race + && est.Slot == type + && est.SetId == info.PrimaryId + ); + + // Try to use an entry from provided manipulations, falling back to the current collection. + var targetId = modEst?.Entry + ?? collections.Current.MetaCache?.GetEstEntry(type, info.GenderRace, info.PrimaryId) + ?? 0; + + // If there's no entries, we can assume that there's no additional skeleton. + if (targetId == 0) + return []; + + return [GamePaths.Skeleton.Sklb.Path(info.GenderRace, EstManipulation.ToName(type), targetId)]; + } + private Task Enqueue(IAction action) { if (_disposed) @@ -83,37 +121,47 @@ 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($"[GLTF Export] Exporting model to {outputPath}..."); + Penumbra.Log.Debug("[GLTF Export] Reading skeletons..."); + var xivSkeletons = BuildSkeletons(cancel); - Penumbra.Log.Debug("Converting model."); - var model = ModelExporter.Export(mdl, xivSkeleton); + Penumbra.Log.Debug("[GLTF Export] Converting model..."); + var model = ModelExporter.Export(mdl, xivSkeletons); - Penumbra.Log.Debug("Building scene."); + Penumbra.Log.Debug("[GLTF Export] Building scene..."); var scene = new SceneBuilder(); model.AddToScene(scene); - Penumbra.Log.Debug("Saving."); + Penumbra.Log.Debug("[GLTF Export] Saving..."); var gltfModel = scene.ToGltf2(); gltfModel.SaveGLTF(outputPath); + Penumbra.Log.Debug("[GLTF Export] Done."); } /// Attempt to read out the pertinent information from a .sklb. - private XivSkeleton? BuildSkeleton(CancellationToken cancel) + private IEnumerable BuildSkeletons(CancellationToken cancel) { - if (sklb == null) - return null; + var havokTasks = sklbs + .WithIndex() + .Select(CreateHavokTask) + .ToArray(); - var xmlTask = manager._framework.RunOnFrameworkThread(() => HavokConverter.HkxToXml(sklb.Skeleton)); - xmlTask.Wait(cancel); - var xml = xmlTask.Result; + // Result waits automatically. + return havokTasks.Select(task => SkeletonConverter.FromXml(task.Result)); - return SkeletonConverter.FromXml(xml); + // 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, cancellationToken: cancel); } public bool Equals(IAction? other) diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs index 06196610..c3fc4963 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; @@ -22,14 +22,13 @@ 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; Initialize(new MdlFile(bytes)); - if (mod != null) - FindGamePaths(path, mod); + FindGamePaths(path); } [MemberNotNull(nameof(Mdl), nameof(_attributes))] @@ -59,9 +58,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]; @@ -88,7 +91,49 @@ public partial class ModEditWindow }); } - /// Import a model from an interchange format. + 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 is 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) + { + IEnumerable skeletons; + try + { + var sklbPaths = _edit._models.ResolveSklbsForMdl(mdlPath.ToString(), GetCurrentEstManipulations()); + skeletons = sklbPaths.Select(ReadSklb).ToArray(); + } + catch (Exception exception) + { + IoException = exception.ToString(); + return; + } + + PendingIo = true; + _edit._models.ExportToGltf(Mdl, skeletons, outputPath) + .ContinueWith(task => + { + IoException = task.Exception?.ToString(); + PendingIo = false; + }); + } + + /// Import a model from an interchange format. /// Disk path to load model data from. public void Import(string inputPath) { @@ -107,32 +152,6 @@ public partial class ModEditWindow }); } - /// Export model to an interchange format. - /// Disk path to save the resulting file to. - /// Game path to consider as the canonical .mdl path during export, used for resolution of other files. - public void Export(string outputPath, Utf8GamePath mdlPath) - { - SklbFile? sklb = null; - try - { - var sklbPath = _edit._models.ResolveSklbForMdl(mdlPath.ToString()); - sklb = sklbPath != null ? ReadSklb(sklbPath) : null; - } - catch (Exception exception) - { - IoException = exception?.ToString(); - return; - } - - PendingIo = true; - _edit._models.ExportToGltf(Mdl, sklb, outputPath) - .ContinueWith(task => - { - IoException = task.Exception?.ToString(); - PendingIo = false; - }); - } - /// Read a .sklb from the active collection or game. /// Game path to the .sklb to load. private SklbFile ReadSklb(string sklbPath) 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,