Merge branch 'mdl-export'

This commit is contained in:
Ottermandias 2024-01-06 18:38:17 +01:00
commit fe92ac34f0
18 changed files with 1282 additions and 38 deletions

@ -1 +1 @@
Subproject commit ac3fc0981ac8f503ac91d2419bd28c54f271763e
Subproject commit 83c012752cd9d13d39248eda85ab18cc59070a76

View file

@ -19,6 +19,9 @@ public sealed class ModPathChanged()
{
public enum Priority
{
/// <seealso cref="EphemeralConfig.OnModPathChanged"/>
EphemeralConfig = -500,
/// <seealso cref="Collections.Cache.CollectionCacheManager.OnModChangeAddition"/>
CollectionCacheManagerAddition = -100,

View file

@ -2,7 +2,10 @@ using Dalamud.Interface.Internal.Notifications;
using Newtonsoft.Json;
using OtterGui.Classes;
using Penumbra.Api.Enums;
using Penumbra.Communication;
using Penumbra.Enums;
using Penumbra.Mods;
using Penumbra.Mods.Manager;
using Penumbra.Services;
using Penumbra.UI;
using Penumbra.UI.ResourceWatcher;
@ -11,11 +14,14 @@ using ErrorEventArgs = Newtonsoft.Json.Serialization.ErrorEventArgs;
namespace Penumbra;
public class EphemeralConfig : ISavable
public class EphemeralConfig : ISavable, IDisposable
{
[JsonIgnore]
private readonly SaveService _saveService;
[JsonIgnore]
private readonly ModPathChanged _modPathChanged;
public int Version { get; set; } = Configuration.Constants.CurrentVersion;
public int LastSeenVersion { get; set; } = PenumbraChangelog.LastChangelogVersion;
public bool DebugSeparateWindow { get; set; } = false;
@ -31,17 +37,24 @@ public class EphemeralConfig : ISavable
public TabType SelectedTab { get; set; } = TabType.Settings;
public ChangedItemDrawer.ChangedItemIcon ChangedItemFilter { get; set; } = ChangedItemDrawer.DefaultFlags;
public bool FixMainWindow { get; set; } = false;
public string LastModPath { get; set; } = string.Empty;
public bool AdvancedEditingOpen { get; set; } = false;
/// <summary>
/// Load the current configuration.
/// Includes adding new colors and migrating from old versions.
/// </summary>
public EphemeralConfig(SaveService saveService)
public EphemeralConfig(SaveService saveService, ModPathChanged modPathChanged)
{
_saveService = saveService;
_saveService = saveService;
_modPathChanged = modPathChanged;
Load();
_modPathChanged.Subscribe(OnModPathChanged, ModPathChanged.Priority.EphemeralConfig);
}
public void Dispose()
=> _modPathChanged.Unsubscribe(OnModPathChanged);
private void Load()
{
static void HandleDeserializationError(object? sender, ErrorEventArgs errorArgs)
@ -80,8 +93,19 @@ public class EphemeralConfig : ISavable
public void Save(StreamWriter writer)
{
using var jWriter = new JsonTextWriter(writer) { Formatting = Formatting.Indented };
using var jWriter = new JsonTextWriter(writer);
jWriter.Formatting = Formatting.Indented;
var serializer = new JsonSerializer { Formatting = Formatting.Indented };
serializer.Serialize(jWriter, this);
}
/// <summary> Overwrite the last saved mod path if it changes. </summary>
private void OnModPathChanged(ModPathChangeType type, Mod mod, DirectoryInfo? old, DirectoryInfo? _)
{
if (type is not ModPathChangeType.Moved || !string.Equals(old?.Name, LastModPath, StringComparison.OrdinalIgnoreCase))
return;
LastModPath = mod.Identifier;
Save();
}
}

View file

@ -0,0 +1,463 @@
using System.Collections.Immutable;
using Lumina.Data.Parsing;
using Lumina.Extensions;
using OtterGui;
using Penumbra.GameData.Files;
using SharpGLTF.Geometry;
using SharpGLTF.Geometry.VertexTypes;
using SharpGLTF.IO;
using SharpGLTF.Materials;
using SharpGLTF.Scenes;
namespace Penumbra.Import.Models.Export;
public class MeshExporter
{
public class Mesh(IEnumerable<IMeshBuilder<MaterialBuilder>> meshes, NodeBuilder[]? joints)
{
public void AddToScene(SceneBuilder scene)
{
foreach (var mesh in meshes)
{
if (joints == null)
scene.AddRigidMesh(mesh, Matrix4x4.Identity);
else
scene.AddSkinnedMesh(mesh, Matrix4x4.Identity, joints);
}
}
}
public static Mesh Export(MdlFile mdl, byte lod, ushort meshIndex, GltfSkeleton? skeleton)
{
var self = new MeshExporter(mdl, lod, meshIndex, skeleton?.Names);
return new Mesh(self.BuildMeshes(), skeleton?.Joints);
}
private const byte MaximumMeshBufferStreams = 3;
private readonly MdlFile _mdl;
private readonly byte _lod;
private readonly ushort _meshIndex;
private MdlStructs.MeshStruct XivMesh
=> _mdl.Meshes[_meshIndex];
private readonly Dictionary<ushort, int>? _boneIndexMap;
private readonly Type _geometryType;
private readonly Type _materialType;
private readonly Type _skinningType;
private MeshExporter(MdlFile mdl, byte lod, ushort meshIndex, IReadOnlyDictionary<string, int>? boneNameMap)
{
_mdl = mdl;
_lod = lod;
_meshIndex = meshIndex;
if (boneNameMap != null)
_boneIndexMap = BuildBoneIndexMap(boneNameMap);
var usages = _mdl.VertexDeclarations[_meshIndex].VertexElements
.ToImmutableDictionary(
element => (MdlFile.VertexUsage)element.Usage,
element => (MdlFile.VertexType)element.Type
);
_geometryType = GetGeometryType(usages);
_materialType = GetMaterialType(usages);
_skinningType = GetSkinningType(usages);
// If there's skinning usages but no bone mapping, there's probably something wrong with the data.
if (_skinningType != typeof(VertexEmpty) && _boneIndexMap == null)
Penumbra.Log.Warning($"Mesh {meshIndex} has skinned vertex usages but no bone information was provided.");
Penumbra.Log.Debug(
$"Mesh {meshIndex} using vertex types geometry: {_geometryType.Name}, material: {_materialType.Name}, skinning: {_skinningType.Name}");
}
/// <summary> Build a mapping between indices in this mesh's bone table (if any), and the glTF joint indices provided. </summary>
private Dictionary<ushort, int>? BuildBoneIndexMap(IReadOnlyDictionary<string, int> boneNameMap)
{
// A BoneTableIndex of 255 means that this mesh is not skinned.
if (XivMesh.BoneTableIndex == 255)
return null;
var xivBoneTable = _mdl.BoneTables[XivMesh.BoneTableIndex];
var indexMap = new Dictionary<ushort, int>();
foreach (var (xivBoneIndex, tableIndex) in xivBoneTable.BoneIndex.Take(xivBoneTable.BoneCount).WithIndex())
{
var boneName = _mdl.Bones[xivBoneIndex];
if (!boneNameMap.TryGetValue(boneName, out var gltfBoneIndex))
throw new Exception($"Armature does not contain bone \"{boneName}\" requested by mesh {_meshIndex}.");
indexMap.Add((ushort)tableIndex, gltfBoneIndex);
}
return indexMap;
}
/// <summary> Build glTF meshes for this XIV mesh. </summary>
private IMeshBuilder<MaterialBuilder>[] BuildMeshes()
{
var indices = BuildIndices();
var vertices = BuildVertices();
// NOTE: Index indices are specified relative to the LOD's 0, but we're reading chunks for each mesh, so we're specifying the index base relative to the mesh's base.
if (XivMesh.SubMeshCount == 0)
return [BuildMesh($"mesh {_meshIndex}", indices, vertices, 0, (int)XivMesh.IndexCount)];
return _mdl.SubMeshes
.Skip(XivMesh.SubMeshIndex)
.Take(XivMesh.SubMeshCount)
.WithIndex()
.Select(subMesh => BuildMesh($"mesh {_meshIndex}.{subMesh.Index}", indices, vertices,
(int)(subMesh.Value.IndexOffset - XivMesh.StartIndex), (int)subMesh.Value.IndexCount))
.ToArray();
}
/// <summary> Build a mesh from the provided indices and vertices. A subset of the full indices may be built by providing an index base and count. </summary>
private IMeshBuilder<MaterialBuilder> BuildMesh(
string name,
IReadOnlyList<ushort> indices,
IReadOnlyList<IVertexBuilder> vertices,
int indexBase,
int indexCount
)
{
var meshBuilderType = typeof(MeshBuilder<,,,>).MakeGenericType(
typeof(MaterialBuilder),
_geometryType,
_materialType,
_skinningType
);
var meshBuilder = (IMeshBuilder<MaterialBuilder>)Activator.CreateInstance(meshBuilderType, name)!;
// TODO: share materials &c
var materialBuilder = new MaterialBuilder()
.WithDoubleSide(true)
.WithMetallicRoughnessShader()
.WithChannelParam(KnownChannel.BaseColor, KnownProperty.RGBA, new Vector4(1, 1, 1, 1));
var primitiveBuilder = meshBuilder.UsePrimitive(materialBuilder);
// Store a list of the glTF indices. The list index will be equivalent to the xiv (submesh) index.
var gltfIndices = new List<int>();
// All XIV meshes use triangle lists.
for (var indexOffset = 0; indexOffset < indexCount; indexOffset += 3)
{
var (a, b, c) = primitiveBuilder.AddTriangle(
vertices[indices[indexBase + indexOffset + 0]],
vertices[indices[indexBase + indexOffset + 1]],
vertices[indices[indexBase + indexOffset + 2]]
);
gltfIndices.AddRange([a, b, c]);
}
var primitiveVertices = meshBuilder.Primitives.First().Vertices;
var shapeNames = new List<string>();
foreach (var shape in _mdl.Shapes)
{
// Filter down to shape values for the current mesh that sit within the bounds of the current submesh.
var shapeValues = _mdl.ShapeMeshes
.Skip(shape.ShapeMeshStartIndex[_lod])
.Take(shape.ShapeMeshCount[_lod])
.Where(shapeMesh => shapeMesh.MeshIndexOffset == XivMesh.StartIndex)
.SelectMany(shapeMesh =>
_mdl.ShapeValues
.Skip((int)shapeMesh.ShapeValueOffset)
.Take((int)shapeMesh.ShapeValueCount)
)
.Where(shapeValue =>
shapeValue.BaseIndicesIndex >= indexBase
&& shapeValue.BaseIndicesIndex < indexBase + indexCount
)
.ToList();
if (shapeValues.Count == 0)
continue;
var morphBuilder = meshBuilder.UseMorphTarget(shapeNames.Count);
shapeNames.Add(shape.ShapeName);
foreach (var shapeValue in shapeValues)
{
morphBuilder.SetVertex(
primitiveVertices[gltfIndices[shapeValue.BaseIndicesIndex - indexBase]].GetGeometry(),
vertices[shapeValue.ReplacingVertexIndex].GetGeometry()
);
}
}
meshBuilder.Extras = JsonContent.CreateFrom(new Dictionary<string, object>()
{
{ "targetNames", shapeNames },
});
return meshBuilder;
}
/// <summary> Read in the indices for this mesh. </summary>
private IReadOnlyList<ushort> BuildIndices()
{
var reader = new BinaryReader(new MemoryStream(_mdl.RemainingData));
reader.Seek(_mdl.IndexOffset[_lod] + XivMesh.StartIndex * sizeof(ushort));
return reader.ReadStructuresAsArray<ushort>((int)XivMesh.IndexCount);
}
/// <summary> Build glTF-compatible vertex data for all vertices in this mesh. </summary>
private IReadOnlyList<IVertexBuilder> BuildVertices()
{
var vertexBuilderType = typeof(VertexBuilder<,,>)
.MakeGenericType(_geometryType, _materialType, _skinningType);
// NOTE: This assumes that buffer streams are tightly packed, which has proven safe across tested files. If this assumption is broken, seeks will need to be moved into the vertex element loop.
var streams = new BinaryReader[MaximumMeshBufferStreams];
for (var streamIndex = 0; streamIndex < MaximumMeshBufferStreams; streamIndex++)
{
streams[streamIndex] = new BinaryReader(new MemoryStream(_mdl.RemainingData));
streams[streamIndex].Seek(_mdl.VertexOffset[_lod] + XivMesh.VertexBufferOffset[streamIndex]);
}
var sortedElements = _mdl.VertexDeclarations[_meshIndex].VertexElements
.OrderBy(element => element.Offset)
.Select(element => ((MdlFile.VertexUsage)element.Usage, element))
.ToList();
var vertices = new List<IVertexBuilder>();
var attributes = new Dictionary<MdlFile.VertexUsage, object>();
for (var vertexIndex = 0; vertexIndex < XivMesh.VertexCount; vertexIndex++)
{
attributes.Clear();
foreach (var (usage, element) in sortedElements)
attributes[usage] = ReadVertexAttribute((MdlFile.VertexType)element.Type, streams[element.Stream]);
var vertexGeometry = BuildVertexGeometry(attributes);
var vertexMaterial = BuildVertexMaterial(attributes);
var vertexSkinning = BuildVertexSkinning(attributes);
var vertexBuilder = (IVertexBuilder)Activator.CreateInstance(vertexBuilderType, vertexGeometry, vertexMaterial, vertexSkinning)!;
vertices.Add(vertexBuilder);
}
return vertices;
}
/// <summary> Read a vertex attribute of the specified type from a vertex buffer stream. </summary>
private static object ReadVertexAttribute(MdlFile.VertexType type, BinaryReader reader)
{
return type switch
{
MdlFile.VertexType.Single3 => new Vector3(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()),
MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()),
MdlFile.VertexType.UInt => reader.ReadBytes(4),
MdlFile.VertexType.ByteFloat4 => new Vector4(reader.ReadByte() / 255f, reader.ReadByte() / 255f, reader.ReadByte() / 255f,
reader.ReadByte() / 255f),
MdlFile.VertexType.Half2 => new Vector2((float)reader.ReadHalf(), (float)reader.ReadHalf()),
MdlFile.VertexType.Half4 => new Vector4((float)reader.ReadHalf(), (float)reader.ReadHalf(), (float)reader.ReadHalf(),
(float)reader.ReadHalf()),
_ => throw new ArgumentOutOfRangeException(),
};
}
/// <summary> Get the vertex geometry type for this mesh's vertex usages. </summary>
private static Type GetGeometryType(IReadOnlyDictionary<MdlFile.VertexUsage, MdlFile.VertexType> usages)
{
if (!usages.ContainsKey(MdlFile.VertexUsage.Position))
throw new Exception("Mesh does not contain position vertex elements.");
if (!usages.ContainsKey(MdlFile.VertexUsage.Normal))
return typeof(VertexPosition);
if (!usages.ContainsKey(MdlFile.VertexUsage.Tangent1))
return typeof(VertexPositionNormal);
return typeof(VertexPositionNormalTangent);
}
/// <summary> Build a geometry vertex from a vertex's attributes. </summary>
private IVertexGeometry BuildVertexGeometry(IReadOnlyDictionary<MdlFile.VertexUsage, object> attributes)
{
if (_geometryType == typeof(VertexPosition))
return new VertexPosition(
ToVector3(attributes[MdlFile.VertexUsage.Position])
);
if (_geometryType == typeof(VertexPositionNormal))
return new VertexPositionNormal(
ToVector3(attributes[MdlFile.VertexUsage.Position]),
ToVector3(attributes[MdlFile.VertexUsage.Normal])
);
if (_geometryType == typeof(VertexPositionNormalTangent))
return new VertexPositionNormalTangent(
ToVector3(attributes[MdlFile.VertexUsage.Position]),
ToVector3(attributes[MdlFile.VertexUsage.Normal]),
FixTangentVector(ToVector4(attributes[MdlFile.VertexUsage.Tangent1]))
);
throw new Exception($"Unknown geometry type {_geometryType}.");
}
/// <summary> Get the vertex material type for this mesh's vertex usages. </summary>
private static Type GetMaterialType(IReadOnlyDictionary<MdlFile.VertexUsage, MdlFile.VertexType> usages)
{
var uvCount = 0;
if (usages.TryGetValue(MdlFile.VertexUsage.UV, out var type))
uvCount = type switch
{
MdlFile.VertexType.Half2 => 1,
MdlFile.VertexType.Half4 => 2,
MdlFile.VertexType.Single4 => 2,
_ => throw new Exception($"Unexpected UV vertex type {type}."),
};
var materialUsages = (
uvCount,
usages.ContainsKey(MdlFile.VertexUsage.Color)
);
return materialUsages switch
{
(2, true) => typeof(VertexColor1Texture2),
(2, false) => typeof(VertexTexture2),
(1, true) => typeof(VertexColor1Texture1),
(1, false) => typeof(VertexTexture1),
(0, true) => typeof(VertexColor1),
(0, false) => typeof(VertexEmpty),
_ => throw new Exception("Unreachable."),
};
}
/// <summary> Build a material vertex from a vertex's attributes. </summary>
private IVertexMaterial BuildVertexMaterial(IReadOnlyDictionary<MdlFile.VertexUsage, object> attributes)
{
if (_materialType == typeof(VertexEmpty))
return new VertexEmpty();
if (_materialType == typeof(VertexColor1))
return new VertexColor1(ToVector4(attributes[MdlFile.VertexUsage.Color]));
if (_materialType == typeof(VertexTexture1))
return new VertexTexture1(ToVector2(attributes[MdlFile.VertexUsage.UV]));
if (_materialType == typeof(VertexColor1Texture1))
return new VertexColor1Texture1(
ToVector4(attributes[MdlFile.VertexUsage.Color]),
ToVector2(attributes[MdlFile.VertexUsage.UV])
);
// XIV packs two UVs into a single vec4 attribute.
if (_materialType == typeof(VertexTexture2))
{
var uv = ToVector4(attributes[MdlFile.VertexUsage.UV]);
return new VertexTexture2(
new Vector2(uv.X, uv.Y),
new Vector2(uv.Z, uv.W)
);
}
if (_materialType == typeof(VertexColor1Texture2))
{
var uv = ToVector4(attributes[MdlFile.VertexUsage.UV]);
return new VertexColor1Texture2(
ToVector4(attributes[MdlFile.VertexUsage.Color]),
new Vector2(uv.X, uv.Y),
new Vector2(uv.Z, uv.W)
);
}
throw new Exception($"Unknown material type {_skinningType}");
}
/// <summary> Get the vertex skinning type for this mesh's vertex usages. </summary>
private static Type GetSkinningType(IReadOnlyDictionary<MdlFile.VertexUsage, MdlFile.VertexType> usages)
{
if (usages.ContainsKey(MdlFile.VertexUsage.BlendWeights) && usages.ContainsKey(MdlFile.VertexUsage.BlendIndices))
return typeof(VertexJoints4);
return typeof(VertexEmpty);
}
/// <summary> Build a skinning vertex from a vertex's attributes. </summary>
private IVertexSkinning BuildVertexSkinning(IReadOnlyDictionary<MdlFile.VertexUsage, object> attributes)
{
if (_skinningType == typeof(VertexEmpty))
return new VertexEmpty();
if (_skinningType == typeof(VertexJoints4))
{
if (_boneIndexMap == null)
throw new Exception("Tried to build skinned vertex but no bone mappings are available.");
var indices = ToByteArray(attributes[MdlFile.VertexUsage.BlendIndices]);
var weights = ToVector4(attributes[MdlFile.VertexUsage.BlendWeights]);
var bindings = Enumerable.Range(0, 4)
.Select(bindingIndex =>
{
// NOTE: I've not seen any files that throw this error that aren't completely broken.
var xivBoneIndex = indices[bindingIndex];
if (!_boneIndexMap.TryGetValue(xivBoneIndex, out var jointIndex))
throw new Exception($"Vertex contains weight for unknown bone index {xivBoneIndex}.");
return (jointIndex, weights[bindingIndex]);
})
.ToArray();
return new VertexJoints4(bindings);
}
throw new Exception($"Unknown skinning type {_skinningType}");
}
/// <summary> Clamps any tangent W value other than 1 to -1. </summary>
/// <remarks> Some XIV models seemingly store -1 as 0, this patches over that. </remarks>
private static Vector4 FixTangentVector(Vector4 tangent)
=> tangent with { W = tangent.W == 1 ? 1 : -1 };
/// <summary> Convert a vertex attribute value to a Vector2. Supported inputs are Vector2, Vector3, and Vector4. </summary>
private static Vector2 ToVector2(object data)
=> data switch
{
Vector2 v2 => v2,
Vector3 v3 => new Vector2(v3.X, v3.Y),
Vector4 v4 => new Vector2(v4.X, v4.Y),
_ => throw new ArgumentOutOfRangeException($"Invalid Vector2 input {data}"),
};
/// <summary> Convert a vertex attribute value to a Vector3. Supported inputs are Vector2, Vector3, and Vector4. </summary>
private static Vector3 ToVector3(object data)
=> data switch
{
Vector2 v2 => new Vector3(v2.X, v2.Y, 0),
Vector3 v3 => v3,
Vector4 v4 => new Vector3(v4.X, v4.Y, v4.Z),
_ => throw new ArgumentOutOfRangeException($"Invalid Vector3 input {data}"),
};
/// <summary> Convert a vertex attribute value to a Vector4. Supported inputs are Vector2, Vector3, and Vector4. </summary>
private static Vector4 ToVector4(object data)
=> data switch
{
Vector2 v2 => new Vector4(v2.X, v2.Y, 0, 0),
Vector3 v3 => new Vector4(v3.X, v3.Y, v3.Z, 1),
Vector4 v4 => v4,
_ => throw new ArgumentOutOfRangeException($"Invalid Vector3 input {data}"),
};
/// <summary> Convert a vertex attribute value to a byte array. </summary>
private static byte[] ToByteArray(object data)
=> data switch
{
byte[] value => value,
_ => throw new ArgumentOutOfRangeException($"Invalid byte[] input {data}"),
};
}

View file

@ -0,0 +1,92 @@
using Penumbra.GameData.Files;
using SharpGLTF.Scenes;
using SharpGLTF.Transforms;
namespace Penumbra.Import.Models.Export;
public class ModelExporter
{
public class Model(List<MeshExporter.Mesh> meshes, GltfSkeleton? skeleton)
{
public void AddToScene(SceneBuilder scene)
{
// If there's a skeleton, the root node should be added before we add any potentially skinned meshes.
var skeletonRoot = skeleton?.Root;
if (skeletonRoot != null)
scene.AddNode(skeletonRoot);
// Add all the meshes to the scene.
foreach (var mesh in meshes)
mesh.AddToScene(scene);
}
}
/// <summary> Export a model in preparation for usage in a glTF file. If provided, skeleton will be used to skin the resulting meshes where appropriate. </summary>
public static Model Export(MdlFile mdl, XivSkeleton? xivSkeleton)
{
var gltfSkeleton = xivSkeleton != null ? ConvertSkeleton(xivSkeleton) : null;
var meshes = ConvertMeshes(mdl, gltfSkeleton);
return new Model(meshes, gltfSkeleton);
}
/// <summary> Convert a .mdl to a mesh (group) per LoD. </summary>
private static List<MeshExporter.Mesh> ConvertMeshes(MdlFile mdl, GltfSkeleton? skeleton)
{
var meshes = new List<MeshExporter.Mesh>();
for (byte lodIndex = 0; lodIndex < mdl.LodCount; lodIndex++)
{
var lod = mdl.Lods[lodIndex];
// TODO: consider other types of mesh?
for (ushort meshOffset = 0; meshOffset < lod.MeshCount; meshOffset++)
{
var mesh = MeshExporter.Export(mdl, lodIndex, (ushort)(lod.MeshIndex + meshOffset), skeleton);
meshes.Add(mesh);
}
}
return meshes;
}
/// <summary> Convert XIV skeleton data into a glTF-compatible node tree, with mappings. </summary>
private static GltfSkeleton? ConvertSkeleton(XivSkeleton skeleton)
{
NodeBuilder? root = null;
var names = new Dictionary<string, int>();
var joints = new List<NodeBuilder>();
foreach (var bone in skeleton.Bones)
{
if (names.ContainsKey(bone.Name)) continue;
var node = new NodeBuilder(bone.Name);
names[bone.Name] = joints.Count;
joints.Add(node);
node.SetLocalTransform(new AffineTransform(
bone.Transform.Scale,
bone.Transform.Rotation,
bone.Transform.Translation
), false);
if (bone.ParentIndex == -1)
{
root = node;
continue;
}
var parent = joints[names[skeleton.Bones[bone.ParentIndex].Name]];
parent.AddNode(node);
}
if (root == null)
return null;
return new GltfSkeleton
{
Root = root,
Joints = [.. joints],
Names = names,
};
}
}

View file

@ -0,0 +1,35 @@
using SharpGLTF.Scenes;
namespace Penumbra.Import.Models.Export;
/// <summary> Representation of a skeleton within XIV. </summary>
public class XivSkeleton(XivSkeleton.Bone[] bones)
{
public Bone[] Bones = bones;
public struct Bone
{
public string Name;
public int ParentIndex;
public Transform Transform;
}
public struct Transform {
public Vector3 Scale;
public Quaternion Rotation;
public Vector3 Translation;
}
}
/// <summary> Representation of a glTF-compatible skeleton. </summary>
public struct GltfSkeleton
{
/// <summary> Root node of the skeleton. </summary>
public NodeBuilder Root;
/// <summary> Flattened list of skeleton nodes. </summary>
public NodeBuilder[] Joints;
/// <summary> Mapping of bone names to their index within the joints array. </summary>
public Dictionary<string, int> Names;
}

View file

@ -0,0 +1,136 @@
using FFXIVClientStructs.Havok;
namespace Penumbra.Import.Models;
public static unsafe class HavokConverter
{
/// <summary> Creates a temporary file and returns its path. </summary>
private static string CreateTempFile()
{
var stream = File.Create(Path.GetTempFileName());
stream.Close();
return stream.Name;
}
/// <summary> Converts a .hkx file to a .xml file. </summary>
/// <param name="hkx"> A byte array representing the .hkx file. </param>
public static string HkxToXml(byte[] hkx)
{
const hkSerializeUtil.SaveOptionBits options = hkSerializeUtil.SaveOptionBits.SerializeIgnoredMembers
| hkSerializeUtil.SaveOptionBits.TextFormat
| hkSerializeUtil.SaveOptionBits.WriteAttributes;
var tempHkx = CreateTempFile();
File.WriteAllBytes(tempHkx, hkx);
var resource = Read(tempHkx);
File.Delete(tempHkx);
if (resource == null)
throw new Exception("Failed to read havok file.");
var file = Write(resource, options);
file.Close();
var bytes = File.ReadAllText(file.Name);
File.Delete(file.Name);
return bytes;
}
/// <summary> Converts an .xml file to a .hkx file. </summary>
/// <param name="xml"> A string representing the .xml file. </param>
public static byte[] XmlToHkx(string xml)
{
const hkSerializeUtil.SaveOptionBits options = hkSerializeUtil.SaveOptionBits.SerializeIgnoredMembers
| hkSerializeUtil.SaveOptionBits.WriteAttributes;
var tempXml = CreateTempFile();
File.WriteAllText(tempXml, xml);
var resource = Read(tempXml);
File.Delete(tempXml);
if (resource == null)
throw new Exception("Failed to read havok file.");
var file = Write(resource, options);
file.Close();
var bytes = File.ReadAllBytes(file.Name);
File.Delete(file.Name);
return bytes;
}
/// <summary>
/// Parses a serialized file into an hkResource*.
/// The type is guessed automatically by Havok.
/// This pointer might be null - you should check for that.
/// </summary>
/// <param name="filePath"> Path to a file on the filesystem. </param>
private static hkResource* Read(string filePath)
{
var path = Marshal.StringToHGlobalAnsi(filePath);
var builtinTypeRegistry = hkBuiltinTypeRegistry.Instance();
var loadOptions = stackalloc hkSerializeUtil.LoadOptions[1];
loadOptions->Flags = new hkFlags<hkSerializeUtil.LoadOptionBits, int> { Storage = (int)hkSerializeUtil.LoadOptionBits.Default };
loadOptions->ClassNameRegistry = builtinTypeRegistry->GetClassNameRegistry();
loadOptions->TypeInfoRegistry = builtinTypeRegistry->GetTypeInfoRegistry();
// TODO: probably can use LoadFromBuffer for this.
var resource = hkSerializeUtil.LoadFromFile((byte*)path, null, loadOptions);
return resource;
}
/// <summary> Serializes an hkResource* to a temporary file. </summary>
/// <param name="resource"> A pointer to the hkResource, opened through Read(). </param>
/// <param name="optionBits"> Flags representing how to serialize the file. </param>
private static 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 hkFlags<hkSerializeUtil.SaveOptionBits, int> { Storage = (int)optionBits },
};
var builtinTypeRegistry = hkBuiltinTypeRegistry.Instance();
var classNameRegistry = builtinTypeRegistry->GetClassNameRegistry();
var typeInfoRegistry = builtinTypeRegistry->GetTypeInfoRegistry();
try
{
const string name = "hkRootLevelContainer";
var resourcePtr = (hkRootLevelContainer*)resource->GetContentsPointer(name, typeInfoRegistry);
if (resourcePtr == null)
throw new Exception("Failed to retrieve havok root level container resource.");
var hkRootLevelContainerClass = classNameRegistry->GetClassByName(name);
if (hkRootLevelContainerClass == null)
throw new Exception("Failed to retrieve havok root level container type.");
hkSerializeUtil.Save(result, resourcePtr, hkRootLevelContainerClass, oStream.StreamWriter.ptr, saveOptions);
}
finally
{
oStream.Dtor();
}
if (result->Result == hkResult.hkResultEnum.Failure)
throw new Exception("Failed to serialize havok file.");
return new FileStream(tempFile, FileMode.Open);
}
}

View file

@ -0,0 +1,120 @@
using Dalamud.Plugin.Services;
using OtterGui.Tasks;
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(IFramework framework, GamePathParser _parser) : SingleTaskQueue, IDisposable
{
private readonly IFramework _framework = framework;
private readonly ConcurrentDictionary<IAction, (Task, CancellationTokenSource)> _tasks = new();
private bool _disposed;
public void Dispose()
{
_disposed = true;
foreach (var (_, cancel) in _tasks.Values.ToArray())
cancel.Cancel();
_tasks.Clear();
}
public Task ExportToGltf(MdlFile mdl, SklbFile? sklb, string outputPath)
=> Enqueue(new ExportToGltfAction(this, mdl, sklb, outputPath));
/// <summary> Try to find the .sklb path for a .mdl file. </summary>
/// <param name="mdlPath"> .mdl file to look up the skeleton for. </param>
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)
return Task.FromException(new ObjectDisposedException(nameof(ModelManager)));
Task task;
lock (_tasks)
{
task = _tasks.GetOrAdd(action, a =>
{
var token = new CancellationTokenSource();
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;
}
private class ExportToGltfAction(ModelManager manager, MdlFile mdl, SklbFile? sklb, string outputPath)
: IAction
{
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);
Penumbra.Log.Debug("Building scene.");
var scene = new SceneBuilder();
model.AddToScene(scene);
Penumbra.Log.Debug("Saving.");
var gltfModel = scene.ToGltf2();
gltfModel.SaveGLTF(outputPath);
}
/// <summary> Attempt to read out the pertinent information from a .sklb. </summary>
private XivSkeleton? BuildSkeleton(CancellationToken cancel)
{
if (sklb == null)
return null;
var xmlTask = manager._framework.RunOnFrameworkThread(() => HavokConverter.HkxToXml(sklb.Skeleton));
xmlTask.Wait(cancel);
var xml = xmlTask.Result;
return SkeletonConverter.FromXml(xml);
}
public bool Equals(IAction? other)
{
if (other is not ExportToGltfAction rhs)
return false;
// TODO: compare configuration and such
return true;
}
}
}

View file

@ -0,0 +1,138 @@
using System.Xml;
using OtterGui;
using Penumbra.Import.Models.Export;
namespace Penumbra.Import.Models;
public static class SkeletonConverter
{
/// <summary> Parse XIV skeleton data from a havok XML tagfile. </summary>
/// <param name="xml"> Havok XML tagfile containing skeleton data. </param>
public static XivSkeleton FromXml(string xml)
{
var document = new XmlDocument();
document.LoadXml(xml);
var mainSkeletonId = GetMainSkeletonId(document);
var skeletonNode = document.SelectSingleNode($"/hktagfile/object[@type='hkaSkeleton'][@id='{mainSkeletonId}']")
?? throw new InvalidDataException($"Failed to find skeleton with id {mainSkeletonId}.");
var referencePose = ReadReferencePose(skeletonNode);
var parentIndices = ReadParentIndices(skeletonNode);
var boneNames = ReadBoneNames(skeletonNode);
if (boneNames.Length != parentIndices.Length || boneNames.Length != referencePose.Length)
throw new InvalidDataException(
$"Mismatch in bone value array lengths: names({boneNames.Length}) parents({parentIndices.Length}) pose({referencePose.Length})");
var bones = referencePose
.Zip(parentIndices, boneNames)
.Select(values =>
{
var (transform, parentIndex, name) = values;
return new XivSkeleton.Bone()
{
Transform = transform,
ParentIndex = parentIndex,
Name = name,
};
})
.ToArray();
return new XivSkeleton(bones);
}
/// <summary> Get the main skeleton ID for a given skeleton document. </summary>
/// <param name="node"> XML skeleton document. </param>
private static string GetMainSkeletonId(XmlNode node)
{
var animationSkeletons = node
.SelectSingleNode("/hktagfile/object[@type='hkaAnimationContainer']/array[@name='skeletons']")?
.ChildNodes;
if (animationSkeletons?.Count != 1)
throw new Exception($"Assumption broken: Expected 1 hkaAnimationContainer skeleton, got {animationSkeletons?.Count ?? 0}.");
return animationSkeletons[0]!.InnerText;
}
/// <summary> Read the reference pose transforms for a skeleton. </summary>
/// <param name="node"> XML node for the skeleton. </param>
private static XivSkeleton.Transform[] ReadReferencePose(XmlNode node)
{
return ReadArray(
CheckExists(node.SelectSingleNode("array[@name='referencePose']")),
n =>
{
var raw = ReadVec12(n);
return new XivSkeleton.Transform()
{
Translation = new Vector3(raw[0], raw[1], raw[2]),
Rotation = new Quaternion(raw[4], raw[5], raw[6], raw[7]),
Scale = new Vector3(raw[8], raw[9], raw[10]),
};
}
);
}
/// <summary> Read a 12-item vector from a tagfile. </summary>
/// <param name="node"> Havok Vec12 XML node. </param>
private static float[] ReadVec12(XmlNode node)
{
var array = node.ChildNodes
.Cast<XmlNode>()
.Where(n => n.NodeType != XmlNodeType.Comment)
.Select(n =>
{
var text = n.InnerText.Trim()[1..];
// TODO: surely there's a less shit way to do this I mean seriously
return BitConverter.ToSingle(BitConverter.GetBytes(int.Parse(text, NumberStyles.HexNumber)));
})
.ToArray();
if (array.Length != 12)
throw new InvalidDataException($"Unexpected Vector12 length ({array.Length}).");
return array;
}
/// <summary> Read the bone parent relations for a skeleton. </summary>
/// <param name="node"> XML node for the skeleton. </param>
private static int[] ReadParentIndices(XmlNode node)
// todo: would be neat to genericise array between bare and children
=> CheckExists(node.SelectSingleNode("array[@name='parentIndices']"))
.InnerText
.Split((char[]) [' ', '\n'], StringSplitOptions.RemoveEmptyEntries)
.Select(int.Parse)
.ToArray();
/// <summary> Read the names of bones in a skeleton. </summary>
/// <param name="node"> XML node for the skeleton. </param>
private static string[] ReadBoneNames(XmlNode node)
=> ReadArray(
CheckExists(node.SelectSingleNode("array[@name='bones']")),
n => CheckExists(n.SelectSingleNode("string[@name='name']")).InnerText
);
/// <summary> Read an XML tagfile array, converting it via the provided conversion function. </summary>
/// <param name="node"> Tagfile XML array node. </param>
/// <param name="convert"> Function to convert array item nodes to required data types. </param>
private static T[] ReadArray<T>(XmlNode node, Func<XmlNode, T> convert)
{
var element = (XmlElement)node;
var size = int.Parse(element.GetAttribute("size"));
var array = new T[size];
foreach (var (childNode, index) in element.ChildNodes.Cast<XmlElement>().WithIndex())
array[index] = convert(childNode);
return array;
}
/// <summary> Check if the argument is null, returning a non-nullable value if it exists, and throwing if not. </summary>
private static T CheckExists<T>(T? value)
{
ArgumentNullException.ThrowIfNull(value);
return value;
}
}

View file

@ -73,6 +73,8 @@
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.2" />
<PackageReference Include="SharpCompress" Version="0.33.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="SharpGLTF.Core" Version="1.0.0-alpha0030" />
<PackageReference Include="SharpGLTF.Toolkit" Version="1.0.0-alpha0030" />
</ItemGroup>
<ItemGroup>

View file

@ -13,6 +13,7 @@ using Penumbra.Api;
using Penumbra.Collections.Cache;
using Penumbra.Collections.Manager;
using Penumbra.GameData.Actors;
using Penumbra.Import.Models;
using Penumbra.GameData.DataContainers;
using Penumbra.GameData.Structs;
using Penumbra.Import.Textures;
@ -191,7 +192,8 @@ public static class ServiceManagerA
.AddSingleton<ModNormalizer>()
.AddSingleton<ModMerger>()
.AddSingleton<ModEditor>()
.AddSingleton<TextureManager>();
.AddSingleton<TextureManager>()
.AddSingleton<ModelManager>();
private static ServiceManager AddApi(this ServiceManager services)
=> services.AddSingleton<PenumbraApi>()

View file

@ -158,8 +158,6 @@ public class FileEditor<T> : IDisposable where T : class, IWritable
_quickImport = null;
}
_fileDialog.Draw();
}
public void Reset()

View file

@ -1,6 +1,8 @@
using OtterGui;
using Penumbra.GameData;
using Penumbra.GameData.Files;
using Penumbra.Mods;
using Penumbra.String.Classes;
namespace Penumbra.UI.AdvancedWindow;
@ -8,14 +10,26 @@ public partial class ModEditWindow
{
private class MdlTab : IWritable
{
public readonly MdlFile Mdl;
private readonly ModEditWindow _edit;
public readonly MdlFile Mdl;
private readonly List<string>[] _attributes;
public MdlTab(byte[] bytes)
public List<Utf8GamePath>? GamePaths { get; private set; }
public int GamePathIndex;
public bool PendingIo { get; private set; }
public string? IoException { get; private set; }
public MdlTab(ModEditWindow edit, byte[] bytes, string path, IMod? mod)
{
_edit = edit;
Mdl = new MdlFile(bytes);
_attributes = CreateAttributes(Mdl);
if (mod != null)
FindGamePaths(path, mod);
}
/// <inheritdoc/>
@ -26,6 +40,79 @@ public partial class ModEditWindow
public byte[] Write()
=> Mdl.Write();
/// <summary> Find the list of game paths that may correspond to this model. </summary>
/// <param name="path"> Resolved path to a .mdl. </param>
/// <param name="mod"> Mod within which the .mdl is resolved. </param>
private void FindGamePaths(string path, IMod mod)
{
if (!Path.IsPathRooted(path) && Utf8GamePath.FromString(path, out var p))
{
GamePaths = [p];
return;
}
PendingIo = true;
var task = Task.Run(() =>
{
// TODO: Is it worth trying to order results based on option priorities for cases where more than one match is found?
// NOTE: We're using case-insensitive comparisons, as option group paths in mods are stored in lower case, but the mod editor uses paths directly from the file system, which may be mixed case.
return mod.AllSubMods
.SelectMany(m => m.Files.Concat(m.FileSwaps))
.Where(kv => kv.Value.FullName.Equals(path, StringComparison.OrdinalIgnoreCase))
.Select(kv => kv.Key)
.ToList();
});
task.ContinueWith(t =>
{
IoException = t.Exception?.ToString();
GamePaths = t.Result;
PendingIo = false;
});
}
/// <summary> Export model to an interchange format. </summary>
/// <param name="outputPath"> Disk path to save the resulting file to. </param>
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;
});
}
/// <summary> Read a .sklb from the active collection or game. </summary>
/// <param name="sklbPath"> Game path to the .sklb to load. </param>
private SklbFile ReadSklb(string sklbPath)
{
// TODO: if cross-collection lookups are turned off, this conversion can be skipped
if (!Utf8GamePath.FromString(sklbPath, out var utf8SklbPath, true))
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 == 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?");
}
/// <summary> Remove the material given by the index. </summary>
/// <remarks> Meshes using the removed material are redirected to material 0, and those after the index are corrected. </remarks>
public void RemoveMaterial(int materialIndex)

View file

@ -5,6 +5,7 @@ using OtterGui.Raii;
using OtterGui.Widgets;
using Penumbra.GameData;
using Penumbra.GameData.Files;
using Penumbra.Import.Models;
using Penumbra.String.Classes;
namespace Penumbra.UI.AdvancedWindow;
@ -14,9 +15,12 @@ public partial class ModEditWindow
private const int MdlMaterialMaximum = 4;
private readonly FileEditor<MdlTab> _modelTab;
private readonly ModelManager _models;
private string _modelNewMaterial = string.Empty;
private readonly List<TagButtons> _subMeshAttributeTagWidgets = [];
private string _customPath = string.Empty;
private Utf8GamePath _customGamePath = Utf8GamePath.Empty;
private bool DrawModelPanel(MdlTab tab, bool disabled)
{
@ -31,6 +35,8 @@ public partial class ModEditWindow
);
}
DrawExport(tab, disabled);
var ret = false;
ret |= DrawModelMaterialDetails(tab, disabled);
@ -44,6 +50,97 @@ public partial class ModEditWindow
return !disabled && ret;
}
private void DrawExport(MdlTab tab, bool disabled)
{
if (!ImGui.CollapsingHeader("Export"))
return;
if (tab.GamePaths == null)
{
if (tab.IoException == null)
ImGui.TextUnformatted("Resolving model game paths.");
else
ImGuiUtil.TextWrapped(tab.IoException);
return;
}
DrawGamePathCombo(tab);
var gamePath = tab.GamePathIndex >= 0 && tab.GamePathIndex < tab.GamePaths.Count
? tab.GamePaths[tab.GamePathIndex]
: _customGamePath;
if (ImGuiUtil.DrawDisabledButton("Export to glTF", Vector2.Zero, "Exports this mdl file to glTF, for use in 3D authoring applications.",
tab.PendingIo || gamePath.IsEmpty))
_fileDialog.OpenSavePicker("Save model as glTF.", ".gltf", Path.GetFileNameWithoutExtension(gamePath.Filename().ToString()),
".gltf", (valid, path) =>
{
if (!valid)
return;
tab.Export(path, gamePath);
},
_mod!.ModPath.FullName,
false
);
if (tab.IoException != null)
ImGuiUtil.TextWrapped(tab.IoException);
}
private void DrawGamePathCombo(MdlTab tab)
{
if (tab.GamePaths!.Count != 0)
{
DrawComboButton(tab);
return;
}
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;
}
/// <summary> I disliked the combo with only one selection so turn it into a button in that case. </summary>
private static void DrawComboButton(MdlTab tab)
{
const string label = "Game Path";
var preview = tab.GamePaths![tab.GamePathIndex].ToString();
var labelWidth = ImGui.CalcTextSize(label).X + ImGui.GetStyle().ItemInnerSpacing.X;
var buttonWidth = ImGui.GetContentRegionAvail().X - labelWidth;
if (tab.GamePaths!.Count == 1)
{
using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0, 0.5f));
using var color = ImRaii.PushColor(ImGuiCol.Button, ImGui.GetColorU32(ImGuiCol.FrameBg))
.Push(ImGuiCol.ButtonHovered, ImGui.GetColorU32(ImGuiCol.FrameBgHovered))
.Push(ImGuiCol.ButtonActive, ImGui.GetColorU32(ImGuiCol.FrameBgActive));
using var group = ImRaii.Group();
ImGui.Button(preview, new Vector2(buttonWidth, 0));
ImGui.SameLine(0, ImGui.GetStyle().ItemInnerSpacing.X);
ImGui.TextUnformatted("Game Path");
}
else
{
ImGui.SetNextItemWidth(buttonWidth);
using var combo = ImRaii.Combo("Game Path", preview);
if (combo.Success)
foreach (var (path, index) in tab.GamePaths.WithIndex())
{
if (!ImGui.Selectable(path.ToString(), index == tab.GamePathIndex))
continue;
tab.GamePathIndex = index;
}
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
ImGui.SetClipboardText(preview);
ImGuiUtil.HoverTooltip("Right-Click to copy to clipboard.", ImGuiHoveredFlags.AllowWhenDisabled);
}
private bool DrawModelMaterialDetails(MdlTab tab, bool disabled)
{
if (!ImGui.CollapsingHeader("Materials"))

View file

@ -6,7 +6,6 @@ using OtterGui.Raii;
using OtterGui;
using OtterGui.Classes;
using Penumbra.GameData;
using Penumbra.GameData.Data;
using Penumbra.GameData.Files;
using Penumbra.GameData.Interop;
using Penumbra.String;
@ -43,8 +42,6 @@ public partial class ModEditWindow
ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2));
DrawOtherShaderPackageDetails(file);
file.FileDialog.Draw();
ret |= file.Shpk.IsChanged();
return !disabled && ret;

View file

@ -12,6 +12,7 @@ using Penumbra.Collections.Manager;
using Penumbra.Communication;
using Penumbra.GameData.Enums;
using Penumbra.GameData.Files;
using Penumbra.Import.Models;
using Penumbra.Import.Textures;
using Penumbra.Interop.Hooks;
using Penumbra.Interop.ResourceTree;
@ -144,12 +145,20 @@ public partial class ModEditWindow : Window, IDisposable
_materialTab.Reset();
_modelTab.Reset();
_shaderPackageTab.Reset();
_config.Ephemeral.AdvancedEditingOpen = false;
_config.Ephemeral.Save();
}
public override void Draw()
{
using var performance = _performance.Measure(PerformanceType.UiAdvancedWindow);
if (!_config.Ephemeral.AdvancedEditingOpen)
{
_config.Ephemeral.AdvancedEditingOpen = true;
_config.Ephemeral.Save();
}
using var tabBar = ImRaii.TabBar("##tabs");
if (!tabBar)
return;
@ -565,33 +574,36 @@ public partial class ModEditWindow : Window, IDisposable
public ModEditWindow(PerformanceTracker performance, FileDialogService fileDialog, ItemSwapTab itemSwapTab, IDataManager gameData,
Configuration config, ModEditor editor, ResourceTreeFactory resourceTreeFactory, MetaFileManager metaFileManager,
StainService stainService, ActiveCollections activeCollections, ModMergeTab modMergeTab,
CommunicatorService communicator, TextureManager textures, IDragDropManager dragDropManager,
CommunicatorService communicator, TextureManager textures, ModelManager models, IDragDropManager dragDropManager,
ChangedItemDrawer changedItemDrawer, IObjectTable objects, IFramework framework, CharacterBaseDestructor characterBaseDestructor)
: base(WindowBaseLabel)
{
_performance = performance;
_itemSwapTab = itemSwapTab;
_gameData = gameData;
_config = config;
_editor = editor;
_metaFileManager = metaFileManager;
_stainService = stainService;
_activeCollections = activeCollections;
_modMergeTab = modMergeTab;
_communicator = communicator;
_dragDropManager = dragDropManager;
_textures = textures;
_fileDialog = fileDialog;
_objects = objects;
_framework = framework;
_performance = performance;
_itemSwapTab = itemSwapTab;
_gameData = gameData;
_config = config;
_editor = editor;
_metaFileManager = metaFileManager;
_stainService = stainService;
_activeCollections = activeCollections;
_modMergeTab = modMergeTab;
_communicator = communicator;
_dragDropManager = dragDropManager;
_textures = textures;
_models = models;
_fileDialog = fileDialog;
_objects = objects;
_framework = framework;
_characterBaseDestructor = characterBaseDestructor;
_materialTab = new FileEditor<MtrlTab>(this, gameData, config, _editor.Compactor, _fileDialog, "Materials", ".mtrl",
() => PopulateIsOnPlayer(_editor.Files.Mtrl, ResourceType.Mtrl), DrawMaterialPanel, () => _mod?.ModPath.FullName ?? string.Empty,
(bytes, path, writable) => new MtrlTab(this, new MtrlFile(bytes), path, writable));
_modelTab = new FileEditor<MdlTab>(this, gameData, config, _editor.Compactor, _fileDialog, "Models", ".mdl",
() => PopulateIsOnPlayer(_editor.Files.Mdl, ResourceType.Mdl), DrawModelPanel, () => _mod?.ModPath.FullName ?? string.Empty, (bytes, _, _) => new MdlTab(bytes));
() => PopulateIsOnPlayer(_editor.Files.Mdl, ResourceType.Mdl), DrawModelPanel, () => _mod?.ModPath.FullName ?? string.Empty,
(bytes, path, _) => new MdlTab(this, bytes, path, _mod));
_shaderPackageTab = new FileEditor<ShpkTab>(this, gameData, config, _editor.Compactor, _fileDialog, "Shaders", ".shpk",
() => PopulateIsOnPlayer(_editor.Files.Shpk, ResourceType.Shpk), DrawShaderPackagePanel, () => _mod?.ModPath.FullName ?? string.Empty,
() => PopulateIsOnPlayer(_editor.Files.Shpk, ResourceType.Shpk), DrawShaderPackagePanel,
() => _mod?.ModPath.FullName ?? string.Empty,
(bytes, _, _) => new ShpkTab(_fileDialog, bytes));
_center = new CombinedTexture(_left, _right);
_textureSelectCombo = new TextureDrawer.PathSelectCombo(textures, editor, () => GetPlayerResourcesOfType(ResourceType.Tex));
@ -599,6 +611,7 @@ public partial class ModEditWindow : Window, IDisposable
_quickImportViewer =
new ResourceTreeViewer(_config, resourceTreeFactory, changedItemDrawer, 2, OnQuickImportRefresh, DrawQuickImportActions);
_communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModEditWindow);
IsOpen = _config is { OpenWindowAtStart: true, Ephemeral.AdvancedEditingOpen: true };
}
public void Dispose()

View file

@ -39,8 +39,7 @@ public sealed class ModFileSystemSelector : FileSystemSelector<Mod, ModFileSyste
public ModFileSystemSelector(IKeyState keyState, CommunicatorService communicator, ModFileSystem fileSystem, ModManager modManager,
CollectionManager collectionManager, Configuration config, TutorialService tutorial, FileDialogService fileDialog,
MessageService messager,
ModImportManager modImportManager, IDragDropManager dragDrop)
MessageService messager, ModImportManager modImportManager, IDragDropManager dragDrop)
: base(fileSystem, keyState, Penumbra.Log, HandleException, allowMultipleSelection: true)
{
_communicator = communicator;
@ -77,7 +76,15 @@ public sealed class ModFileSystemSelector : FileSystemSelector<Mod, ModFileSyste
// @formatter:on
SetFilterTooltip();
SelectionChanged += OnSelectionChange;
SelectionChanged += OnSelectionChange;
if (_config.Ephemeral.LastModPath.Length > 0)
{
var mod = _modManager.FirstOrDefault(m
=> string.Equals(m.Identifier, _config.Ephemeral.LastModPath, StringComparison.OrdinalIgnoreCase));
if (mod != null)
SelectByValue(mod);
}
_communicator.CollectionChange.Subscribe(OnCollectionChange, CollectionChange.Priority.ModFileSystemSelector);
_communicator.ModSettingChanged.Subscribe(OnSettingChange, ModSettingChanged.Priority.ModFileSystemSelector);
_communicator.CollectionInheritanceChanged.Subscribe(OnInheritanceChange, CollectionInheritanceChanged.Priority.ModFileSystemSelector);
@ -87,15 +94,15 @@ public sealed class ModFileSystemSelector : FileSystemSelector<Mod, ModFileSyste
OnCollectionChange(CollectionType.Current, null, _collectionManager.Active.Current, "");
}
private static readonly string[] ValidModExtensions = new[]
{
private static readonly string[] ValidModExtensions =
[
".ttmp",
".ttmp2",
".pmp",
".zip",
".rar",
".7z",
};
];
public new void Draw(float width)
{
@ -476,6 +483,13 @@ public sealed class ModFileSystemSelector : FileSystemSelector<Mod, ModFileSyste
(var settings, SelectedSettingCollection) = _collectionManager.Active.Current[newSelection.Index];
SelectedSettings = settings ?? ModSettings.Empty;
}
var name = newSelection?.Identifier ?? string.Empty;
if (name != _config.Ephemeral.LastModPath)
{
_config.Ephemeral.LastModPath = name;
_config.Ephemeral.Save();
}
}
// Keep selections across rediscoveries if possible.
@ -522,7 +536,7 @@ public sealed class ModFileSystemSelector : FileSystemSelector<Mod, ModFileSyste
+ "Enter t:[string] to filter for mods set to specific tags.\n"
+ "Enter n:[string] to filter only for mod names and no paths.\n"
+ "Enter a:[string] to filter for mods by specific authors.\n"
+ $"Enter s:[string] to filter for mods by the categories of the items they change (1-{ChangedItemDrawer.NumCategories+1} or partial category name).\n"
+ $"Enter s:[string] to filter for mods by the categories of the items they change (1-{ChangedItemDrawer.NumCategories + 1} or partial category name).\n"
+ "Use None as a placeholder value that only matches empty lists or names.";
}

View file

@ -38,6 +38,21 @@
"resolved": "0.33.0",
"contentHash": "FlHfpTAADzaSlVCBF33iKJk9UhOr3Xj+r5LXbW2GzqYr0SrhiOf6shLX2LC2fqs7g7d+YlwKbBXqWFtb+e7icw=="
},
"SharpGLTF.Core": {
"type": "Direct",
"requested": "[1.0.0-alpha0030, )",
"resolved": "1.0.0-alpha0030",
"contentHash": "HVL6PcrM0H/uEk96nRZfhtPeYvSFGHnni3g1aIckot2IWVp0jLMH5KWgaWfsatEz4Yds3XcdSLUWmJZivDBUPA=="
},
"SharpGLTF.Toolkit": {
"type": "Direct",
"requested": "[1.0.0-alpha0030, )",
"resolved": "1.0.0-alpha0030",
"contentHash": "nsoJWAFhXgEky9bVCY0zLeZVDx+S88u7VjvuebvMb6dJiNyFOGF6FrrMHiJe+x5pcVBxxlc3VoXliBF7r/EqYA==",
"dependencies": {
"SharpGLTF.Runtime": "1.0.0-alpha0030"
}
},
"SixLabors.ImageSharp": {
"type": "Direct",
"requested": "[2.1.2, )",
@ -63,6 +78,14 @@
"resolved": "5.0.0",
"contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ=="
},
"SharpGLTF.Runtime": {
"type": "Transitive",
"resolved": "1.0.0-alpha0030",
"contentHash": "Ysn+fyj9EVXj6mfG0BmzSTBGNi/QvcnTrMd54dBMOlI/TsMRvnOY3JjTn0MpeH2CgHXX4qogzlDt4m+rb3n4Og==",
"dependencies": {
"SharpGLTF.Core": "1.0.0-alpha0030"
}
},
"System.Collections.Immutable": {
"type": "Transitive",
"resolved": "7.0.0",