diff --git a/Penumbra/Interop/ResourceTree.cs b/Penumbra/Interop/ResourceTree.cs deleted file mode 100644 index 3c1cfb70..00000000 --- a/Penumbra/Interop/ResourceTree.cs +++ /dev/null @@ -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 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() - .ToArray(); - } - - public static IEnumerable<(Objects.Character Character, ResourceTree ResourceTree)> FromCharacters( IEnumerable 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( charaStruct->EquipSlotData, 10 ); - // var customize = new ReadOnlySpan( 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(); - 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 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 Materials = new(); - private readonly Dictionary ShaderPackages = new(); - public readonly List Characters; - public readonly Dictionary CharactersById; - - public TreeBuildCache() - { - Characters = new(); - CharactersById = new(); - foreach( var chara in Dalamud.Objects.OfType() ) - { - 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( FullPath path, Dictionary cache, Func 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 Filter( List gamePaths ) - { - var filtered = new List( 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 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 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 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 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 ); - } -} diff --git a/Penumbra/Interop/ResourceTree/FileCache.cs b/Penumbra/Interop/ResourceTree/FileCache.cs new file mode 100644 index 00000000..c6c9ac9d --- /dev/null +++ b/Penumbra/Interop/ResourceTree/FileCache.cs @@ -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 _materials = new(); + private readonly Dictionary _shaderPackages = new(); + + public FileCache(DataManager dataManager) + => _dataManager = dataManager; + + /// Try to read a material file from the given path and cache it on success. + public MtrlFile? ReadMaterial(FullPath path) + => ReadFile(_dataManager, path, _materials, bytes => new MtrlFile(bytes)); + + /// Try to read a shpk file from the given path and cache it on success. + public ShpkFile? ReadShaderPackage(FullPath path) + => ReadFile(_dataManager, path, _shaderPackages, bytes => new ShpkFile(bytes)); + + private static T? ReadFile(DataManager dataManager, FullPath path, Dictionary cache, Func 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; + } +} diff --git a/Penumbra/Interop/ResourceTree/ResolveContext.cs b/Penumbra/Interop/ResourceTree/ResolveContext.cs new file mode 100644 index 00000000..82d8af29 --- /dev/null +++ b/Penumbra/Interop/ResourceTree/ResolveContext.cs @@ -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 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 Filter(List gamePaths) + { + var filtered = new List(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 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 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 array, Index index) + { + var i = index.GetOffset(array.Length); + return i >= 0 && i < array.Length ? array[i] : null; + } +} diff --git a/Penumbra/Interop/ResourceTree/ResourceNode.cs b/Penumbra/Interop/ResourceTree/ResourceNode.cs new file mode 100644 index 00000000..dc0c5fcb --- /dev/null +++ b/Penumbra/Interop/ResourceTree/ResourceNode.cs @@ -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 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(); + } + + 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(); + } + + 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); +} diff --git a/Penumbra/Interop/ResourceTree/ResourceTree.cs b/Penumbra/Interop/ResourceTree/ResourceTree.cs new file mode 100644 index 00000000..b30b5f0e --- /dev/null +++ b/Penumbra/Interop/ResourceTree/ResourceTree.cs @@ -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 Nodes; + + public ResourceTree(string name, nint sourceAddress, string collectionName) + { + Name = name; + SourceAddress = sourceAddress; + CollectionName = collectionName; + Nodes = new List(); + } + + internal unsafe void LoadResources(GlobalResolveContext globalContext) + { + var character = (Character*)SourceAddress; + var model = (CharacterBase*) character->GameObject.GetDrawObject(); + var equipment = new ReadOnlySpan(character->EquipSlotData, 10); + // var customize = new ReadOnlySpan( 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); + } +} diff --git a/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs b/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs new file mode 100644 index 00000000..a90d688e --- /dev/null +++ b/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs @@ -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() + .Select(c => FromCharacter(c, cache, withNames)) + .OfType() + .ToArray(); + } + + public IEnumerable<(Dalamud.Game.ClientState.Objects.Types.Character Character, ResourceTree ResourceTree)> FromCharacters( + IEnumerable 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; + } +} diff --git a/Penumbra/Interop/Structs/WeaponExt.cs b/Penumbra/Interop/Structs/WeaponExt.cs new file mode 100644 index 00000000..de7038d7 --- /dev/null +++ b/Penumbra/Interop/Structs/WeaponExt.cs @@ -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; +} \ No newline at end of file diff --git a/Penumbra/PenumbraNew.cs b/Penumbra/PenumbraNew.cs index 37a099af..00e32edd 100644 --- a/Penumbra/PenumbraNew.cs +++ b/Penumbra/PenumbraNew.cs @@ -11,6 +11,7 @@ using Penumbra.GameData.Data; using Penumbra.Interop; using Penumbra.Interop.Loader; using Penumbra.Interop.Resolver; +using Penumbra.Interop.ResourceTree; using Penumbra.Interop.Services; using Penumbra.Mods; using Penumbra.Services; @@ -95,7 +96,8 @@ public class PenumbraNew // Add Resource services services.AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); // Add Path Resolver services.AddSingleton()