diff --git a/Penumbra.GameData b/Penumbra.GameData index ac3fc098..83c01275 160000 --- a/Penumbra.GameData +++ b/Penumbra.GameData @@ -1 +1 @@ -Subproject commit ac3fc0981ac8f503ac91d2419bd28c54f271763e +Subproject commit 83c012752cd9d13d39248eda85ab18cc59070a76 diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index 35a5e53e..dd796a42 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -1,27 +1,20 @@ using Dalamud.Plugin.Services; using OtterGui.Tasks; -using Penumbra.Collections.Manager; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; using Penumbra.GameData.Files; using Penumbra.Import.Models.Export; using SharpGLTF.Scenes; namespace Penumbra.Import.Models; -public sealed class ModelManager : SingleTaskQueue, IDisposable +public sealed class ModelManager(IFramework framework, GamePathParser _parser) : SingleTaskQueue, IDisposable { - private readonly IFramework _framework; - private readonly IDataManager _gameData; - private readonly ActiveCollectionData _activeCollectionData; + private readonly IFramework _framework = framework; private readonly ConcurrentDictionary _tasks = new(); - private bool _disposed = false; - public ModelManager(IFramework framework, IDataManager gameData, ActiveCollectionData activeCollectionData) - { - _framework = framework; - _gameData = gameData; - _activeCollectionData = activeCollectionData; - } + private bool _disposed; public void Dispose() { @@ -31,6 +24,31 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable _tasks.Clear(); } + public Task ExportToGltf(MdlFile mdl, SklbFile? sklb, string outputPath) + => Enqueue(new ExportToGltfAction(this, mdl, sklb, outputPath)); + + /// Try to find the .sklb path for a .mdl file. + /// .mdl file to look up the skeleton for. + public string? ResolveSklbForMdl(string mdlPath) + { + var info = _parser.GetFileInfo(mdlPath); + if (info.FileType is not FileType.Model) + return null; + + 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.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, + }; + } + private Task Enqueue(IAction action) { if (_disposed) @@ -39,44 +57,34 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable Task task; lock (_tasks) { - task = _tasks.GetOrAdd(action, action => + task = _tasks.GetOrAdd(action, a => { var token = new CancellationTokenSource(); - var task = Enqueue(action, token.Token); - task.ContinueWith(_ => _tasks.TryRemove(action, out var unused), CancellationToken.None); - return (task, token); + var t = Enqueue(a, token.Token); + t.ContinueWith(_ => + { + lock (_tasks) + { + return _tasks.TryRemove(a, out var unused); + } + }, CancellationToken.None); + return (t, token); }).Item1; } return task; } - public Task ExportToGltf(MdlFile mdl, SklbFile? sklb, string outputPath) - => Enqueue(new ExportToGltfAction(this, mdl, sklb, outputPath)); - - private class ExportToGltfAction : IAction + private class ExportToGltfAction(ModelManager manager, MdlFile mdl, SklbFile? sklb, string outputPath) + : IAction { - private readonly ModelManager _manager; - - private readonly MdlFile _mdl; - private readonly SklbFile? _sklb; - private readonly string _outputPath; - - public ExportToGltfAction(ModelManager manager, MdlFile mdl, SklbFile? sklb, string outputPath) - { - _manager = manager; - _mdl = mdl; - _sklb = sklb; - _outputPath = outputPath; - } - public void Execute(CancellationToken cancel) { Penumbra.Log.Debug("Reading skeleton."); var xivSkeleton = BuildSkeleton(cancel); Penumbra.Log.Debug("Converting model."); - var model = ModelExporter.Export(_mdl, xivSkeleton); + var model = ModelExporter.Export(mdl, xivSkeleton); Penumbra.Log.Debug("Building scene."); var scene = new SceneBuilder(); @@ -84,16 +92,16 @@ public sealed class ModelManager : SingleTaskQueue, IDisposable Penumbra.Log.Debug("Saving."); var gltfModel = scene.ToGltf2(); - gltfModel.SaveGLTF(_outputPath); + gltfModel.SaveGLTF(outputPath); } /// Attempt to read out the pertinent information from a .sklb. private XivSkeleton? BuildSkeleton(CancellationToken cancel) { - if (_sklb == null) + if (sklb == null) return null; - var xmlTask = _manager._framework.RunOnFrameworkThread(() => HavokConverter.HkxToXml(_sklb.Skeleton)); + var xmlTask = manager._framework.RunOnFrameworkThread(() => HavokConverter.HkxToXml(sklb.Skeleton)); xmlTask.Wait(cancel); var xml = xmlTask.Result; diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs index 93e674ea..b8573780 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -8,9 +8,9 @@ namespace Penumbra.UI.AdvancedWindow; public partial class ModEditWindow { - private partial class MdlTab : IWritable + private class MdlTab : IWritable { - private ModEditWindow _edit; + private readonly ModEditWindow _edit; public readonly MdlFile Mdl; private readonly List[] _attributes; @@ -18,21 +18,10 @@ public partial class ModEditWindow public List? GamePaths { get; private set; } public int GamePathIndex; - public bool PendingIo { get; private set; } = false; - public string? IoException { get; private set; } = null; + public bool PendingIo { get; private set; } + public string? IoException { get; private set; } - [GeneratedRegex(@"chara/(?:equipment|accessory)/(?'Set'[a-z]\d{4})/model/(?'Race'c\d{4})\k'Set'_[^/]+\.mdl", RegexOptions.Compiled)] - private static partial Regex CharaEquipmentRegex(); - - [GeneratedRegex(@"chara/human/(?'Race'c\d{4})/obj/(?'Type'[^/]+)/(?'Set'[^/]\d{4})/model/(?'Race'c\d{4})\k'Set'_[^/]+\.mdl", - RegexOptions.Compiled)] - private static partial Regex CharaHumanRegex(); - - [GeneratedRegex(@"chara/(?'SubCategory'demihuman|monster|weapon)/(?'Set'w\d{4})/obj/body/(?'Body'b\d{4})/model/\k'Set'\k'Body'.mdl", - RegexOptions.Compiled)] - private static partial Regex CharaBodyRegex(); - - public MdlTab(ModEditWindow edit, byte[] bytes, string path, Mod? mod) + public MdlTab(ModEditWindow edit, byte[] bytes, string path, IMod? mod) { _edit = edit; @@ -54,7 +43,7 @@ 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, Mod mod) + private void FindGamePaths(string path, IMod mod) { if (!Path.IsPathRooted(path) && Utf8GamePath.FromString(path, out var p)) { @@ -77,8 +66,8 @@ public partial class ModEditWindow task.ContinueWith(t => { IoException = t.Exception?.ToString(); - PendingIo = false; GamePaths = t.Result; + PendingIo = false; }); } @@ -89,7 +78,7 @@ public partial class ModEditWindow SklbFile? sklb = null; try { - var sklbPath = GetSklbPath(mdlPath.ToString()); + var sklbPath = _edit._models.ResolveSklbForMdl(mdlPath.ToString()); sklb = sklbPath != null ? ReadSklb(sklbPath) : null; } catch (Exception exception) @@ -107,43 +96,6 @@ public partial class ModEditWindow }); } - /// Try to find the .sklb path for a .mdl file. - /// .mdl file to look up the skeleton for. - private string? GetSklbPath(string mdlPath) - { - // Equipment is skinned to the base body skeleton of the race they target. - var match = CharaEquipmentRegex().Match(mdlPath); - if (match.Success) - { - var race = match.Groups["Race"].Value; - return $"chara/human/{race}/skeleton/base/b0001/skl_{race}b0001.sklb"; - } - - // Some parts of human have their own skeletons. - match = CharaHumanRegex().Match(mdlPath); - if (match.Success) - { - var type = match.Groups["Type"].Value; - var race = match.Groups["Race"].Value; - return type switch - { - "body" or "tail" => $"chara/human/{race}/skeleton/base/b0001/skl_{race}b0001.sklb", - _ => throw new Exception($"Currently unsupported human model type \"{type}\"."), - }; - } - - // A few subcategories - such as weapons, demihumans, and monsters - have dedicated per-"body" skeletons. - match = CharaBodyRegex().Match(mdlPath); - if (match.Success) - { - var subCategory = match.Groups["SubCategory"].Value; - var set = match.Groups["Set"].Value; - return $"chara/{subCategory}/{set}/skeleton/base/b0001/skl_{set}b0001.sklb"; - } - - return null; - } - /// Read a .sklb from the active collection or game. /// Game path to the .sklb to load. private SklbFile ReadSklb(string sklbPath) @@ -153,17 +105,12 @@ public partial class ModEditWindow throw new Exception($"Resolved skeleton path {sklbPath} could not be converted to a game path."); var resolvedPath = _edit._activeCollections.Current.ResolvePath(utf8SklbPath); - // TODO: is it worth trying to use streams for these instead? i'll need to do this for mtrl/tex too, so might be a good idea. that said, the mtrl reader doesn't accept streams, so... - var bytes = resolvedPath switch - { - null => _edit._gameData.GetFile(sklbPath)?.Data, - FullPath path => File.ReadAllBytes(path.ToPath()), - }; - if (bytes == null) - throw new Exception( + // TODO: is it worth trying to use streams for these instead? I'll need to do this for mtrl/tex too, so might be a good idea. that said, the mtrl reader doesn't accept streams, so... + var bytes = resolvedPath == null ? _edit._gameData.GetFile(sklbPath)?.Data : File.ReadAllBytes(resolvedPath.Value.ToPath()); + return bytes != null + ? new SklbFile(bytes) + : throw new Exception( $"Resolved skeleton path {sklbPath} could not be found. If modded, is it enabled in the current collection?"); - - return new SklbFile(bytes); } /// Remove the material given by the index. diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs index 3891eb95..24b45b88 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -15,7 +15,6 @@ public partial class ModEditWindow private const int MdlMaterialMaximum = 4; private readonly FileEditor _modelTab; - private readonly ModelManager _models; private string _modelNewMaterial = string.Empty; @@ -91,19 +90,21 @@ public partial class ModEditWindow private void DrawGamePathCombo(MdlTab tab) { - if (tab.GamePaths!.Count == 0) + if (tab.GamePaths!.Count != 0) { - ImGui.TextUnformatted("No associated game path detected. Valid game paths are currently necessary for exporting."); - if (ImGui.InputTextWithHint("##customInput", "Enter custom game path...", ref _customPath, 256)) - if (!Utf8GamePath.FromString(_customPath, out _customGamePath, false)) - _customGamePath = Utf8GamePath.Empty; - + DrawComboButton(tab); return; } - DrawComboButton(tab); + ImGui.TextUnformatted("No associated game path detected. Valid game paths are currently necessary for exporting."); + if (!ImGui.InputTextWithHint("##customInput", "Enter custom game path...", ref _customPath, 256)) + return; + + if (!Utf8GamePath.FromString(_customPath, out _customGamePath, false)) + _customGamePath = Utf8GamePath.Empty; } + /// I disliked the combo with only one selection so turn it into a button in that case. private static void DrawComboButton(MdlTab tab) { const string label = "Game Path";