mirror of
https://github.com/xivdev/Penumbra.git
synced 2025-12-13 20:24:17 +01:00
Cleanup and fit ResourceTree to new paradigm.
This commit is contained in:
parent
d28299f699
commit
e6b17d536b
8 changed files with 602 additions and 679 deletions
|
|
@ -1,678 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Text;
|
|
||||||
using Dalamud.Game.ClientState.Objects.Enums;
|
|
||||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
|
||||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
|
||||||
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
|
||||||
using FFXIVClientStructs.FFXIV.Client.System.Resource;
|
|
||||||
using OtterGui;
|
|
||||||
using Penumbra.Collections;
|
|
||||||
using Penumbra.GameData.Actors;
|
|
||||||
using Penumbra.GameData.Enums;
|
|
||||||
using Penumbra.GameData.Files;
|
|
||||||
using Penumbra.Interop.Resolver;
|
|
||||||
using Penumbra.Interop.Structs;
|
|
||||||
using Penumbra.String;
|
|
||||||
using Penumbra.String.Classes;
|
|
||||||
using Objects = Dalamud.Game.ClientState.Objects.Types;
|
|
||||||
using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
|
|
||||||
|
|
||||||
namespace Penumbra.Interop;
|
|
||||||
|
|
||||||
public class ResourceTree
|
|
||||||
{
|
|
||||||
public readonly string Name;
|
|
||||||
public readonly nint SourceAddress;
|
|
||||||
public readonly bool PlayerRelated;
|
|
||||||
public readonly string CollectionName;
|
|
||||||
public readonly List<Node> Nodes;
|
|
||||||
|
|
||||||
public ResourceTree( string name, nint sourceAddress, bool playerRelated, string collectionName )
|
|
||||||
{
|
|
||||||
Name = name;
|
|
||||||
SourceAddress = sourceAddress;
|
|
||||||
PlayerRelated = playerRelated;
|
|
||||||
CollectionName = collectionName;
|
|
||||||
Nodes = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ResourceTree[] FromObjectTable( bool withNames = true )
|
|
||||||
{
|
|
||||||
var cache = new TreeBuildCache();
|
|
||||||
|
|
||||||
return cache.Characters
|
|
||||||
.Select( chara => FromCharacter( chara, cache, withNames ) )
|
|
||||||
.OfType<ResourceTree>()
|
|
||||||
.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static IEnumerable<(Objects.Character Character, ResourceTree ResourceTree)> FromCharacters( IEnumerable<Objects.Character> characters, bool withNames = true )
|
|
||||||
{
|
|
||||||
var cache = new TreeBuildCache();
|
|
||||||
foreach( var chara in characters )
|
|
||||||
{
|
|
||||||
var tree = FromCharacter( chara, cache, withNames );
|
|
||||||
if( tree != null )
|
|
||||||
{
|
|
||||||
yield return (chara, tree);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static unsafe ResourceTree? FromCharacter( Objects.Character chara, bool withNames = true )
|
|
||||||
{
|
|
||||||
return FromCharacter( chara, new TreeBuildCache(), withNames );
|
|
||||||
}
|
|
||||||
|
|
||||||
private static unsafe ResourceTree? FromCharacter( Objects.Character chara, TreeBuildCache cache, bool withNames = true )
|
|
||||||
{
|
|
||||||
if( !chara.IsValid() )
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var charaStruct = ( Character* )chara.Address;
|
|
||||||
var gameObjStruct = &charaStruct->GameObject;
|
|
||||||
var model = ( CharacterBase* )gameObjStruct->GetDrawObject();
|
|
||||||
if( model == null )
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var equipment = new ReadOnlySpan<EquipmentRecord>( charaStruct->EquipSlotData, 10 );
|
|
||||||
// var customize = new ReadOnlySpan<byte>( charaStruct->CustomizeData, 26 );
|
|
||||||
|
|
||||||
var collectionResolveData = PathResolver.IdentifyCollection( gameObjStruct, true );
|
|
||||||
if( !collectionResolveData.Valid )
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
var collection = collectionResolveData.ModCollection;
|
|
||||||
|
|
||||||
var name = GetCharacterName( chara, cache );
|
|
||||||
var tree = new ResourceTree( name.Name, new nint( charaStruct ), name.PlayerRelated, collection.Name );
|
|
||||||
|
|
||||||
var globalContext = new GlobalResolveContext(
|
|
||||||
FileCache: cache,
|
|
||||||
Collection: collection,
|
|
||||||
Skeleton: charaStruct->ModelSkeletonId,
|
|
||||||
WithNames: withNames
|
|
||||||
);
|
|
||||||
|
|
||||||
for( var i = 0; i < model->SlotCount; ++i )
|
|
||||||
{
|
|
||||||
var context = globalContext.CreateContext(
|
|
||||||
Slot: ( i < equipment.Length ) ? ( ( uint )i ).ToEquipSlot() : EquipSlot.Unknown,
|
|
||||||
Equipment: ( i < equipment.Length ) ? equipment[i] : default
|
|
||||||
);
|
|
||||||
|
|
||||||
var imc = ( ResourceHandle* )model->IMCArray[i];
|
|
||||||
var imcNode = context.CreateNodeFromImc( imc );
|
|
||||||
if( imcNode != null )
|
|
||||||
{
|
|
||||||
tree.Nodes.Add( withNames ? imcNode.WithName( imcNode.Name ?? $"IMC #{i}" ) : imcNode );
|
|
||||||
}
|
|
||||||
|
|
||||||
var mdl = ( RenderModel* )model->ModelArray[i];
|
|
||||||
var mdlNode = context.CreateNodeFromRenderModel( mdl );
|
|
||||||
if( mdlNode != null )
|
|
||||||
{
|
|
||||||
tree.Nodes.Add( withNames ? mdlNode.WithName(mdlNode.Name ?? $"Model #{i}") : mdlNode );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if( chara is PlayerCharacter )
|
|
||||||
{
|
|
||||||
AddHumanResources( tree, globalContext, ( HumanExt* )model );
|
|
||||||
}
|
|
||||||
|
|
||||||
return tree;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static unsafe (string Name, bool PlayerRelated) GetCharacterName( Objects.Character chara, TreeBuildCache cache )
|
|
||||||
{
|
|
||||||
var identifier = Penumbra.Actors.FromObject( ( GameObject* )chara.Address, out var owner, true, false, false );
|
|
||||||
var name = chara.Name.ToString().Trim();
|
|
||||||
var playerRelated = true;
|
|
||||||
|
|
||||||
if( chara.ObjectKind != ObjectKind.Player )
|
|
||||||
{
|
|
||||||
name = $"{name} ({chara.ObjectKind.ToName()})".Trim();
|
|
||||||
playerRelated = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if( identifier.Type == IdentifierType.Owned && cache.CharactersById.TryGetValue( owner->ObjectID, out var ownerChara ) )
|
|
||||||
{
|
|
||||||
var ownerName = GetCharacterName( ownerChara, cache );
|
|
||||||
name = $"[{ownerName.Name}] {name}".Trim();
|
|
||||||
playerRelated |= ownerName.PlayerRelated;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (name, playerRelated);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static unsafe void AddHumanResources( ResourceTree tree, GlobalResolveContext globalContext, HumanExt* human )
|
|
||||||
{
|
|
||||||
var firstSubObject = ( CharacterBase* )human->Human.CharacterBase.DrawObject.Object.ChildObject;
|
|
||||||
if( firstSubObject != null )
|
|
||||||
{
|
|
||||||
var subObjectNodes = new List<Node>();
|
|
||||||
var subObject = firstSubObject;
|
|
||||||
var subObjectIndex = 0;
|
|
||||||
do
|
|
||||||
{
|
|
||||||
var weapon = ( subObject->GetModelType() == CharacterBase.ModelType.Weapon ) ? ( Weapon* )subObject : null;
|
|
||||||
var subObjectNamePrefix = ( weapon != null ) ? "Weapon" : "Fashion Acc.";
|
|
||||||
var subObjectContext = globalContext.CreateContext(
|
|
||||||
Slot: ( weapon != null ) ? EquipSlot.MainHand : EquipSlot.Unknown,
|
|
||||||
Equipment: ( weapon != null ) ? new EquipmentRecord( weapon->ModelSetId, ( byte )weapon->Variant, ( byte )weapon->ModelUnknown ) : default
|
|
||||||
);
|
|
||||||
|
|
||||||
for( var i = 0; i < subObject->SlotCount; ++i )
|
|
||||||
{
|
|
||||||
var imc = ( ResourceHandle* )subObject->IMCArray[i];
|
|
||||||
var imcNode = subObjectContext.CreateNodeFromImc( imc );
|
|
||||||
if( imcNode != null )
|
|
||||||
{
|
|
||||||
subObjectNodes.Add( globalContext.WithNames ? imcNode.WithName( imcNode.Name ?? $"{subObjectNamePrefix} #{subObjectIndex}, IMC #{i}" ) : imcNode );
|
|
||||||
}
|
|
||||||
|
|
||||||
var mdl = ( RenderModel* )subObject->ModelArray[i];
|
|
||||||
var mdlNode = subObjectContext.CreateNodeFromRenderModel( mdl );
|
|
||||||
if( mdlNode != null )
|
|
||||||
{
|
|
||||||
subObjectNodes.Add( globalContext.WithNames ? mdlNode.WithName( mdlNode.Name ?? $"{subObjectNamePrefix} #{subObjectIndex}, Model #{i}" ) : mdlNode );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subObject = ( CharacterBase* )subObject->DrawObject.Object.NextSiblingObject;
|
|
||||||
++subObjectIndex;
|
|
||||||
} while( subObject != null && subObject != firstSubObject );
|
|
||||||
tree.Nodes.InsertRange( 0, subObjectNodes );
|
|
||||||
}
|
|
||||||
|
|
||||||
var context = globalContext.CreateContext(
|
|
||||||
Slot: EquipSlot.Unknown,
|
|
||||||
Equipment: default
|
|
||||||
);
|
|
||||||
|
|
||||||
var skeletonNode = context.CreateHumanSkeletonNode( human->Human.RaceSexId );
|
|
||||||
if( skeletonNode != null )
|
|
||||||
{
|
|
||||||
tree.Nodes.Add( globalContext.WithNames ? skeletonNode.WithName( skeletonNode.Name ?? "Skeleton" ) : skeletonNode );
|
|
||||||
}
|
|
||||||
|
|
||||||
var decalNode = context.CreateNodeFromTex( human->Decal );
|
|
||||||
if( decalNode != null )
|
|
||||||
{
|
|
||||||
tree.Nodes.Add( globalContext.WithNames ? decalNode.WithName( decalNode.Name ?? "Face Decal" ) : decalNode );
|
|
||||||
}
|
|
||||||
|
|
||||||
var legacyDecalNode = context.CreateNodeFromTex( human->LegacyBodyDecal );
|
|
||||||
if( legacyDecalNode != null )
|
|
||||||
{
|
|
||||||
tree.Nodes.Add( globalContext.WithNames ? legacyDecalNode.WithName( legacyDecalNode.Name ?? "Legacy Body Decal" ) : legacyDecalNode );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static unsafe bool CreateOwnedGamePath( byte* rawGamePath, out Utf8GamePath gamePath, bool addDx11Prefix = false, bool isShader = false )
|
|
||||||
{
|
|
||||||
if( rawGamePath == null )
|
|
||||||
{
|
|
||||||
gamePath = default;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if( isShader )
|
|
||||||
{
|
|
||||||
var path = $"shader/sm5/shpk/{new ByteString( rawGamePath )}";
|
|
||||||
return Utf8GamePath.FromString( path, out gamePath );
|
|
||||||
}
|
|
||||||
|
|
||||||
if( addDx11Prefix )
|
|
||||||
{
|
|
||||||
var unprefixed = MemoryMarshal.CreateReadOnlySpanFromNullTerminated( rawGamePath );
|
|
||||||
var lastDirectorySeparator = unprefixed.LastIndexOf( ( byte )'/' );
|
|
||||||
if( unprefixed[lastDirectorySeparator + 1] != ( byte )'-' || unprefixed[lastDirectorySeparator + 2] != ( byte )'-' )
|
|
||||||
{
|
|
||||||
Span<byte> prefixed = stackalloc byte[unprefixed.Length + 2];
|
|
||||||
unprefixed[..( lastDirectorySeparator + 1 )].CopyTo( prefixed );
|
|
||||||
prefixed[lastDirectorySeparator + 1] = ( byte )'-';
|
|
||||||
prefixed[lastDirectorySeparator + 2] = ( byte )'-';
|
|
||||||
unprefixed[( lastDirectorySeparator + 1 )..].CopyTo( prefixed[( lastDirectorySeparator + 3 )..] );
|
|
||||||
|
|
||||||
if( !Utf8GamePath.FromSpan( prefixed, out gamePath ) )
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
gamePath = gamePath.Clone();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if( !Utf8GamePath.FromPointer( rawGamePath, out gamePath ) )
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
gamePath = gamePath.Clone();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout( LayoutKind.Sequential, Pack = 1, Size = 4 )]
|
|
||||||
private readonly record struct EquipmentRecord( ushort SetId, byte Variant, byte Dye );
|
|
||||||
|
|
||||||
private class TreeBuildCache
|
|
||||||
{
|
|
||||||
private readonly Dictionary<FullPath, MtrlFile?> Materials = new();
|
|
||||||
private readonly Dictionary<FullPath, ShpkFile?> ShaderPackages = new();
|
|
||||||
public readonly List<Objects.Character> Characters;
|
|
||||||
public readonly Dictionary<uint, Objects.Character> CharactersById;
|
|
||||||
|
|
||||||
public TreeBuildCache()
|
|
||||||
{
|
|
||||||
Characters = new();
|
|
||||||
CharactersById = new();
|
|
||||||
foreach( var chara in Dalamud.Objects.OfType<Objects.Character>() )
|
|
||||||
{
|
|
||||||
if( chara.IsValid() )
|
|
||||||
{
|
|
||||||
Characters.Add( chara );
|
|
||||||
if( chara.ObjectId != Objects.GameObject.InvalidGameObjectId && !CharactersById.TryAdd( chara.ObjectId, chara ) )
|
|
||||||
{
|
|
||||||
var existingChara = CharactersById[chara.ObjectId];
|
|
||||||
Penumbra.Log.Warning( $"Duplicate character ID {chara.ObjectId:X8} (old: {existingChara.Name} {existingChara.ObjectKind}, new: {chara.Name} {chara.ObjectKind})" );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public MtrlFile? ReadMaterial( FullPath path )
|
|
||||||
{
|
|
||||||
return ReadFile( path, Materials, bytes => new MtrlFile( bytes ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
public ShpkFile? ReadShaderPackage( FullPath path )
|
|
||||||
{
|
|
||||||
return ReadFile( path, ShaderPackages, bytes => new ShpkFile( bytes ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
private static T? ReadFile<T>( FullPath path, Dictionary<FullPath, T?> cache, Func<byte[], T> parseFile ) where T : class
|
|
||||||
{
|
|
||||||
if( path.FullName.Length == 0 )
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if( cache.TryGetValue( path, out var cached ) )
|
|
||||||
{
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
var pathStr = path.ToPath();
|
|
||||||
T? parsed;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if( path.IsRooted )
|
|
||||||
{
|
|
||||||
parsed = parseFile( File.ReadAllBytes( pathStr ) );
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var bytes = Dalamud.GameData.GetFile( pathStr )?.Data;
|
|
||||||
parsed = ( bytes != null ) ? parseFile( bytes ) : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch( Exception e )
|
|
||||||
{
|
|
||||||
Penumbra.Log.Error( $"Could not read file {pathStr}:\n{e}" );
|
|
||||||
parsed = null;
|
|
||||||
}
|
|
||||||
cache.Add( path, parsed );
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private record class GlobalResolveContext( TreeBuildCache FileCache, ModCollection Collection, int Skeleton, bool WithNames )
|
|
||||||
{
|
|
||||||
public ResolveContext CreateContext( EquipSlot Slot, EquipmentRecord Equipment )
|
|
||||||
=> new( FileCache, Collection, Skeleton, WithNames, Slot, Equipment );
|
|
||||||
}
|
|
||||||
|
|
||||||
private record class ResolveContext( TreeBuildCache FileCache, ModCollection Collection, int Skeleton, bool WithNames, EquipSlot Slot, EquipmentRecord Equipment )
|
|
||||||
{
|
|
||||||
private unsafe Node? CreateNodeFromGamePath( ResourceType type, nint sourceAddress, byte* rawGamePath, bool @internal, bool addDx11Prefix = false, bool isShader = false )
|
|
||||||
{
|
|
||||||
if( !CreateOwnedGamePath( rawGamePath, out var gamePath, addDx11Prefix, isShader ) )
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return CreateNodeFromGamePath( type, sourceAddress, gamePath, @internal );
|
|
||||||
}
|
|
||||||
|
|
||||||
private unsafe Node CreateNodeFromGamePath( ResourceType type, nint sourceAddress, Utf8GamePath gamePath, bool @internal )
|
|
||||||
=> new( null, type, sourceAddress, gamePath, FilterFullPath( Collection.ResolvePath( gamePath ) ?? new FullPath( gamePath ) ), @internal );
|
|
||||||
|
|
||||||
private unsafe Node? CreateNodeFromResourceHandle( ResourceType type, nint sourceAddress, ResourceHandle* handle, bool @internal, bool withName )
|
|
||||||
{
|
|
||||||
if( handle == null )
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var name = handle->FileNameAsSpan();
|
|
||||||
if( name.Length == 0 )
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if( name[0] == ( byte )'|' )
|
|
||||||
{
|
|
||||||
name = name[1..];
|
|
||||||
var pos = name.IndexOf( ( byte )'|' );
|
|
||||||
if( pos < 0 )
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
name = name[( pos + 1 )..];
|
|
||||||
}
|
|
||||||
|
|
||||||
var fullPath = new FullPath( Encoding.UTF8.GetString( name ) );
|
|
||||||
var gamePaths = Collection.ReverseResolvePath( fullPath ).ToList();
|
|
||||||
fullPath = FilterFullPath( fullPath );
|
|
||||||
|
|
||||||
if( gamePaths.Count > 1 )
|
|
||||||
{
|
|
||||||
gamePaths = Filter( gamePaths );
|
|
||||||
}
|
|
||||||
|
|
||||||
if( gamePaths.Count == 1 )
|
|
||||||
{
|
|
||||||
return new Node( withName ? GuessNameFromPath( gamePaths[0] ) : null, type, sourceAddress, gamePaths[0], fullPath, @internal );
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Penumbra.Log.Information( $"Found {gamePaths.Count} game paths while reverse-resolving {fullPath} in {Collection.Name}:" );
|
|
||||||
foreach( var gamePath in gamePaths )
|
|
||||||
{
|
|
||||||
Penumbra.Log.Information( $"Game path: {gamePath}" );
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Node( null, type, sourceAddress, gamePaths.ToArray(), fullPath, @internal );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public unsafe Node? CreateHumanSkeletonNode( ushort raceSexId )
|
|
||||||
{
|
|
||||||
var raceSexIdStr = raceSexId.ToString( "D4" );
|
|
||||||
var path = $"chara/human/c{raceSexIdStr}/skeleton/base/b0001/skl_c{raceSexIdStr}b0001.sklb";
|
|
||||||
|
|
||||||
if( !Utf8GamePath.FromString( path, out var gamePath ) )
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return CreateNodeFromGamePath( ResourceType.Sklb, 0, gamePath, false );
|
|
||||||
}
|
|
||||||
|
|
||||||
public unsafe Node? CreateNodeFromImc( ResourceHandle* imc )
|
|
||||||
{
|
|
||||||
var node = CreateNodeFromResourceHandle( ResourceType.Imc, new nint( imc ), imc, true, false );
|
|
||||||
if( node == null )
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if( WithNames )
|
|
||||||
{
|
|
||||||
var name = GuessModelName( node.GamePath );
|
|
||||||
node = node.WithName( ( name != null ) ? $"IMC: {name}" : null );
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
public unsafe Node? CreateNodeFromTex( ResourceHandle* tex )
|
|
||||||
{
|
|
||||||
return CreateNodeFromResourceHandle( ResourceType.Tex, new nint( tex ), tex, false, WithNames );
|
|
||||||
}
|
|
||||||
|
|
||||||
public unsafe Node? CreateNodeFromRenderModel( RenderModel* mdl )
|
|
||||||
{
|
|
||||||
if( mdl == null || mdl->ResourceHandle == null || mdl->ResourceHandle->Category != ResourceCategory.Chara )
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var node = CreateNodeFromResourceHandle( ResourceType.Mdl, new nint( mdl ), mdl->ResourceHandle, false, false );
|
|
||||||
if( node == null )
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if( WithNames )
|
|
||||||
{
|
|
||||||
node = node.WithName( GuessModelName( node.GamePath ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
for( var i = 0; i < mdl->MaterialCount; i++ )
|
|
||||||
{
|
|
||||||
var mtrl = ( Material* )mdl->Materials[i];
|
|
||||||
var mtrlNode = CreateNodeFromMaterial( mtrl );
|
|
||||||
if( mtrlNode != null )
|
|
||||||
{
|
|
||||||
// Don't keep the material's name if it's redundant with the model's name.
|
|
||||||
node.Children.Add( WithNames ? mtrlNode.WithName( ( string.Equals( mtrlNode.Name, node.Name, StringComparison.Ordinal ) ? null : mtrlNode.Name ) ?? $"Material #{i}" ) : mtrlNode );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
private unsafe Node? CreateNodeFromMaterial( Material* mtrl )
|
|
||||||
{
|
|
||||||
if( mtrl == null )
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var resource = ( MtrlResource* )mtrl->ResourceHandle;
|
|
||||||
var node = CreateNodeFromResourceHandle( ResourceType.Mtrl, new nint( mtrl ), &resource->Handle, false, WithNames );
|
|
||||||
if( node == null )
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
var mtrlFile = WithNames ? FileCache.ReadMaterial( node.FullPath ) : null;
|
|
||||||
|
|
||||||
var shpkNode = CreateNodeFromGamePath( ResourceType.Shpk, 0, resource->ShpkString, false, isShader: true );
|
|
||||||
if( shpkNode != null )
|
|
||||||
{
|
|
||||||
node.Children.Add( WithNames ? shpkNode.WithName( "Shader Package" ) : shpkNode );
|
|
||||||
}
|
|
||||||
var shpkFile = ( WithNames && shpkNode != null ) ? FileCache.ReadShaderPackage( shpkNode.FullPath ) : null;
|
|
||||||
var samplers = WithNames ? mtrlFile?.GetSamplersByTexture(shpkFile) : null;
|
|
||||||
|
|
||||||
for( var i = 0; i < resource->NumTex; i++ )
|
|
||||||
{
|
|
||||||
var texNode = CreateNodeFromGamePath( ResourceType.Tex, 0, resource->TexString( i ), false, addDx11Prefix: resource->TexIsDX11( i ) );
|
|
||||||
if( texNode != null )
|
|
||||||
{
|
|
||||||
if( WithNames )
|
|
||||||
{
|
|
||||||
var name = ( samplers != null && i < samplers.Count ) ? samplers[i].Item2?.Name : null;
|
|
||||||
node.Children.Add( texNode.WithName( name ?? $"Texture #{i}" ) );
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
node.Children.Add( texNode );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static FullPath FilterFullPath( FullPath fullPath )
|
|
||||||
{
|
|
||||||
if( !fullPath.IsRooted )
|
|
||||||
{
|
|
||||||
return fullPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
var relPath = Path.GetRelativePath( Penumbra.Config.ModDirectory, fullPath.FullName );
|
|
||||||
if( relPath == "." || !relPath.StartsWith( '.' ) && !Path.IsPathRooted( relPath ) )
|
|
||||||
{
|
|
||||||
return fullPath.Exists ? fullPath : FullPath.Empty;
|
|
||||||
}
|
|
||||||
return FullPath.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<Utf8GamePath> Filter( List<Utf8GamePath> gamePaths )
|
|
||||||
{
|
|
||||||
var filtered = new List<Utf8GamePath>( gamePaths.Count );
|
|
||||||
foreach( var path in gamePaths )
|
|
||||||
{
|
|
||||||
// In doubt, keep the paths.
|
|
||||||
if( IsMatch( path.ToString().Split( new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries ) ) ?? true )
|
|
||||||
{
|
|
||||||
filtered.Add( path );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool? IsMatch( ReadOnlySpan<string> path )
|
|
||||||
=> SafeGet( path, 0 ) switch
|
|
||||||
{
|
|
||||||
"chara" => SafeGet( path, 1 ) switch
|
|
||||||
{
|
|
||||||
"accessory" => IsMatchEquipment( path[2..], $"a{Equipment.SetId:D4}" ),
|
|
||||||
"equipment" => IsMatchEquipment( path[2..], $"e{Equipment.SetId:D4}" ),
|
|
||||||
"monster" => SafeGet( path, 2 ) == $"m{Skeleton:D4}",
|
|
||||||
"weapon" => IsMatchEquipment( path[2..], $"w{Equipment.SetId:D4}" ),
|
|
||||||
_ => null,
|
|
||||||
},
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
private bool? IsMatchEquipment( ReadOnlySpan<string> path, string equipmentDir )
|
|
||||||
=> SafeGet( path, 0 ) == equipmentDir
|
|
||||||
? SafeGet( path, 1 ) switch
|
|
||||||
{
|
|
||||||
"material" => SafeGet( path, 2 ) == $"v{Equipment.Variant:D4}",
|
|
||||||
_ => null,
|
|
||||||
}
|
|
||||||
: false;
|
|
||||||
|
|
||||||
private string? GuessModelName( Utf8GamePath gamePath )
|
|
||||||
{
|
|
||||||
var path = gamePath.ToString().Split( new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries );
|
|
||||||
// Weapons intentionally left out.
|
|
||||||
var isEquipment = SafeGet( path, 0 ) == "chara" && SafeGet( path, 1 ) is "accessory" or "equipment";
|
|
||||||
if( isEquipment )
|
|
||||||
{
|
|
||||||
foreach( var item in Penumbra.Identifier.Identify( Equipment.SetId, Equipment.Variant, Slot.ToSlot() ) )
|
|
||||||
{
|
|
||||||
return Slot switch
|
|
||||||
{
|
|
||||||
EquipSlot.RFinger => "R: ",
|
|
||||||
EquipSlot.LFinger => "L: ",
|
|
||||||
_ => string.Empty,
|
|
||||||
} + item.Name.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var nameFromPath = GuessNameFromPath( gamePath );
|
|
||||||
if( nameFromPath != null )
|
|
||||||
{
|
|
||||||
return nameFromPath;
|
|
||||||
}
|
|
||||||
if( isEquipment )
|
|
||||||
{
|
|
||||||
return Slot.ToName();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? GuessNameFromPath( Utf8GamePath gamePath )
|
|
||||||
{
|
|
||||||
foreach( var obj in Penumbra.Identifier.Identify( gamePath.ToString() ) )
|
|
||||||
{
|
|
||||||
var name = obj.Key;
|
|
||||||
if( name.StartsWith( "Customization:" ) )
|
|
||||||
{
|
|
||||||
name = name[14..].Trim();
|
|
||||||
}
|
|
||||||
if( name != "Unknown" )
|
|
||||||
{
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? SafeGet( ReadOnlySpan<string> array, Index index )
|
|
||||||
{
|
|
||||||
var i = index.GetOffset( array.Length );
|
|
||||||
return ( i >= 0 && i < array.Length ) ? array[i] : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Node
|
|
||||||
{
|
|
||||||
public readonly string? Name;
|
|
||||||
public readonly ResourceType Type;
|
|
||||||
public readonly nint SourceAddress;
|
|
||||||
public readonly Utf8GamePath GamePath;
|
|
||||||
public readonly Utf8GamePath[] PossibleGamePaths;
|
|
||||||
public readonly FullPath FullPath;
|
|
||||||
public readonly bool Internal;
|
|
||||||
public readonly List<Node> Children;
|
|
||||||
|
|
||||||
public Node( string? name, ResourceType type, nint sourceAddress, Utf8GamePath gamePath, FullPath fullPath, bool @internal )
|
|
||||||
{
|
|
||||||
Name = name;
|
|
||||||
Type = type;
|
|
||||||
SourceAddress = sourceAddress;
|
|
||||||
GamePath = gamePath;
|
|
||||||
PossibleGamePaths = new[] { gamePath };
|
|
||||||
FullPath = fullPath;
|
|
||||||
Internal = @internal;
|
|
||||||
Children = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Node( string? name, ResourceType type, nint sourceAddress, Utf8GamePath[] possibleGamePaths, FullPath fullPath, bool @internal )
|
|
||||||
{
|
|
||||||
Name = name;
|
|
||||||
Type = type;
|
|
||||||
SourceAddress = sourceAddress;
|
|
||||||
GamePath = ( possibleGamePaths.Length == 1 ) ? possibleGamePaths[0] : Utf8GamePath.Empty;
|
|
||||||
PossibleGamePaths = possibleGamePaths;
|
|
||||||
FullPath = fullPath;
|
|
||||||
Internal = @internal;
|
|
||||||
Children = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Node( string? name, Node originalNode )
|
|
||||||
{
|
|
||||||
Name = name;
|
|
||||||
Type = originalNode.Type;
|
|
||||||
SourceAddress = originalNode.SourceAddress;
|
|
||||||
GamePath = originalNode.GamePath;
|
|
||||||
PossibleGamePaths = originalNode.PossibleGamePaths;
|
|
||||||
FullPath = originalNode.FullPath;
|
|
||||||
Internal = originalNode.Internal;
|
|
||||||
Children = originalNode.Children;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Node WithName( string? name )
|
|
||||||
=> string.Equals( Name, name, StringComparison.Ordinal ) ? this : new Node( name, this );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
60
Penumbra/Interop/ResourceTree/FileCache.cs
Normal file
60
Penumbra/Interop/ResourceTree/FileCache.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using Dalamud.Data;
|
||||||
|
using Penumbra.GameData.Files;
|
||||||
|
using Penumbra.String.Classes;
|
||||||
|
|
||||||
|
namespace Penumbra.Interop.ResourceTree;
|
||||||
|
|
||||||
|
internal class FileCache
|
||||||
|
{
|
||||||
|
private readonly DataManager _dataManager;
|
||||||
|
private readonly Dictionary<FullPath, MtrlFile?> _materials = new();
|
||||||
|
private readonly Dictionary<FullPath, ShpkFile?> _shaderPackages = new();
|
||||||
|
|
||||||
|
public FileCache(DataManager dataManager)
|
||||||
|
=> _dataManager = dataManager;
|
||||||
|
|
||||||
|
/// <summary> Try to read a material file from the given path and cache it on success. </summary>
|
||||||
|
public MtrlFile? ReadMaterial(FullPath path)
|
||||||
|
=> ReadFile(_dataManager, path, _materials, bytes => new MtrlFile(bytes));
|
||||||
|
|
||||||
|
/// <summary> Try to read a shpk file from the given path and cache it on success. </summary>
|
||||||
|
public ShpkFile? ReadShaderPackage(FullPath path)
|
||||||
|
=> ReadFile(_dataManager, path, _shaderPackages, bytes => new ShpkFile(bytes));
|
||||||
|
|
||||||
|
private static T? ReadFile<T>(DataManager dataManager, FullPath path, Dictionary<FullPath, T?> cache, Func<byte[], T> parseFile)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
if (path.FullName.Length == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (cache.TryGetValue(path, out var cached))
|
||||||
|
return cached;
|
||||||
|
|
||||||
|
var pathStr = path.ToPath();
|
||||||
|
T? parsed;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (path.IsRooted)
|
||||||
|
{
|
||||||
|
parsed = parseFile(File.ReadAllBytes(pathStr));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var bytes = dataManager.GetFile(pathStr)?.Data;
|
||||||
|
parsed = bytes != null ? parseFile(bytes) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Penumbra.Log.Error($"Could not read file {pathStr}:\n{e}");
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.Add(path, parsed);
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
289
Penumbra/Interop/ResourceTree/ResolveContext.cs
Normal file
289
Penumbra/Interop/ResourceTree/ResolveContext.cs
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.System.Resource;
|
||||||
|
using Penumbra.Collections;
|
||||||
|
using Penumbra.GameData;
|
||||||
|
using Penumbra.GameData.Enums;
|
||||||
|
using Penumbra.GameData.Structs;
|
||||||
|
using Penumbra.Interop.Structs;
|
||||||
|
using Penumbra.String;
|
||||||
|
using Penumbra.String.Classes;
|
||||||
|
|
||||||
|
namespace Penumbra.Interop.ResourceTree;
|
||||||
|
|
||||||
|
internal record class GlobalResolveContext(Configuration Config, IObjectIdentifier Identifier, FileCache FileCache, ModCollection Collection, int Skeleton, bool WithNames)
|
||||||
|
{
|
||||||
|
public ResolveContext CreateContext(EquipSlot slot, CharacterArmor equipment)
|
||||||
|
=> new(Config, Identifier, FileCache, Collection, Skeleton, WithNames, slot, equipment);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal record class ResolveContext(Configuration Config, IObjectIdentifier Identifier, FileCache FileCache, ModCollection Collection, int Skeleton, bool WithNames, EquipSlot Slot,
|
||||||
|
CharacterArmor Equipment)
|
||||||
|
{
|
||||||
|
private static readonly ByteString ShpkPrefix = ByteString.FromSpanUnsafe("shader/sm5/shpk"u8, true, true, true);
|
||||||
|
private ResourceNode? CreateNodeFromShpk(nint sourceAddress, ByteString gamePath, bool @internal)
|
||||||
|
{
|
||||||
|
if (gamePath.IsEmpty)
|
||||||
|
return null;
|
||||||
|
if (!Utf8GamePath.FromByteString(ByteString.Join((byte)'/', ShpkPrefix, gamePath), out var path, false))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return CreateNodeFromGamePath(ResourceType.Shpk, sourceAddress, path, @internal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResourceNode? CreateNodeFromTex(nint sourceAddress, ByteString gamePath, bool @internal, bool dx11)
|
||||||
|
{
|
||||||
|
if (dx11)
|
||||||
|
{
|
||||||
|
var lastDirectorySeparator = gamePath.LastIndexOf((byte)'/');
|
||||||
|
if (lastDirectorySeparator == -1 || lastDirectorySeparator > gamePath.Length - 3)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (gamePath[lastDirectorySeparator + 1] != (byte)'-' || gamePath[lastDirectorySeparator + 2] != (byte)'-')
|
||||||
|
{
|
||||||
|
Span<byte> prefixed = stackalloc byte[gamePath.Length + 3];
|
||||||
|
gamePath.Span[..(lastDirectorySeparator + 1)].CopyTo(prefixed);
|
||||||
|
prefixed[lastDirectorySeparator + 1] = (byte)'-';
|
||||||
|
prefixed[lastDirectorySeparator + 2] = (byte)'-';
|
||||||
|
gamePath.Span[(lastDirectorySeparator + 1)..].CopyTo(prefixed[(lastDirectorySeparator + 3)..]);
|
||||||
|
|
||||||
|
if (!Utf8GamePath.FromSpan(prefixed, out var tmp))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
gamePath = tmp.Path.Clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Utf8GamePath.FromByteString(gamePath, out var path))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return CreateNodeFromGamePath(ResourceType.Tex, sourceAddress, path, @internal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResourceNode CreateNodeFromGamePath(ResourceType type, nint sourceAddress, Utf8GamePath gamePath, bool @internal)
|
||||||
|
=> new(null, type, sourceAddress, gamePath, FilterFullPath(Collection.ResolvePath(gamePath) ?? new FullPath(gamePath)), @internal);
|
||||||
|
|
||||||
|
private unsafe ResourceNode? CreateNodeFromResourceHandle(ResourceType type, nint sourceAddress, ResourceHandle* handle, bool @internal,
|
||||||
|
bool withName)
|
||||||
|
{
|
||||||
|
if (handle == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var name = handle->FileName();
|
||||||
|
if (name.IsEmpty)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (name[0] == (byte)'|')
|
||||||
|
{
|
||||||
|
var pos = name.IndexOf((byte)'|', 1);
|
||||||
|
if (pos < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
name = name.Substring(pos + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullPath = new FullPath(Utf8GamePath.FromByteString(name, out var p) ? p : Utf8GamePath.Empty);
|
||||||
|
var gamePaths = Collection.ReverseResolvePath(fullPath).ToList();
|
||||||
|
fullPath = FilterFullPath(fullPath);
|
||||||
|
|
||||||
|
if (gamePaths.Count > 1)
|
||||||
|
gamePaths = Filter(gamePaths);
|
||||||
|
|
||||||
|
if (gamePaths.Count == 1)
|
||||||
|
return new ResourceNode(withName ? GuessNameFromPath(gamePaths[0]) : null, type, sourceAddress, gamePaths[0], fullPath, @internal);
|
||||||
|
|
||||||
|
Penumbra.Log.Information($"Found {gamePaths.Count} game paths while reverse-resolving {fullPath} in {Collection.Name}:");
|
||||||
|
foreach (var gamePath in gamePaths)
|
||||||
|
Penumbra.Log.Information($"Game path: {gamePath}");
|
||||||
|
|
||||||
|
return new ResourceNode(null, type, sourceAddress, gamePaths.ToArray(), fullPath, @internal);
|
||||||
|
}
|
||||||
|
public unsafe ResourceNode? CreateHumanSkeletonNode(GenderRace gr)
|
||||||
|
{
|
||||||
|
var raceSexIdStr = gr.ToRaceCode();
|
||||||
|
var path = $"chara/human/c{raceSexIdStr}/skeleton/base/b0001/skl_c{raceSexIdStr}b0001.sklb";
|
||||||
|
|
||||||
|
if (!Utf8GamePath.FromString(path, out var gamePath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return CreateNodeFromGamePath(ResourceType.Sklb, 0, gamePath, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe ResourceNode? CreateNodeFromImc(ResourceHandle* imc)
|
||||||
|
{
|
||||||
|
var node = CreateNodeFromResourceHandle(ResourceType.Imc, (nint) imc, imc, true, false);
|
||||||
|
if (node == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (WithNames)
|
||||||
|
{
|
||||||
|
var name = GuessModelName(node.GamePath);
|
||||||
|
node = node.WithName(name != null ? $"IMC: {name}" : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe ResourceNode? CreateNodeFromTex(ResourceHandle* tex)
|
||||||
|
=> CreateNodeFromResourceHandle(ResourceType.Tex, (nint) tex, tex, false, WithNames);
|
||||||
|
|
||||||
|
public unsafe ResourceNode? CreateNodeFromRenderModel(RenderModel* mdl)
|
||||||
|
{
|
||||||
|
if (mdl == null || mdl->ResourceHandle == null || mdl->ResourceHandle->Category != ResourceCategory.Chara)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var node = CreateNodeFromResourceHandle(ResourceType.Mdl, (nint) mdl, mdl->ResourceHandle, false, false);
|
||||||
|
if (node == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (WithNames)
|
||||||
|
node = node.WithName(GuessModelName(node.GamePath));
|
||||||
|
|
||||||
|
for (var i = 0; i < mdl->MaterialCount; i++)
|
||||||
|
{
|
||||||
|
var mtrl = (Material*)mdl->Materials[i];
|
||||||
|
var mtrlNode = CreateNodeFromMaterial(mtrl);
|
||||||
|
if (mtrlNode != null)
|
||||||
|
// Don't keep the material's name if it's redundant with the model's name.
|
||||||
|
node.Children.Add(WithNames
|
||||||
|
? mtrlNode.WithName((string.Equals(mtrlNode.Name, node.Name, StringComparison.Ordinal) ? null : mtrlNode.Name)
|
||||||
|
?? $"Material #{i}")
|
||||||
|
: mtrlNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe ResourceNode? CreateNodeFromMaterial(Material* mtrl)
|
||||||
|
{
|
||||||
|
if (mtrl == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var resource = (MtrlResource*)mtrl->ResourceHandle;
|
||||||
|
var node = CreateNodeFromResourceHandle(ResourceType.Mtrl, (nint) mtrl, &resource->Handle, false, WithNames);
|
||||||
|
if (node == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var mtrlFile = WithNames ? FileCache.ReadMaterial(node.FullPath) : null;
|
||||||
|
|
||||||
|
var shpkNode = CreateNodeFromShpk(nint.Zero, new ByteString(resource->ShpkString), false);
|
||||||
|
if (shpkNode != null)
|
||||||
|
node.Children.Add(WithNames ? shpkNode.WithName("Shader Package") : shpkNode);
|
||||||
|
var shpkFile = WithNames && shpkNode != null ? FileCache.ReadShaderPackage(shpkNode.FullPath) : null;
|
||||||
|
var samplers = WithNames ? mtrlFile?.GetSamplersByTexture(shpkFile) : null;
|
||||||
|
|
||||||
|
for (var i = 0; i < resource->NumTex; i++)
|
||||||
|
{
|
||||||
|
var texNode = CreateNodeFromTex(nint.Zero, new ByteString(resource->TexString(i)), false, resource->TexIsDX11(i));
|
||||||
|
if (texNode == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (WithNames)
|
||||||
|
{
|
||||||
|
var name = samplers != null && i < samplers.Count ? samplers[i].Item2?.Name : null;
|
||||||
|
node.Children.Add(texNode.WithName(name ?? $"Texture #{i}"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
node.Children.Add(texNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FullPath FilterFullPath(FullPath fullPath)
|
||||||
|
{
|
||||||
|
if (!fullPath.IsRooted)
|
||||||
|
return fullPath;
|
||||||
|
|
||||||
|
var relPath = Path.GetRelativePath(Config.ModDirectory, fullPath.FullName);
|
||||||
|
if (relPath == "." || !relPath.StartsWith('.') && !Path.IsPathRooted(relPath))
|
||||||
|
return fullPath.Exists ? fullPath : FullPath.Empty;
|
||||||
|
|
||||||
|
return FullPath.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Utf8GamePath> Filter(List<Utf8GamePath> gamePaths)
|
||||||
|
{
|
||||||
|
var filtered = new List<Utf8GamePath>(gamePaths.Count);
|
||||||
|
foreach (var path in gamePaths)
|
||||||
|
{
|
||||||
|
// In doubt, keep the paths.
|
||||||
|
if (IsMatch(path.ToString().Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
?? true)
|
||||||
|
filtered.Add(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool? IsMatch(ReadOnlySpan<string> path)
|
||||||
|
=> SafeGet(path, 0) switch
|
||||||
|
{
|
||||||
|
"chara" => SafeGet(path, 1) switch
|
||||||
|
{
|
||||||
|
"accessory" => IsMatchEquipment(path[2..], $"a{Equipment.Set.Value:D4}"),
|
||||||
|
"equipment" => IsMatchEquipment(path[2..], $"e{Equipment.Set.Value:D4}"),
|
||||||
|
"monster" => SafeGet(path, 2) == $"m{Skeleton:D4}",
|
||||||
|
"weapon" => IsMatchEquipment(path[2..], $"w{Equipment.Set.Value:D4}"),
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private bool? IsMatchEquipment(ReadOnlySpan<string> path, string equipmentDir)
|
||||||
|
=> SafeGet(path, 0) == equipmentDir
|
||||||
|
? SafeGet(path, 1) switch
|
||||||
|
{
|
||||||
|
"material" => SafeGet(path, 2) == $"v{Equipment.Variant:D4}",
|
||||||
|
_ => null,
|
||||||
|
}
|
||||||
|
: false;
|
||||||
|
|
||||||
|
private string? GuessModelName(Utf8GamePath gamePath)
|
||||||
|
{
|
||||||
|
var path = gamePath.ToString().Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
// Weapons intentionally left out.
|
||||||
|
var isEquipment = SafeGet(path, 0) == "chara" && SafeGet(path, 1) is "accessory" or "equipment";
|
||||||
|
if (isEquipment)
|
||||||
|
foreach (var item in Identifier.Identify(Equipment.Set, Equipment.Variant, Slot.ToSlot()))
|
||||||
|
{
|
||||||
|
return Slot switch
|
||||||
|
{
|
||||||
|
EquipSlot.RFinger => "R: ",
|
||||||
|
EquipSlot.LFinger => "L: ",
|
||||||
|
_ => string.Empty,
|
||||||
|
}
|
||||||
|
+ item.Name.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameFromPath = GuessNameFromPath(gamePath);
|
||||||
|
if (nameFromPath != null)
|
||||||
|
return nameFromPath;
|
||||||
|
|
||||||
|
return isEquipment ? Slot.ToName() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? GuessNameFromPath(Utf8GamePath gamePath)
|
||||||
|
{
|
||||||
|
foreach (var obj in Identifier.Identify(gamePath.ToString()))
|
||||||
|
{
|
||||||
|
var name = obj.Key;
|
||||||
|
if (name.StartsWith("Customization:"))
|
||||||
|
name = name[14..].Trim();
|
||||||
|
if (name != "Unknown")
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? SafeGet(ReadOnlySpan<string> array, Index index)
|
||||||
|
{
|
||||||
|
var i = index.GetOffset(array.Length);
|
||||||
|
return i >= 0 && i < array.Length ? array[i] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
Penumbra/Interop/ResourceTree/ResourceNode.cs
Normal file
61
Penumbra/Interop/ResourceTree/ResourceNode.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Penumbra.GameData.Enums;
|
||||||
|
using Penumbra.String.Classes;
|
||||||
|
|
||||||
|
namespace Penumbra.Interop.ResourceTree;
|
||||||
|
|
||||||
|
public class ResourceNode
|
||||||
|
{
|
||||||
|
public readonly string? Name;
|
||||||
|
public readonly ResourceType Type;
|
||||||
|
public readonly nint SourceAddress;
|
||||||
|
public readonly Utf8GamePath GamePath;
|
||||||
|
public readonly Utf8GamePath[] PossibleGamePaths;
|
||||||
|
public readonly FullPath FullPath;
|
||||||
|
public readonly bool Internal;
|
||||||
|
public readonly List<ResourceNode> Children;
|
||||||
|
|
||||||
|
public ResourceNode(string? name, ResourceType type, nint sourceAddress, Utf8GamePath gamePath, FullPath fullPath, bool @internal)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Type = type;
|
||||||
|
SourceAddress = sourceAddress;
|
||||||
|
GamePath = gamePath;
|
||||||
|
PossibleGamePaths = new[]
|
||||||
|
{
|
||||||
|
gamePath,
|
||||||
|
};
|
||||||
|
FullPath = fullPath;
|
||||||
|
Internal = @internal;
|
||||||
|
Children = new List<ResourceNode>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResourceNode(string? name, ResourceType type, nint sourceAddress, Utf8GamePath[] possibleGamePaths, FullPath fullPath,
|
||||||
|
bool @internal)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Type = type;
|
||||||
|
SourceAddress = sourceAddress;
|
||||||
|
GamePath = possibleGamePaths.Length == 1 ? possibleGamePaths[0] : Utf8GamePath.Empty;
|
||||||
|
PossibleGamePaths = possibleGamePaths;
|
||||||
|
FullPath = fullPath;
|
||||||
|
Internal = @internal;
|
||||||
|
Children = new List<ResourceNode>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResourceNode(string? name, ResourceNode originalResourceNode)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Type = originalResourceNode.Type;
|
||||||
|
SourceAddress = originalResourceNode.SourceAddress;
|
||||||
|
GamePath = originalResourceNode.GamePath;
|
||||||
|
PossibleGamePaths = originalResourceNode.PossibleGamePaths;
|
||||||
|
FullPath = originalResourceNode.FullPath;
|
||||||
|
Internal = originalResourceNode.Internal;
|
||||||
|
Children = originalResourceNode.Children;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResourceNode WithName(string? name)
|
||||||
|
=> string.Equals(Name, name, StringComparison.Ordinal) ? this : new ResourceNode(name, this);
|
||||||
|
}
|
||||||
99
Penumbra/Interop/ResourceTree/ResourceTree.cs
Normal file
99
Penumbra/Interop/ResourceTree/ResourceTree.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.Game.Object;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
||||||
|
using Penumbra.GameData.Enums;
|
||||||
|
using Penumbra.GameData.Structs;
|
||||||
|
using Penumbra.Interop.Structs;
|
||||||
|
|
||||||
|
namespace Penumbra.Interop.ResourceTree;
|
||||||
|
|
||||||
|
public class ResourceTree
|
||||||
|
{
|
||||||
|
public readonly string Name;
|
||||||
|
public readonly nint SourceAddress;
|
||||||
|
public readonly string CollectionName;
|
||||||
|
public readonly List<ResourceNode> Nodes;
|
||||||
|
|
||||||
|
public ResourceTree(string name, nint sourceAddress, string collectionName)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
SourceAddress = sourceAddress;
|
||||||
|
CollectionName = collectionName;
|
||||||
|
Nodes = new List<ResourceNode>();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal unsafe void LoadResources(GlobalResolveContext globalContext)
|
||||||
|
{
|
||||||
|
var character = (Character*)SourceAddress;
|
||||||
|
var model = (CharacterBase*) character->GameObject.GetDrawObject();
|
||||||
|
var equipment = new ReadOnlySpan<CharacterArmor>(character->EquipSlotData, 10);
|
||||||
|
// var customize = new ReadOnlySpan<byte>( character->CustomizeData, 26 );
|
||||||
|
|
||||||
|
for (var i = 0; i < model->SlotCount; ++i)
|
||||||
|
{
|
||||||
|
var context = globalContext.CreateContext(
|
||||||
|
i < equipment.Length ? ((uint)i).ToEquipSlot() : EquipSlot.Unknown,
|
||||||
|
i < equipment.Length ? equipment[i] : default
|
||||||
|
);
|
||||||
|
|
||||||
|
var imc = (ResourceHandle*)model->IMCArray[i];
|
||||||
|
var imcNode = context.CreateNodeFromImc(imc);
|
||||||
|
if (imcNode != null)
|
||||||
|
Nodes.Add(globalContext.WithNames ? imcNode.WithName(imcNode.Name ?? $"IMC #{i}") : imcNode);
|
||||||
|
|
||||||
|
var mdl = (RenderModel*)model->ModelArray[i];
|
||||||
|
var mdlNode = context.CreateNodeFromRenderModel(mdl);
|
||||||
|
if (mdlNode != null)
|
||||||
|
Nodes.Add(globalContext.WithNames ? mdlNode.WithName(mdlNode.Name ?? $"Model #{i}") : mdlNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character->GameObject.GetObjectKind() == (byte) ObjectKind.Pc)
|
||||||
|
AddHumanResources(globalContext, (HumanExt*)model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe void AddHumanResources(GlobalResolveContext globalContext, HumanExt* human)
|
||||||
|
{
|
||||||
|
var prependIndex = 0;
|
||||||
|
|
||||||
|
var firstWeapon = (WeaponExt*)human->Human.CharacterBase.DrawObject.Object.ChildObject;
|
||||||
|
if (firstWeapon != null)
|
||||||
|
{
|
||||||
|
var weapon = firstWeapon;
|
||||||
|
var weaponIndex = 0;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
var weaponContext = globalContext.CreateContext(
|
||||||
|
slot: EquipSlot.MainHand,
|
||||||
|
equipment: new CharacterArmor(weapon->Weapon.ModelSetId, (byte)weapon->Weapon.Variant, (byte)weapon->Weapon.ModelUnknown)
|
||||||
|
);
|
||||||
|
|
||||||
|
var weaponMdlNode = weaponContext.CreateNodeFromRenderModel(*weapon->WeaponRenderModel);
|
||||||
|
if (weaponMdlNode != null)
|
||||||
|
Nodes.Insert(prependIndex++,
|
||||||
|
globalContext.WithNames ? weaponMdlNode.WithName(weaponMdlNode.Name ?? $"Weapon Model #{weaponIndex}") : weaponMdlNode);
|
||||||
|
|
||||||
|
weapon = (WeaponExt*)weapon->Weapon.CharacterBase.DrawObject.Object.NextSiblingObject;
|
||||||
|
++weaponIndex;
|
||||||
|
} while (weapon != null && weapon != firstWeapon);
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = globalContext.CreateContext(
|
||||||
|
EquipSlot.Unknown,
|
||||||
|
default
|
||||||
|
);
|
||||||
|
|
||||||
|
var skeletonNode = context.CreateHumanSkeletonNode((GenderRace) human->Human.RaceSexId);
|
||||||
|
if (skeletonNode != null)
|
||||||
|
Nodes.Add(globalContext.WithNames ? skeletonNode.WithName(skeletonNode.Name ?? "Skeleton") : skeletonNode);
|
||||||
|
|
||||||
|
var decalNode = context.CreateNodeFromTex(human->Decal);
|
||||||
|
if (decalNode != null)
|
||||||
|
Nodes.Add(globalContext.WithNames ? decalNode.WithName(decalNode.Name ?? "Face Decal") : decalNode);
|
||||||
|
|
||||||
|
var legacyDecalNode = context.CreateNodeFromTex(human->LegacyBodyDecal);
|
||||||
|
if (legacyDecalNode != null)
|
||||||
|
Nodes.Add(globalContext.WithNames ? legacyDecalNode.WithName(legacyDecalNode.Name ?? "Legacy Body Decal") : legacyDecalNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs
Normal file
76
Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Dalamud.Data;
|
||||||
|
using Dalamud.Game.ClientState.Objects;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.Game.Object;
|
||||||
|
using Penumbra.GameData;
|
||||||
|
using Penumbra.Interop.Resolver;
|
||||||
|
using Penumbra.Services;
|
||||||
|
|
||||||
|
namespace Penumbra.Interop.ResourceTree;
|
||||||
|
|
||||||
|
public class ResourceTreeFactory
|
||||||
|
{
|
||||||
|
private readonly DataManager _gameData;
|
||||||
|
private readonly ObjectTable _objects;
|
||||||
|
private readonly CollectionResolver _collectionResolver;
|
||||||
|
private readonly IdentifierService _identifier;
|
||||||
|
private readonly Configuration _config;
|
||||||
|
|
||||||
|
public ResourceTreeFactory(DataManager gameData, ObjectTable objects, CollectionResolver resolver, IdentifierService identifier,
|
||||||
|
Configuration config)
|
||||||
|
{
|
||||||
|
_gameData = gameData;
|
||||||
|
_objects = objects;
|
||||||
|
_collectionResolver = resolver;
|
||||||
|
_identifier = identifier;
|
||||||
|
_config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResourceTree[] FromObjectTable(bool withNames = true)
|
||||||
|
{
|
||||||
|
var cache = new FileCache(_gameData);
|
||||||
|
|
||||||
|
return _objects
|
||||||
|
.OfType<Dalamud.Game.ClientState.Objects.Types.Character>()
|
||||||
|
.Select(c => FromCharacter(c, cache, withNames))
|
||||||
|
.OfType<ResourceTree>()
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<(Dalamud.Game.ClientState.Objects.Types.Character Character, ResourceTree ResourceTree)> FromCharacters(
|
||||||
|
IEnumerable<Dalamud.Game.ClientState.Objects.Types.Character> characters,
|
||||||
|
bool withNames = true)
|
||||||
|
{
|
||||||
|
var cache = new FileCache(_gameData);
|
||||||
|
foreach (var character in characters)
|
||||||
|
{
|
||||||
|
var tree = FromCharacter(character, cache, withNames);
|
||||||
|
if (tree != null)
|
||||||
|
yield return (character, tree);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResourceTree? FromCharacter(Dalamud.Game.ClientState.Objects.Types.Character character, bool withNames = true)
|
||||||
|
=> FromCharacter(character, new FileCache(_gameData), withNames);
|
||||||
|
|
||||||
|
private unsafe ResourceTree? FromCharacter(Dalamud.Game.ClientState.Objects.Types.Character character, FileCache cache,
|
||||||
|
bool withNames = true)
|
||||||
|
{
|
||||||
|
var gameObjStruct = (GameObject*)character.Address;
|
||||||
|
if (gameObjStruct->GetDrawObject() == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var collectionResolveData = _collectionResolver.IdentifyCollection(gameObjStruct, true);
|
||||||
|
if (!collectionResolveData.Valid)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var tree = new ResourceTree(character.Name.ToString(), (nint)gameObjStruct, collectionResolveData.ModCollection.Name);
|
||||||
|
var globalContext = new GlobalResolveContext(_config, _identifier.AwaitedService, cache, collectionResolveData.ModCollection,
|
||||||
|
((Character*)gameObjStruct)->ModelCharaId,
|
||||||
|
withNames);
|
||||||
|
tree.LoadResources(globalContext);
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Penumbra/Interop/Structs/WeaponExt.cs
Normal file
14
Penumbra/Interop/Structs/WeaponExt.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
||||||
|
|
||||||
|
namespace Penumbra.Interop.Structs;
|
||||||
|
|
||||||
|
[StructLayout( LayoutKind.Explicit )]
|
||||||
|
public unsafe struct WeaponExt
|
||||||
|
{
|
||||||
|
[FieldOffset( 0x0 )]
|
||||||
|
public Weapon Weapon;
|
||||||
|
|
||||||
|
[FieldOffset( 0xA8 )]
|
||||||
|
public RenderModel** WeaponRenderModel;
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ using Penumbra.GameData.Data;
|
||||||
using Penumbra.Interop;
|
using Penumbra.Interop;
|
||||||
using Penumbra.Interop.Loader;
|
using Penumbra.Interop.Loader;
|
||||||
using Penumbra.Interop.Resolver;
|
using Penumbra.Interop.Resolver;
|
||||||
|
using Penumbra.Interop.ResourceTree;
|
||||||
using Penumbra.Interop.Services;
|
using Penumbra.Interop.Services;
|
||||||
using Penumbra.Mods;
|
using Penumbra.Mods;
|
||||||
using Penumbra.Services;
|
using Penumbra.Services;
|
||||||
|
|
@ -95,7 +96,8 @@ public class PenumbraNew
|
||||||
|
|
||||||
// Add Resource services
|
// Add Resource services
|
||||||
services.AddSingleton<ResourceLoader>()
|
services.AddSingleton<ResourceLoader>()
|
||||||
.AddSingleton<ResourceWatcher>();
|
.AddSingleton<ResourceWatcher>()
|
||||||
|
.AddSingleton<ResourceTreeFactory>();
|
||||||
|
|
||||||
// Add Path Resolver
|
// Add Path Resolver
|
||||||
services.AddSingleton<AnimationHookService>()
|
services.AddSingleton<AnimationHookService>()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue