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,