├── .gitignore ├── Xande ├── Enums │ ├── ExportType.cs │ ├── Equip.cs │ └── Race.cs ├── Service.cs ├── IPathResolver.cs ├── Models │ ├── Export │ │ ├── ExtraDataManager.cs │ │ ├── SklbResolver.cs │ │ ├── RaceDeformer.cs │ │ └── MeshBuilder.cs │ └── ModelHelpers.cs ├── Exceptions.cs ├── Xande.csproj ├── Havok │ ├── XmlUtils.cs │ ├── HavokXml.cs │ ├── XmlMapping.cs │ ├── Types.cs │ ├── XmlSkeleton.cs │ └── HavokConverter.cs ├── Files │ ├── SklbFile.cs │ └── PbdFile.cs ├── packages.lock.json ├── LuminaManager.cs ├── MdlResolver.cs └── ColorUtility.cs ├── Xande.TestPlugin ├── Xande.TestPlugin.json ├── Configuration.cs ├── Service.cs ├── DalamudLogger.cs ├── PenumbraIPCPathResolver.cs ├── Plugin.cs ├── Xande.TestPlugin.csproj ├── packages.lock.json └── Windows │ └── MainWindow.cs ├── Xande.GltfImporter ├── Xande.GltfImporter.csproj ├── ShapeBuilder.cs ├── MeshVertexData.cs ├── StringTableBuilder.cs ├── MeshBuilder.cs ├── VertexDataBuilder.cs ├── MdlFileWriter.cs └── SubmeshBuilder.cs ├── Xande.sln ├── XandeNotes.md ├── README.md └── .editorconfig /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | .vs/ 4 | .idea/ 5 | *.user 6 | -------------------------------------------------------------------------------- /Xande/Enums/ExportType.cs: -------------------------------------------------------------------------------- 1 | namespace Xande.Enums; 2 | 3 | [Flags] 4 | public enum ExportType { 5 | Gltf = 1, 6 | Glb = 2, 7 | Wavefront = 4, 8 | All = Gltf | Glb | Wavefront 9 | } -------------------------------------------------------------------------------- /Xande/Service.cs: -------------------------------------------------------------------------------- 1 | using Dalamud.IoC; 2 | using Dalamud.Plugin.Services; 3 | 4 | namespace Xande; 5 | 6 | public class Service { 7 | [PluginService] 8 | public required IGameInteropProvider GameInteropProvider { get; set; } 9 | } -------------------------------------------------------------------------------- /Xande/IPathResolver.cs: -------------------------------------------------------------------------------- 1 | namespace Xande { 2 | public interface IPathResolver { 3 | string ResolveDefaultPath( string gamePath ); 4 | string ResolvePlayerPath( string gamePath ); 5 | string ResolveCharacterPath( string gamePath, string characterPath ); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Xande.TestPlugin/Xande.TestPlugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "Author": "XIV Dev", 3 | "Name": "Xande.TestPlugin", 4 | "Punchline": "\"unit testing in my plugin development?\" - Ottermandias", 5 | "Description": "Do I really need to put a description here?", 6 | "InternalName": "Xande.TestPlugin", 7 | "ApplicableVersion": "any", 8 | "Tags": ["xande", "model", "exporting"] 9 | } 10 | -------------------------------------------------------------------------------- /Xande/Models/Export/ExtraDataManager.cs: -------------------------------------------------------------------------------- 1 | using Lumina.Models.Models; 2 | using SharpGLTF.IO; 3 | 4 | namespace Xande.Models.Export; 5 | 6 | public class ExtraDataManager { 7 | private readonly Dictionary< string, object > _extraData = new(); 8 | 9 | public void AddShapeNames( IEnumerable< Shape > shapes ) { 10 | _extraData.Add( "targetNames", shapes.Select( s => s.Name ).ToArray() ); 11 | } 12 | 13 | public JsonContent Serialize() => JsonContent.CreateFrom( _extraData ); 14 | } -------------------------------------------------------------------------------- /Xande.TestPlugin/Configuration.cs: -------------------------------------------------------------------------------- 1 | using Dalamud.Configuration; 2 | 3 | namespace Xande.TestPlugin; 4 | 5 | [Serializable] 6 | public class Configuration : IPluginConfiguration { 7 | public int Version { get; set; } = 0; 8 | 9 | public bool AutoOpen { get; set; } = false; 10 | 11 | public Dictionary< string, string > ResolverOverrides = new() { 12 | { "this_path_will_never_appear", "this_path_will_never_appear" } 13 | }; 14 | 15 | public void Save() { 16 | Service.PluginInterface.SavePluginConfig( this ); 17 | } 18 | } -------------------------------------------------------------------------------- /Xande.GltfImporter/Xande.GltfImporter.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Xande/Enums/Equip.cs: -------------------------------------------------------------------------------- 1 | namespace Xande.Enums; 2 | 3 | // https://github.com/ktisis-tools/Ktisis/blob/a9345539b8957d4af34715ca6907fbd7df196738/Ktisis/Structs/Actor/Equipment.cs#L35 4 | 5 | public enum EquipSlot : uint { 6 | Head, 7 | Chest, 8 | Hands, 9 | Legs, 10 | Feet, 11 | Earring, 12 | Necklace, 13 | Bracelet, 14 | RingRight, 15 | RingLeft 16 | } 17 | 18 | public enum ModelSlot : uint { 19 | Hat, 20 | Top, 21 | Gloves, 22 | Legs, 23 | Shoes, 24 | Ears, 25 | Neck, 26 | Wrists, 27 | RingRight, 28 | RingLeft, 29 | Hair, 30 | Face, 31 | TailEars 32 | } -------------------------------------------------------------------------------- /Xande.TestPlugin/Service.cs: -------------------------------------------------------------------------------- 1 | using Dalamud.Game; 2 | using Dalamud.Game.Command; 3 | using Dalamud.IoC; 4 | using Dalamud.Plugin; 5 | using Dalamud.Plugin.Services; 6 | 7 | namespace Xande.TestPlugin; 8 | 9 | public class Service { 10 | [PluginService] 11 | public static DalamudPluginInterface PluginInterface { get; private set; } = null!; 12 | 13 | [PluginService] 14 | public static IFramework Framework { get; private set; } = null!; 15 | 16 | [PluginService] 17 | public static ICommandManager CommandManager { get; private set; } = null!; 18 | 19 | [PluginService] 20 | public static IPluginLog Logger { get; private set; } = null!; 21 | } -------------------------------------------------------------------------------- /Xande/Exceptions.cs: -------------------------------------------------------------------------------- 1 | namespace Xande.Havok; 2 | 3 | // I don't know if this is a good idea or not, someone please yell at me if so (you can tell I miss Result<> and thiserror) ~jules 4 | public class Exceptions { 5 | public class HavokFailureException : Exception { 6 | public HavokFailureException() : base( "Havok returned failure" ) { } 7 | } 8 | 9 | public class HavokReadException : Exception { 10 | public HavokReadException() : base( "Havok failed to read resource" ) { } 11 | } 12 | 13 | public class HavokWriteException : Exception { 14 | public HavokWriteException() : base( "Havok failed to write resource" ) { } 15 | } 16 | } -------------------------------------------------------------------------------- /Xande.TestPlugin/DalamudLogger.cs: -------------------------------------------------------------------------------- 1 | using Dalamud.Logging; 2 | using Dalamud.Plugin.Services; 3 | using Lumina; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | using Xande.TestPlugin; 10 | 11 | namespace Xande { 12 | internal class DalamudLogger : ILogger { 13 | public void Debug( string template, params object[] values ) { 14 | Plugin.Logger.Debug( template, values ); 15 | } 16 | 17 | public void Error( string template, params object[] values ) { 18 | Plugin.Logger.Error( template, values ); 19 | } 20 | 21 | public void Fatal( string template, params object[] values ) { 22 | Plugin.Logger.Fatal( template, values ); 23 | } 24 | 25 | public void Information( string template, params object[] values ) { 26 | Plugin.Logger.Information( template, values ); 27 | } 28 | 29 | public void Verbose( string template, params object[] values ) { 30 | Plugin.Logger.Verbose( template, values ); 31 | } 32 | 33 | public void Warning( string template, params object[] values ) { 34 | Plugin.Logger.Warning( template, values ); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Xande/Models/ModelHelpers.cs: -------------------------------------------------------------------------------- 1 | using SharpGLTF.Scenes; 2 | using Xande.Havok; 3 | 4 | namespace Xande.Models; 5 | 6 | public static class ModelHelpers { 7 | /// Builds a skeleton tree from a list of .sklb paths. 8 | /// A list of HavokXml instances. 9 | /// The root bone node. 10 | /// A mapping of bone name to node in the scene. 11 | public static Dictionary GetBoneMap( HavokXml[] skeletons, out NodeBuilder? root ) { 12 | Dictionary boneMap = new(); 13 | root = null; 14 | 15 | foreach( var xml in skeletons ) { 16 | var skeleton = xml.GetMainSkeleton(); 17 | var boneNames = skeleton.BoneNames; 18 | var refPose = skeleton.ReferencePose; 19 | var parentIndices = skeleton.ParentIndices; 20 | 21 | for( var j = 0; j < boneNames.Length; j++ ) { 22 | var name = boneNames[j]; 23 | if( boneMap.ContainsKey( name ) ) continue; 24 | 25 | var bone = new NodeBuilder( name ); 26 | bone.SetLocalTransform( XmlUtils.CreateAffineTransform( refPose[j] ), false ); 27 | 28 | var boneRootId = parentIndices[j]; 29 | if( boneRootId != -1 ) { 30 | var parent = boneMap[boneNames[boneRootId]]; 31 | parent.AddNode( bone ); 32 | } 33 | else { root = bone; } 34 | 35 | boneMap[name] = bone; 36 | } 37 | } 38 | 39 | return boneMap; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Xande/Xande.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net7.0-windows 4 | enable 5 | enable 6 | true 7 | false 8 | false 9 | true 10 | true 11 | 12 | 13 | 14 | $(appdata)\XIVLauncher\addon\Hooks\dev\ 15 | 16 | 17 | 18 | $(DALAMUD_HOME)/ 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | $(DalamudLibPath)Dalamud.dll 28 | false 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /Xande.TestPlugin/PenumbraIPCPathResolver.cs: -------------------------------------------------------------------------------- 1 | using Dalamud.Logging; 2 | using Dalamud.Plugin; 3 | using Lumina.Data; 4 | using Lumina.Data.Files; 5 | using Penumbra.Api; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Text; 10 | using System.Threading.Tasks; 11 | 12 | namespace Xande { 13 | public class PenumbraIPCPathResolver : IPathResolver { 14 | private readonly DalamudPluginInterface _plugin; 15 | public PenumbraIPCPathResolver( DalamudPluginInterface pi ) { 16 | _plugin = pi; 17 | } 18 | 19 | // TODO: Ability to resolve paths in various manners 20 | public string ResolveDefaultPath( string gamePath ) { 21 | var resolvedPath = Ipc.ResolveDefaultPath.Subscriber( _plugin ).Invoke( gamePath ); 22 | PluginLog.Debug( $"Resolving DEFAULT path: \"{gamePath}\" -> \"{resolvedPath}\" " ); 23 | return resolvedPath; 24 | } 25 | 26 | public string ResolveCharacterPath( string gamePath, string characterName ) { 27 | var resolvedPath = Ipc.ResolveCharacterPath.Subscriber( _plugin ).Invoke( gamePath, characterName ); 28 | PluginLog.Debug( $"Resolving character \"{characterName}\" : \"{gamePath}\" -> \"{resolvedPath}\"" ); 29 | return resolvedPath; 30 | } 31 | 32 | public string ResolvePlayerPath( string gamePath ) { 33 | var resolvedPath = Ipc.ResolvePlayerPath.Subscriber( _plugin ).Invoke( gamePath ); 34 | PluginLog.Debug( $"Resolving PLAYER path: \"{gamePath}\" -> \"{resolvedPath}\"" ); 35 | return resolvedPath; 36 | } 37 | 38 | public IList GetCollections() { 39 | return Ipc.GetCollections.Subscriber( _plugin ).Invoke(); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Xande.TestPlugin/Plugin.cs: -------------------------------------------------------------------------------- 1 | using Dalamud.Game.Command; 2 | using Dalamud.Interface.Windowing; 3 | using Dalamud.Plugin; 4 | using Dalamud.Plugin.Services; 5 | using Xande.TestPlugin.Windows; 6 | 7 | namespace Xande.TestPlugin; 8 | 9 | public class Plugin : IDalamudPlugin { 10 | public string Name => "Xande.TestPlugin"; 11 | 12 | public static Configuration Configuration { get; set; } = null!; 13 | private static readonly WindowSystem WindowSystem = new("Xande.TestPlugin"); 14 | public static IPluginLog Logger { get; set; } 15 | private readonly MainWindow _mainWindow; 16 | 17 | public Plugin( DalamudPluginInterface pluginInterface ) { 18 | pluginInterface.Create< Service >(); 19 | 20 | Configuration = pluginInterface.GetPluginConfig() as Configuration ?? new Configuration(); 21 | Configuration.Save(); 22 | 23 | _mainWindow = new MainWindow(); 24 | WindowSystem.AddWindow( _mainWindow ); 25 | 26 | Service.CommandManager.AddHandler( "/xande", new CommandInfo( OnCommand ) { 27 | HelpMessage = "Open the test menu" 28 | } ); 29 | 30 | Service.PluginInterface.UiBuilder.Draw += DrawUi; 31 | Service.PluginInterface.UiBuilder.OpenConfigUi += OpenUi; 32 | Logger = Service.Logger; 33 | } 34 | 35 | public void Dispose() { 36 | _mainWindow.Dispose(); 37 | WindowSystem.RemoveAllWindows(); 38 | 39 | Service.CommandManager.RemoveHandler( "/xande" ); 40 | 41 | Service.PluginInterface.UiBuilder.Draw -= DrawUi; 42 | Service.PluginInterface.UiBuilder.OpenConfigUi -= OpenUi; 43 | } 44 | 45 | private void OnCommand( string command, string args ) { 46 | OpenUi(); 47 | } 48 | 49 | private void OpenUi() { 50 | _mainWindow.IsOpen = true; 51 | } 52 | 53 | private void DrawUi() { 54 | WindowSystem.Draw(); 55 | } 56 | } -------------------------------------------------------------------------------- /Xande/Havok/XmlUtils.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using System.Numerics; 3 | using System.Text.RegularExpressions; 4 | using SharpGLTF.Transforms; 5 | 6 | namespace Xande.Havok; 7 | 8 | public static class XmlUtils { 9 | /// Parses a vec12 from Havok XML. 10 | /// The inner text of the vec12 node. 11 | /// An array of floats. 12 | public static float[] ParseVec12( string innerText ) { 13 | var commentRegex = new Regex( "" ); 14 | var noComments = commentRegex.Replace( innerText, "" ); 15 | 16 | var floats = noComments.Split( " " ) 17 | .Select( x => x.Trim() ) 18 | .Where( x => !string.IsNullOrWhiteSpace( x ) ) 19 | .Select( x => x[ 1.. ] ) 20 | .Select( x => BitConverter.ToSingle( BitConverter.GetBytes( int.Parse( x, NumberStyles.HexNumber ) ) ) ); 21 | 22 | return floats.ToArray(); 23 | } 24 | 25 | /// Creates an affine transform for a bone from the reference pose in the Havok XML file. 26 | /// The reference pose. 27 | /// The affine transform. 28 | /// Thrown if the reference pose is invalid. 29 | public static AffineTransform CreateAffineTransform( ReadOnlySpan< float > refPos ) { 30 | // Compared with packfile vs tagfile and xivModdingFramework code 31 | if( refPos.Length < 11 ) throw new Exception( "RefPos does not contain enough values for affine transformation." ); 32 | var translation = new Vector3( refPos[ 0 ], refPos[ 1 ], refPos[ 2 ] ); 33 | var rotation = new Quaternion( refPos[ 4 ], refPos[ 5 ], refPos[ 6 ], refPos[ 7 ] ); 34 | var scale = new Vector3( refPos[ 8 ], refPos[ 9 ], refPos[ 10 ] ); 35 | return new AffineTransform( scale, rotation, translation ); 36 | } 37 | } -------------------------------------------------------------------------------- /Xande.GltfImporter/ShapeBuilder.cs: -------------------------------------------------------------------------------- 1 | using Lumina; 2 | using Lumina.Data.Parsing; 3 | using SharpGLTF.Schema2; 4 | using System.Numerics; 5 | 6 | namespace Xande.GltfImporter { 7 | internal class ShapeBuilder { 8 | public readonly string ShapeName; 9 | public readonly List ShapeValues = new(); 10 | public readonly List DifferentVertices = new(); 11 | //private VertexDataBuilder _vertexDataBuilder; 12 | private ILogger? _logger; 13 | public int VertexCount => DifferentVertices.Count; 14 | 15 | public ShapeBuilder( string name, MeshPrimitive primitive, int morphTargetIndex, MdlStructs.VertexDeclarationStruct vertexDeclarationStruct, ILogger? logger = null ) { 16 | ShapeName = name; 17 | //_vertexDataBuilder = new( primitive, vertexDeclarationStruct ); 18 | 19 | var shape = primitive.GetMorphTargetAccessors( morphTargetIndex ); 20 | //_vertexDataBuilder.AddShape( ShapeName, shape ); 21 | 22 | shape.TryGetValue( "POSITION", out var positionsAccessor ); 23 | var shapePositions = positionsAccessor?.AsVector3Array(); 24 | 25 | var indices = primitive.GetIndices(); 26 | 27 | for( var indexIdx = 0; indexIdx < indices.Count; indexIdx++ ) { 28 | var vertexIdx = indices[indexIdx]; 29 | if( shapePositions[( int )vertexIdx] == Vector3.Zero ) { 30 | continue; 31 | } 32 | 33 | if( !DifferentVertices.Contains( ( int )vertexIdx ) ) { 34 | DifferentVertices.Add( ( int )vertexIdx ); 35 | } 36 | ShapeValues.Add( new() { 37 | BaseIndicesIndex = ( ushort )indexIdx, 38 | ReplacingVertexIndex = ( ushort )DifferentVertices.IndexOf( ( int )vertexIdx ) 39 | } ); 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Xande.TestPlugin/Xande.TestPlugin.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1.0.0.7 4 | net7.0-windows 5 | enable 6 | enable 7 | true 8 | false 9 | false 10 | true 11 | true 12 | 13 | 14 | 15 | $(appdata)\XIVLauncher\addon\Hooks\dev\ 16 | 17 | 18 | 19 | $(DALAMUD_HOME)/ 20 | 21 | 22 | 23 | 24 | 25 | 26 | $(DalamudLibPath)Dalamud.dll 27 | false 28 | 29 | 30 | $(DalamudLibPath)FFXIVClientStructs.dll 31 | false 32 | 33 | 34 | $(DalamudLibPath)ImGui.NET.dll 35 | false 36 | 37 | 38 | $(DalamudLibPath)Lumina.dll 39 | false 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /Xande/Havok/HavokXml.cs: -------------------------------------------------------------------------------- 1 | using System.Xml; 2 | 3 | // ReSharper disable NotAccessedField.Global 4 | // ReSharper disable MemberCanBePrivate.Global 5 | 6 | namespace Xande.Havok; 7 | 8 | public class HavokXml { 9 | public readonly XmlSkeleton[] Skeletons; 10 | public readonly XmlMapping[] Mappings; 11 | public readonly int MainSkeleton; 12 | 13 | /// Constructs a new HavokXml object from the given XML string. 14 | /// The XML data. 15 | public HavokXml( string xml ) { 16 | var document = new XmlDocument(); 17 | document.LoadXml( xml ); 18 | 19 | Skeletons = document.SelectNodes( "/hktagfile/object[@type='hkaSkeleton']" )! 20 | .Cast< XmlElement >() 21 | .Select( x => new XmlSkeleton( x ) ).ToArray(); 22 | 23 | Mappings = document.SelectNodes( "/hktagfile/object[@type='hkaSkeletonMapper']" )! 24 | .Cast< XmlElement >() 25 | .Select( x => new XmlMapping( x ) ).ToArray(); 26 | 27 | var animationContainer = document.SelectSingleNode( "/hktagfile/object[@type='hkaAnimationContainer']" )!; 28 | var animationSkeletons = animationContainer 29 | .SelectNodes( "array[@name='skeletons']" )! 30 | .Cast< XmlElement >() 31 | .First(); 32 | 33 | // A recurring theme in Havok XML is that IDs start with a hash 34 | // If you see a string[1..], that's probably what it is 35 | var mainSkeleton = animationSkeletons.ChildNodes[ 0 ]!.InnerText; 36 | MainSkeleton = int.Parse( mainSkeleton[ 1.. ] ); 37 | } 38 | 39 | /// 40 | /// Gets the "main" skeleton from the XML file. 41 | /// This assumes the skeleton represented in the animation container is the main skeleton. 42 | /// 43 | public XmlSkeleton GetMainSkeleton() { 44 | return GetSkeletonById( MainSkeleton ); 45 | } 46 | 47 | /// Gets a skeleton by its ID. 48 | public XmlSkeleton GetSkeletonById( int id ) { 49 | return Skeletons.First( x => x.Id == id ); 50 | } 51 | } -------------------------------------------------------------------------------- /Xande/Havok/XmlMapping.cs: -------------------------------------------------------------------------------- 1 | using System.Xml; 2 | 3 | // ReSharper disable NotAccessedField.Global 4 | // ReSharper disable MemberCanBePrivate.Global 5 | 6 | namespace Xande.Havok; 7 | 8 | public class XmlMapping { 9 | public readonly int Id; 10 | public readonly int SkeletonA; 11 | public readonly int SkeletonB; 12 | public readonly BoneMapping[] BoneMappings; 13 | 14 | public XmlMapping( XmlElement element ) { 15 | Id = int.Parse( element.GetAttribute( "id" )[ 1.. ] ); 16 | 17 | var skeletonA = element.SelectSingleNode( "struct/ref[@name='skeletonA']" )!; 18 | SkeletonA = int.Parse( skeletonA.InnerText[ 1.. ] ); 19 | 20 | var skeletonB = element.SelectSingleNode( "struct/ref[@name='skeletonB']" )!; 21 | SkeletonB = int.Parse( skeletonB.InnerText[ 1.. ] ); 22 | 23 | var simpleMappings = ( XmlElement )element.SelectSingleNode( "struct/array[@name='simpleMappings']" )!; 24 | var count = int.Parse( simpleMappings.GetAttribute( "size" ) ); 25 | BoneMappings = new BoneMapping[count]; 26 | 27 | for( var i = 0; i < count; i++ ) { 28 | var mapping = simpleMappings.SelectSingleNode( $"struct[{i + 1}]" )!; 29 | var boneA = int.Parse( mapping.SelectSingleNode( "int[@name='boneA']" )?.InnerText ?? "0" ); 30 | var boneB = int.Parse( mapping.SelectSingleNode( "int[@name='boneB']" )?.InnerText ?? "0" ); 31 | var transform = XmlUtils.ParseVec12( mapping.SelectSingleNode( "vec12[@name='aFromBTransform']" )!.InnerText ); 32 | 33 | var mappingClass = new BoneMapping( boneA, boneB, transform ); 34 | BoneMappings[ i ] = mappingClass; 35 | } 36 | } 37 | 38 | public class BoneMapping { 39 | public readonly int BoneA; 40 | public readonly int BoneB; 41 | public readonly float[] Transform; 42 | 43 | public BoneMapping( int boneA, int boneB, float[] transform ) { 44 | BoneA = boneA; 45 | BoneB = boneB; 46 | Transform = transform; 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /Xande.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.6.33513.286 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Xande", "Xande\Xande.csproj", "{3728CC45-B56B-4EC9-BCE1-5885B8E40C69}" 7 | ProjectSection(ProjectDependencies) = postProject 8 | {895BA7CB-1FB5-4471-A7E8-85FBFC5A18F1} = {895BA7CB-1FB5-4471-A7E8-85FBFC5A18F1} 9 | EndProjectSection 10 | EndProject 11 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Xande.TestPlugin", "Xande.TestPlugin\Xande.TestPlugin.csproj", "{D55C2DEE-6341-4CD6-B2B2-9D45A0C27318}" 12 | EndProject 13 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Xande.GltfImporter", "Xande.GltfImporter\Xande.GltfImporter.csproj", "{895BA7CB-1FB5-4471-A7E8-85FBFC5A18F1}" 14 | EndProject 15 | Global 16 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 17 | Debug|Any CPU = Debug|Any CPU 18 | Release|Any CPU = Release|Any CPU 19 | EndGlobalSection 20 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 21 | {3728CC45-B56B-4EC9-BCE1-5885B8E40C69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 22 | {3728CC45-B56B-4EC9-BCE1-5885B8E40C69}.Debug|Any CPU.Build.0 = Debug|Any CPU 23 | {3728CC45-B56B-4EC9-BCE1-5885B8E40C69}.Release|Any CPU.ActiveCfg = Release|Any CPU 24 | {3728CC45-B56B-4EC9-BCE1-5885B8E40C69}.Release|Any CPU.Build.0 = Release|Any CPU 25 | {D55C2DEE-6341-4CD6-B2B2-9D45A0C27318}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {D55C2DEE-6341-4CD6-B2B2-9D45A0C27318}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {D55C2DEE-6341-4CD6-B2B2-9D45A0C27318}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {D55C2DEE-6341-4CD6-B2B2-9D45A0C27318}.Release|Any CPU.Build.0 = Release|Any CPU 29 | {895BA7CB-1FB5-4471-A7E8-85FBFC5A18F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {895BA7CB-1FB5-4471-A7E8-85FBFC5A18F1}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {895BA7CB-1FB5-4471-A7E8-85FBFC5A18F1}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {895BA7CB-1FB5-4471-A7E8-85FBFC5A18F1}.Release|Any CPU.Build.0 = Release|Any CPU 33 | EndGlobalSection 34 | GlobalSection(SolutionProperties) = preSolution 35 | HideSolutionNode = FALSE 36 | EndGlobalSection 37 | GlobalSection(ExtensibilityGlobals) = postSolution 38 | SolutionGuid = {57E992B3-4B05-436A-A2E2-75A06B26663E} 39 | EndGlobalSection 40 | EndGlobal 41 | -------------------------------------------------------------------------------- /XandeNotes.md: -------------------------------------------------------------------------------- 1 | This short guide will assume the user has the basic knowledge to import an fbx into TexTools. Also, my experience comes solely from Blender. 2 | 3 | # Reminder from TexTools 4 | Export your model with mesh names including X.Y where X and Y are numbers 5 | Meshes with the same X will be put together in the same mesh/group, while each Y is a submesh/part 6 | 7 | For example: 8 | Mesh 0.0, Mesh 0.1 9 | Mesh 1.0, Mesh 1.1, Mesh 1.2 10 | 11 | The actual numbers shouldn't matter 12 | 13 | # GLTF Stuff 14 | Unlike TexTools, Xande takes the .gltf or .glb file as input. Blender can export to .glb just like it can export to .fbx 15 | 16 | Some quirks of gltf files 17 | They are automatically triangulated 18 | By default, vertices are exported with a maximum of four weights, which is the same for ffxiv 19 | * The weights are first normalized, then the highest four values are taken while the rest are removed 20 | 21 | I've ran into issues where Blender was unable to export the file. 22 | * Try unticking the "Animation" checkbox. It seemed to fix the problem, at least for me. 23 | 24 | # Xande Specifics 25 | 26 | | Attributes can be added to submeshes by adding it as a ShapeKey, note that you must have a "Basis" and the attribute must begin with "atr_" | ![attribute assignment cropped](https://github.com/adamm789/Xande/assets/114926302/be1f1bfc-202c-4451-a290-6d4bf1a38ce2) | 27 | | - | - | 28 | 29 | 30 | 31 | | Xande will pull the material name from the Material assigned to the mesh in Blender. If you forget to add it, Penumbra offers the handy ability to assign material names in the Advanced Editing window. | ![material assignment cropped](https://github.com/adamm789/Xande/assets/114926302/dd04b8df-98a6-4380-98c8-4fcf0f34fa03) | 32 | | - | - | 33 | 34 | **Important note on material paths** 35 | If the original .mdl (the one that's being replaced) is expceting a relative path, such /mt_c0101e6111_top_a.mtrl and your model doesn't start with that forward slash, YOUR GAME WILL CRASH. 36 | If the original .mdl is expecting the full path, such as bgcommon/hou/indoor/... and yours begins with the forward slash, YOU GAME WILL CRASH. 37 | If you immediately crash upon loading the model, check the material path. 38 | 39 | The other major reason for a crash is because there is no armature when one is expected. So make absolutely sure that your glb has the armature included. 40 | -------------------------------------------------------------------------------- /Xande/Files/SklbFile.cs: -------------------------------------------------------------------------------- 1 | using Lumina.Extensions; 2 | 3 | // ReSharper disable NotAccessedField.Global 4 | // ReSharper disable MemberCanBePrivate.Global 5 | #pragma warning disable CS8618 6 | 7 | namespace Xande.Files; 8 | 9 | /// Class for parsing .hkx data from a .sklb file. 10 | public sealed class SklbFile { 11 | public short VersionOne; 12 | public short VersionTwo; 13 | public int HavokOffset; 14 | 15 | public byte[] RawHeader; 16 | public byte[] HkxData; 17 | 18 | /// Constructs a new SklbFile instance from a stream. 19 | /// A stream to the .sklb data. 20 | /// Thrown if magic does not match. 21 | public static SklbFile FromStream( Stream stream ) { 22 | using var reader = new BinaryReader( stream ); 23 | 24 | var magic = reader.ReadInt32(); 25 | if( magic != 0x736B6C62 ) { throw new InvalidDataException( "Invalid .sklb magic" ); } 26 | 27 | var versionOne = reader.ReadInt16(); 28 | var versionTwo = reader.ReadInt16(); 29 | 30 | var isOldHeader = versionTwo switch { 31 | 0x3132 => true, 32 | 0x3133 => false, 33 | _ => false 34 | }; 35 | 36 | int havokOffset; 37 | if( isOldHeader ) { 38 | // Version one 39 | reader.ReadInt16(); // Skip unkOffset 40 | havokOffset = reader.ReadInt16(); 41 | } 42 | else { 43 | // Version two 44 | reader.ReadInt32(); // Skip unkOffset 45 | havokOffset = reader.ReadInt32(); 46 | } 47 | 48 | reader.Seek( 0 ); 49 | var rawHeader = reader.ReadBytes( havokOffset ); 50 | reader.Seek( havokOffset ); 51 | var hkxData = reader.ReadBytes( ( int )( reader.BaseStream.Length - havokOffset ) ); 52 | 53 | return new SklbFile { 54 | VersionOne = versionOne, 55 | VersionTwo = versionTwo, 56 | HavokOffset = havokOffset, 57 | 58 | RawHeader = rawHeader, 59 | HkxData = hkxData 60 | }; 61 | } 62 | 63 | /// Splices the given .hkx file into the .sklb. 64 | /// A byte array representing an .hkx file. 65 | public void ReplaceHkxData( byte[] hkxData ) { 66 | HkxData = hkxData; 67 | } 68 | 69 | public void Write( Stream stream ) { 70 | stream.Write( RawHeader ); 71 | stream.Write( HkxData ); 72 | } 73 | } -------------------------------------------------------------------------------- /Xande/Havok/Types.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | using FFXIVClientStructs.Havok; 3 | 4 | // ReSharper disable EnumUnderlyingTypeIsInt 5 | // ReSharper disable InconsistentNaming 6 | 7 | namespace Xande.Havok; 8 | 9 | // TODO: move these into ClientStructs 10 | [Flags] 11 | internal enum hkSerializeUtil_SaveOptionBits : 12 | int { 13 | SAVE_DEFAULT = 0x0, 14 | SAVE_TEXT_FORMAT = 0x1, 15 | SAVE_SERIALIZE_IGNORED_MEMBERS = 0x2, 16 | SAVE_WRITE_ATTRIBUTES = 0x4, 17 | SAVE_CONCISE = 0x8, 18 | } 19 | 20 | internal enum hkSerializeUtil_LoadOptionBits : int { 21 | LOAD_DEFAULT = 0, 22 | LOAD_FAIL_IF_VERSIONING = 1, 23 | LOAD_FORCED = 2, 24 | } 25 | 26 | internal struct hkTypeInfoRegistry { } 27 | 28 | internal struct hkClassNameRegistry { } 29 | 30 | [StructLayout( LayoutKind.Explicit, Size = 0x18 )] 31 | internal unsafe struct hkSerializeUtil_LoadOptions { 32 | [FieldOffset( 0x0 )] 33 | internal hkEnum< hkSerializeUtil_LoadOptionBits, int > options; 34 | 35 | [FieldOffset( 0x8 )] 36 | internal hkClassNameRegistry* m_classNameReg; 37 | 38 | [FieldOffset( 0x10 )] 39 | internal hkTypeInfoRegistry* m_typeInfoReg; 40 | } 41 | 42 | [StructLayout( LayoutKind.Explicit, Size = 0x18 )] 43 | internal struct hkOStream { 44 | [FieldOffset( 0x10 )] 45 | internal hkRefPtr< hkStreamWriter > m_writer; 46 | } 47 | 48 | [StructLayout( LayoutKind.Explicit, Size = 0x10 )] 49 | internal struct hkStreamWriter { } 50 | 51 | [StructLayout( LayoutKind.Explicit, Size = 0x10 )] 52 | internal struct hkStreamReader { } 53 | 54 | [StructLayout( LayoutKind.Explicit, Size = 0x50 )] 55 | internal struct hkClass { } 56 | 57 | [StructLayout( LayoutKind.Explicit, Size = 0x48 )] 58 | internal unsafe struct hkResourceVtbl { 59 | [FieldOffset( 0x38 )] 60 | internal readonly delegate* unmanaged[Stdcall] getContentsPointer; 61 | } 62 | 63 | [StructLayout( LayoutKind.Explicit, Size = 0x8 )] // probably larger 64 | internal unsafe struct hkBuiltinTypeRegistry { 65 | [FieldOffset( 0x0 )] 66 | internal readonly hkBuiltinTypeRegistryVtbl* vtbl; 67 | } 68 | 69 | [StructLayout( LayoutKind.Explicit, Size = 0x48 )] 70 | internal unsafe struct hkBuiltinTypeRegistryVtbl { 71 | [FieldOffset( 0x20 )] 72 | internal readonly delegate* unmanaged[Stdcall] GetTypeInfoRegistry; 73 | 74 | [FieldOffset( 0x28 )] 75 | internal readonly delegate* unmanaged[Stdcall] GetClassNameRegistry; 76 | } -------------------------------------------------------------------------------- /Xande/packages.lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "dependencies": { 4 | "net7.0-windows7.0": { 5 | "SkiaSharp": { 6 | "type": "Direct", 7 | "requested": "[2.88.6, )", 8 | "resolved": "2.88.6", 9 | "contentHash": "wdfeBAQrEQCbJIRgAiargzP1Uy+0grZiG4CSgBnhAgcJTsPzlifIaO73JRdwIlT3TyBoeU9jEqzwFUhl4hTYnQ==", 10 | "dependencies": { 11 | "SkiaSharp.NativeAssets.Win32": "2.88.6", 12 | "SkiaSharp.NativeAssets.macOS": "2.88.6" 13 | } 14 | }, 15 | "Serilog": { 16 | "type": "Transitive", 17 | "resolved": "2.11.0", 18 | "contentHash": "ysv+hBzTul6Dp+Hvm10FlhJO3yMQcFKSAleus+LpiIzvNstpeV4Z7gGuIZ1OPNfIMulSHOjmLuGAEDKzpnV8ZQ==" 19 | }, 20 | "SharpGLTF.Core": { 21 | "type": "Transitive", 22 | "resolved": "1.0.0-alpha0030", 23 | "contentHash": "HVL6PcrM0H/uEk96nRZfhtPeYvSFGHnni3g1aIckot2IWVp0jLMH5KWgaWfsatEz4Yds3XcdSLUWmJZivDBUPA==" 24 | }, 25 | "SharpGLTF.Runtime": { 26 | "type": "Transitive", 27 | "resolved": "1.0.0-alpha0030", 28 | "contentHash": "Ysn+fyj9EVXj6mfG0BmzSTBGNi/QvcnTrMd54dBMOlI/TsMRvnOY3JjTn0MpeH2CgHXX4qogzlDt4m+rb3n4Og==", 29 | "dependencies": { 30 | "SharpGLTF.Core": "1.0.0-alpha0030" 31 | } 32 | }, 33 | "SharpGLTF.Toolkit": { 34 | "type": "Transitive", 35 | "resolved": "1.0.0-alpha0030", 36 | "contentHash": "nsoJWAFhXgEky9bVCY0zLeZVDx+S88u7VjvuebvMb6dJiNyFOGF6FrrMHiJe+x5pcVBxxlc3VoXliBF7r/EqYA==", 37 | "dependencies": { 38 | "SharpGLTF.Runtime": "1.0.0-alpha0030" 39 | } 40 | }, 41 | "SkiaSharp.NativeAssets.macOS": { 42 | "type": "Transitive", 43 | "resolved": "2.88.6", 44 | "contentHash": "Sko9LFxRXSjb3OGh5/RxrVRXxYo48tr5NKuuSy6jB85GrYt8WRqVY1iLOLwtjPiVAt4cp+pyD4i30azanS64dw==" 45 | }, 46 | "SkiaSharp.NativeAssets.Win32": { 47 | "type": "Transitive", 48 | "resolved": "2.88.6", 49 | "contentHash": "7TzFO0u/g2MpQsTty4fyCDdMcfcWI+aLswwfnYXr3gtNS6VLKdMXPMeKpJa3pJSLnUBN6wD0JjuCe8OoLBQ6cQ==" 50 | }, 51 | "ffxivclientstructs": { 52 | "type": "Project", 53 | "dependencies": { 54 | "Serilog": "[2.11.0, )" 55 | } 56 | }, 57 | "Lumina2": { 58 | "type": "Project" 59 | }, 60 | "xande.gltfimporter": { 61 | "type": "Project", 62 | "dependencies": { 63 | "Lumina2": "[2.4.2, )", 64 | "SharpGLTF.Core": "[1.0.0-alpha0030, )", 65 | "SharpGLTF.Toolkit": "[1.0.0-alpha0030, )" 66 | } 67 | } 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /Xande.TestPlugin/packages.lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "dependencies": { 4 | "net7.0-windows7.0": { 5 | "DalamudPackager": { 6 | "type": "Direct", 7 | "requested": "[2.1.12, )", 8 | "resolved": "2.1.12", 9 | "contentHash": "Sc0PVxvgg4NQjcI8n10/VfUQBAS4O+Fw2pZrAqBdRMbthYGeogzu5+xmIGCGmsEZ/ukMOBuAqiNiB5qA3MRalg==" 10 | }, 11 | "Penumbra.Api": { 12 | "type": "Direct", 13 | "requested": "[1.0.11, )", 14 | "resolved": "1.0.11", 15 | "contentHash": "MscPf7uEoe2Dza09mZD8CqDhfSBr3hcwH15jTTQTStfyTnAVLPGRLPIhYM2aC83Qt1blE855xO9O2ox9mV2K2Q==" 16 | }, 17 | "Lumina": { 18 | "type": "Transitive", 19 | "resolved": "3.11.0", 20 | "contentHash": "G7HQq2i5k4eL8kXxQCtrwQtRBuQi5qW1reb5oYZv+hazgOgpqX6YAX56dW6r5uraef1L1j/jMXMlXQDb3wMUmQ==" 21 | }, 22 | "Microsoft.Win32.SystemEvents": { 23 | "type": "Transitive", 24 | "resolved": "8.0.0-preview.2.23128.3", 25 | "contentHash": "2ugSY6UuqJfYhvVUaf4ueuUiMjIyN9dU03wpbKfRDsn13XvZ91yOlw7NLhYvB1Xc8gCAZpND20Tgbe3uBVytKA==" 26 | }, 27 | "SharpGLTF.Core": { 28 | "type": "Transitive", 29 | "resolved": "1.0.0-alpha0028", 30 | "contentHash": "ALHHo0St08I77sZGEf/eFOIpSFF8aP/nbWNIvh27omQz/ds9MJADU4aF4MyOrHaW4Ir4bwZ1xF3QLwKyIH3Oow==" 31 | }, 32 | "SharpGLTF.Toolkit": { 33 | "type": "Transitive", 34 | "resolved": "1.0.0-alpha0028", 35 | "contentHash": "CNw7Cc0vPtbeqag4Jv+/x7Dgc5Vfcz6PmE5TiVzfsyLho4uEve2suQO42KSJa85q8g9MeWU/cMVy0ND/9gz+Qw==", 36 | "dependencies": { 37 | "SharpGLTF.Core": "1.0.0-alpha0028" 38 | } 39 | }, 40 | "System.Drawing.Common": { 41 | "type": "Transitive", 42 | "resolved": "8.0.0-preview.2.23128.3", 43 | "contentHash": "kTEwdz83KrBKPVWEcrF4OGqLeJ6aK7jsPWBp/hX7ngT8BCvWLKAaBd5HZ4cmux0t7k5Lka/B/DqmKC8jc8TrOQ==", 44 | "dependencies": { 45 | "Microsoft.Win32.SystemEvents": "8.0.0-preview.2.23128.3" 46 | } 47 | }, 48 | "xande": { 49 | "type": "Project", 50 | "dependencies": { 51 | "SharpGLTF.Core": "[1.0.0-alpha0028, )", 52 | "SharpGLTF.Toolkit": "[1.0.0-alpha0028, )", 53 | "System.Drawing.Common": "[8.0.0-preview.2.23128.3, )", 54 | "Xande.GltfImporter": "[1.0.0, )" 55 | } 56 | }, 57 | "xande.gltfimporter": { 58 | "type": "Project", 59 | "dependencies": { 60 | "Lumina": "[3.11.0, )", 61 | "SharpGLTF.Core": "[1.0.0-alpha0028, )", 62 | "SharpGLTF.Toolkit": "[1.0.0-alpha0028, )" 63 | } 64 | } 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /Xande/Enums/Race.cs: -------------------------------------------------------------------------------- 1 | namespace Xande.Enums; 2 | 3 | public enum BodyType : byte { 4 | Unknown = 0, 5 | Normal = 1, 6 | Elder = 3, 7 | Child = 4 8 | } 9 | 10 | // https://github.com/xivdev/Penumbra/blob/182546ee101561f8512fad54da445462afab356f/Penumbra.GameData/Enums/Race.cs 11 | 12 | public enum Race : byte { 13 | Unknown, 14 | Hyur, 15 | Elezen, 16 | Lalafell, 17 | Miqote, 18 | Roegadyn, 19 | AuRa, 20 | Hrothgar, 21 | Viera, 22 | } 23 | 24 | public enum Gender : byte { 25 | Unknown, 26 | Male, 27 | Female, 28 | MaleNpc, 29 | FemaleNpc, 30 | } 31 | 32 | public enum ModelRace : byte { 33 | Unknown, 34 | Midlander, 35 | Highlander, 36 | Elezen, 37 | Lalafell, 38 | Miqote, 39 | Roegadyn, 40 | AuRa, 41 | Hrothgar, 42 | Viera, 43 | } 44 | 45 | public enum Clan : byte { 46 | Unknown, 47 | Midlander, 48 | Highlander, 49 | Wildwood, 50 | Duskwight, 51 | Plainsfolk, 52 | Dunesfolk, 53 | SeekerOfTheSun, 54 | KeeperOfTheMoon, 55 | Seawolf, 56 | Hellsguard, 57 | Raen, 58 | Xaela, 59 | Helion, 60 | Lost, 61 | Rava, 62 | Veena, 63 | } 64 | 65 | // The combined gender-race-npc numerical code as used by the game. 66 | public enum GenderRace : ushort { 67 | Unknown = 0, 68 | MidlanderMale = 0101, 69 | MidlanderMaleNpc = 0104, 70 | MidlanderFemale = 0201, 71 | MidlanderFemaleNpc = 0204, 72 | HighlanderMale = 0301, 73 | HighlanderMaleNpc = 0304, 74 | HighlanderFemale = 0401, 75 | HighlanderFemaleNpc = 0404, 76 | ElezenMale = 0501, 77 | ElezenMaleNpc = 0504, 78 | ElezenFemale = 0601, 79 | ElezenFemaleNpc = 0604, 80 | MiqoteMale = 0701, 81 | MiqoteMaleNpc = 0704, 82 | MiqoteFemale = 0801, 83 | MiqoteFemaleNpc = 0804, 84 | RoegadynMale = 0901, 85 | RoegadynMaleNpc = 0904, 86 | RoegadynFemale = 1001, 87 | RoegadynFemaleNpc = 1004, 88 | LalafellMale = 1101, 89 | LalafellMaleNpc = 1104, 90 | LalafellFemale = 1201, 91 | LalafellFemaleNpc = 1204, 92 | AuRaMale = 1301, 93 | AuRaMaleNpc = 1304, 94 | AuRaFemale = 1401, 95 | AuRaFemaleNpc = 1404, 96 | HrothgarMale = 1501, 97 | HrothgarMaleNpc = 1504, 98 | HrothgarFemale = 1601, 99 | HrothgarFemaleNpc = 1604, 100 | VieraMale = 1701, 101 | VieraMaleNpc = 1704, 102 | VieraFemale = 1801, 103 | VieraFemaleNpc = 1804, 104 | UnknownMaleNpc = 9104, 105 | UnknownFemaleNpc = 9204, 106 | } -------------------------------------------------------------------------------- /Xande.GltfImporter/MeshVertexData.cs: -------------------------------------------------------------------------------- 1 | using Lumina; 2 | 3 | namespace Xande.GltfImporter { 4 | internal class MeshVertexData { 5 | private Dictionary> _vertexData = new(); 6 | private Dictionary> _shapeVertexData = new(); 7 | 8 | private List>>> _vertexDataTasks = new(); 9 | private List>>> _shapeVertexDataTasks = new(); 10 | 11 | private ILogger? _logger; 12 | 13 | public MeshVertexData(ILogger? logger = null) { 14 | _logger = logger; 15 | } 16 | 17 | public void AddVertexData(Task>> task) { 18 | _vertexDataTasks.Add(task); 19 | } 20 | 21 | public void AddShapeVertexData( Task>> task ) { 22 | _shapeVertexDataTasks.Add( task ); 23 | } 24 | public void AddVertexData( Dictionary> vertexData ) { 25 | foreach( var (stream, data) in vertexData ) { 26 | if( !_vertexData.ContainsKey( stream ) ) { 27 | _vertexData[stream] = new List(); 28 | } 29 | _vertexData[stream].AddRange( data ); 30 | } 31 | } 32 | 33 | public void AddShapeVertexData( Dictionary> vertexData ) { 34 | foreach( var (stream, data) in vertexData ) { 35 | if( !_shapeVertexData.ContainsKey( stream ) ) { 36 | _shapeVertexData[stream] = new List(); 37 | } 38 | _shapeVertexData[stream].AddRange( data ); 39 | } 40 | } 41 | 42 | public async Task> GetBytesAsync() { 43 | var ret = new List(); 44 | await Task.WhenAll( _vertexDataTasks ); 45 | await Task.WhenAll(_shapeVertexDataTasks ); 46 | foreach (var v in _vertexDataTasks ) { 47 | AddVertexData( await v ); 48 | } 49 | foreach (var s in _shapeVertexDataTasks) { 50 | AddShapeVertexData( await s ); 51 | } 52 | 53 | return GetBytes(); 54 | } 55 | 56 | public List GetBytes() { 57 | var ret = new List(); 58 | foreach( var stream in _vertexData.Keys ) { 59 | ret.AddRange( _vertexData[stream] ); 60 | 61 | if( _shapeVertexData.Count > 0 ) { 62 | if( !_shapeVertexData.ContainsKey( stream ) ) { 63 | _logger?.Error( $"Vertices and shape vertices do not have the same stream: {stream}" ); 64 | continue; 65 | } 66 | ret.AddRange( _shapeVertexData[stream] ); 67 | } 68 | } 69 | return ret; 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Xande/Files/PbdFile.cs: -------------------------------------------------------------------------------- 1 | using Lumina.Data; 2 | using Lumina.Extensions; 3 | 4 | // ReSharper disable UnassignedField.Global 5 | #pragma warning disable CS8618 6 | 7 | namespace Xande.Files; 8 | 9 | /// Parses a human.pbd file to deform models. This file is located at chara/xls/boneDeformer/human.pbd in the game's data files. 10 | public class PbdFile : FileResource { 11 | public Header[] Headers; 12 | public (int, Deformer)[] Deformers; 13 | 14 | public override void LoadFile() { 15 | var entryCount = Reader.ReadInt32(); 16 | 17 | Headers = new Header[entryCount]; 18 | Deformers = new (int, Deformer)[entryCount]; 19 | 20 | for( var i = 0; i < entryCount; i++ ) { Headers[ i ] = Reader.ReadStructure< Header >(); } 21 | 22 | // No idea what this is 23 | var unkSize = entryCount * 8; 24 | Reader.Seek( Reader.BaseStream.Position + unkSize ); 25 | 26 | // First deformer (101) seems... strange, just gonna skip it for now 27 | for( var i = 1; i < entryCount; i++ ) { 28 | var header = Headers[ i ]; 29 | var offset = header.Offset; 30 | Reader.Seek( offset ); 31 | 32 | Deformers[ i ] = ( offset, Deformer.Read( Reader ) ); 33 | } 34 | } 35 | 36 | public struct Header { 37 | public ushort Id; 38 | public ushort DeformerId; 39 | public int Offset; 40 | public float Unk2; 41 | } 42 | 43 | public struct Deformer { 44 | public int BoneCount; 45 | public string[] BoneNames; 46 | public float[]?[] DeformMatrices; 47 | 48 | public static Deformer Read( BinaryReader reader ) { 49 | var boneCount = reader.ReadInt32(); 50 | var offsetStartPos = reader.BaseStream.Position; 51 | 52 | var boneNames = new string[boneCount]; 53 | var offsets = reader.ReadStructuresAsArray< short >( boneCount ); 54 | 55 | // Read bone names 56 | for( var i = 0; i < boneCount; i++ ) { 57 | var offset = offsets[ i ]; 58 | reader.Seek( offsetStartPos - 4 + offset ); 59 | 60 | // is there really no better way to read a null terminated string? 61 | var str = ""; 62 | 63 | while( true ) { 64 | var c = reader.ReadChar(); 65 | if( c == '\0' ) { break; } 66 | 67 | str += c; 68 | } 69 | 70 | boneNames[ i ] = str; 71 | } 72 | 73 | // ??? 74 | var offsetEndPos = reader.BaseStream.Position; 75 | var padding = boneCount * 2 % 4; 76 | reader.Seek( offsetStartPos + boneCount * 2 + padding ); 77 | 78 | var deformMatrices = new float[boneCount][]; 79 | for( var i = 0; i < boneCount; i++ ) { 80 | var deformMatrix = reader.ReadStructuresAsArray< float >( 12 ); 81 | deformMatrices[ i ] = deformMatrix; 82 | } 83 | 84 | reader.Seek( offsetEndPos ); 85 | 86 | return new Deformer { 87 | BoneCount = boneCount, 88 | BoneNames = boneNames, 89 | DeformMatrices = deformMatrices 90 | }; 91 | } 92 | } 93 | 94 | public Deformer GetDeformerFromRaceCode( ushort raceCode ) { 95 | var header = Headers.First( h => h.Id == raceCode ); 96 | return Deformers.First( d => d.Item1 == header.Offset ).Item2; 97 | } 98 | } -------------------------------------------------------------------------------- /Xande/Havok/XmlSkeleton.cs: -------------------------------------------------------------------------------- 1 | using System.Xml; 2 | 3 | // ReSharper disable MemberCanBePrivate.Global 4 | // ReSharper disable UnusedAutoPropertyAccessor.Global 5 | 6 | namespace Xande.Havok; 7 | 8 | /// Representation of a skeleton in XML data. 9 | public class XmlSkeleton { 10 | /// The ID of the skeleton. 11 | public readonly int Id; 12 | 13 | /// The reference pose of the skeleton (also known as the "resting" or "base" pose). 14 | public readonly float[][] ReferencePose; 15 | 16 | /// The parent indices of the skeleton. The root bone will have a parent index of -1. 17 | public readonly int[] ParentIndices; 18 | 19 | /// The names of the bones in the skeleton. A bone's "ID" is represented by the index it has in this array. 20 | public readonly string[] BoneNames; 21 | 22 | public XmlSkeleton( XmlElement element ) { 23 | Id = int.Parse( element.GetAttribute( "id" )[ 1.. ] ); 24 | 25 | ReferencePose = ReadReferencePose( element ); 26 | ParentIndices = ReadParentIndices( element ); 27 | BoneNames = ReadBoneNames( element ); 28 | } 29 | 30 | private float[][] ReadReferencePose( XmlElement element ) { 31 | var referencePose = element.GetElementsByTagName( "array" ) 32 | .Cast< XmlElement >() 33 | .Where( x => x.GetAttribute( "name" ) == "referencePose" ) 34 | .ToArray()[ 0 ]; 35 | 36 | var size = int.Parse( referencePose.GetAttribute( "size" ) ); 37 | 38 | var referencePoseArr = new float[size][]; 39 | 40 | var i = 0; 41 | foreach( var node in referencePose.ChildNodes.Cast< XmlElement >() ) { 42 | referencePoseArr[ i ] = XmlUtils.ParseVec12( node.InnerText ); 43 | i += 1; 44 | } 45 | 46 | return referencePoseArr; 47 | } 48 | 49 | private int[] ReadParentIndices( XmlElement element ) { 50 | var parentIndices = element.GetElementsByTagName( "array" ) 51 | .Cast< XmlElement >() 52 | .Where( x => x.GetAttribute( "name" ) == "parentIndices" ) 53 | .ToArray()[ 0 ]; 54 | 55 | var parentIndicesArr = new int[int.Parse( parentIndices.GetAttribute( "size" ) )]; 56 | 57 | var parentIndicesStr = parentIndices.InnerText.Split( "\n" ) 58 | .Select( x => x.Trim() ) 59 | .Where( x => !string.IsNullOrWhiteSpace( x ) ) 60 | .ToArray(); 61 | 62 | var i = 0; 63 | foreach( var str2 in parentIndicesStr ) { 64 | foreach( var str3 in str2.Split( " " ) ) { 65 | parentIndicesArr[ i ] = int.Parse( str3 ); 66 | i++; 67 | } 68 | } 69 | 70 | return parentIndicesArr; 71 | } 72 | 73 | private string[] ReadBoneNames( XmlElement element ) { 74 | var bonesObj = element.GetElementsByTagName( "array" ) 75 | .Cast< XmlElement >() 76 | .Where( x => x.GetAttribute( "name" ) == "bones" ) 77 | .ToArray()[ 0 ]; 78 | 79 | var bones = new string[int.Parse( bonesObj.GetAttribute( "size" ) )]; 80 | 81 | var boneNames = bonesObj.GetElementsByTagName( "struct" ) 82 | .Cast< XmlElement >() 83 | .Select( x => x.GetElementsByTagName( "string" ) 84 | .Cast< XmlElement >() 85 | .First( y => y.GetAttribute( "name" ) == "name" ) ); 86 | 87 | var i = 0; 88 | foreach( var boneName in boneNames ) { 89 | bones[ i ] = boneName.InnerText; 90 | i++; 91 | } 92 | 93 | return bones; 94 | } 95 | } -------------------------------------------------------------------------------- /Xande/Models/Export/SklbResolver.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | using Dalamud.Plugin; 3 | using Dalamud.Utility.Signatures; 4 | using Xande.Enums; 5 | 6 | namespace Xande.Models.Export; 7 | 8 | public class SklbResolver { 9 | // Native 10 | 11 | private delegate ushort PartialIdDelegate( ushort root, int partial, ushort set ); 12 | 13 | [Signature( "44 8B C9 83 EA 01" )] 14 | private readonly PartialIdDelegate ResolvePartialId = null!; 15 | 16 | // SklbResolver 17 | 18 | public SklbResolver( DalamudPluginInterface pi ) { 19 | pi.Create< Service >()!.GameInteropProvider.InitializeFromAttributes( this ); 20 | } 21 | 22 | // Path resolver 23 | 24 | public string? Resolve( string mdl ) { 25 | var split = mdl.Split( "/" ); 26 | 27 | var dir = split[ 1 ]; 28 | switch( dir ) { 29 | case "human": 30 | return ResolveHuman( mdl, split ); 31 | case "equipment": 32 | return ResolveHuman( mdl, split, true ); 33 | default: 34 | if( dir != "demihuman" && dir != "monster" && dir != "weapon" ) return null; 35 | return string.Format( 36 | "chara/{0}/{1}{2:D4}/skeleton/base/b0001/skl_{1}{2:D4}b0001.sklb", 37 | dir, dir[ 0 ], ushort.Parse( split[ 2 ][ 1.. ] ) 38 | ); 39 | } 40 | } 41 | 42 | public string[] ResolveAll( string[] mdls ) 43 | => mdls.Select( Resolve ).OfType< string >().ToArray(); 44 | 45 | // Handling for human skeletons 46 | 47 | private readonly Regex EquipRx = new(@"c([0-9]{4})e([0-9]{4})_([a-z]{0,}).mdl"); 48 | private readonly List< string > Partials = new() { "body", "face", "hair", "met", "top" }; 49 | 50 | public string? ResolveHuman( string mdl, string[] split, bool isEquipment = false ) { 51 | string type; 52 | ushort root; 53 | ushort set; 54 | 55 | if( isEquipment ) { 56 | // For equipment with additional physics. 57 | var v = EquipRx.Matches( mdl ).First().Groups; 58 | type = v[ 3 ].Value; 59 | root = ushort.Parse( v[ 1 ].Value ); 60 | set = ushort.Parse( v[ 2 ].Value ); 61 | } else { 62 | // Face and hair. 63 | type = split[ 4 ]; 64 | root = ushort.Parse( split[ 2 ][ 1.. ] ); 65 | set = ushort.Parse( split[ 5 ][ 1.. ] ); 66 | } 67 | 68 | // TODO: Figure out the best way to handle this. 69 | if( type == "body" ) return null; 70 | 71 | var partial = Partials.IndexOf( type ); 72 | var id = ResolvePartialId( root, partial, set ); 73 | if( id == 0xFFFF ) return null; 74 | 75 | return string.Format( 76 | "chara/human/c{0:D4}/skeleton/{1}/{2}{3:D4}/skl_c{0:D4}{2}{3:D4}.sklb", 77 | root, type, type[ 0 ], id 78 | ); 79 | } 80 | 81 | // Model ID resolver 82 | 83 | public ushort GetHumanId( byte clan, byte sex = 0, byte bodyType = 1 ) { 84 | if( bodyType is <= 0 or 3 or > 5 ) 85 | bodyType = 1; 86 | var x = clan <= 1 ? 0 : clan; 87 | if( x is > 4 and < 11 ) 88 | x += x < 7 ? 4 : -2; 89 | x += 1 + sex + x % 2; 90 | return ( ushort )( x * 100 + bodyType ); 91 | } 92 | 93 | public string ResolveHumanBase( Clan clan, Gender sex, BodyType bodyType = BodyType.Normal ) 94 | => GetHumanBasePath( GetHumanId( ( byte )clan, ( byte )sex, ( byte )bodyType ) ); 95 | 96 | public string ResolveHumanBase( byte clan, byte sex = 0, byte bodyType = 1 ) 97 | => GetHumanBasePath( GetHumanId( clan, sex, bodyType ) ); 98 | 99 | public string GetHumanBasePath( GenderRace modelId ) 100 | => GetHumanBasePath( ( ushort )modelId ); 101 | 102 | public string GetHumanBasePath( ushort modelId ) 103 | => string.Format( "chara/human/c{0:D4}/skeleton/base/b0001/skl_c{0:D4}b0001.sklb", modelId ); 104 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Xande 2 | 3 | Xande is a (WIP) C# library meant to be used in Dalamud plugins for interacting with FINAL FANTASY XIV models. It is able to parse Havok files (`.hkx`, `.xml`) using functions in the client, and perform model exports (imports WIP). 4 | 5 | Xande was made possible thanks to: 6 | 7 | - [perchbird](https://github.com/lmcintyre) 8 | - [AnimAssist](https://github.com/lmcintyre/AnimAssist), providing information on the `.sklb` file format 9 | - Contributing model code to [Lumina](https://github.com/NotAdam/Lumina) 10 | - Contributing Havok information to [FFXIVClientStructs](https://github.com/aers/FFXIVClientStructs) 11 | - [Wintermute](https://github.com/pmgr) 12 | - Writing prototype glTF export code 13 | - [aers](https://github.com/aers) 14 | - Leading [FFXIVClientStructs](https://github.com/aers/FFXIVClientStructs) 15 | - Helping investigate Havok information in the client 16 | - [goatcorp](https://github.com/goatcorp) 17 | - Making [Dalamud](https://github.com/goatcorp/Dalamud) 18 | - Various mod makers in the [Penumbra Discord server](https://discord.gg/kVva7DHV4r) for examining model exports 19 | 20 | ## Installation & Usage 21 | 22 | Right now, Xande must be referenced as a Git submodule: 23 | 24 | ```shell 25 | git submodule add https://github.com/xivdev/Xande.git 26 | ``` 27 | 28 | Then reference `Xande.csproj` in your plugin. 29 | 30 | --- 31 | 32 | Xande functions by calling functions of the Havok SDK that were bundled with the FFXIV client. Because of this, right now, it is only possible to use in the context of a Dalamud plugin. 33 | 34 | To convert a `.sklb` file into an `.xml` file (and vice versa): 35 | 36 | ```csharp 37 | // Obtain a byte array, either through the filesystem or through Lumina 38 | var sklbData = File.ReadAllBytes("skl_c0101b0001.sklb"); 39 | // Parse the .sklb to obtain the .hkx 40 | var readStream = new MemoryStream(sklbData); 41 | var sklb = SklbFile.FromStream(readStream); 42 | 43 | // Do the thing 44 | var converter = new HavokConverter(); 45 | var xml = converter.HkxToXml(sklb.HkxData); 46 | 47 | // Convert the .xml back into a .hkx 48 | var hkx = converter.XmlToHkx(xml); 49 | // Replace the .sklb's .hkx with the new one 50 | sklb.ReplaceHkxData(hkx); 51 | 52 | // Write the new .sklb to disk 53 | var writeStream = new MemoryStream(); 54 | sklb.Write(writeStream); 55 | File.WriteAllBytes("skl_c0101b0001.sklb", writeStream.ToArray()); 56 | ``` 57 | 58 | To export a model: 59 | 60 | ```csharp 61 | var havokConverter = new HavokConverter(); 62 | var luminaManager = new LuminaManager(DataManager.GameData); 63 | var modelConverter = new ModelConverter(luminaManager); 64 | 65 | // outputDir can be any directory that exists and is writable, temp paths are used for demonstration 66 | var outputDir = Path.Combine(Path.GetTempPath(), "XandeModelExport"); 67 | Directory.CreateDirectory(outputDir); 68 | 69 | // This is Grebuloff 70 | var mdlPaths = new string[] { "chara/monster/m0405/obj/body/b0002/model/m0405b0002.mdl" }; 71 | var sklbPaths = new string[] { "chara/monster/m0405/skeleton/base/b0001/skl_m0405b0001.sklb" }; 72 | 73 | var skeletons = sklbPaths.Select(path => { 74 | var file = luminaManager.GetFile(path); 75 | var sklb = SklbFile.FromStream(file.Reader.BaseStream); 76 | var xmlStr = havokConverter.HkxToXml(sklb.HkxData); 77 | return new HavokXml(xmlStr); 78 | }).ToArray(); 79 | 80 | modelConverter.ExportModel(outputDir, mdlPaths, skeletons); 81 | ``` 82 | 83 | Multiple models can be supplied to export them into one scene. When exporting a full body character, pass the `deform` parameter representing the race code of the character. 84 | 85 | Skeleton paths can be automatically resolved with the `SklbResolver` class. Note that the skeleton array order is important (base skeletons must come first, and skeletons that depend on other skeletons must come after the dependencies). 86 | 87 | ## Safety 88 | 89 | Xande tries to do its best to wrap Havok for you, but at its core, it is a library in another game's address space calling random functions. 90 | 91 | When contributing Havok code, please make sure to check for null pointers and failed `hkResult`s. 92 | 93 | Havok functions are not thread-safe, so you should use `HavokConverter` on the Framework thread (see `Framework.RunOnFrameworkThread` and `Framework.RunOnTick`). Model exports are thread-safe. 94 | -------------------------------------------------------------------------------- /Xande/Models/Export/RaceDeformer.cs: -------------------------------------------------------------------------------- 1 | using System.Numerics; 2 | using SharpGLTF.Scenes; 3 | using Xande.Files; 4 | 5 | namespace Xande.Models.Export; 6 | 7 | public class BoneNodeBuilder : NodeBuilder { 8 | public string BoneName { get; set; } 9 | private int? suffix; 10 | public int? Suffix { 11 | get => suffix; 12 | set { 13 | suffix = value; 14 | Name = 15 | suffix is int val ? 16 | $"{BoneName}_{val}" : 17 | BoneName; 18 | } 19 | } 20 | 21 | public BoneNodeBuilder( string name ) : base( name ) { 22 | BoneName = name; 23 | } 24 | 25 | public void SetSuffixRecursively( int? suffix ) { 26 | Suffix = suffix; 27 | foreach( var child in VisualChildren ) { 28 | if( child is BoneNodeBuilder boneChild ) 29 | boneChild.SetSuffixRecursively( suffix ); 30 | } 31 | } 32 | } 33 | 34 | /// Calculates deformations from a PBD file. 35 | public class RaceDeformer { 36 | public PbdFile PbdFile { get; } 37 | private List BoneMap { get; } 38 | 39 | public RaceDeformer( PbdFile pbd, List boneMap ) { 40 | PbdFile = pbd; 41 | BoneMap = boneMap; 42 | } 43 | 44 | /// Gets the parent of a given race code. 45 | public ushort? GetParent( ushort raceCode ) { 46 | // TODO: npcs 47 | // Annoying special cases 48 | if( raceCode == 1201 ) return 1101; // Lalafell F -> Lalafell M 49 | if( raceCode == 0201 ) return 0101; // Midlander F -> Midlander M 50 | if( raceCode == 1001 ) return 0201; // Roegadyn F -> Midlander F 51 | if( raceCode == 0101 ) return null; // Midlander M has no parent 52 | 53 | // First two digits being odd or even can tell us gender 54 | var isMale = raceCode / 100 % 2 == 1; 55 | 56 | // Midlander M / Midlander F 57 | return ( ushort )( isMale ? 0101 : 0201 ); 58 | } 59 | 60 | /// Parses a model path to obtain its race code. 61 | public ushort? RaceCodeFromPath( string path ) { 62 | var fileName = Path.GetFileNameWithoutExtension( path ); 63 | if( fileName[ 0 ] != 'c' ) return null; 64 | 65 | return ushort.Parse( fileName[ 1..5 ] ); 66 | } 67 | 68 | private float[]? ResolveDeformation( PbdFile.Deformer deformer, string name ) { 69 | // Try and fetch it from the PBD 70 | var boneNames = deformer.BoneNames; 71 | var boneIdx = Array.FindIndex( boneNames, x => x == name ); 72 | if( boneIdx != -1 ) { return deformer.DeformMatrices[ boneIdx ]; } 73 | 74 | // Try and get it from the parent 75 | var boneNode = BoneMap.First(b => b.BoneName.Equals(name, StringComparison.Ordinal)); 76 | if( boneNode.Parent != null ) { return ResolveDeformation( deformer, (boneNode.Parent as BoneNodeBuilder ?? throw new InvalidOperationException("Parent isn't a bone node")).BoneName ); } 77 | 78 | // No deformation, just use identity 79 | return new float[] { 80 | 0, 0, 0, 0, // Translation (vec3 + unused) 81 | 0, 0, 0, 1, // Rotation (vec4) 82 | 1, 1, 1, 0 // Scale (vec3 + unused) 83 | }; 84 | } 85 | 86 | /// Deforms a vertex using a deformer. 87 | /// The deformer to use. 88 | /// The index of the bone name in the deformer's bone name list. 89 | /// The original position of the vertex. 90 | /// The deformed position of the vertex. 91 | public Vector3? DeformVertex( PbdFile.Deformer deformer, int nameIndex, Vector3 origPos ) { 92 | var matrix = ResolveDeformation( deformer, BoneMap[nameIndex].BoneName ); 93 | if( matrix != null ) { return MatrixTransform( origPos, matrix ); } 94 | 95 | return null; 96 | } 97 | 98 | // Literally ripped directly from xivModdingFramework because I am lazy 99 | private static Vector3 MatrixTransform( Vector3 vector, float[] transform ) => new( 100 | vector.X * transform[ 0 ] + vector.Y * transform[ 1 ] + vector.Z * transform[ 2 ] + 1.0f * transform[ 3 ], 101 | vector.X * transform[ 4 ] + vector.Y * transform[ 5 ] + vector.Z * transform[ 6 ] + 1.0f * transform[ 7 ], 102 | vector.X * transform[ 8 ] + vector.Y * transform[ 9 ] + vector.Z * transform[ 10 ] + 1.0f * transform[ 11 ] 103 | ); 104 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | [*] 3 | charset=utf-8 4 | end_of_line=lf 5 | trim_trailing_whitespace=true 6 | insert_final_newline=false 7 | indent_style=space 8 | indent_size=4 9 | 10 | # Microsoft .NET properties 11 | csharp_indent_braces=false 12 | csharp_new_line_before_members_in_object_initializers=false 13 | csharp_new_line_before_open_brace=none 14 | csharp_preferred_modifier_order=public, private, protected, internal, new, abstract, virtual, sealed, override, static, readonly, extern, unsafe, volatile, async:suggestion 15 | csharp_prefer_braces=true:none 16 | csharp_space_after_cast=false 17 | csharp_space_after_keywords_in_control_flow_statements=false 18 | csharp_space_between_method_call_parameter_list_parentheses=true 19 | csharp_space_between_method_declaration_parameter_list_parentheses=true 20 | csharp_space_between_parentheses=control_flow_statements,expressions,type_casts 21 | csharp_style_var_elsewhere=true:suggestion 22 | csharp_style_var_for_built_in_types=true:suggestion 23 | csharp_style_var_when_type_is_apparent=true:suggestion 24 | dotnet_style_parentheses_in_arithmetic_binary_operators=never_if_unnecessary:none 25 | dotnet_style_parentheses_in_other_binary_operators=never_if_unnecessary:none 26 | dotnet_style_parentheses_in_relational_binary_operators=never_if_unnecessary:none 27 | dotnet_style_predefined_type_for_locals_parameters_members=true:suggestion 28 | dotnet_style_predefined_type_for_member_access=true:suggestion 29 | dotnet_style_qualification_for_event=false:suggestion 30 | dotnet_style_qualification_for_field=false:suggestion 31 | dotnet_style_qualification_for_method=false:suggestion 32 | dotnet_style_qualification_for_property=false:suggestion 33 | dotnet_style_require_accessibility_modifiers=for_non_interface_members:suggestion 34 | 35 | # ReSharper properties 36 | resharper_align_multiline_binary_expressions_chain=false 37 | resharper_align_multiline_calls_chain=false 38 | resharper_autodetect_indent_settings=true 39 | resharper_blank_lines_after_control_transfer_statements=0 40 | resharper_braces_for_ifelse=required_for_multiline_statement 41 | resharper_braces_redundant=true 42 | resharper_constructor_or_destructor_body=expression_body 43 | resharper_csharp_empty_block_style=together 44 | resharper_csharp_max_line_length=180 45 | resharper_csharp_space_within_array_access_brackets=true 46 | resharper_enforce_line_ending_style=true 47 | resharper_int_align_assignments=true 48 | resharper_int_align_comments=true 49 | resharper_int_align_fields=true 50 | resharper_int_align_invocations=false 51 | resharper_int_align_nested_ternary=true 52 | resharper_int_align_properties=false 53 | resharper_int_align_switch_expressions=true 54 | resharper_int_align_switch_sections=true 55 | resharper_int_align_variables=true 56 | resharper_local_function_body=expression_body 57 | resharper_method_or_operator_body=expression_body 58 | resharper_place_attribute_on_same_line=false 59 | resharper_place_constructor_initializer_on_same_line=true 60 | resharper_place_expr_method_on_single_line=if_owner_is_single_line 61 | resharper_place_expr_property_on_single_line=if_owner_is_single_line 62 | resharper_place_simple_embedded_block_on_same_line=true 63 | resharper_place_simple_embedded_statement_on_same_line=if_owner_is_single_line 64 | resharper_space_after_cast=false 65 | resharper_space_within_checked_parentheses=true 66 | resharper_space_within_default_parentheses=true 67 | resharper_space_within_nameof_parentheses=true 68 | resharper_space_within_single_line_array_initializer_braces=true 69 | resharper_space_within_sizeof_parentheses=true 70 | resharper_space_within_typeof_parentheses=true 71 | resharper_space_within_type_argument_angles=true 72 | resharper_space_within_type_parameter_angles=true 73 | resharper_use_indent_from_vs=false 74 | resharper_wrap_lines=true 75 | 76 | # ReSharper inspection severities 77 | resharper_arrange_redundant_parentheses_highlighting=hint 78 | resharper_arrange_this_qualifier_highlighting=hint 79 | resharper_arrange_type_member_modifiers_highlighting=hint 80 | resharper_arrange_type_modifiers_highlighting=hint 81 | resharper_built_in_type_reference_style_for_member_access_highlighting=hint 82 | resharper_built_in_type_reference_style_highlighting=hint 83 | resharper_redundant_base_qualifier_highlighting=warning 84 | resharper_suggest_var_or_type_built_in_types_highlighting=hint 85 | resharper_suggest_var_or_type_elsewhere_highlighting=hint 86 | resharper_suggest_var_or_type_simple_types_highlighting=hint 87 | resharper_web_config_module_not_resolved_highlighting=warning 88 | resharper_web_config_type_not_resolved_highlighting=warning 89 | resharper_web_config_wrong_module_highlighting=warning 90 | 91 | [*.{appxmanifest,asax,ascx,aspx,build,cg,cginc,compute,cs,cshtml,dtd,hlsl,hlsli,hlslinc,master,nuspec,razor,resw,resx,shader,skin,usf,ush,vb,xaml,xamlx,xoml,xsd}] 92 | indent_style=space 93 | indent_size=4 94 | tab_width=4 95 | -------------------------------------------------------------------------------- /Xande.GltfImporter/StringTableBuilder.cs: -------------------------------------------------------------------------------- 1 | using Lumina; 2 | using System.Text; 3 | 4 | namespace Xande.GltfImporter; 5 | 6 | public class StringTableBuilder { 7 | public SortedSet Attributes = new(); 8 | public SortedSet Bones = new(); 9 | public List HierarchyBones = new(); 10 | public readonly List Materials = new(); 11 | public SortedSet Shapes = new(); 12 | public readonly List Extras = new(); 13 | private ILogger? _logger; 14 | 15 | public StringTableBuilder(ILogger? logger = null) { 16 | _logger = logger; 17 | } 18 | 19 | public void AddAttribute( string attr ) { 20 | if( !Attributes.Contains( attr ) ) { 21 | Attributes.Add( attr ); 22 | } 23 | } 24 | 25 | public void AddAttributes( IEnumerable attr ) { 26 | foreach( var a in attr ) { 27 | AddAttribute( a ); 28 | } 29 | } 30 | 31 | public bool RemoveAttribute( string attr ) { 32 | return Attributes.Remove( attr ); 33 | } 34 | 35 | public void AddBones( IEnumerable bones ) { 36 | foreach( var bone in bones ) { 37 | AddBone( bone ); 38 | } 39 | } 40 | 41 | public void AddBone( string bone ) { 42 | if( !Bones.Contains( bone ) ) { 43 | Bones.Add( bone ); 44 | } 45 | } 46 | 47 | public void AddMaterial( string material ) { 48 | if( !Materials.Contains( material ) ) { 49 | Materials.Add( material ); 50 | } 51 | } 52 | 53 | public void AddShape( string shape ) { 54 | if( !Shapes.Contains( shape ) ) { 55 | Shapes.Add( shape ); 56 | } 57 | } 58 | 59 | public void AddShapes( List shapes ) { 60 | foreach( var s in shapes ) { 61 | AddShape( s ); 62 | } 63 | } 64 | public bool RemoveShape( string shape ) { 65 | return Shapes.Remove( shape ); 66 | } 67 | 68 | internal int GetStringCount() { 69 | return Attributes.Count + Bones.Count + Materials.Count + Shapes.Count + Extras.Count; 70 | } 71 | 72 | internal List GetChars() { 73 | var str = String.Join( ' ', Attributes, Bones, Materials, Shapes, Extras ); 74 | _logger?.Debug( $"Getting chars: {str}" ); 75 | return str.ToCharArray().ToList(); 76 | } 77 | 78 | internal byte[] GetBytes() { 79 | var aggregator = GetStrings(); 80 | 81 | var str = String.Join( "\0", aggregator ); 82 | 83 | // I don't know if this is actually necessary 84 | if( Attributes.Count == 0 ) { 85 | str += "\0"; 86 | } 87 | if( Bones.Count == 0 ) { 88 | str += "\0"; 89 | } 90 | if( Materials.Count == 0 ) { 91 | str += "\0"; 92 | } 93 | if( Shapes.Count == 0 ) { 94 | str += "\0"; 95 | } 96 | if( Extras.Count == 0 ) { 97 | str += "\0"; 98 | } 99 | 100 | // This one is required, though 101 | if( !str.EndsWith( "\0" ) ) { 102 | str += "\0"; 103 | } 104 | 105 | return Encoding.UTF8.GetBytes( str ); 106 | } 107 | 108 | internal uint[] GetAttributeNameOffsets() { 109 | return GetOffsets( Attributes.ToList() ).ToArray(); 110 | } 111 | 112 | internal uint[] GetMaterialNameOffsets() { 113 | return GetOffsets( Materials.ToList() ).ToArray(); 114 | } 115 | 116 | internal uint[] GetBoneNameOffsets() { 117 | return GetOffsets( Bones.ToList() ).ToArray(); 118 | } 119 | 120 | internal uint GetShapeNameOffset( string v ) { 121 | return GetOffsets( new List { v } ).ToArray()[0]; 122 | } 123 | 124 | public uint GetOffset( string input ) { 125 | var aggregator = GetStrings(); 126 | var str = string.Join( "\0", aggregator ); 127 | return ( uint )str.IndexOf( input ); 128 | } 129 | 130 | public List GetOffsets( List strings ) { 131 | var ret = new List(); 132 | var aggregator = GetStrings(); 133 | var str = string.Join( "\0", aggregator ); 134 | foreach( var s in strings ) { 135 | var index = str.IndexOf( s ); 136 | if( index >= 0 ) { 137 | ret.Add( ( uint )index ); 138 | } 139 | else { 140 | _logger?.Error( $"Could not locate index for {s}" ); 141 | } 142 | } 143 | return ret; 144 | } 145 | 146 | internal List GetStrings() { 147 | var aggregator = new List(); 148 | aggregator.AddRange( Attributes ); 149 | aggregator.AddRange( Bones ); 150 | aggregator.AddRange( Materials ); 151 | aggregator.AddRange( Shapes ); 152 | aggregator.AddRange( Extras ); 153 | return aggregator; 154 | } 155 | } -------------------------------------------------------------------------------- /Xande/LuminaManager.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Drawing; 3 | using System.Drawing.Imaging; 4 | using Lumina; 5 | using Lumina.Data; 6 | using Lumina.Data.Files; 7 | using Lumina.Models.Materials; 8 | using Lumina.Models.Models; 9 | using SkiaSharp; 10 | using Xande.Files; 11 | 12 | // ReSharper disable MemberCanBePrivate.Global 13 | 14 | namespace Xande; 15 | 16 | public class LuminaManager { 17 | /// Provided by Lumina. 18 | public readonly GameData GameData; 19 | 20 | /// Used to resolve paths to files. Return a path (either on disk or in SqPack) to override file resolution. 21 | public Func< string, string? >? FileResolver; 22 | 23 | /// Construct a LuminaManager instance. 24 | public LuminaManager() { 25 | var luminaOptions = new LuminaOptions { 26 | LoadMultithreaded = true, 27 | CacheFileResources = true, 28 | #if NEVER // Lumina bug 29 | PanicOnSheetChecksumMismatch = true, 30 | #else 31 | PanicOnSheetChecksumMismatch = false, 32 | #endif 33 | DefaultExcelLanguage = Language.English, 34 | }; 35 | 36 | var processModule = Process.GetCurrentProcess().MainModule; 37 | if( processModule != null ) { GameData = new GameData( Path.Combine( Path.GetDirectoryName( processModule.FileName )!, "sqpack" ), luminaOptions ); } 38 | else { throw new Exception( "Could not find process data to create lumina." ); } 39 | } 40 | 41 | public LuminaManager( Func< string, string? > fileResolver ) : this() => FileResolver = fileResolver; 42 | 43 | public T? GetFile< T >( string path, string? origPath = null ) where T : FileResource { 44 | var actualPath = FileResolver?.Invoke( path ) ?? path; 45 | return Path.IsPathRooted( actualPath ) 46 | ? GameData.GetFileFromDisk< T >( actualPath, origPath ) 47 | : GameData.GetFile< T >( actualPath ); 48 | } 49 | 50 | /// Obtain and parse a model structure from a given path. 51 | public Model GetModel( string path ) { 52 | var mdlFile = GetFile< MdlFile >( path ); 53 | return mdlFile != null 54 | ? new Model( mdlFile ) 55 | : throw new FileNotFoundException(); 56 | } 57 | 58 | /// Obtain and parse a material structure. 59 | public Material GetMaterial( string path, string? origPath = null) { 60 | var mtrlFile = GetFile< MtrlFile >( path, origPath ); 61 | return mtrlFile != null 62 | ? new Material( mtrlFile ) 63 | : throw new FileNotFoundException(); 64 | } 65 | 66 | /// 67 | public Material GetMaterial( Material mtrl ) => GetMaterial( mtrl.ResolvedPath ?? mtrl.MaterialPath ); 68 | 69 | /// Obtain and parse a skeleton from a given path. 70 | public SklbFile GetSkeleton( string path ) { 71 | var sklbFile = GetFile< FileResource >( path ); 72 | return sklbFile != null 73 | ? SklbFile.FromStream( sklbFile.Reader.BaseStream ) 74 | : throw new FileNotFoundException(); 75 | } 76 | 77 | public PbdFile GetPbdFile() { 78 | return GetFile< PbdFile >( "chara/xls/boneDeformer/human.pbd" )!; 79 | } 80 | 81 | /// Obtain and parse a texture to a Bitmap. 82 | public unsafe SKBitmap GetTextureBuffer( string path, string? origPath = null ) { 83 | var texFile = GetFile< TexFile >( path, origPath ); 84 | if( texFile == null ) throw new Exception( $"Lumina was unable to fetch a .tex file from {path}." ); 85 | var texBuffer = texFile.TextureBuffer.Filter( format: TexFile.TextureFormat.B8G8R8A8 ); 86 | var bitmap = new SKBitmap( texBuffer.Width, texBuffer.Height, SKColorType.Bgra8888, SKAlphaType.Unpremul ); 87 | bitmap.Erase(new(0)); 88 | fixed( byte* raw = texBuffer.RawData ) 89 | bitmap.InstallPixels(new SKImageInfo(texBuffer.Width, texBuffer.Height, SKColorType.Bgra8888, SKAlphaType.Unpremul), (nint)raw, texBuffer.Width * 4 ); 90 | return bitmap; 91 | } 92 | 93 | /// 94 | public SKBitmap GetTextureBuffer( Texture texture ) => GetTextureBuffer( texture.TexturePath ); 95 | 96 | /// Save a texture to PNG. 97 | /// The directory the file should be saved in. 98 | /// The texture to be saved. 99 | /// 100 | public string SaveTexture( string basePath, Texture texture ) { 101 | using var png = GetTextureBuffer( texture ); 102 | var convPath = texture.TexturePath[ ( texture.TexturePath.LastIndexOf( '/' ) + 1 ).. ] + ".png"; 103 | 104 | using var pngData = png.Encode( SKEncodedImageFormat.Png, 100 ); 105 | using var pngStream = pngData.AsStream(); 106 | using var fileStream = File.Create( basePath + convPath ); 107 | pngStream.CopyTo( fileStream ); 108 | return convPath; 109 | } 110 | } -------------------------------------------------------------------------------- /Xande/MdlResolver.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | using Dalamud.Plugin; 3 | using Dalamud.Utility.Signatures; 4 | using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; 5 | using Xande.Enums; 6 | 7 | namespace Xande; 8 | 9 | public class MdlResolver { 10 | // Native 11 | // TODO: Get CharacterUtility into ClientStructs 12 | 13 | private delegate nint EqpDataDelegate( nint a1, ushort a2, uint a3, ushort a4 ); 14 | 15 | [Signature( "E8 ?? ?? ?? ?? 66 3B 85 ?? ?? ?? ??" )] 16 | private readonly EqpDataDelegate GetEqpDataFunc = null!; 17 | 18 | [Signature( "48 8B 0D ?? ?? ?? ?? 48 8D 55 80 44 8B C3", ScanType = ScanType.StaticAddress )] 19 | private readonly unsafe nint* CharaUtilInstance = null!; 20 | 21 | // CharaUtils wrappers 22 | 23 | private unsafe ushort GetEqpData( ushort a2, uint a3, ushort a4 ) 24 | => ( ushort )GetEqpDataFunc( *CharaUtilInstance, a2, a3, a4 ); 25 | 26 | // MdlResolver 27 | 28 | public MdlResolver( DalamudPluginInterface pi ) { 29 | pi.Create< Service >()!.GameInteropProvider.InitializeFromAttributes( this ); 30 | } 31 | 32 | // Main resolve func 33 | 34 | public string? Resolve( GenderRace dataId, ModelSlot slot, ushort id ) 35 | => Resolve( ( ushort )dataId, ( uint )slot, id ); 36 | 37 | public string? Resolve( GenderRace dataId, uint slot, ushort id ) 38 | => Resolve( ( ushort )dataId, slot, id ); 39 | 40 | public string? Resolve( ushort dataId, ModelSlot slot, ushort id ) 41 | => Resolve( dataId, ( uint )slot, id ); 42 | 43 | public string? Resolve( ushort dataId, uint slot, ushort id ) { 44 | return slot switch { 45 | < 10 => ResolveEquipPath( dataId, slot, id ), 46 | 10 => ResolveHairPath( dataId, id ), 47 | 11 => ResolveFacePath( dataId, id ), 48 | 12 => ResolveTailEarsPath( dataId, id ), 49 | _ => throw new Exception( $"{slot} is not a valid slot index." ) 50 | }; 51 | } 52 | 53 | // Resolve for objects 54 | 55 | public unsafe string? ResolveFor( Human* human, uint slot ) { 56 | var dataStart = ( nint )human + 0x910; 57 | var offset = slot < 10 58 | ? slot * 4 59 | : 0x2A + ( slot - 10 ) * 2; 60 | 61 | var id = ( ushort )Marshal.ReadInt16( dataStart + ( nint )offset ); 62 | if( ( ModelSlot )slot == ModelSlot.Face && id < 201 ) { 63 | switch( ( Clan )human->Customize.Clan ) { 64 | case Clan.Xaela or Clan.Lost or Clan.Veena: 65 | case Clan.KeeperOfTheMoon when human->Customize.BodyType == 4: 66 | id -= 100; 67 | break; 68 | } 69 | } 70 | 71 | return Resolve( human->RaceSexId, slot, id ); 72 | } 73 | 74 | public unsafe string[] ResolveAllFor( Human* human ) 75 | => Enumerable.Range( 0, 13 ) 76 | .Select( n => ResolveFor( human, ( uint )n ) ) 77 | .OfType< string >().ToArray(); 78 | 79 | // Equipment 80 | 81 | private readonly string[] SlotTypes = { "met", "top", "glv", "dwn", "sho", "ear", "nek", "wrs", "rir", "ril" }; 82 | 83 | public string? ResolveEquipPath( GenderRace dataId, EquipSlot slot, ushort setId ) 84 | => ResolveEquipPath( ( ushort )dataId, ( uint )slot, setId ); 85 | 86 | public string? ResolveEquipPath( GenderRace dataId, uint slot, ushort setId ) 87 | => ResolveEquipPath( ( ushort )dataId, slot, setId ); 88 | 89 | public string? ResolveEquipPath( ushort dataId, uint slot, ushort setId ) { 90 | switch( slot ) { 91 | case 0 or > 4 when setId is 0: 92 | return null; 93 | case > 9: 94 | throw new Exception( $"{slot} is not a valid slot index." ); 95 | } 96 | 97 | if( setId == 0 ) setId = 1; 98 | 99 | var type = slot > 4 ? "accessory" : "equipment"; 100 | var c = GetEqpData( dataId, slot, setId ); 101 | 102 | return string.Format( 103 | "chara/{0}/{1}{2:D4}/model/c{3:D4}{1}{2:D4}_{4}.mdl", 104 | type, type[ 0 ], setId, c, SlotTypes[ slot ] 105 | ); 106 | } 107 | 108 | // Hair / Face / Tail / Ears 109 | 110 | public string? ResolveHairPath( GenderRace dataId, ushort hair ) 111 | => ResolveHairPath( ( ushort )dataId, hair ); 112 | 113 | public string? ResolveHairPath( ushort dataId, ushort hair ) => hair == 0 114 | ? null 115 | : string.Format( 116 | "chara/human/c{0:D4}/obj/hair/h{1:D4}/model/c{0:D4}h{1:D4}_hir.mdl", 117 | dataId, hair 118 | ); 119 | 120 | public string? ResolveFacePath( GenderRace dataId, ushort hair ) 121 | => ResolveFacePath( ( ushort )dataId, hair ); 122 | 123 | public string? ResolveFacePath( ushort dataId, ushort face ) => face == 0 124 | ? null 125 | : string.Format( 126 | "chara/human/c{0:D4}/obj/face/f{1:D4}/model/c{0:D4}f{1:D4}_fac.mdl", 127 | dataId, face 128 | ); 129 | 130 | public string? ResolveTailEarsPath( GenderRace dataId, ushort hair ) 131 | => ResolveTailEarsPath( ( ushort )dataId, hair ); 132 | 133 | public string? ResolveTailEarsPath( ushort dataId, ushort id ) { 134 | var type = dataId is >= 1700 and < 1900 ? "zear" : "tail"; 135 | return id == 0 136 | ? null 137 | : string.Format( 138 | "chara/human/c{0:D4}/obj/{1}/{2}{3:D4}/model/c{0:D4}{2}{3:D4}_{4}.mdl", 139 | dataId, type, type[ 0 ], id, type == "zear" ? "zer" : "til" 140 | ); 141 | } 142 | } -------------------------------------------------------------------------------- /Xande.GltfImporter/MeshBuilder.cs: -------------------------------------------------------------------------------- 1 | using Lumina; 2 | using Lumina.Data.Parsing; 3 | 4 | namespace Xande.GltfImporter { 5 | internal class MeshBuilder { 6 | public List Submeshes = new(); 7 | public Dictionary> VertexData = new(); 8 | public SortedSet Attributes = new(); 9 | public MdlStructs.BoneTableStruct BoneTableStruct; 10 | public List Bones => _originalBoneIndexToString.Values.ToList(); 11 | public string Material = String.Empty; 12 | public List Shapes = new(); 13 | private readonly ILogger? _logger; 14 | 15 | private Dictionary _originalBoneIndexToString = new(); 16 | private Dictionary _blendIndicesDict = new(); 17 | 18 | public uint IndexCount { get; protected set; } = 0; 19 | 20 | public MeshBuilder( List submeshes, ILogger? logger = null) { 21 | _logger = logger; 22 | foreach( var sm in submeshes ) { 23 | Submeshes.Add( sm ); 24 | TryAddBones( sm ); 25 | AddShapes( sm ); 26 | AddSubmeshAttributes( sm ); 27 | 28 | IndexCount += sm.IndexCount; 29 | 30 | if( String.IsNullOrEmpty( Material ) ) { 31 | Material = sm.MaterialPath; 32 | } 33 | else { 34 | if( Material != sm.MaterialPath ) { 35 | _logger?.Error( $"Found multiple materials. Original \"{Material}\" vs \"{sm.MaterialPath}\"" ); 36 | } 37 | } 38 | } 39 | 40 | if( Bones.Count == 0 ) { 41 | _logger?.Warning( $" Mesh had zero bones. This can cause a game crash if a skeleton is expected." ); 42 | } 43 | } 44 | 45 | public int GetVertexCount() { 46 | return GetVertexCount( false ); 47 | } 48 | 49 | /// 50 | /// 51 | /// 52 | /// An optional list of strings that may or may not contain the names of used shapes 53 | /// 54 | public int GetVertexCount( bool includeShapes, List? strings = null ) { 55 | var vertexCount = 0; 56 | foreach( var submeshBuilder in Submeshes ) { 57 | vertexCount += submeshBuilder.GetVertexCount( includeShapes, strings ); 58 | } 59 | return vertexCount; 60 | } 61 | 62 | public int GetMaterialIndex( List materials ) { 63 | return materials.IndexOf( Material ); 64 | } 65 | 66 | public MdlStructs.BoneTableStruct GetBoneTableStruct( List bones, List hierarchyBones ) { 67 | _blendIndicesDict = new(); 68 | var boneTable = new List(); 69 | var values = _originalBoneIndexToString.Values.Where( x => x != "n_root" ).ToList(); 70 | var newValues = new List(); 71 | foreach( var b in hierarchyBones ) { 72 | if( values.Contains( b ) ) { 73 | newValues.Add( b ); 74 | } 75 | } 76 | 77 | foreach( var v in _originalBoneIndexToString ) { 78 | var value = newValues.IndexOf( v.Value ); 79 | if( value != -1 ) { 80 | _blendIndicesDict.Add( v.Key, value ); 81 | } 82 | } 83 | 84 | foreach( var v in newValues ) { 85 | var index = bones.IndexOf( v ); 86 | if( index >= 0 ) { 87 | boneTable.Add( ( ushort )index ); 88 | } 89 | } 90 | 91 | var boneCount = boneTable.Count; 92 | 93 | while( boneTable.Count < 64 ) { 94 | boneTable.Add( 0 ); 95 | } 96 | 97 | foreach( var sm in Submeshes ) { 98 | sm.SetBlendIndicesDict( _blendIndicesDict ); 99 | } 100 | 101 | return new() { 102 | BoneIndex = boneTable.ToArray(), 103 | BoneCount = ( byte )boneCount 104 | }; 105 | } 106 | 107 | public List GetSubmeshBoneMap( List bones ) { 108 | var ret = new List(); 109 | foreach( var b in _originalBoneIndexToString.Values ) { 110 | var index = bones.IndexOf( b ); 111 | ret.Add( ( ushort )index ); 112 | } 113 | return ret; 114 | } 115 | 116 | public Dictionary> GetVertexData() { 117 | var vertexDict = new Dictionary>(); 118 | foreach( var submesh in Submeshes ) { 119 | var submeshVertexData = submesh.GetVertexData(); 120 | 121 | foreach( var stream in submeshVertexData.Keys ) { 122 | if( !vertexDict.ContainsKey( stream ) ) { 123 | vertexDict.Add( stream, new() ); 124 | } 125 | vertexDict[stream].AddRange( submeshVertexData[stream] ); 126 | } 127 | } 128 | return vertexDict; 129 | } 130 | 131 | public async Task>> GetVertexDataAsync() { 132 | var vertexDict = new Dictionary> (); 133 | var tasks = new Task>>[ Submeshes.Count ]; 134 | for (var i = 0; i < Submeshes.Count; i++) { 135 | var j = i; 136 | tasks[j] = Task.Run( () => Task.FromResult( Submeshes[j].GetVertexData() ) ); 137 | } 138 | 139 | Task.WaitAll( tasks ); 140 | 141 | for (var i = 0; i < Submeshes.Count; i++ ) { 142 | var t = await tasks[i]; 143 | foreach (var stream in t.Keys ) { 144 | if (!vertexDict.ContainsKey(stream)) { 145 | vertexDict.Add( stream, new() ); 146 | 147 | } 148 | vertexDict[stream].AddRange( t[stream] ); 149 | 150 | } 151 | } 152 | 153 | return vertexDict; 154 | } 155 | 156 | private void TryAddBones( SubmeshBuilder submesh ) { 157 | foreach( var kvp in submesh.OriginalBoneIndexToStrings ) { 158 | if( !_originalBoneIndexToString.ContainsKey( kvp.Key ) ) { 159 | _originalBoneIndexToString.Add( kvp.Key, kvp.Value ); 160 | } 161 | } 162 | if( _originalBoneIndexToString.Keys.Count > 64 ) { 163 | _logger?.Error( $"There are currently {_originalBoneIndexToString.Keys.Count} bones, which is over the allowed 64." ); 164 | } 165 | } 166 | 167 | private void AddShapes( SubmeshBuilder submesh ) { 168 | foreach( var s in submesh.Shapes ) { 169 | if( !Shapes.Contains( s ) ) { 170 | Shapes.Add( s ); 171 | } 172 | } 173 | } 174 | 175 | private void AddSubmeshAttributes( SubmeshBuilder submesh ) { 176 | foreach( var attr in submesh.Attributes ) { 177 | AddAttribute( attr ); 178 | } 179 | } 180 | 181 | public bool AddAttribute( string s ) { 182 | if( !Attributes.Contains( s ) ) { 183 | Attributes.Add( s ); 184 | return true; 185 | } 186 | return false; 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /Xande/ColorUtility.cs: -------------------------------------------------------------------------------- 1 | using Dalamud.Memory; 2 | using SkiaSharp; 3 | using System.Numerics; 4 | using System.Runtime.CompilerServices; 5 | using System.Runtime.InteropServices; 6 | 7 | // ReSharper disable MemberCanBePrivate.Global 8 | 9 | namespace Xande; 10 | 11 | [StructLayout(LayoutKind.Sequential, Size = 32)] 12 | public unsafe struct ColorSetRow { 13 | public fixed ushort DataBuffer[16]; 14 | 15 | public Span Data => new( ( Half* )Unsafe.AsPointer( ref DataBuffer[0] ), 16 ); 16 | 17 | public ColorSetRow( ReadOnlySpan data ) { 18 | if( data.Length != 16 ) 19 | throw new ArgumentException( "Color set row must be 16 elements long.", nameof( data ) ); 20 | 21 | data.CopyTo( Data ); 22 | } 23 | 24 | public Vector3 Diffuse => new( (float)Data[0], ( float )Data[1], ( float )Data[2] ); 25 | public Vector3 Specular => new( ( float )Data[4], ( float )Data[5], ( float )Data[6] ); 26 | public Vector3 Emissive => new( ( float )Data[8], ( float )Data[9], ( float )Data[10] ); 27 | 28 | [Obsolete] 29 | public Vector2 TileRepeat => new( ( float )Data[12], ( float )Data[15] ); 30 | [Obsolete] 31 | public Vector2 TileSkew => new( ( float )Data[13], ( float )Data[14] ); 32 | 33 | public Vector4 TileMatrix => new( ( float )Data[12], ( float )Data[13], ( float )Data[14], ( float )Data[15] ); 34 | 35 | public float SpecularStrength => ( float )Data[3]; 36 | public float GlossStrength => ( float )Data[7]; 37 | 38 | public ushort TileSet => ( ushort )( ( float )Data[11] * 64 ); 39 | } 40 | 41 | [StructLayout( LayoutKind.Sequential, Size = 512 )] 42 | public unsafe struct ColorSet { 43 | public fixed ushort DataBuffer[256]; 44 | 45 | public Span DataBits => new( Unsafe.AsPointer( ref DataBuffer[0] ), 256 ); 46 | public Span Data => MemoryMarshal.Cast( DataBits ); 47 | public Span Rows => MemoryMarshal.Cast( DataBits ); 48 | 49 | public ColorSet( ReadOnlySpan data ) { 50 | if( data.Length != 256 ) 51 | throw new ArgumentException( "Color set must be 256 elements long.", nameof( data ) ); 52 | 53 | data.CopyTo( Data ); 54 | } 55 | 56 | public (SKColorF Diffuse, SKColorF Specular, SKColorF Emissive, Vector4 TileMatrix, ushort TileSet, float SpecularStrength, float GlossStrength) Blend( byte alpha ) { 57 | var (row0, row1, blend) = GetBlendedRow( alpha ); 58 | var tileRow = Rows[(int)GetDiscreteIdx( alpha )]; 59 | 60 | var diffuse = Vector3.Lerp( row0.Diffuse, row1.Diffuse, blend ); 61 | var specular = Vector3.Lerp( row0.Specular, row1.Specular, blend ); 62 | var emissive = Vector3.Lerp( row0.Emissive, row1.Emissive, blend ); 63 | var tileMatrix = Vector4.Lerp( row0.TileMatrix, row1.TileMatrix, blend ); 64 | var specularStrength = ColorUtility.Lerp( row0.SpecularStrength, row1.SpecularStrength, blend ); 65 | var glossStrength = ColorUtility.Lerp( row0.GlossStrength, row1.GlossStrength, blend ); 66 | 67 | return (diffuse.AsColorF(), specular.AsColorF(), emissive.AsColorF(), tileMatrix, tileRow.TileSet, specularStrength, glossStrength); 68 | } 69 | 70 | public (ColorSetRow A, ColorSetRow B, float Blend) GetBlendedRow( byte alpha ) { 71 | var blendIdx = GetBlendedIdx( alpha ); 72 | var idx0 = MathF.Floor( blendIdx ); 73 | var blend = blendIdx - idx0; 74 | var idx1 = MathF.Min( idx0 + 1, 15 ); 75 | 76 | var row0 = Rows[( int )idx0]; 77 | var row1 = Rows[( int )idx1]; 78 | 79 | return (row0, row1, blend); 80 | } 81 | 82 | public static float GetBlendedIdx( byte alpha ) { 83 | var r0y = alpha / 255f; 84 | var r7x = 15 * r0y; 85 | var r7y = 7.5f * r0y; 86 | var r0z = r7y % 1f; 87 | r0z = r0z + r0z; 88 | var r2w = r0y * 15 + 0.5f; 89 | r0z = MathF.Floor( r0z ); 90 | r2w = MathF.Floor( r2w ); 91 | r0y = -r0y * 15 + r2w; 92 | r0y = r0z * r0y + r7x; 93 | 94 | return r0y; 95 | } 96 | 97 | public static float GetDiscreteIdx( byte alpha ) { 98 | var r0y = GetBlendedIdx( alpha ); 99 | 100 | r0y = 0.5f + r0y; 101 | r0y = MathF.Floor( r0y ); 102 | 103 | return r0y; 104 | } 105 | } 106 | 107 | public static class ColorUtility { 108 | public enum TextureType { 109 | Diffuse = 0, 110 | Specular = 4, 111 | Emissive = 8, 112 | }; 113 | 114 | public static SKColorF AsColorF( this Vector3 vec ) => new( vec.X, vec.Y, vec.Z ); 115 | 116 | public static SKColorF AsColorF( this Vector4 vec ) => new( vec.X, vec.Y, vec.Z, vec.W ); 117 | 118 | public static Vector4 AsVector4( this SKColorF colorF ) => new( colorF.Red, colorF.Green, colorF.Blue, colorF.Alpha ); 119 | 120 | public static Vector2 AsVector2_XY(this Vector4 vec) => new(vec.X, vec.Y); 121 | 122 | public static Vector2 AsVector2_ZW(this Vector4 vec) => new(vec.Z, vec.W); 123 | 124 | public static Vector3 Sign(this Vector3 vec) => new(MathF.Sign(vec.X), MathF.Sign(vec.Y), MathF.Sign(vec.Z)); 125 | 126 | public static SKColor AsColor( this SKColorF colorF ) => (SKColor)colorF; 127 | 128 | public static SKColorF BilinearSample(this SKBitmap bitmap, Vector2 uv ) { 129 | var u = uv.X * bitmap.Width; 130 | var v = uv.Y * bitmap.Height; 131 | 132 | var x0 = ( int )MathF.Floor( u ); 133 | var y0 = ( int )MathF.Floor( v ); 134 | 135 | var x1 = ( int )MathF.Ceiling( u ); 136 | var y1 = ( int )MathF.Ceiling( v ); 137 | 138 | x1 = Math.Min( x1, bitmap.Width - 1 ); 139 | y1 = Math.Min( y1, bitmap.Height - 1 ); 140 | 141 | if ( x0 == x1 && y0 == y1 ) 142 | return bitmap.GetPixel( x0, y0 ); 143 | else if (x0 == x1) 144 | return Lerp( bitmap.GetPixel( x0, y0 ), bitmap.GetPixel( x0, y1 ), v - y0 ); 145 | else if (y0 == y1) 146 | return Lerp( bitmap.GetPixel( x0, y0 ), bitmap.GetPixel( x1, y0 ), u - x0 ); 147 | else 148 | return Lerp( 149 | Lerp( bitmap.GetPixel( x0, y0 ), bitmap.GetPixel( x1, y0 ), u - x0 ), 150 | Lerp( bitmap.GetPixel( x0, y1 ), bitmap.GetPixel( x1, y1 ), u - x0 ), 151 | v - y0 ); 152 | } 153 | 154 | public static SKColorF Lerp(SKColorF color, SKColorF other, float blend ) => 155 | Vector4.Lerp(color.AsVector4(), other.AsVector4(), blend).AsColorF(); 156 | 157 | public static float Lerp( float a, float b, float blend ) => 158 | ( a * ( 1.0f - blend ) ) + ( b * blend ); 159 | 160 | public static Vector2 TransformUV( Vector2 uv, Vector4 matrix ) { 161 | var x = Vector2.Dot( uv, new( matrix.X, matrix.Y ) ); 162 | var y = Vector2.Dot( uv, new( matrix.Z, matrix.W ) ); 163 | 164 | return new( x, y ); 165 | } 166 | 167 | // source: character.shpk ps10 168 | public static SKColorF MixNormals(SKColorF normalColor, SKColorF tileColor) { 169 | var normal = normalColor.AsVector4(); 170 | var tileNormal = tileColor.AsVector4(); 171 | 172 | var r1 = new Vector3( tileNormal.X, tileNormal.Y, 0 ); 173 | r1 -= new Vector3( 0.5f, 0.5f, 0 ); 174 | var r0w = r1.LengthSquared(); 175 | r0w = .25f - r0w; 176 | r0w = MathF.Max( 0, r0w ); 177 | r1.Z = MathF.Sqrt( r0w ); 178 | r1 = Vector3.Normalize( r1 ); 179 | 180 | var r2 = new Vector3( normal.X, normal.Y, 0 ); 181 | 182 | var r0x = normal.Z; // * vertex color alpha 183 | r0x = .25f - r0x; 184 | r0x = MathF.Max( 0, r0x ); 185 | r2.Z = MathF.Sqrt( r0x ); 186 | 187 | var r0 = Vector3.Normalize(r2); 188 | 189 | static float Sign(float v) => 0 < v ? 1 : -1; 190 | 191 | r2 = new( Sign( r0.X ), Sign( r0.Y ), Sign( r0.Z ) ); 192 | var r3 = Vector3.One - r2; 193 | r0 = Vector3.One - Vector3.Abs( r0 ); 194 | r1 = r1 + r2; 195 | r0 = r0 * r1 + r3; 196 | r0 = Vector3.Normalize( r0 ); 197 | 198 | return ((r0 + Vector3.One) * .5f).AsColorF(); 199 | } 200 | 201 | public static Vector3 Lerp( Vector3 x, Vector3 y, Vector3 s ) => 202 | x * ( Vector3.One - s ) + y * s; 203 | } -------------------------------------------------------------------------------- /Xande/Havok/HavokConverter.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | using Dalamud.Plugin; 3 | using Dalamud.Utility.Signatures; 4 | using FFXIVClientStructs.Havok; 5 | 6 | // ReSharper disable InconsistentNaming 7 | 8 | namespace Xande.Havok; 9 | 10 | /// 11 | /// Responsible for calling Havok functions in the game binary. Allows you to convert between .hkx (binary) and .xml (text). 12 | /// This class functions internally by calling Havok functions in the game binary - while safety is a priority, it is not guaranteed, being unsafe in nature. 13 | /// This class uses disk I/O and temporary files to convert between .hkx and .xml. This can theoretically be worked around with more research around memory streams in Havok. 14 | /// 15 | public unsafe class HavokConverter { 16 | private delegate hkResource* hkSerializeUtil_LoadDelegate( 17 | char* path, 18 | void* idk, 19 | hkSerializeUtil_LoadOptions* options 20 | ); 21 | 22 | private delegate hkResult* hkSerializeUtil_SaveDelegate( 23 | hkResult* result, 24 | void* obj, 25 | hkClass* klass, 26 | hkStreamWriter* writer, 27 | hkFlags< hkSerializeUtil_SaveOptionBits, uint > flags 28 | ); 29 | 30 | private delegate void hkOstream_CtorDelegate( hkOStream* self, byte* streamWriter ); 31 | 32 | private delegate void hkOstream_DtorDelegate( hkOStream* self ); 33 | 34 | [Signature( 35 | "40 53 48 83 EC 60 41 0F 10 00 48 8B DA 48 8B D1 F2 41 0F 10 48 ?? 48 8D 4C 24 ?? 0F 29 44 24 ?? F2 0F 11 4C 24 ?? E8 ?? ?? ?? ?? 4C 8D 44 24 ?? 48 8B D3 48 8B 48 10 E8 ?? ?? ?? ?? 48 8D 4C 24 ?? 48 8B D8 E8 ?? ?? ?? ?? 48 8B C3 48 83 C4 60 5B C3 CC CC CC CC CC CC CC CC CC CC CC CC CC CC 48 89 5C 24 ??" )] 36 | private readonly hkSerializeUtil_LoadDelegate hkSerializeUtil_Load = null!; 37 | 38 | [Signature( "40 53 48 83 EC 30 8B 44 24 60 48 8B D9 89 44 24 28" )] 39 | private readonly hkSerializeUtil_SaveDelegate hkSerializeUtil_Save = null!; 40 | 41 | [Signature( 42 | "48 89 5C 24 ?? 57 48 83 EC 20 C7 41 ?? ?? ?? ?? ?? 48 8D 05 ?? ?? ?? ?? 48 89 01 48 8B F9 48 C7 41 ?? ?? ?? ?? ?? 4C 8B C2 48 8B 0D ?? ?? ?? ?? 48 8D 54 24 ?? 41 B9 ?? ?? ?? ?? 48 8B 01 FF 50 28", 43 | ScanType = ScanType.Text )] 44 | private readonly hkOstream_CtorDelegate hkOstream_Ctor = null!; 45 | 46 | [Signature( "E8 ?? ?? ?? ?? 44 8B 44 24 ?? 4C 8B 7C 24 ??" )] 47 | private readonly hkOstream_DtorDelegate hkOstream_Dtor = null!; 48 | 49 | [Signature( 50 | "48 8D 0D ?? ?? ?? ?? E8 ?? ?? ?? ?? 48 83 C4 78 C3 CC CC CC 48 83 EC 78 33 C9 C7 44 24 ?? ?? ?? ?? ?? 89 4C 24 60 48 8D 05 ?? ?? ?? ?? 48 89 4C 24 ?? 48 8D 15 ?? ?? ?? ?? 48 89 4C 24 ?? 45 33 C0 C7 44 24 ?? ?? ?? ?? ?? 44 8D 49 18", 51 | ScanType = ScanType.StaticAddress )] 52 | private readonly hkClass* hkRootLevelContainerClass = null; 53 | 54 | [Signature( "48 8B 0D ?? ?? ?? ?? 48 8B 01 FF 50 20 4C 8B 0F 48 8B D3 4C 8B C0 48 8B CF 48 8B 5C 24 ?? 48 83 C4 20 5F 49 FF 61 48", ScanType = ScanType.StaticAddress )] 55 | private readonly hkBuiltinTypeRegistry** hkBuiltinTypeRegistrySingletonPtr = null; 56 | 57 | private readonly hkBuiltinTypeRegistry* hkBuiltinTypeRegistrySingleton; 58 | 59 | /// Thrown if signatures fail to match. Signatures were last checked on game version 2023.03.24.0000.0000. 60 | public HavokConverter( DalamudPluginInterface pi ) { 61 | pi.Create< Service >()!.GameInteropProvider.InitializeFromAttributes( this ); 62 | hkBuiltinTypeRegistrySingleton = *hkBuiltinTypeRegistrySingletonPtr; 63 | } 64 | 65 | /// Creates a temporary file and returns its path. 66 | /// Path to a temporary file. 67 | private string CreateTempFile() { 68 | var s = File.Create( Path.GetTempFileName() ); 69 | s.Close(); 70 | return s.Name; 71 | } 72 | 73 | /// Converts a .hkx file to a .xml file. 74 | /// A byte array representing the .hkx file. 75 | /// A string representing the .xml file. 76 | /// Thrown if parsing the .hkx file fails. 77 | /// Thrown if writing the .xml file fails. 78 | public string HkxToXml( byte[] hkx ) { 79 | var tempHkx = CreateTempFile(); 80 | File.WriteAllBytes( tempHkx, hkx ); 81 | 82 | var resource = Read( tempHkx ); 83 | File.Delete( tempHkx ); 84 | 85 | if( resource == null ) throw new Exceptions.HavokReadException(); 86 | 87 | var options = hkSerializeUtil_SaveOptionBits.SAVE_SERIALIZE_IGNORED_MEMBERS 88 | | hkSerializeUtil_SaveOptionBits.SAVE_TEXT_FORMAT 89 | | hkSerializeUtil_SaveOptionBits.SAVE_WRITE_ATTRIBUTES; 90 | 91 | var file = Write( resource, options ); 92 | file.Close(); 93 | 94 | var bytes = File.ReadAllText( file.Name ); 95 | File.Delete( file.Name ); 96 | 97 | return bytes; 98 | } 99 | 100 | /// Converts a .xml file to a .hkx file. 101 | /// A string representing the .xml file. 102 | /// A byte array representing the .hkx file. 103 | /// Thrown if parsing the .xml file fails. 104 | /// Thrown if writing the .hkx file fails. 105 | public byte[] XmlToHkx( string xml ) { 106 | var tempXml = CreateTempFile(); 107 | File.WriteAllText( tempXml, xml ); 108 | 109 | var resource = Read( tempXml ); 110 | File.Delete( tempXml ); 111 | 112 | if( resource == null ) throw new Exceptions.HavokReadException(); 113 | 114 | var options = hkSerializeUtil_SaveOptionBits.SAVE_SERIALIZE_IGNORED_MEMBERS 115 | | hkSerializeUtil_SaveOptionBits.SAVE_WRITE_ATTRIBUTES; 116 | 117 | var file = Write( resource, options ); 118 | file.Close(); 119 | 120 | var bytes = File.ReadAllBytes( file.Name ); 121 | File.Delete( file.Name ); 122 | 123 | return bytes; 124 | } 125 | 126 | /// 127 | /// Parses a serialized file into an hkResource*. 128 | /// The type is guessed automatically by Havok. 129 | /// This pointer might be null - you should check for that. 130 | /// 131 | /// Path to a file on the filesystem. 132 | /// A (potentially null) pointer to an hkResource. 133 | private hkResource* Read( string filePath ) { 134 | var path = Marshal.StringToHGlobalAnsi( filePath ); 135 | 136 | var loadOptions = stackalloc hkSerializeUtil_LoadOptions[1]; 137 | loadOptions->m_typeInfoReg = 138 | hkBuiltinTypeRegistrySingleton->vtbl->GetTypeInfoRegistry( hkBuiltinTypeRegistrySingleton ); 139 | loadOptions->m_classNameReg = 140 | hkBuiltinTypeRegistrySingleton->vtbl->GetClassNameRegistry( hkBuiltinTypeRegistrySingleton ); 141 | loadOptions->options = new hkEnum< hkSerializeUtil_LoadOptionBits, int >(); 142 | loadOptions->options.Storage = ( int )hkSerializeUtil_LoadOptionBits.LOAD_DEFAULT; 143 | 144 | var resource = hkSerializeUtil_Load( ( char* )path, null, loadOptions ); 145 | return resource; 146 | } 147 | 148 | /// Serializes an hkResource* to a temporary file. 149 | /// A pointer to the hkResource, opened through Read(). 150 | /// Flags representing how to serialize the file. 151 | /// An opened FileStream of a temporary file. You are expected to read the file and delete it. 152 | /// Thrown if accessing the root level container fails. 153 | /// Thrown if an unknown failure in writing occurs. 154 | private FileStream Write( 155 | hkResource* resource, 156 | hkSerializeUtil_SaveOptionBits optionBits 157 | ) { 158 | var oStream = stackalloc hkOStream[1]; 159 | var tempFile = CreateTempFile(); 160 | var path = Marshal.StringToHGlobalAnsi( tempFile ); 161 | hkOstream_Ctor( oStream, ( byte* )path ); 162 | 163 | var result = stackalloc hkResult[1]; 164 | var options = new hkFlags< hkSerializeUtil_SaveOptionBits, uint > { 165 | Storage = ( uint )optionBits, 166 | }; 167 | 168 | try { 169 | var name = @"hkRootLevelContainer"u8; 170 | var resourceVtbl = *( hkResourceVtbl** )resource; 171 | hkRootLevelContainer* resourcePtr; 172 | fixed( byte* n = name ) { 173 | resourcePtr = ( hkRootLevelContainer* )resourceVtbl->getContentsPointer( 174 | resource, n, hkBuiltinTypeRegistrySingleton->vtbl->GetTypeInfoRegistry( hkBuiltinTypeRegistrySingleton ) ); 175 | } 176 | 177 | if( resourcePtr == null ) throw new Exceptions.HavokWriteException(); 178 | 179 | hkSerializeUtil_Save( result, resourcePtr, hkRootLevelContainerClass, oStream->m_writer.ptr, options ); 180 | } finally { hkOstream_Dtor( oStream ); } 181 | 182 | if( result->Result == hkResult.hkResultEnum.Failure ) throw new Exceptions.HavokFailureException(); 183 | 184 | return new FileStream( tempFile, FileMode.Open ); 185 | } 186 | } -------------------------------------------------------------------------------- /Xande/Models/Export/MeshBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.Numerics; 2 | using Lumina.Models.Models; 3 | using SharpGLTF.Geometry; 4 | using SharpGLTF.Geometry.VertexTypes; 5 | using SharpGLTF.Materials; 6 | using Xande.Files; 7 | 8 | namespace Xande.Models.Export; 9 | 10 | public class MeshBuilder { 11 | private readonly Mesh _mesh; 12 | private readonly List< object > _geometryParamCache = new(); 13 | private readonly List< object > _materialParamCache = new(); 14 | private readonly List< (int, float) > _skinningParamCache = new(); 15 | private readonly object[] _vertexBuilderParams = new object[3]; 16 | 17 | private readonly IReadOnlyDictionary< int, int > _jointMap; 18 | private readonly MaterialBuilder _materialBuilder; 19 | private readonly RaceDeformer _raceDeformer; 20 | 21 | private readonly Type _geometryT; 22 | private readonly Type _materialT; 23 | private readonly Type _skinningT; 24 | private readonly Type _vertexBuilderT; 25 | private readonly Type _meshBuilderT; 26 | 27 | private List< PbdFile.Deformer > _deformers = new(); 28 | 29 | private readonly List< IVertexBuilder > _vertices; 30 | 31 | public MeshBuilder( 32 | Mesh mesh, 33 | bool useSkinning, 34 | IReadOnlyDictionary< int, int > jointMap, 35 | MaterialBuilder materialBuilder, 36 | RaceDeformer raceDeformer 37 | ) { 38 | _mesh = mesh; 39 | _jointMap = jointMap; 40 | _materialBuilder = materialBuilder; 41 | _raceDeformer = raceDeformer; 42 | 43 | _geometryT = GetVertexGeometryType( _mesh.Vertices ); 44 | _materialT = GetVertexMaterialType( _mesh.Vertices ); 45 | _skinningT = useSkinning ? typeof( VertexJoints4 ) : typeof( VertexEmpty ); 46 | _vertexBuilderT = typeof( VertexBuilder< ,, > ).MakeGenericType( _geometryT, _materialT, _skinningT ); 47 | _meshBuilderT = typeof( MeshBuilder< ,,, > ).MakeGenericType( typeof( MaterialBuilder ), _geometryT, _materialT, _skinningT ); 48 | _vertices = new List< IVertexBuilder >( _mesh.Vertices.Length ); 49 | } 50 | 51 | /// Calculates the deformation steps from two given races. 52 | /// The current race of the mesh. 53 | /// The target race of the mesh. 54 | public void SetupDeformSteps( ushort from, ushort to ) { 55 | // Nothing to do 56 | if( from == to ) return; 57 | 58 | var deformSteps = new List< ushort >(); 59 | ushort? current = to; 60 | 61 | while( current != null ) { 62 | deformSteps.Add( current.Value ); 63 | current = _raceDeformer.GetParent( current.Value ); 64 | if( current == from ) break; 65 | } 66 | 67 | // Reverse it to the right order 68 | deformSteps.Reverse(); 69 | 70 | // Turn these into deformers 71 | var pbd = _raceDeformer.PbdFile; 72 | var deformers = new PbdFile.Deformer[deformSteps.Count]; 73 | for( var i = 0; i < deformSteps.Count; i++ ) { 74 | var raceCode = deformSteps[ i ]; 75 | var deformer = pbd.GetDeformerFromRaceCode( raceCode ); 76 | deformers[ i ] = deformer; 77 | } 78 | 79 | _deformers = deformers.ToList(); 80 | } 81 | 82 | /// Builds the vertices. This must be called before building meshes. 83 | public void BuildVertices() { 84 | _vertices.Clear(); 85 | _vertices.AddRange( _mesh.Vertices.Select( BuildVertex ) ); 86 | } 87 | 88 | /// Creates a mesh from the given submesh. 89 | public IMeshBuilder< MaterialBuilder > BuildSubmesh( Submesh submesh ) { 90 | var ret = ( IMeshBuilder< MaterialBuilder > )Activator.CreateInstance( _meshBuilderT, string.Empty )!; 91 | var primitive = ret.UsePrimitive( _materialBuilder ); 92 | 93 | for( var triIdx = 0; triIdx < submesh.IndexNum; triIdx += 3 ) { 94 | var triA = _vertices[ _mesh.Indices[ triIdx + ( int )submesh.IndexOffset + 0 ] ]; 95 | var triB = _vertices[ _mesh.Indices[ triIdx + ( int )submesh.IndexOffset + 1 ] ]; 96 | var triC = _vertices[ _mesh.Indices[ triIdx + ( int )submesh.IndexOffset + 2 ] ]; 97 | primitive.AddTriangle( triA, triB, triC ); 98 | } 99 | 100 | return ret; 101 | } 102 | 103 | /// Creates a mesh from the entire mesh. 104 | public IMeshBuilder< MaterialBuilder > BuildMesh() { 105 | var ret = ( IMeshBuilder< MaterialBuilder > )Activator.CreateInstance( _meshBuilderT, string.Empty )!; 106 | var primitive = ret.UsePrimitive( _materialBuilder ); 107 | 108 | for( var triIdx = 0; triIdx < _mesh.Indices.Length; triIdx += 3 ) { 109 | var triA = _vertices[ _mesh.Indices[ triIdx + 0 ] ]; 110 | var triB = _vertices[ _mesh.Indices[ triIdx + 1 ] ]; 111 | var triC = _vertices[ _mesh.Indices[ triIdx + 2 ] ]; 112 | primitive.AddTriangle( triA, triB, triC ); 113 | } 114 | 115 | return ret; 116 | } 117 | 118 | /// Builds shape keys (known as morph targets in glTF). 119 | public void BuildShapes( IReadOnlyList< Shape > shapes, IMeshBuilder< MaterialBuilder > builder, int subMeshStart, int subMeshEnd ) { 120 | var primitive = builder.Primitives.First(); 121 | var triangles = primitive.Triangles; 122 | var vertices = primitive.Vertices; 123 | var vertexList = new List< (IVertexGeometry, IVertexGeometry) >(); 124 | var nameList = new List< Shape >(); 125 | for( var i = 0; i < shapes.Count; ++i ) { 126 | var shape = shapes[ i ]; 127 | vertexList.Clear(); 128 | foreach( var shapeMesh in shape.Meshes.Where( m => m.AssociatedMesh == _mesh ) ) { 129 | foreach( var (baseIdx, otherIdx) in shapeMesh.Values ) { 130 | if( baseIdx < subMeshStart || baseIdx >= subMeshEnd ) continue; // different submesh? 131 | var triIdx = ( baseIdx - subMeshStart ) / 3; 132 | var vertexIdx = ( baseIdx - subMeshStart ) % 3; 133 | 134 | var triA = triangles[ triIdx ]; 135 | var vertexA = vertices[ vertexIdx switch { 136 | 0 => triA.A, 137 | 1 => triA.B, 138 | _ => triA.C, 139 | } ]; 140 | 141 | vertexList.Add( ( vertexA.GetGeometry(), _vertices[ otherIdx ].GetGeometry() ) ); 142 | } 143 | } 144 | 145 | if( vertexList.Count == 0 ) continue; 146 | 147 | var morph = builder.UseMorphTarget( nameList.Count ); 148 | foreach( var (a, b) in vertexList ) { morph.SetVertex( a, b ); } 149 | 150 | nameList.Add( shape ); 151 | } 152 | 153 | var data = new ExtraDataManager(); 154 | data.AddShapeNames( nameList ); 155 | builder.Extras = data.Serialize(); 156 | } 157 | 158 | private IVertexBuilder BuildVertex( Vertex vertex ) { 159 | ClearCaches(); 160 | 161 | var skinningIsEmpty = _skinningT == typeof( VertexEmpty ); 162 | if( !skinningIsEmpty ) { 163 | for( var k = 0; k < 4; k++ ) { 164 | var boneIndex = vertex.BlendIndices[ k ]; 165 | var mappedBoneIndex = _jointMap[ boneIndex ]; 166 | var boneWeight = vertex.BlendWeights != null ? vertex.BlendWeights.Value[ k ] : 0; 167 | 168 | var binding = ( mappedBoneIndex, boneWeight ); 169 | _skinningParamCache.Add( binding ); 170 | } 171 | } 172 | 173 | var origPos = ToVec3( vertex.Position!.Value ); 174 | var currentPos = origPos; 175 | 176 | if( _deformers.Count > 0 ) { 177 | foreach( var deformer in _deformers ) { 178 | var deformedPos = Vector3.Zero; 179 | 180 | foreach( var (idx, weight) in _skinningParamCache ) { 181 | if( weight == 0 ) continue; 182 | 183 | var deformPos = _raceDeformer.DeformVertex( deformer, idx, currentPos ); 184 | if( deformPos != null ) deformedPos += deformPos.Value * weight; 185 | } 186 | 187 | currentPos = deformedPos; 188 | } 189 | } 190 | 191 | _geometryParamCache.Add( currentPos ); 192 | 193 | // Means it's either VertexPositionNormal or VertexPositionNormalTangent; both have Normal 194 | if( _geometryT != typeof( VertexPosition ) ) _geometryParamCache.Add( vertex.Normal!.Value ); 195 | 196 | // Tangent W should be 1 or -1, but sometimes XIV has their -1 as 0? 197 | if( _geometryT == typeof( VertexPositionNormalTangent ) ) { 198 | // ReSharper disable once CompareOfFloatsByEqualityOperator 199 | _geometryParamCache.Add( vertex.Tangent1!.Value with { W = vertex.Tangent1.Value.W == 1 ? 1 : -1 } ); 200 | } 201 | 202 | // AKA: Has "TextureN" component 203 | if( _materialT != typeof( VertexColor1 ) ) _materialParamCache.Add( ToVec2( vertex.UV!.Value ) ); 204 | 205 | // AKA: Has "Color1" component 206 | //if( _materialT != typeof( VertexTexture1 ) ) _materialParamCache.Insert( 0, vertex.Color!.Value ); 207 | if( _materialT != typeof( VertexTexture1 ) ) _materialParamCache.Insert( 0, new Vector4( 255, 255, 255, 255 ) ); 208 | 209 | 210 | _vertexBuilderParams[ 0 ] = Activator.CreateInstance( _geometryT, _geometryParamCache.ToArray() )!; 211 | _vertexBuilderParams[ 1 ] = Activator.CreateInstance( _materialT, _materialParamCache.ToArray() )!; 212 | _vertexBuilderParams[ 2 ] = skinningIsEmpty 213 | ? Activator.CreateInstance( _skinningT )! 214 | : Activator.CreateInstance( _skinningT, _skinningParamCache.ToArray() )!; 215 | 216 | return ( IVertexBuilder )Activator.CreateInstance( _vertexBuilderT, _vertexBuilderParams )!; 217 | } 218 | 219 | private void ClearCaches() { 220 | _geometryParamCache.Clear(); 221 | _materialParamCache.Clear(); 222 | _skinningParamCache.Clear(); 223 | } 224 | 225 | /// Obtain the correct geometry type for a given set of vertices. 226 | private static Type GetVertexGeometryType( Vertex[] vertex ) 227 | => vertex[ 0 ].Tangent1 != null ? typeof( VertexPositionNormalTangent ) : 228 | vertex[ 0 ].Normal != null ? typeof( VertexPositionNormal ) : typeof( VertexPosition ); 229 | 230 | /// Obtain the correct material type for a set of vertices. 231 | private static Type GetVertexMaterialType( Vertex[] vertex ) { 232 | var hasColor = vertex[ 0 ].Color != null; 233 | var hasUv = vertex[ 0 ].UV != null; 234 | 235 | return hasColor switch { 236 | true when hasUv => typeof( VertexColor1Texture1 ), 237 | false when hasUv => typeof( VertexTexture1 ), 238 | _ => typeof( VertexColor1 ), 239 | }; 240 | } 241 | 242 | private static Vector3 ToVec3( Vector4 v ) => new(v.X, v.Y, v.Z); 243 | private static Vector2 ToVec2( Vector4 v ) => new(v.X, v.Y); 244 | } -------------------------------------------------------------------------------- /Xande.GltfImporter/VertexDataBuilder.cs: -------------------------------------------------------------------------------- 1 | using Lumina; 2 | using Lumina.Data.Parsing; 3 | using Lumina.Models.Models; 4 | using SharpGLTF.Schema2; 5 | using System.Numerics; 6 | 7 | namespace Xande.GltfImporter { 8 | internal class VertexDataBuilder { 9 | public Dictionary? BlendIndicesDict = null; 10 | private Dictionary> ShapesAccessor = new(); 11 | public IReadOnlyList? Bitangents = null; 12 | public List<(List pos, float weight)>? AppliedShapePositions; 13 | public List<(List nor, float weight)>? AppliedShapeNormals; 14 | public bool ApplyShapes = true; 15 | 16 | private List? _positions = null; 17 | private List? _blendWeights = null; 18 | private List? _blendIndices = null; 19 | private List? _normals = null; 20 | private List? _texCoords1 = null; 21 | private List? _texCoords2 = null; 22 | private List? _tangent1 = null; 23 | private List? _colors = null; 24 | 25 | private ILogger? _logger; 26 | 27 | private MdlStructs.VertexDeclarationStruct _vertexDeclaration; 28 | 29 | public VertexDataBuilder( MeshPrimitive primitive, MdlStructs.VertexDeclarationStruct vertexDeclaration, ILogger? logger = null ) { 30 | _logger = logger; 31 | _vertexDeclaration = vertexDeclaration; 32 | _positions = primitive.GetVertexAccessor( "POSITION" )?.AsVector3Array().ToList(); 33 | _blendWeights = primitive.GetVertexAccessor( "WEIGHTS_0" )?.AsVector4Array().ToList(); 34 | _blendIndices = primitive.GetVertexAccessor( "JOINTS_0" )?.AsVector4Array().ToList(); 35 | _normals = primitive.GetVertexAccessor( "NORMAL" )?.AsVector3Array().ToList(); 36 | _texCoords1 = primitive.GetVertexAccessor( "TEXCOORD_0" )?.AsVector2Array().ToList(); 37 | _texCoords2 = primitive.GetVertexAccessor( "TEXCOORD_1" )?.AsVector2Array().ToList(); 38 | _tangent1 = primitive.GetVertexAccessor( "TANGENT" )?.AsVector4Array().ToList(); 39 | _colors = primitive.GetVertexAccessor( "COLOR_0" )?.AsVector4Array().ToList(); 40 | } 41 | 42 | public void SetBitangents( IReadOnlyList values ) { 43 | Bitangents = values; 44 | /* 45 | var bitans = _primitive.GetVertexAccessor( "BITANS" )?.AsVector4Array(); 46 | if( bitans == null ) { 47 | _primitive = _primitive.WithVertexAccessor( "BITANS", values ); 48 | } 49 | */ 50 | } 51 | 52 | public void AddShape( string shapeName, IReadOnlyDictionary accessor ) { 53 | ShapesAccessor.Add( shapeName, accessor ); 54 | } 55 | 56 | public Dictionary> GetVertexData() { 57 | var streams = new Dictionary>(); 58 | for( var vertexId = 0; vertexId < _positions?.Count; vertexId++ ) { 59 | foreach( var ve in _vertexDeclaration.VertexElements ) { 60 | if( ve.Stream == 255 ) break; 61 | if( !streams.ContainsKey( ve.Stream ) ) { 62 | streams.Add( ve.Stream, new List() ); 63 | } 64 | 65 | streams[ve.Stream].AddRange( GetVertexData( vertexId, ve ) ); 66 | } 67 | } 68 | 69 | return streams; 70 | } 71 | 72 | public Dictionary> GetShapeVertexData( List diffVertices, string? shapeName = null ) { 73 | var streams = new Dictionary>(); 74 | if( ShapesAccessor == null ) { 75 | _logger?.Error( $"Shape accessor was null" ); 76 | } 77 | 78 | foreach( var vertexId in diffVertices ) { 79 | foreach( var ve in _vertexDeclaration.VertexElements ) { 80 | if( ve.Stream == 255 ) { break; } 81 | if( !streams.ContainsKey( ve.Stream ) ) { 82 | streams.Add( ve.Stream, new List() ); 83 | } 84 | 85 | streams[ve.Stream].AddRange( GetVertexData( vertexId, ve, shapeName ) ); 86 | } 87 | } 88 | 89 | return streams; 90 | } 91 | 92 | private List GetVertexData( int index, MdlStructs.VertexElement ve, string? shapeName = null ) { 93 | return GetBytes( GetVector4( index, ( Vertex.VertexUsage )ve.Usage, shapeName ), ( Vertex.VertexType )ve.Type ); 94 | } 95 | 96 | private IList GetShapePositions( string shapeName ) { 97 | return ShapesAccessor[shapeName]["POSITION"].AsVector3Array(); 98 | } 99 | 100 | private IList GetShapeNormals( string shapeName ) { 101 | return ShapesAccessor[shapeName]["NORMAL"].AsVector3Array(); 102 | } 103 | 104 | private Vector4 GetVector4( int index, Vertex.VertexUsage usage, string? shapeName = null ) { 105 | var vector4 = new Vector4( 0, 0, 0, 0 ); 106 | switch( usage ) { 107 | case Vertex.VertexUsage.Position: 108 | vector4 = new Vector4( _positions[index], 0 ); 109 | if( shapeName != null ) { 110 | var shapePositions = GetShapePositions( shapeName ); 111 | vector4 += new Vector4( shapePositions[index], 0 ); 112 | } 113 | if( ApplyShapes && AppliedShapePositions != null ) { 114 | foreach( var appliedShape in AppliedShapePositions ) { 115 | var list = appliedShape.pos; 116 | var weight = appliedShape.weight; 117 | 118 | if( list.Count > index ) { 119 | vector4 += new Vector4( list[index] * weight, 0 ); 120 | } 121 | } 122 | } 123 | break; 124 | case Vertex.VertexUsage.BlendWeights: 125 | vector4 = _blendWeights?[index] ?? vector4; 126 | break; 127 | case Vertex.VertexUsage.BlendIndices: 128 | if( _blendIndices != null ) { 129 | vector4 = _blendIndices[index]; 130 | if( BlendIndicesDict != null ) { 131 | for( var i = 0; i < 4; i++ ) { 132 | if( BlendIndicesDict.ContainsKey( ( int )_blendIndices[index][i] ) ) { 133 | vector4[i] = BlendIndicesDict[( int )_blendIndices[index][i]]; 134 | } 135 | } 136 | } 137 | } 138 | break; 139 | case Vertex.VertexUsage.Normal: 140 | if( _normals != null ) { 141 | vector4 = new Vector4( _normals[index], 0 ); 142 | if( shapeName != null ) { 143 | var shapeNormals = GetShapeNormals( shapeName ); 144 | vector4 += new Vector4( shapeNormals[index], 0 ); 145 | } 146 | if( ApplyShapes && AppliedShapeNormals != null ) { 147 | foreach( var appliedShape in AppliedShapeNormals ) { 148 | var list = appliedShape.nor; 149 | var weight = appliedShape.weight; 150 | 151 | if( list.Count > index ) { 152 | vector4 += new Vector4( list[index] * weight, 0 ); 153 | } 154 | } 155 | } 156 | } 157 | else { 158 | _logger?.Error( $"normals were null" ); 159 | vector4 = new( 1, 1, 1, 1 ); 160 | } 161 | break; 162 | case Vertex.VertexUsage.UV: 163 | if( _texCoords1 != null ) { 164 | if( _texCoords2 != null ) { 165 | vector4 = new Vector4( _texCoords1[index].X, _texCoords1[index].Y, _texCoords2[index].X, _texCoords2[index].Y ); 166 | } 167 | else { 168 | vector4 = new Vector4( _texCoords1[index], -1, 2 ); 169 | } 170 | } 171 | else { 172 | _logger?.Error( $"tex coordinates were null" ); 173 | } 174 | break; 175 | case Vertex.VertexUsage.Tangent2: 176 | break; 177 | case Vertex.VertexUsage.Tangent1: 178 | //vector4 = _tangent1?[index] ?? vector4; 179 | if( Bitangents != null && Bitangents.Count > index ) { 180 | 181 | // I don't know why this math sorta works 182 | // The values seem "close enough" (where I think the difference is due to floating point arithmetic) 183 | var vec = new Vector3( Bitangents[index].X, Bitangents[index].Y, Bitangents[index].Z ); 184 | vec = Vector3.Normalize( vec ); 185 | //vector4 = Bitangents?[index] + new Vector4(1, 1, 1, 0) ?? vector4; // maybe?? 186 | //vector4 = Bitangents[index]; 187 | var val = ( Vector3.One - vec ) / 2; 188 | 189 | vector4 = new Vector4( val, Bitangents[index].W > 0 ? 0 : 1 ); 190 | 191 | } 192 | else { 193 | vector4 = new Vector4( 1, 1, 1, 1 ); 194 | } 195 | break; 196 | case Vertex.VertexUsage.Color: 197 | if( _colors != null ) { 198 | vector4 = _colors[index]; 199 | } 200 | else { 201 | vector4 = new( 1, 1, 1, 1 ); 202 | } 203 | break; 204 | } 205 | return vector4; 206 | } 207 | 208 | private static List GetBytes( Vector4 vector4, Vertex.VertexType type ) { 209 | var ret = new List(); 210 | switch( type ) { 211 | case Vertex.VertexType.Single3: 212 | ret.AddRange( BitConverter.GetBytes( vector4.X ) ); 213 | ret.AddRange( BitConverter.GetBytes( vector4.Y ) ); 214 | ret.AddRange( BitConverter.GetBytes( vector4.Z ) ); 215 | break; 216 | case Vertex.VertexType.Single4: 217 | ret.AddRange( BitConverter.GetBytes( vector4.X ) ); 218 | ret.AddRange( BitConverter.GetBytes( vector4.Y ) ); 219 | ret.AddRange( BitConverter.GetBytes( vector4.Z ) ); 220 | ret.AddRange( BitConverter.GetBytes( vector4.W ) ); 221 | break; 222 | case Vertex.VertexType.UInt: 223 | ret.Add( ( byte )vector4.X ); 224 | ret.Add( ( byte )vector4.Y ); 225 | ret.Add( ( byte )vector4.Z ); 226 | ret.Add( ( byte )vector4.W ); 227 | break; 228 | case Vertex.VertexType.ByteFloat4: 229 | ret.Add( ( byte )Math.Round( vector4.X * 255f ) ); 230 | ret.Add( ( byte )Math.Round( vector4.Y * 255f ) ); 231 | ret.Add( ( byte )Math.Round( vector4.Z * 255f ) ); 232 | ret.Add( ( byte )Math.Round( vector4.W * 255f ) ); 233 | break; 234 | case Vertex.VertexType.Half2: 235 | ret.AddRange( BitConverter.GetBytes( ( Half )vector4.X ) ); 236 | ret.AddRange( BitConverter.GetBytes( ( Half )vector4.Y ) ); 237 | break; 238 | case Vertex.VertexType.Half4: 239 | ret.AddRange( BitConverter.GetBytes( ( Half )vector4.X ) ); 240 | ret.AddRange( BitConverter.GetBytes( ( Half )vector4.Y ) ); 241 | ret.AddRange( BitConverter.GetBytes( ( Half )vector4.Z ) ); 242 | ret.AddRange( BitConverter.GetBytes( ( Half )vector4.W ) ); 243 | break; 244 | } 245 | return ret; 246 | } 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /Xande.GltfImporter/MdlFileWriter.cs: -------------------------------------------------------------------------------- 1 | using Lumina; 2 | using Lumina.Data.Files; 3 | using Lumina.Data.Parsing; 4 | using System.Collections; 5 | 6 | namespace Xande.GltfImporter { 7 | public class MdlFileWriter : IDisposable { 8 | private MdlFile _file; 9 | private BinaryWriter _w; 10 | private ILogger? _logger; 11 | 12 | public MdlFileWriter( MdlFile file, Stream stream, ILogger? logger = null ) { 13 | _logger = logger; 14 | _file = file; 15 | _w = new BinaryWriter( stream ); 16 | } 17 | 18 | public void WriteAll( IEnumerable vertexData, IEnumerable indexData ) { 19 | WriteFileHeader( _file.FileHeader ); 20 | WriteVertexDeclarations( _file.VertexDeclarations ); 21 | 22 | _w.Write( _file.StringCount ); 23 | _w.Write( ( ushort )0 ); 24 | _w.Write( ( uint )_file.Strings.Length ); 25 | _w.Write( _file.Strings ); 26 | 27 | WriteModelHeader( _file.ModelHeader ); 28 | WriteElementIds( _file.ElementIds ); 29 | WriteLods( _file.Lods ); 30 | 31 | if( _file.ModelHeader.ExtraLodEnabled ) { 32 | WriteExtraLods( _file.ExtraLods ); 33 | } 34 | 35 | WriteMeshStructs( _file.Meshes ); 36 | 37 | for( var i = 0; i < _file.AttributeNameOffsets.Length; i++ ) { 38 | _w.Write( _file.AttributeNameOffsets[i] ); 39 | } 40 | WriteTerrainShadowMeshes( _file.TerrainShadowMeshes ); 41 | WriteSubmeshStructs( _file.Submeshes ); 42 | WriteTerrainShadowSubmeshes( _file.TerrainShadowSubmeshes ); 43 | 44 | for( var i = 0; i < _file.MaterialNameOffsets.Length; i++ ) { 45 | _w.Write( _file.MaterialNameOffsets[i] ); 46 | } 47 | 48 | for( var i = 0; i < _file.BoneNameOffsets.Length; i++ ) { 49 | _w.Write( _file.BoneNameOffsets[i] ); 50 | } 51 | 52 | WriteBoneTableStructs( _file.BoneTables ); 53 | WriteShapeStructs( _file.Shapes ); 54 | WriteShapeMeshStructs( _file.ShapeMeshes ); 55 | WriteShapeValueStructs( _file.ShapeValues ); 56 | var submeshBoneMapSize = _file.SubmeshBoneMap.Length * 2; 57 | _w.Write( ( uint )submeshBoneMapSize ); 58 | foreach( var val in _file.SubmeshBoneMap ) { 59 | _w.Write( val ); 60 | }; 61 | 62 | _w.Write( ( byte )7 ); 63 | _w.Seek( 7, SeekOrigin.Current ); 64 | 65 | WriteBoundingBoxStructs( _file.BoundingBoxes ); 66 | WriteBoundingBoxStructs( _file.ModelBoundingBoxes ); 67 | WriteBoundingBoxStructs( _file.WaterBoundingBoxes ); 68 | WriteBoundingBoxStructs( _file.VerticalFogBoundingBoxes ); 69 | foreach( var boneBoundingBox in _file.BoneBoundingBoxes ) { 70 | WriteBoundingBoxStructs( boneBoundingBox ); 71 | } 72 | 73 | _w.Write( vertexData.ToArray() ); 74 | _w.Write( indexData.ToArray() ); 75 | 76 | _logger?.Debug( "Finished writing" ); 77 | } 78 | 79 | private void WriteFileHeader( MdlStructs.ModelFileHeader modelFileHeader ) { 80 | _w.Write( modelFileHeader.Version ); 81 | _w.Write( modelFileHeader.StackSize ); 82 | _w.Write( modelFileHeader.RuntimeSize ); 83 | _w.Write( modelFileHeader.VertexDeclarationCount ); 84 | _w.Write( modelFileHeader.MaterialCount ); 85 | for( var i = 0; i < 3; i++ ) { 86 | _w.Write( modelFileHeader.VertexOffset[i] ); 87 | } 88 | for( var i = 0; i < 3; i++ ) { 89 | _w.Write( modelFileHeader.IndexOffset[i] ); 90 | } 91 | for( var i = 0; i < 3; i++ ) { 92 | _w.Write( modelFileHeader.VertexBufferSize[i] ); 93 | } 94 | for( var i = 0; i < 3; i++ ) { 95 | _w.Write( modelFileHeader.IndexBufferSize[i] ); 96 | } 97 | _w.Write( modelFileHeader.LodCount ); 98 | _w.Write( modelFileHeader.EnableIndexBufferStreaming ); 99 | _w.Write( modelFileHeader.EnableEdgeGeometry ); 100 | _w.Write( ( byte )0 ); 101 | } 102 | 103 | private void WriteVertexDeclarations( MdlStructs.VertexDeclarationStruct[] declarations ) { 104 | foreach( var declaration in declarations ) { 105 | foreach( var vertexElement in declaration.VertexElements ) { 106 | _w.Write( vertexElement.Stream ); 107 | _w.Write( vertexElement.Offset ); 108 | _w.Write( vertexElement.Type ); 109 | _w.Write( vertexElement.Usage ); 110 | _w.Write( vertexElement.UsageIndex ); 111 | _w.Seek( 3, SeekOrigin.Current ); 112 | } 113 | } 114 | } 115 | 116 | private void WriteModelHeader( MdlStructs.ModelHeader modelHeader ) { 117 | _w.Write( modelHeader.Radius ); 118 | _w.Write( modelHeader.MeshCount ); 119 | _w.Write( modelHeader.AttributeCount ); 120 | _w.Write( modelHeader.SubmeshCount ); 121 | _w.Write( modelHeader.MaterialCount ); 122 | _w.Write( modelHeader.BoneCount ); 123 | _w.Write( modelHeader.BoneTableCount ); 124 | _w.Write( modelHeader.ShapeCount ); 125 | _w.Write( modelHeader.ShapeMeshCount ); 126 | _w.Write( modelHeader.ShapeValueCount ); 127 | _w.Write( modelHeader.LodCount ); 128 | 129 | // Flags are private, so we need to do this - ugly 130 | var flags1 = new BitArray( new bool[] 131 | { 132 | modelHeader.DustOcclusionEnabled, 133 | modelHeader.SnowOcclusionEnabled, 134 | modelHeader.RainOcclusionEnabled, 135 | modelHeader.Unknown1, 136 | modelHeader.BgLightingReflectionEnabled, 137 | //modelHeader.WavingAnimationDisabled, 138 | true, 139 | modelHeader.LightShadowDisabled, 140 | modelHeader.ShadowDisabled 141 | }.Reverse().ToArray() 142 | ); 143 | 144 | var flags1Byte = new byte[1]; 145 | flags1.CopyTo( flags1Byte, 0 ); 146 | _w.Write( flags1Byte[0] ); 147 | 148 | _w.Write( modelHeader.ElementIdCount ); 149 | _w.Write( modelHeader.TerrainShadowMeshCount ); 150 | 151 | var flags2 = new BitArray( new bool[] { 152 | modelHeader.Unknown2, 153 | modelHeader.BgUvScrollEnabled, 154 | modelHeader.EnableForceNonResident, 155 | modelHeader.ExtraLodEnabled, 156 | modelHeader.ShadowMaskEnabled, 157 | modelHeader.ForceLodRangeEnabled, 158 | modelHeader.EdgeGeometryEnabled, 159 | modelHeader.Unknown3 160 | } ); 161 | var flags2Byte = new byte[1]; 162 | flags2.CopyTo( flags2Byte, 0 ); 163 | _w.Write( flags2Byte[0] ); 164 | 165 | _w.Write( modelHeader.ModelClipOutDistance ); 166 | _w.Write( modelHeader.ShadowClipOutDistance ); 167 | _w.Write( modelHeader.Unknown4 ); 168 | _w.Write( modelHeader.TerrainShadowSubmeshCount ); 169 | _w.Write( ( byte )0 ); // ??? why is that private in lumina 170 | _w.Write( modelHeader.BGChangeMaterialIndex ); 171 | _w.Write( modelHeader.BGCrestChangeMaterialIndex ); 172 | _w.Write( modelHeader.Unknown6 ); 173 | _w.Write( modelHeader.Unknown7 ); 174 | _w.Write( modelHeader.Unknown8 ); 175 | _w.Write( modelHeader.Unknown9 ); 176 | _w.Seek( 6, SeekOrigin.Current ); 177 | } 178 | 179 | private void WriteElementIds( MdlStructs.ElementIdStruct[] elements ) { 180 | foreach( var e in elements ) { 181 | _w.Write( e.ElementId ); 182 | _w.Write( e.ParentBoneName ); 183 | for( var i = 0; i < 3; i++ ) { 184 | _w.Write( e.Translate[i] ); 185 | } 186 | for( var i = 0; i < 3; i++ ) { 187 | _w.Write( e.Rotate[i] ); 188 | } 189 | } 190 | } 191 | 192 | private void WriteLods( MdlStructs.LodStruct[] lods ) { 193 | for( var i = 0; i < 3; i++ ) { 194 | var lod = lods[i]; 195 | 196 | _w.Write( lod.MeshIndex ); 197 | _w.Write( lod.MeshCount ); 198 | _w.Write( lod.ModelLodRange ); 199 | _w.Write( lod.TextureLodRange ); 200 | _w.Write( lod.WaterMeshIndex ); 201 | _w.Write( lod.WaterMeshCount ); 202 | _w.Write( lod.ShadowMeshIndex ); 203 | _w.Write( lod.ShadowMeshCount ); 204 | _w.Write( lod.TerrainShadowMeshIndex ); 205 | _w.Write( lod.TerrainShadowMeshCount ); 206 | _w.Write( lod.VerticalFogMeshIndex ); 207 | _w.Write( lod.VerticalFogMeshCount ); 208 | 209 | _w.Write( lod.EdgeGeometrySize ); 210 | _w.Write( lod.EdgeGeometryDataOffset ); 211 | _w.Write( lod.PolygonCount ); 212 | _w.Write( lod.Unknown1 ); 213 | _w.Write( lod.VertexBufferSize ); 214 | _w.Write( lod.IndexBufferSize ); 215 | _w.Write( lod.VertexDataOffset ); 216 | _w.Write( lod.IndexDataOffset ); 217 | } 218 | } 219 | 220 | private void WriteExtraLods( MdlStructs.ExtraLodStruct[] lods ) { 221 | foreach( var lod in lods ) { 222 | _w.Write( lod.LightShaftMeshIndex ); 223 | _w.Write( lod.LightShaftMeshCount ); 224 | _w.Write( lod.GlassMeshIndex ); 225 | _w.Write( lod.GlassMeshCount ); 226 | _w.Write( lod.MaterialChangeMeshIndex ); 227 | _w.Write( lod.MaterialChangeMeshCount ); 228 | _w.Write( lod.CrestChangeMeshIndex ); 229 | _w.Write( lod.CrestChangeMeshCount ); 230 | _w.Write( lod.Unknown1 ); 231 | _w.Write( lod.Unknown2 ); 232 | _w.Write( lod.Unknown3 ); 233 | _w.Write( lod.Unknown4 ); 234 | _w.Write( lod.Unknown5 ); 235 | _w.Write( lod.Unknown6 ); 236 | _w.Write( lod.Unknown7 ); 237 | _w.Write( lod.Unknown8 ); 238 | _w.Write( lod.Unknown9 ); 239 | _w.Write( lod.Unknown10 ); 240 | _w.Write( lod.Unknown11 ); 241 | _w.Write( lod.Unknown12 ); 242 | } 243 | } 244 | 245 | private void WriteMeshStructs( MdlStructs.MeshStruct[] meshes ) { 246 | foreach( var mesh in meshes ) { 247 | _w.Write( mesh.VertexCount ); 248 | _w.Write( ( ushort )0 ); // mesh.Padding 249 | _w.Write( mesh.IndexCount ); 250 | _w.Write( mesh.MaterialIndex ); 251 | _w.Write( mesh.SubMeshIndex ); 252 | _w.Write( mesh.SubMeshCount ); 253 | _w.Write( mesh.BoneTableIndex ); 254 | _w.Write( mesh.StartIndex ); 255 | for( var i = 0; i < 3; i++ ) { 256 | _w.Write( mesh.VertexBufferOffset[i] ); 257 | } 258 | _w.Write( mesh.VertexBufferStride ); 259 | _w.Write( mesh.VertexStreamCount ); 260 | } 261 | } 262 | 263 | private void WriteTerrainShadowMeshes( MdlStructs.TerrainShadowMeshStruct[] meshes ) { 264 | foreach( var mesh in meshes ) { 265 | _w.Write( mesh.IndexCount ); 266 | _w.Write( mesh.StartIndex ); 267 | _w.Write( mesh.VertexBufferOffset ); 268 | _w.Write( mesh.VertexCount ); 269 | _w.Write( mesh.SubMeshIndex ); 270 | _w.Write( mesh.SubMeshCount ); 271 | _w.Write( mesh.VertexBufferStride ); 272 | _w.Write( ( byte )0 ); // Padding 273 | } 274 | } 275 | 276 | private void WriteSubmeshStructs( MdlStructs.SubmeshStruct[] submeshes ) { 277 | foreach( var submesh in submeshes ) { 278 | _w.Write( submesh.IndexOffset ); 279 | _w.Write( submesh.IndexCount ); 280 | _w.Write( submesh.AttributeIndexMask ); 281 | _w.Write( submesh.BoneStartIndex ); 282 | _w.Write( submesh.BoneCount ); 283 | } 284 | } 285 | 286 | private void WriteTerrainShadowSubmeshes( MdlStructs.TerrainShadowSubmeshStruct[] submeshes ) { 287 | foreach( var submesh in submeshes ) { 288 | _w.Write( submesh.IndexOffset ); 289 | _w.Write( submesh.IndexCount ); 290 | _w.Write( submesh.Unknown1 ); 291 | _w.Write( submesh.Unknown2 ); 292 | } 293 | } 294 | 295 | private void WriteBoneTableStructs( MdlStructs.BoneTableStruct[] bonetables ) { 296 | foreach( var boneTable in bonetables ) { 297 | for( var i = 0; i < 64; i++ ) { 298 | _w.Write( boneTable.BoneIndex[i] ); 299 | } 300 | _w.Write( boneTable.BoneCount ); 301 | _w.Seek( 3, SeekOrigin.Current ); 302 | } 303 | } 304 | 305 | private void WriteShapeStructs( MdlStructs.ShapeStruct[] shapeStructs ) { 306 | foreach( var shapeStruct in shapeStructs ) { 307 | _w.Write( shapeStruct.StringOffset ); 308 | for( var i = 0; i < 3; i++ ) { 309 | _w.Write( shapeStruct.ShapeMeshStartIndex[i] ); 310 | } 311 | for( var i = 0; i < 3; i++ ) { 312 | _w.Write( shapeStruct.ShapeMeshCount[i] ); 313 | } 314 | } 315 | } 316 | 317 | private void WriteShapeMeshStructs( MdlStructs.ShapeMeshStruct[] shapeMeshStructs ) { 318 | foreach( var shapeMeshStruct in shapeMeshStructs ) { 319 | _w.Write( shapeMeshStruct.MeshIndexOffset ); 320 | _w.Write( shapeMeshStruct.ShapeValueCount ); 321 | _w.Write( shapeMeshStruct.ShapeValueOffset ); 322 | } 323 | } 324 | 325 | private void WriteShapeValueStructs( MdlStructs.ShapeValueStruct[] shapeValueStructs ) { 326 | foreach( var shapeValueStruct in shapeValueStructs ) { 327 | _w.Write( shapeValueStruct.BaseIndicesIndex ); 328 | _w.Write( shapeValueStruct.ReplacingVertexIndex ); 329 | } 330 | } 331 | 332 | private void WriteBoundingBoxStructs( MdlStructs.BoundingBoxStruct bb ) { 333 | for( var i = 0; i < 4; i++ ) { 334 | _w.Write( bb.Min[i] ); 335 | } 336 | for( var i = 0; i < 4; i++ ) { 337 | _w.Write( bb.Max[i] ); 338 | } 339 | } 340 | 341 | public void Dispose() { 342 | _w.Dispose(); 343 | } 344 | } 345 | } 346 | -------------------------------------------------------------------------------- /Xande.GltfImporter/SubmeshBuilder.cs: -------------------------------------------------------------------------------- 1 | using Lumina; 2 | using Lumina.Data.Parsing; 3 | using SharpGLTF.Schema2; 4 | using System.Numerics; 5 | using System.Text.Json.Nodes; 6 | 7 | using Mesh = SharpGLTF.Schema2.Mesh; 8 | 9 | 10 | namespace Xande.GltfImporter { 11 | internal class SubmeshBuilder { 12 | public uint IndexCount => ( uint )Indices.LongCount(); 13 | public List Indices = new(); 14 | public int BoneCount { get; } = 0; 15 | public List Attributes = new(); 16 | public List Shapes => _shapeBuilders.Keys.ToList(); 17 | private Dictionary _shapeBuilders = new(); 18 | private Mesh _mesh; 19 | public string MaterialPath = String.Empty; 20 | private string _material = String.Empty; 21 | 22 | public Vector4 MinBoundingBox = new( 9999f, 9999f, 9999f, -9999f ); 23 | public Vector4 MaxBoundingBox = new( -9999f, -9999f, -9999f, 9999f ); 24 | 25 | // Does not include shape vertices 26 | private int _vertexCount = 0; 27 | 28 | public Dictionary OriginalBoneIndexToStrings = new(); 29 | public List<(List, float)> AppliedShapes = new(); 30 | public List<(List, float)> AppliedShapesNormals = new(); 31 | 32 | private List? _bitangents = null; 33 | private ILogger? _logger; 34 | private VertexDataBuilder VertexDataBuilder; 35 | 36 | /// 37 | /// 38 | /// 39 | /// 40 | /// 41 | public SubmeshBuilder( Mesh mesh, List skeleton, MdlStructs.VertexDeclarationStruct vertexDeclarationStruct, ILogger? logger = null ) { 42 | _logger = logger; 43 | _mesh = mesh; 44 | if( _mesh.Primitives.Count == 0 ) { 45 | _logger?.Error( $"Submesh had zero primitives" ); 46 | } 47 | if( _mesh.Primitives.Count > 1 ) { 48 | _logger?.Warning( $"Submesh had more than one primitive." ); 49 | } 50 | 51 | var primitive = _mesh.Primitives[0]; 52 | //foreach( var primitive in _mesh.Primitives ) { 53 | VertexDataBuilder = new( primitive, vertexDeclarationStruct, _logger ); 54 | Indices.AddRange( primitive.GetIndices() ); 55 | 56 | var blendIndices = primitive.GetVertexAccessor( "JOINTS_0" )?.AsVector4Array(); 57 | var positions = primitive.GetVertexAccessor( "POSITION" )?.AsVector3Array(); 58 | var material = primitive.Material?.Name; 59 | 60 | if( String.IsNullOrEmpty( material ) ) { 61 | // TODO: Figure out what to do in this case 62 | // Have a Model as an argument and take the first material from that? 63 | _logger?.Error( "Submesh had null material name" ); 64 | } 65 | else { 66 | if( String.IsNullOrEmpty( MaterialPath ) ) { 67 | _material = material; 68 | //MaterialPath = AdjustMaterialPath( _material ); 69 | MaterialPath = _material; 70 | } 71 | else { 72 | if( material != _material || material != MaterialPath ) { 73 | _logger?.Error( $"Found more than one material name. Original: \"{MaterialPath}\" vs \"{material}\"" ); 74 | } 75 | } 76 | } 77 | 78 | if( positions != null ) { 79 | _vertexCount += positions.Count; 80 | 81 | foreach( var pos in positions ) { 82 | MinBoundingBox.X = MinBoundingBox.X < pos.X ? MinBoundingBox.X : pos.X; 83 | MinBoundingBox.Y = MinBoundingBox.Y < pos.Y ? MinBoundingBox.Y : pos.Y; 84 | MinBoundingBox.Z = MinBoundingBox.Z < pos.Z ? MinBoundingBox.Z : pos.Z; 85 | 86 | MaxBoundingBox.X = MaxBoundingBox.X > pos.X ? MaxBoundingBox.X : pos.X; 87 | MaxBoundingBox.Y = MaxBoundingBox.Y > pos.Y ? MaxBoundingBox.Y : pos.Y; 88 | MaxBoundingBox.Z = MaxBoundingBox.Z > pos.Z ? MaxBoundingBox.Z : pos.Z; 89 | } 90 | } 91 | else { 92 | _logger?.Error( "This submesh had no positions." ); 93 | } 94 | 95 | var includeNHara = skeleton.Where( x => x.StartsWith( "n_hara" ) ).Count() > 1; 96 | 97 | if( blendIndices != null ) { 98 | foreach( var blendIndex in blendIndices ) { 99 | for( var i = 0; i < 4; i++ ) { 100 | var index = ( int )blendIndex[i]; 101 | if( !OriginalBoneIndexToStrings.ContainsKey( index ) && index < skeleton.Count && 102 | skeleton[index] != "n_root" && ( skeleton[index] != "n_hara" || includeNHara ) ) { 103 | OriginalBoneIndexToStrings.Add( index, skeleton[index] ); 104 | } 105 | } 106 | } 107 | } 108 | 109 | var shapeWeights = mesh.GetMorphWeights(); 110 | try { 111 | var json = mesh.Extras.Content; 112 | if( json != null ) { 113 | var jsonNode = JsonNode.Parse( mesh.Extras.ToJson() ); 114 | if( jsonNode != null ) { 115 | var names = jsonNode["targetNames"]?.AsArray(); 116 | if( names != null && names.Any() ) { 117 | for( var i = 0; i < names.Count; i++ ) { 118 | var shapeName = names[i]?.ToString(); 119 | if( shapeName == null ) { continue; } 120 | 121 | if( shapeName.StartsWith( "shp_" ) ) { 122 | _shapeBuilders[shapeName] = new ShapeBuilder( shapeName, primitive, i, vertexDeclarationStruct, _logger ); 123 | VertexDataBuilder.AddShape( shapeName, primitive.GetMorphTargetAccessors( i ) ); 124 | } 125 | else if( shapeName.StartsWith( "atr_" ) && !Attributes.Contains( shapeName ) ) { 126 | Attributes.Add( shapeName ); 127 | } 128 | else { 129 | var shapeWeight = shapeWeights[i]; 130 | if( shapeWeight == 0 ) { continue; } 131 | 132 | var target = primitive.GetMorphTargetAccessors( i ); 133 | if( target == null ) { continue; } 134 | target.TryGetValue( "POSITION", out var shapeAccessor ); 135 | target.TryGetValue( "NORMAL", out var shapeNormalAccessor ); 136 | var appliedPositions = shapeAccessor?.AsVector3Array(); 137 | var appliedNormalPositions = shapeNormalAccessor?.AsVector3Array(); 138 | 139 | if( appliedPositions != null && appliedPositions.Any() && appliedPositions.Where( x => x != Vector3.Zero ).Any() ) { 140 | _logger?.Debug( $"AppliedShape: {shapeName} with weight {shapeWeight}" ); 141 | AppliedShapes.Add( (appliedPositions.ToList(), shapeWeight) ); 142 | } 143 | if( appliedNormalPositions != null && appliedNormalPositions.Any() && appliedNormalPositions.Where( x => x != Vector3.Zero ).Any() ) { 144 | // Unsure if this actually matters 145 | AppliedShapesNormals.Add( (appliedNormalPositions.ToList(), shapeWeight) ); 146 | } 147 | } 148 | } 149 | } 150 | } 151 | } 152 | else { 153 | _logger?.Debug( "Mesh contained no extras." ); 154 | } 155 | } 156 | catch( Exception ex ) { 157 | _logger?.Error( "Could not add shapes." ); 158 | _logger?.Error( ex.ToString() ); 159 | } 160 | //} 161 | VertexDataBuilder.AppliedShapePositions = AppliedShapes; 162 | VertexDataBuilder.AppliedShapeNormals = AppliedShapesNormals; 163 | 164 | BoneCount = OriginalBoneIndexToStrings.Keys.Count; 165 | } 166 | 167 | public void SetBlendIndicesDict( Dictionary dict ) { 168 | VertexDataBuilder.BlendIndicesDict = dict; 169 | } 170 | 171 | public List GetSubmeshBoneMap( List bones ) { 172 | var ret = new List(); 173 | foreach( var val in OriginalBoneIndexToStrings.Values ) { 174 | var index = bones.IndexOf( val ); 175 | ret.Add( ( ushort )index ); 176 | } 177 | return ret; 178 | } 179 | 180 | public int GetVertexCount( bool includeShapes = false, List? strings = null ) { 181 | var ret = _vertexCount; 182 | if( includeShapes ) { 183 | foreach( var shapeName in _shapeBuilders.Keys ) { 184 | if( strings == null || strings.Contains( shapeName ) ) { 185 | ret += _shapeBuilders[shapeName].VertexCount; 186 | } 187 | } 188 | } 189 | return ret; 190 | } 191 | 192 | public int GetShapeVertexCount( string str ) { 193 | foreach( var shapeName in _shapeBuilders.Keys ) { 194 | if( str == shapeName ) { 195 | return _shapeBuilders[str].VertexCount; 196 | } 197 | } 198 | return 0; 199 | } 200 | 201 | public uint GetAttributeIndexMask( List attributes ) { 202 | var ret = 0; 203 | for( var i = 0; i < attributes.Count; i++ ) { 204 | if( Attributes.Contains( attributes[i] ) ) { 205 | ret += ( 1 << i ); 206 | } 207 | } 208 | return ( uint )ret; 209 | } 210 | 211 | /// 212 | /// 213 | /// 214 | /// The value to add to all index values 215 | /// 216 | public List GetIndexData( int indexOffset = 0 ) { 217 | var ret = new List(); 218 | foreach( var index in Indices ) { 219 | ret.AddRange( BitConverter.GetBytes( ( ushort )( index + indexOffset ) ) ); 220 | } 221 | return ret; 222 | } 223 | 224 | public static string AdjustMaterialPath( string mat ) { 225 | var ret = mat; 226 | // TODO: More nuanced method for adjusting the material path 227 | // furniture paths are the entire path 228 | if( !mat.StartsWith( "/" ) ) { 229 | ret = "/" + ret; 230 | } 231 | if( !mat.EndsWith( ".mtrl" ) ) { 232 | ret += ".mtrl"; 233 | } 234 | return ret; 235 | } 236 | 237 | public bool AddAttribute( string name ) { 238 | if( !Attributes.Contains( name ) ) { 239 | Attributes.Add( name ); 240 | return true; 241 | } 242 | return false; 243 | } 244 | 245 | // TODO: Do we actually need to calculate these values? 246 | public void CalculateBitangents( bool forceRecalculate = false ) { 247 | if( _bitangents != null && !forceRecalculate ) { 248 | return; 249 | } 250 | 251 | try { 252 | var tris = _mesh.EvaluateTriangles(); 253 | var indices = _mesh.Primitives[0].GetIndices(); 254 | _bitangents = new List(); 255 | var positions = _mesh.Primitives[0].GetVertexAccessor( "POSITION" )?.AsVector3Array(); 256 | var uvs = _mesh.Primitives[0].GetVertexAccessor( "TEXCOORD_0" )?.AsVector2Array().ToList(); 257 | var normals = _mesh.Primitives[0].GetVertexAccessor( "NORMAL" )?.AsVector3Array().ToList(); 258 | var colors = _mesh.Primitives[0].GetVertexAccessor( "COLOR_0" )?.AsVector4Array().ToList(); 259 | 260 | var binormalDict = new SortedDictionary(); 261 | var tangentDict = new SortedDictionary(); 262 | 263 | // https://github.com/TexTools/xivModdingFramework/blob/f8d442688e61851a90646e309b868783c47122be/xivModdingFramework/Models/Helpers/ModelModifiers.cs#L1575 264 | var connectedVertices = new Dictionary>(); 265 | for( var i = 0; i < indices.Count; i += 3 ) { 266 | var t0 = ( int )indices[i]; 267 | var t1 = ( int )indices[i + 1]; 268 | var t2 = ( int )indices[i + 2]; 269 | 270 | if( !connectedVertices.ContainsKey( t0 ) ) { 271 | connectedVertices.Add( t0, new HashSet() ); 272 | } 273 | if( !connectedVertices.ContainsKey( t1 ) ) { 274 | connectedVertices.Add( t1, new HashSet() ); 275 | } 276 | if( !connectedVertices.ContainsKey( t2 ) ) { 277 | connectedVertices.Add( t2, new HashSet() ); 278 | } 279 | 280 | connectedVertices[t0].Add( t1 ); 281 | connectedVertices[t0].Add( t2 ); 282 | connectedVertices[t1].Add( t0 ); 283 | connectedVertices[t1].Add( t2 ); 284 | connectedVertices[t2].Add( t0 ); 285 | connectedVertices[t2].Add( t1 ); 286 | } 287 | 288 | var vertTranslation = new Dictionary(); 289 | var weldedVerts = new Dictionary>(); 290 | var tempVertices = new List(); 291 | 292 | for( var oIdx = 0; oIdx < positions?.Count; oIdx++ ) { 293 | var idx = -1; 294 | for( var nIdx = 0; nIdx < tempVertices.Count; nIdx++ ) { 295 | if( positions[nIdx] == positions[oIdx] 296 | && uvs?[nIdx] == uvs?[oIdx] 297 | && normals?[nIdx] == normals?[oIdx] 298 | && colors?[nIdx] != colors?[oIdx] ) { 299 | var alreadyMergedVerts = weldedVerts[nIdx]; 300 | var alreadyConnectedOldVerts = new HashSet(); 301 | foreach( var amIdx in alreadyMergedVerts ) { 302 | foreach( var cv in connectedVertices[amIdx] ) { 303 | alreadyConnectedOldVerts.Add( cv ); 304 | } 305 | } 306 | 307 | var myConnectedVerts = connectedVertices[oIdx]; 308 | var isMirror = false; 309 | foreach( var weldedConnection in alreadyConnectedOldVerts ) { 310 | foreach( var newConnection in myConnectedVerts ) { 311 | if( uvs[newConnection] == uvs[weldedConnection] && 312 | positions[newConnection] != positions[weldedConnection] ) { 313 | isMirror = true; 314 | break; 315 | } 316 | } 317 | if( isMirror ) { 318 | break; 319 | } 320 | } 321 | 322 | if( !isMirror ) { 323 | idx = nIdx; 324 | break; 325 | } 326 | } 327 | } 328 | if( idx == -1 ) { 329 | tempVertices.Add( oIdx ); 330 | idx = tempVertices.Count - 1; 331 | weldedVerts.Add( idx, new List() ); 332 | } 333 | 334 | weldedVerts[idx].Add( oIdx ); 335 | vertTranslation.Add( oIdx, idx ); 336 | } 337 | 338 | var tempIndices = new List(); 339 | for( var i = 0; i < indices.Count; i++ ) { 340 | var oldVert = indices[i]; 341 | var newVert = vertTranslation[( int )oldVert]; 342 | tempIndices.Add( newVert ); 343 | } 344 | 345 | var tangents = new List( tempVertices.Count ); 346 | tangents.AddRange( Enumerable.Repeat( Vector3.Zero, tempVertices.Count ) ); 347 | var bitangents = new List( tempVertices.Count ); 348 | bitangents.AddRange( Enumerable.Repeat( Vector3.Zero, tempVertices.Count ) ); 349 | 350 | for( var a = 0; a < tempIndices.Count; a += 3 ) { 351 | // applied shapes? 352 | var vertexId1 = tempIndices[a]; 353 | var vertexId2 = tempIndices[a + 1]; 354 | var vertexId3 = tempIndices[a + 2]; 355 | 356 | var vertex1 = tempVertices[vertexId1]; 357 | var vertex2 = tempVertices[vertexId2]; 358 | var vertex3 = tempVertices[vertexId3]; 359 | 360 | var deltaX1 = positions[vertex2].X - positions[vertex1].X; 361 | var deltaX2 = positions[vertex3].X - positions[vertex1].X; 362 | 363 | var deltaY1 = positions[vertex2].Y - positions[vertex1].Y; 364 | var deltaY2 = positions[vertex3].Y - positions[vertex1].Y; 365 | 366 | var deltaZ1 = positions[vertex2].Z - positions[vertex1].Z; 367 | var deltaZ2 = positions[vertex3].Z - positions[vertex1].Z; 368 | 369 | var deltaU1 = uvs[vertex2].X - uvs[vertex1].X; 370 | var deltaU2 = uvs[vertex3].X - uvs[vertex1].X; 371 | 372 | var deltaV1 = uvs[vertex2].Y - uvs[vertex1].Y; 373 | var deltaV2 = uvs[vertex3].Y - uvs[vertex1].Y; 374 | 375 | var r = 1.0f / ( deltaU1 * deltaV2 - deltaU2 * deltaV1 ); 376 | var sdir = new Vector3( ( deltaV2 * deltaX1 - deltaV1 * deltaX2 ) * r, ( deltaV2 * deltaY1 - deltaV1 * deltaY2 ) * r, ( deltaV2 * deltaZ1 - deltaV1 * deltaZ2 ) * r ); 377 | var tdir = new Vector3( ( deltaU1 * deltaX2 - deltaU2 * deltaX1 ) * r, ( deltaU1 * deltaY2 - deltaU2 * deltaY1 ) * r, ( deltaU1 * deltaZ2 - deltaU2 * deltaZ1 ) * r ); 378 | 379 | tangents[vertexId1] += sdir; 380 | tangents[vertexId2] += sdir; 381 | tangents[vertexId3] += sdir; 382 | 383 | bitangents[vertexId1] += tdir; 384 | bitangents[vertexId2] += tdir; 385 | bitangents[vertexId3] += tdir; 386 | } 387 | 388 | for( var vertexId = 0; vertexId < tempVertices.Count; vertexId++ ) { 389 | var vertex = tempVertices[vertexId]; 390 | var oVertices = vertTranslation.Where( x => x.Value == vertexId ).Select( x => x.Key ).ToList(); 391 | 392 | var n = normals[vertex]; 393 | var t = tangents[vertexId]; 394 | var b = bitangents[vertexId]; 395 | 396 | var tangent = t - ( n * Vector3.Dot( n, t ) ); 397 | tangent = Vector3.Normalize( tangent ); 398 | 399 | var binormal = Vector3.Cross( n, tangent ); 400 | binormal = Vector3.Normalize( binormal ); 401 | var handedness = Vector3.Dot( Vector3.Cross( t, b ), n ) > 0 ? 1 : -1; 402 | binormal *= handedness; 403 | 404 | _bitangents.Add( new Vector4( binormal, handedness ) ); 405 | 406 | foreach( var vIdx in oVertices ) { 407 | if( !binormalDict.ContainsKey( vIdx ) ) { 408 | binormalDict.Add( vIdx, new Vector4( binormal, handedness ) ); 409 | } 410 | if( !tangentDict.ContainsKey( vIdx ) ) { 411 | tangentDict.Add( vIdx, tangent ); 412 | } 413 | } 414 | 415 | } 416 | } 417 | catch( Exception ex ) { 418 | _logger?.Error( $"Could not calculate bitangents. {ex}" ); 419 | } 420 | } 421 | 422 | public Dictionary> GetVertexData() { 423 | CalculateBitangents(); 424 | //VertexDataBuilder.Bitangents = _bitangents; 425 | VertexDataBuilder.SetBitangents( _bitangents ); 426 | return VertexDataBuilder.GetVertexData(); 427 | } 428 | 429 | public IDictionary>> GetShapeVertexData( List? strings = null ) { 430 | var ret = new Dictionary>>(); 431 | 432 | foreach( var shapeName in _shapeBuilders.Keys ) { 433 | if( strings == null || strings.Contains( shapeName ) ) { 434 | ret.Add( shapeName, VertexDataBuilder.GetShapeVertexData( _shapeBuilders[shapeName].DifferentVertices, shapeName ) ); 435 | } 436 | } 437 | return ret; 438 | } 439 | 440 | public Dictionary> GetShapeVertexData( string shapeName ) { 441 | if( _shapeBuilders.ContainsKey( shapeName ) ) { 442 | return VertexDataBuilder.GetShapeVertexData( _shapeBuilders[shapeName].DifferentVertices ); 443 | } 444 | return new Dictionary>(); 445 | 446 | } 447 | 448 | public List GetShapeValues( string str ) { 449 | if( _shapeBuilders.ContainsKey( str ) ) { 450 | return _shapeBuilders[str].ShapeValues; 451 | } 452 | return new List(); 453 | } 454 | } 455 | } 456 | -------------------------------------------------------------------------------- /Xande.TestPlugin/Windows/MainWindow.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Numerics; 3 | using System.Reflection; 4 | using Dalamud.Interface; 5 | using Dalamud.Interface.ImGuiFileDialog; 6 | using Dalamud.Interface.Windowing; 7 | using Dalamud.Logging; 8 | using ImGuiNET; 9 | using Lumina.Data; 10 | using Xande.Files; 11 | using Xande.Havok; 12 | using Xande.Models; 13 | using Xande.Models.Export; 14 | 15 | namespace Xande.TestPlugin.Windows; 16 | 17 | public class MainWindow : Window, IDisposable { 18 | private readonly FileDialogManager _fileDialogManager; 19 | private readonly HavokConverter _converter; 20 | private readonly LuminaManager _luminaManager; 21 | private readonly ModelConverter _modelConverter; 22 | private readonly SklbResolver _sklbResolver; 23 | 24 | private const string SklbFilter = "FFXIV Skeleton{.sklb}"; 25 | private const string PbdFilter = "FFXIV Bone Deformer{.pbd}"; 26 | private const string HkxFilter = "Havok Packed File{.hkx}"; 27 | private const string XmlFilter = "Havok XML File{.xml}"; 28 | private const string GltfFilter = "glTF 2.0 File{.gltf,.glb}"; 29 | 30 | enum ExportStatus { 31 | Idle, 32 | ParsingSkeletons, 33 | ExportingModel, 34 | Done, 35 | Error 36 | } 37 | 38 | private ExportStatus _exportStatus = ExportStatus.Idle; 39 | 40 | private string _modelPaths = "chara/monster/m0405/obj/body/b0002/model/m0405b0002.mdl"; 41 | private string _skeletonPaths = string.Empty; 42 | private string _gltfPath = string.Empty; 43 | private string _outputMdlPath = string.Empty; 44 | private string _inputMdl = string.Empty; 45 | private int _deform = 0; 46 | private ExportModelType _exportModelType = ExportModelType.UNMODDED; 47 | 48 | public MainWindow() : base( "Xande.TestPlugin" ) { 49 | _fileDialogManager = new FileDialogManager(); 50 | _converter = new HavokConverter( Service.PluginInterface ); 51 | if( GetQuickAccessFolders( out var folders ) ) { 52 | foreach( var folder in folders ) { 53 | _fileDialogManager.CustomSideBarItems.Add( 54 | ( folder.Name, 55 | folder.Path, 56 | FontAwesomeIcon.Folder, 57 | -1 58 | ) ); 59 | } 60 | } 61 | 62 | SizeConstraints = new WindowSizeConstraints { 63 | MinimumSize = new Vector2( 375, 350 ), 64 | MaximumSize = new Vector2( 1000, 500 ), 65 | }; 66 | 67 | _luminaManager = new LuminaManager( origPath => Plugin.Configuration.ResolverOverrides.TryGetValue( origPath, out var newPath ) ? newPath : null ); 68 | _modelConverter = new ModelConverter( _luminaManager, new PenumbraIPCPathResolver( Service.PluginInterface ), new DalamudLogger() ); 69 | _sklbResolver = new SklbResolver( Service.PluginInterface ); 70 | IsOpen = Plugin.Configuration.AutoOpen; 71 | } 72 | 73 | public void Dispose() { } 74 | 75 | public override void Draw() { 76 | _fileDialogManager.Draw(); 77 | 78 | ImGui.BeginTabBar( "Xande.TestPlugin" ); 79 | if( ImGui.BeginTabItem( "Main" ) ) { 80 | DrawStatus(); 81 | DrawMainTab(); 82 | ImGui.EndTabItem(); 83 | } 84 | 85 | if( ImGui.BeginTabItem( "Paths" ) ) { 86 | DrawStatus(); 87 | DrawPathsTab(); 88 | ImGui.EndTabItem(); 89 | } 90 | 91 | if( ImGui.BeginTabItem( "Import .gltf" ) ) { 92 | DrawStatus(); 93 | DrawImportTab(); 94 | ImGui.EndTabItem(); 95 | } 96 | 97 | if( ImGui.BeginTabItem( "Export .mdl" ) ) { 98 | DrawStatus(); 99 | DrawExportTab(); 100 | ImGui.EndTabItem(); 101 | } 102 | 103 | ImGui.EndTabBar(); 104 | } 105 | 106 | // Thank you Otter: https://github.com/Ottermandias/OtterGui/blob/main/Functions.cs#L186 107 | public static bool GetQuickAccessFolders( out List< (string Name, string Path) > folders ) { 108 | folders = new List< (string Name, string Path) >(); 109 | try { 110 | var shellAppType = Type.GetTypeFromProgID( "Shell.Application" ); 111 | if( shellAppType == null ) 112 | return false; 113 | 114 | var shell = Activator.CreateInstance( shellAppType ); 115 | 116 | var obj = shellAppType.InvokeMember( "NameSpace", BindingFlags.InvokeMethod, null, shell, new object[] { 117 | "shell:::{679f85cb-0220-4080-b29b-5540cc05aab6}", 118 | } ); 119 | if( obj == null ) 120 | return false; 121 | 122 | 123 | foreach( var fi in ( ( dynamic )obj ).Items() ) { 124 | if( !fi.IsLink && !fi.IsFolder ) 125 | continue; 126 | 127 | folders.Add( ( fi.Name, fi.Path ) ); 128 | } 129 | 130 | return true; 131 | } catch { return false; } 132 | } 133 | 134 | private void DrawImportTab() { 135 | var cra = ImGui.GetContentRegionAvail(); 136 | var textSize = cra with { Y = cra.Y / 2 - 20 }; 137 | 138 | if( ImGui.Button( "Browse .gltf" ) ) { OpenFileDialog( "Select gltf file", GltfFilter, path => { _gltfPath = path; } ); } 139 | ImGui.SameLine(); 140 | ImGui.InputText( "gltf file", ref _gltfPath, 1024 ); 141 | 142 | ImGui.InputText( "original .mdl (optional)", ref _modelPaths, 1024 ); 143 | 144 | if( ImGui.Button( "Convert" ) ) { 145 | if( !File.Exists( _gltfPath ) ) { 146 | PluginLog.Error( $"gltf file does not exist: {_gltfPath}" ); 147 | return; 148 | } 149 | if( !_luminaManager.GameData.FileExists( _modelPaths ) ) { 150 | PluginLog.Error( $"Original mdl file does not exist: {_modelPaths}" ); 151 | //return; 152 | } 153 | if( _exportStatus != ExportStatus.ExportingModel ) { 154 | Task.Run( async () => { 155 | try { 156 | _exportStatus = ExportStatus.ExportingModel; 157 | var data = _modelConverter.ImportModel( _gltfPath, _modelPaths ); 158 | SaveFileDialog( "Save .mdl", "FFXIV Mdl{.mdl}", "model.mdl", ".mdl", path2 => { 159 | PluginLog.Debug( $"Writing file to: {path2}" ); 160 | File.WriteAllBytes( path2, data ); 161 | Process.Start( "explorer.exe", Path.GetDirectoryName( path2 ) ); 162 | } ); 163 | } catch( Exception ex ) { PluginLog.Error( $"Model could not be imported.\n{ex}" ); } finally { _exportStatus = ExportStatus.Idle; } 164 | } ); 165 | } 166 | } 167 | } 168 | 169 | private void DrawExportTab() { 170 | var cra = ImGui.GetContentRegionAvail(); 171 | var textSize = cra with { Y = cra.Y / 2 - 20 }; 172 | 173 | ImGui.Text( $"Model file" ); 174 | ImGui.InputTextMultiline( ".mdl file", ref _inputMdl, 1024 * 4, textSize ); 175 | ImGui.Text( $".sklb file(s)" ); 176 | ImGui.InputTextMultiline( ".sklb file", ref _skeletonPaths, 1024 * 4, textSize ); 177 | 178 | if( ImGui.Button( "Export .gltf" ) ) { 179 | if( !string.IsNullOrWhiteSpace( _skeletonPaths ) ) { 180 | Task.Run( async () => { 181 | /* 182 | var skel = _sklbResolver.Resolve( _inputMdl ); 183 | if( skel != null && !_skeletonPaths.Contains( skel ) ) { 184 | PluginLog.Debug( $"Adding {skel}" ); 185 | _skeletonPaths += $"\n {skel}"; 186 | } 187 | */ 188 | var tempDir = await DoTheThingWithTheModels( _inputMdl.Trim().Split( '\n' ), _skeletonPaths.Trim().Split( '\n' ) ); 189 | Process.Start( "explorer.exe", tempDir ); 190 | } ); 191 | } else { 192 | Task.Run( async () => { 193 | var s = _sklbResolver.Resolve( _inputMdl ); 194 | 195 | PluginLog.Debug( $"got: {s}" ); 196 | 197 | var tempDir = await DoTheThingWithTheModels( _inputMdl.Trim().Split( '\n' ), new string[] { s } ); 198 | Process.Start( "explorer.exe", tempDir ); 199 | } ); 200 | } 201 | } 202 | ImGui.SameLine(); 203 | /* 204 | Currently, some model parsing will be needed before we can export modded models 205 | if( ImGui.BeginCombo( "ExportType", $"{_exportModelType}" ) ) { 206 | if( ImGui.Selectable( $"{ExportModelType.UNMODDED}" ) ) { 207 | _exportModelType = ExportModelType.UNMODDED; 208 | } 209 | if( ImGui.Selectable( $"{ExportModelType.DEFAULT}" ) ) { 210 | _exportModelType = ExportModelType.DEFAULT; 211 | } 212 | if( ImGui.Selectable( $"{ExportModelType.CHARACTER}" ) ) { 213 | _exportModelType = ExportModelType.CHARACTER; 214 | } 215 | ImGui.EndCombo(); 216 | } 217 | */ 218 | } 219 | 220 | private void DrawStatus() { 221 | var status = _exportStatus switch { 222 | ExportStatus.Idle => "Idle", 223 | ExportStatus.ParsingSkeletons => "Parsing skeletons", 224 | ExportStatus.ExportingModel => "Exporting model", 225 | ExportStatus.Done => "Done", 226 | ExportStatus.Error => "Error exporting model", 227 | _ => "" 228 | }; 229 | 230 | ImGui.Text( $"Export status: {status}" ); 231 | ImGui.Separator(); 232 | } 233 | 234 | private void DrawMainTab() { 235 | DrawModel(); 236 | ImGui.Separator(); 237 | 238 | DrawParseExport(); 239 | ImGui.Separator(); 240 | 241 | DrawConvert(); 242 | } 243 | 244 | private Task< string > DoTheThingWithTheModels( string[] models, string[] skeletons, ushort? deform = null, ExportModelType type = ExportModelType.UNMODDED ) { 245 | var tempDir = Path.Combine( Path.GetTempPath(), "Xande.TestPlugin" ); 246 | Directory.CreateDirectory( tempDir ); 247 | 248 | var tempPath = Path.Combine( tempDir, $"model-{DateTime.Now:yyyy-MM-dd-HH-mm-ss}" ); 249 | Directory.CreateDirectory( tempPath ); 250 | 251 | return Service.Framework.RunOnTick( () => { 252 | _exportStatus = ExportStatus.ParsingSkeletons; 253 | var skellies = skeletons.Select( path => { 254 | var file = _luminaManager.GetFile< FileResource >( path ); 255 | var sklb = SklbFile.FromStream( file.Reader.BaseStream ); 256 | var xml = _converter.HkxToXml( sklb.HkxData ); 257 | return new HavokXml( xml ); 258 | } ).ToArray(); 259 | 260 | return Task.Run( () => { 261 | _exportStatus = ExportStatus.ExportingModel; 262 | 263 | try { 264 | //_modelConverter.ExportModel( tempPath, models, skellies, deform, _exportModelType ); 265 | _modelConverter.ExportModel( tempPath, models, skellies, deform, type ); 266 | PluginLog.Information( "Exported model to {0}", tempPath ); 267 | _exportStatus = ExportStatus.Done; 268 | } catch( Exception e ) { 269 | PluginLog.Error( e, "Failed to export model" ); 270 | _exportStatus = ExportStatus.Error; 271 | } 272 | 273 | return tempPath; 274 | } ); 275 | } ); 276 | } 277 | 278 | private Task< string > DoTheThingWithTheModels( string[] models, string? baseModel = null, ushort? deform = null, ExportModelType type = ExportModelType.UNMODDED ) { 279 | var skeletons = _sklbResolver.ResolveAll( models ); 280 | if( baseModel != null ) 281 | skeletons = skeletons.Prepend( baseModel ).ToArray(); 282 | return DoTheThingWithTheModels( models, skeletons, deform: deform, type: type ); 283 | } 284 | 285 | private void OpenFileDialog( string title, string filters, Action< string > callback ) { 286 | _fileDialogManager.OpenFileDialog( title, filters, ( result, path ) => { 287 | if( !result ) return; 288 | Service.Framework.RunOnTick( () => { callback( path ); } ); 289 | } ); 290 | } 291 | 292 | private void SaveFileDialog( string title, string filters, string defaultFileName, string defaultExtension, Action< string > callback ) { 293 | _fileDialogManager.SaveFileDialog( title, filters, defaultFileName, defaultExtension, ( result, path ) => { 294 | if( !result ) return; 295 | Service.Framework.RunOnTick( () => { callback( path ); } ); 296 | } ); 297 | } 298 | 299 | private void DrawModel() { 300 | if( ImGui.Button( "Model (full body)" ) ) { 301 | DoTheThingWithTheModels( 302 | new[] { 303 | "chara/human/c0101/obj/face/f0002/model/c0101f0002_fac.mdl", 304 | "chara/human/c0101/obj/body/b0001/model/c0101b0001_top.mdl", 305 | "chara/human/c0101/obj/body/b0001/model/c0101b0001_glv.mdl", 306 | "chara/human/c0101/obj/body/b0001/model/c0101b0001_dwn.mdl", 307 | "chara/human/c0101/obj/body/b0001/model/c0101b0001_sho.mdl" 308 | }, 309 | _sklbResolver.ResolveHumanBase( 1 ) 310 | ); 311 | } 312 | 313 | ImGui.SameLine(); 314 | 315 | if( ImGui.Button( "Model (chair)" ) ) { 316 | DoTheThingWithTheModels( 317 | new[] { 318 | "bg/ffxiv/sea_s1/twn/s1ta/bgparts/s1ta_ga_char1.mdl" 319 | }, 320 | new string[] { } 321 | ); 322 | } 323 | 324 | ImGui.SameLine(); 325 | 326 | if( ImGui.Button( "Model (grebuloff)" ) ) { 327 | DoTheThingWithTheModels( 328 | new[] { 329 | "chara/monster/m0405/obj/body/b0002/model/m0405b0002.mdl" 330 | } 331 | ); 332 | } 333 | 334 | ImGui.SameLine(); 335 | 336 | if( ImGui.Button( "Model (miqote face)" ) ) { 337 | DoTheThingWithTheModels( 338 | new[] { 339 | "chara/human/c1801/obj/face/f0004/model/c1801f0004_fac.mdl" 340 | }, 341 | _sklbResolver.ResolveHumanBase( 8, 1 ) 342 | ); 343 | } 344 | 345 | if( ImGui.Button( "Model (jules)" ) ) { 346 | DoTheThingWithTheModels( 347 | new[] { 348 | "chara/human/c0801/obj/face/f0102/model/c0801f0102_fac.mdl", 349 | "chara/human/c0801/obj/hair/h0008/model/c0801h0008_hir.mdl", 350 | "chara/human/c0801/obj/tail/t0002/model/c0801t0002_til.mdl", 351 | "chara/equipment/e0287/model/c0201e0287_top.mdl", 352 | "chara/equipment/e6024/model/c0201e6024_dwn.mdl", 353 | "chara/equipment/e6090/model/c0201e6090_sho.mdl", 354 | "chara/equipment/e0227/model/c0101e0227_glv.mdl" 355 | }, 356 | new[] { 357 | "chara/human/c0801/skeleton/base/b0001/skl_c0801b0001.sklb", 358 | "chara/human/c0801/skeleton/face/f0002/skl_c0801f0002.sklb", 359 | "chara/human/c0801/skeleton/hair/h0009/skl_c0801h0009.sklb", 360 | "chara/human/c0801/skeleton/hair/h0008/skl_c0801h0008.sklb" 361 | }, 362 | deform: 801 363 | ); 364 | } 365 | 366 | ImGui.SameLine(); 367 | 368 | if( ImGui.Button( "Model (Xande)" ) ) { 369 | DoTheThingWithTheModels( 370 | new[] { 371 | "chara/monster/m0127/obj/body/b0001/model/m0127b0001.mdl", 372 | "chara/monster/m0127/obj/body/b0001/model/m0127b0001_top.mdl" 373 | }, 374 | new[] { 375 | "chara/monster/m0127/skeleton/base/b0001/skl_m0127b0001.sklb" 376 | } ); 377 | } 378 | 379 | ImGui.SameLine(); 380 | if( ImGui.Button( "Model (Gloves)" ) ) { 381 | DoTheThingWithTheModels( new[] { "chara/equipment/e0180/model/c0201e0180_glv.mdl" }, new string[] { "chara/human/c0201/skeleton/base/b0001/skl_c0201b0001.sklb" } ); 382 | } 383 | 384 | ImGui.Separator(); 385 | 386 | if( ImGui.Button( "Model export & import test" ) ) { 387 | Task.Run( async () => { 388 | var model = "chara/equipment/e6111/model/c0201e6111_sho.mdl"; 389 | var skellies = new[] { "chara/human/c0801/skeleton/base/b0001/skl_c0801b0001.sklb" }; 390 | 391 | var tempDir = await DoTheThingWithTheModels( new[] { model }, skellies ); 392 | var file = Path.Combine( tempDir, "mesh.glb" ); 393 | PluginLog.Log( "Importing model..." ); 394 | var bytes = _modelConverter.ImportModel( file, model ); 395 | File.WriteAllBytes( Path.Combine( tempDir, "mesh.mdl" ), bytes ); 396 | PluginLog.Log( "Imported rountrip to {Dir}", tempDir ); 397 | } ); 398 | } 399 | } 400 | 401 | private void DrawParseExport() { 402 | if( ImGui.Button( "Parse .sklb" ) ) { 403 | var file = _luminaManager.GameData.GetFile( "chara/human/c0101/skeleton/base/b0001/skl_c0101b0001.sklb" )!; 404 | var sklb = SklbFile.FromStream( file.Reader.BaseStream ); 405 | 406 | var headerHex = BitConverter.ToString( sklb.RawHeader ).Replace( "-", " " ); 407 | var hkxHex = BitConverter.ToString( sklb.HkxData ).Replace( "-", " " ); 408 | PluginLog.Debug( "Header (len {0}): {1}", sklb.RawHeader.Length, headerHex ); 409 | PluginLog.Debug( "HKX data (len {0}): {1}", sklb.HkxData.Length, hkxHex ); 410 | } 411 | 412 | ImGui.SameLine(); 413 | 414 | if( ImGui.Button( "Parse .pbd" ) ) { 415 | var pbd = _luminaManager.GameData.GetFile< PbdFile >( "chara/xls/boneDeformer/human.pbd" )!; 416 | 417 | PluginLog.Debug( "Header count: {0}", pbd.Headers.Length ); 418 | for( var i = 0; i < pbd.Headers.Length; i++ ) { 419 | var header = pbd.Headers[ i ]; 420 | PluginLog.Debug( "\tHeader {0} - ID: {1}, offset: {2}", i, header.Id, header.Offset ); 421 | } 422 | 423 | PluginLog.Debug( "Deformer count: {0}", pbd.Deformers.Length ); 424 | for( var i = 0; i < pbd.Deformers.Length; i++ ) { 425 | var (offset, deformer) = pbd.Deformers[ i ]; 426 | PluginLog.Debug( "\tDeformer {0} (offset {1}) - bone count: {2}", i, offset, deformer.BoneCount ); 427 | for( var j = 0; j < deformer.BoneCount; j++ ) { 428 | PluginLog.Debug( "\t\tBone {0} - name: {1}, deform matrix: {2}", j, deformer.BoneNames[ j ], deformer.DeformMatrices[ j ] ); 429 | } 430 | } 431 | } 432 | 433 | if( ImGui.Button( "Export .sklb" ) ) { 434 | var file = _luminaManager.GameData.GetFile( "chara/human/c0101/skeleton/base/b0001/skl_c0101b0001.sklb" )!; 435 | SaveFileDialog( "Save SKLB file", SklbFilter, "skl_c0101b0001.sklb", ".sklb", path => { File.WriteAllBytes( path, file.Data ); } ); 436 | } 437 | 438 | ImGui.SameLine(); 439 | 440 | if( ImGui.Button( "Export .pbd" ) ) { 441 | var file = _luminaManager.GameData.GetFile( "chara/xls/boneDeformer/human.pbd" )!; 442 | SaveFileDialog( "Save PBD file", PbdFilter, "human.pbd", ".pbd", path => { File.WriteAllBytes( path, file.Data ); } ); 443 | } 444 | } 445 | 446 | private void DrawConvert() { 447 | if( ImGui.Button( "SKLB->HKX" ) ) { 448 | OpenFileDialog( "Select SKLB file", SklbFilter, path => { 449 | var sklbData = File.ReadAllBytes( path ); 450 | var sklb = SklbFile.FromStream( new MemoryStream( sklbData ) ); 451 | var outputName = Path.GetFileNameWithoutExtension( path ) + ".hkx"; 452 | SaveFileDialog( "Save HKX file", HkxFilter, outputName, ".hkx", path2 => { File.WriteAllBytes( path2, sklb.HkxData ); } ); 453 | } ); 454 | } 455 | 456 | ImGui.SameLine(); 457 | 458 | if( ImGui.Button( "SKLB->XML" ) ) { 459 | OpenFileDialog( "Select SKLB file", SklbFilter, path => { 460 | var sklbData = File.ReadAllBytes( path ); 461 | var readStream = new MemoryStream( sklbData ); 462 | var sklb = SklbFile.FromStream( readStream ); 463 | var xml = _converter.HkxToXml( sklb.HkxData ); 464 | var outputName = Path.GetFileNameWithoutExtension( path ) + ".xml"; 465 | SaveFileDialog( "Save XML file", XmlFilter, outputName, ".xml", path2 => { File.WriteAllText( path2, xml ); } ); 466 | } ); 467 | } 468 | 469 | if( ImGui.Button( "HKX->XML" ) ) { 470 | OpenFileDialog( "Select HKX file", HkxFilter, path => { 471 | var hkx = File.ReadAllBytes( path ); 472 | var xml = _converter.HkxToXml( hkx ); 473 | var outputName = Path.GetFileNameWithoutExtension( path ) + ".xml"; 474 | SaveFileDialog( "Save XML file", XmlFilter, outputName, ".xml", path2 => { File.WriteAllText( path2, xml ); } ); 475 | } ); 476 | } 477 | 478 | if( ImGui.Button( "XML->HKX" ) ) { 479 | OpenFileDialog( "Select XML file", XmlFilter, path => { 480 | var xml = File.ReadAllText( path ); 481 | var hkx = _converter.XmlToHkx( xml ); 482 | var outputName = Path.GetFileNameWithoutExtension( path ) + ".hkx"; 483 | SaveFileDialog( "Save HKX file", HkxFilter, outputName, ".hkx", path2 => { File.WriteAllBytes( path2, hkx ); } ); 484 | } ); 485 | } 486 | 487 | ImGui.SameLine(); 488 | 489 | if( ImGui.Button( "XML->SKLB" ) ) { 490 | OpenFileDialog( "Select XML file", XmlFilter, path => { 491 | OpenFileDialog( "Select original SKLB file", SklbFilter, path2 => { 492 | var xml = File.ReadAllText( path ); 493 | var hkx = _converter.XmlToHkx( xml ); 494 | var sklbData = File.ReadAllBytes( path2 ); 495 | var readStream = new MemoryStream( sklbData ); 496 | var sklb = SklbFile.FromStream( readStream ); 497 | 498 | sklb.ReplaceHkxData( hkx ); 499 | 500 | var outputName = Path.GetFileNameWithoutExtension( path ) + ".sklb"; 501 | SaveFileDialog( "Save SKLB file", SklbFilter, outputName, ".sklb", path3 => { 502 | var ms = new MemoryStream(); 503 | sklb.Write( ms ); 504 | File.WriteAllBytes( path3, ms.ToArray() ); 505 | } ); 506 | } ); 507 | } ); 508 | } 509 | 510 | var autoOpen = Plugin.Configuration.AutoOpen; 511 | if( ImGui.Checkbox( "Auto Open Window", ref autoOpen ) ) { 512 | Plugin.Configuration.AutoOpen = autoOpen; 513 | Plugin.Configuration.Save(); 514 | } 515 | } 516 | 517 | private void DrawPathsTab() { 518 | ImGui.TextUnformatted( "Chuck paths into the bottom text boxes below.\n" 519 | + "Use the first box for model paths and the second path for skeleton paths.\n" 520 | + "When importing a model, put the original path in the models textbox." 521 | ); 522 | 523 | ImGui.InputInt( "Character deformation", ref _deform ); 524 | 525 | var names = Enum.GetNames( typeof( ExportModelType ) ); 526 | var modelType = ( int )_exportModelType; 527 | if( ImGui.Combo( "Export type", ref modelType, names, names.Length ) ) { _exportModelType = ( ExportModelType )modelType; } 528 | 529 | var cra = ImGui.GetContentRegionAvail(); 530 | var textSize = cra with { Y = cra.Y / 2 - 20 }; 531 | 532 | ImGui.InputTextMultiline( "Model paths", ref _modelPaths, 1024 * 16, textSize ); 533 | ImGui.InputTextMultiline( "Skeleton paths", ref _skeletonPaths, 1024 * 16, textSize ); 534 | 535 | if( ImGui.Button( "Export (create glTF)" ) ) { 536 | Task.Run( async () => { 537 | var models = _modelPaths.Trim().Split( '\n' ) 538 | .Where( x => x.Trim() != string.Empty ).ToArray(); 539 | var skeletons = _skeletonPaths.Trim().Split( '\n' ) 540 | .Where( x => x.Trim() != string.Empty ).ToArray(); 541 | 542 | ushort? deform = _deform == 0 ? null : ( ushort )_deform; 543 | var tempDir = skeletons.Length == 0 544 | ? await DoTheThingWithTheModels( models, deform: deform, type: _exportModelType ) 545 | : await DoTheThingWithTheModels( models, skeletons, deform: deform, type: _exportModelType ); 546 | 547 | Process.Start( "explorer.exe", tempDir ); 548 | } ); 549 | } 550 | 551 | ImGui.SameLine(); 552 | 553 | if( ImGui.Button( "Import (create MDL)" ) ) { 554 | OpenFileDialog( "Select glTF", GltfFilter, path => { 555 | var firstModel = _modelPaths.Trim().Split( '\n' )[ 0 ]; 556 | var data = _modelConverter.ImportModel( path, firstModel ); 557 | 558 | var tempDir = Path.Combine( Path.GetTempPath(), "Xande.TestPlugin" ); 559 | Directory.CreateDirectory( tempDir ); 560 | var tempPath = Path.Combine( tempDir, $"model-{DateTime.Now:yyyy-MM-dd-HH-mm-ss}" ); 561 | Directory.CreateDirectory( tempPath ); 562 | var tempFile = Path.Combine( tempPath, "model.mdl" ); 563 | File.WriteAllBytes( tempFile, data ); 564 | 565 | Process.Start( "explorer.exe", tempPath ); 566 | } ); 567 | } 568 | } 569 | } --------------------------------------------------------------------------------