├── .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_" |  |
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. |  |
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 | }
--------------------------------------------------------------------------------