mirror of
https://github.com/xivdev/Penumbra.git
synced 2025-12-12 18:27:24 +01:00
On-Screen resource tree & quick import
This commit is contained in:
parent
3c564add0e
commit
045c84512f
11 changed files with 1194 additions and 18 deletions
613
Penumbra/Interop/ResourceTree.cs
Normal file
613
Penumbra/Interop/ResourceTree.cs
Normal file
|
|
@ -0,0 +1,613 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
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.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;
|
||||
|
||||
namespace Penumbra.Interop;
|
||||
|
||||
public class ResourceTree
|
||||
{
|
||||
public readonly string Name;
|
||||
public readonly nint SourceAddress;
|
||||
public readonly string CollectionName;
|
||||
public readonly List<Node> Nodes;
|
||||
|
||||
public ResourceTree( string name, nint sourceAddress, string collectionName )
|
||||
{
|
||||
Name = name;
|
||||
SourceAddress = sourceAddress;
|
||||
CollectionName = collectionName;
|
||||
Nodes = new();
|
||||
}
|
||||
|
||||
public static ResourceTree[] FromObjectTable( bool withNames = true )
|
||||
{
|
||||
var cache = new FileCache();
|
||||
|
||||
return Dalamud.Objects
|
||||
.OfType<Objects.Character>()
|
||||
.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 FileCache();
|
||||
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 FileCache(), withNames );
|
||||
}
|
||||
|
||||
private static unsafe ResourceTree? FromCharacter( Objects.Character chara, FileCache cache, bool withNames = true )
|
||||
{
|
||||
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 tree = new ResourceTree( chara.Name.ToString(), new nint( charaStruct ), 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 void AddHumanResources( ResourceTree tree, 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 EquipmentRecord( weapon->Weapon.ModelSetId, ( byte )weapon->Weapon.Variant, ( byte )weapon->Weapon.ModelUnknown )
|
||||
);
|
||||
|
||||
var weaponMdlNode = weaponContext.CreateNodeFromRenderModel( *weapon->WeaponRenderModel );
|
||||
if( weaponMdlNode != null )
|
||||
{
|
||||
tree.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(
|
||||
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 FileCache
|
||||
{
|
||||
private readonly Dictionary<FullPath, MtrlFile?> Materials = new();
|
||||
private readonly Dictionary<FullPath, ShpkFile?> ShaderPackages = new();
|
||||
|
||||
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( FileCache 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( FileCache 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 );
|
||||
}
|
||||
}
|
||||
17
Penumbra/Interop/Structs/HumanExt.cs
Normal file
17
Penumbra/Interop/Structs/HumanExt.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
||||
|
||||
namespace Penumbra.Interop.Structs;
|
||||
|
||||
[StructLayout( LayoutKind.Explicit )]
|
||||
public unsafe struct HumanExt
|
||||
{
|
||||
[FieldOffset( 0x0 )]
|
||||
public Human Human;
|
||||
|
||||
[FieldOffset( 0x9E8 )]
|
||||
public ResourceHandle* Decal;
|
||||
|
||||
[FieldOffset( 0x9F0 )]
|
||||
public ResourceHandle* LegacyBodyDecal;
|
||||
}
|
||||
|
|
@ -12,12 +12,8 @@ public unsafe struct Material
|
|||
[FieldOffset( 0x28 )]
|
||||
public void* MaterialData;
|
||||
|
||||
[FieldOffset( 0x48 )]
|
||||
public Texture* Tex1;
|
||||
[FieldOffset( 0x30 )]
|
||||
public void** Textures;
|
||||
|
||||
[FieldOffset( 0x60 )]
|
||||
public Texture* Tex2;
|
||||
|
||||
[FieldOffset( 0x78 )]
|
||||
public Texture* Tex3;
|
||||
public Texture* Texture( int index ) => ( Texture* )Textures[3 * index + 1];
|
||||
}
|
||||
|
|
@ -58,8 +58,11 @@ public unsafe struct ResourceHandle
|
|||
public ByteString FileName()
|
||||
=> ByteString.FromByteStringUnsafe( FileNamePtr(), FileNameLength, true );
|
||||
|
||||
public ReadOnlySpan< byte > FileNameAsSpan()
|
||||
=> new( FileNamePtr(), FileNameLength );
|
||||
|
||||
public bool GamePath( out Utf8GamePath path )
|
||||
=> Utf8GamePath.FromSpan( new ReadOnlySpan< byte >( FileNamePtr(), FileNameLength ), out path );
|
||||
=> Utf8GamePath.FromSpan( FileNameAsSpan(), out path );
|
||||
|
||||
[FieldOffset( 0x00 )]
|
||||
public void** VTable;
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using Dalamud.Interface;
|
||||
|
|
@ -18,6 +19,7 @@ public partial class ModEditWindow
|
|||
{
|
||||
private class FileEditor< T > where T : class, IWritable
|
||||
{
|
||||
private readonly ModEditWindow _owner;
|
||||
private readonly string _tabName;
|
||||
private readonly string _fileType;
|
||||
private readonly Func< IReadOnlyList< Mod.Editor.FileRegistry > > _getFiles;
|
||||
|
|
@ -30,18 +32,23 @@ public partial class ModEditWindow
|
|||
private Exception? _currentException;
|
||||
private bool _changed;
|
||||
|
||||
private string _defaultPath = string.Empty;
|
||||
private bool _inInput;
|
||||
private T? _defaultFile;
|
||||
private Exception? _defaultException;
|
||||
private string _defaultPath = string.Empty;
|
||||
private bool _inInput;
|
||||
private Utf8GamePath _defaultPathUtf8;
|
||||
private bool _isDefaultPathUtf8Valid;
|
||||
private T? _defaultFile;
|
||||
private Exception? _defaultException;
|
||||
|
||||
private QuickImportAction? _quickImport;
|
||||
|
||||
private IReadOnlyList< Mod.Editor.FileRegistry > _list = null!;
|
||||
|
||||
private readonly FileDialogManager _fileDialog = ConfigWindow.SetupFileManager();
|
||||
|
||||
public FileEditor( string tabName, string fileType, Func< IReadOnlyList< Mod.Editor.FileRegistry > > getFiles,
|
||||
public FileEditor( ModEditWindow owner, string tabName, string fileType, Func< IReadOnlyList< Mod.Editor.FileRegistry > > getFiles,
|
||||
Func< T, bool, bool > drawEdit, Func< string > getInitialPath, Func< byte[], T? >? parseFile )
|
||||
{
|
||||
_owner = owner;
|
||||
_tabName = tabName;
|
||||
_fileType = fileType;
|
||||
_getFiles = getFiles;
|
||||
|
|
@ -56,6 +63,7 @@ public partial class ModEditWindow
|
|||
using var tab = ImRaii.TabItem( _tabName );
|
||||
if( !tab )
|
||||
{
|
||||
_quickImport = null;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -74,11 +82,13 @@ public partial class ModEditWindow
|
|||
private void DefaultInput()
|
||||
{
|
||||
using var spacing = ImRaii.PushStyle( ImGuiStyleVar.ItemSpacing, ImGui.GetStyle().ItemSpacing with { X = 3 * ImGuiHelpers.GlobalScale } );
|
||||
ImGui.SetNextItemWidth( ImGui.GetContentRegionAvail().X - 3 * ImGuiHelpers.GlobalScale - ImGui.GetFrameHeight() );
|
||||
ImGui.SetNextItemWidth( ImGui.GetContentRegionAvail().X - 2 * (3 * ImGuiHelpers.GlobalScale + ImGui.GetFrameHeight() ) );
|
||||
ImGui.InputTextWithHint( "##defaultInput", "Input game path to compare...", ref _defaultPath, Utf8GamePath.MaxGamePathLength );
|
||||
_inInput = ImGui.IsItemActive();
|
||||
if( ImGui.IsItemDeactivatedAfterEdit() && _defaultPath.Length > 0 )
|
||||
{
|
||||
_isDefaultPathUtf8Valid = Utf8GamePath.FromString( _defaultPath, out _defaultPathUtf8, true );
|
||||
_quickImport = null;
|
||||
_fileDialog.Reset();
|
||||
try
|
||||
{
|
||||
|
|
@ -122,6 +132,22 @@ public partial class ModEditWindow
|
|||
}, _getInitialPath() );
|
||||
}
|
||||
|
||||
_quickImport ??= QuickImportAction.Prepare( _owner, _isDefaultPathUtf8Valid ? _defaultPathUtf8 : Utf8GamePath.Empty, _defaultFile );
|
||||
|
||||
ImGui.SameLine();
|
||||
if( ImGuiUtil.DrawDisabledButton( FontAwesomeIcon.FileImport.ToIconString(), new Vector2( ImGui.GetFrameHeight() ), $"Add a copy of this file to {_quickImport.OptionName}.", !_quickImport.CanExecute, true ) )
|
||||
{
|
||||
try
|
||||
{
|
||||
UpdateCurrentFile( _quickImport.Execute() );
|
||||
}
|
||||
catch( Exception e )
|
||||
{
|
||||
Penumbra.Log.Error( $"Could not add a copy of {_quickImport.GamePath} to {_quickImport.OptionName}:\n{e}" );
|
||||
}
|
||||
_quickImport = null;
|
||||
}
|
||||
|
||||
_fileDialog.Draw();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using Dalamud.Interface;
|
||||
|
|
|
|||
352
Penumbra/UI/Classes/ModEditWindow.QuickImport.cs
Normal file
352
Penumbra/UI/Classes/ModEditWindow.QuickImport.cs
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.ImGuiFileDialog;
|
||||
using ImGuiNET;
|
||||
using Lumina.Data;
|
||||
using OtterGui;
|
||||
using OtterGui.Raii;
|
||||
using Penumbra.GameData.Files;
|
||||
using Penumbra.Interop;
|
||||
using Penumbra.Mods;
|
||||
using Penumbra.String.Classes;
|
||||
|
||||
namespace Penumbra.UI.Classes;
|
||||
|
||||
public partial class ModEditWindow
|
||||
{
|
||||
private ResourceTree[]? _quickImportTrees;
|
||||
private HashSet<ResourceTree.Node>? _quickImportUnfolded;
|
||||
private Dictionary<FullPath, IWritable?>? _quickImportWritables;
|
||||
private Dictionary<(Utf8GamePath, IWritable?), QuickImportAction>? _quickImportActions;
|
||||
|
||||
private readonly FileDialogManager _quickImportFileDialog = ConfigWindow.SetupFileManager();
|
||||
|
||||
private void DrawQuickImportTab()
|
||||
{
|
||||
using var tab = ImRaii.TabItem( "Import from Screen" );
|
||||
if( !tab )
|
||||
{
|
||||
_quickImportActions = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_quickImportUnfolded ??= new();
|
||||
_quickImportWritables ??= new();
|
||||
_quickImportActions ??= new();
|
||||
|
||||
if( ImGui.Button( "Refresh Character List" ) )
|
||||
{
|
||||
try
|
||||
{
|
||||
_quickImportTrees = ResourceTree.FromObjectTable();
|
||||
}
|
||||
catch( Exception e )
|
||||
{
|
||||
Penumbra.Log.Error( $"Could not get character list for Import from Screen tab:\n{e}" );
|
||||
_quickImportTrees = Array.Empty<ResourceTree>();
|
||||
}
|
||||
_quickImportUnfolded.Clear();
|
||||
_quickImportWritables.Clear();
|
||||
_quickImportActions.Clear();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_quickImportTrees ??= ResourceTree.FromObjectTable();
|
||||
}
|
||||
catch( Exception e )
|
||||
{
|
||||
Penumbra.Log.Error( $"Could not get character list for Import from Screen tab:\n{e}" );
|
||||
_quickImportTrees ??= Array.Empty<ResourceTree>();
|
||||
}
|
||||
|
||||
foreach( var (tree, index) in _quickImportTrees.WithIndex() )
|
||||
{
|
||||
if( !ImGui.CollapsingHeader( $"{tree.Name}##{index}", ( index == 0 ) ? ImGuiTreeNodeFlags.DefaultOpen : 0 ) )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
using var id = ImRaii.PushId( index );
|
||||
|
||||
ImGui.Text( $"Collection: {tree.CollectionName}" );
|
||||
|
||||
using var table = ImRaii.Table( "##ResourceTree", 4,
|
||||
ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg );
|
||||
if( !table )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ImGui.TableSetupColumn( string.Empty , ImGuiTableColumnFlags.WidthStretch, 0.2f );
|
||||
ImGui.TableSetupColumn( "Game Path" , ImGuiTableColumnFlags.WidthStretch, 0.3f );
|
||||
ImGui.TableSetupColumn( "Actual Path", ImGuiTableColumnFlags.WidthStretch, 0.5f );
|
||||
ImGui.TableSetupColumn( string.Empty , ImGuiTableColumnFlags.WidthFixed, 3 * ImGuiHelpers.GlobalScale + 2 * ImGui.GetFrameHeight() );
|
||||
ImGui.TableHeadersRow();
|
||||
|
||||
DrawQuickImportNodes( tree.Nodes, 0 );
|
||||
}
|
||||
|
||||
_quickImportFileDialog.Draw();
|
||||
}
|
||||
|
||||
private void DrawQuickImportNodes( IEnumerable<ResourceTree.Node> resourceNodes, int level )
|
||||
{
|
||||
var debugMode = Penumbra.Config.DebugMode;
|
||||
var frameHeight = ImGui.GetFrameHeight();
|
||||
foreach( var (resourceNode, index) in resourceNodes.WithIndex() )
|
||||
{
|
||||
if( resourceNode.Internal && !debugMode )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
using var id = ImRaii.PushId( index );
|
||||
ImGui.TableNextColumn();
|
||||
var unfolded = _quickImportUnfolded!.Contains( resourceNode );
|
||||
using( var indent = ImRaii.PushIndent( level ) )
|
||||
{
|
||||
ImGui.TableHeader( ( ( resourceNode.Children.Count > 0 ) ? ( unfolded ? "[-] " : "[+] " ) : string.Empty ) + resourceNode.Name );
|
||||
if( ImGui.IsItemClicked() && resourceNode.Children.Count > 0 )
|
||||
{
|
||||
if( unfolded )
|
||||
{
|
||||
_quickImportUnfolded.Remove( resourceNode );
|
||||
}
|
||||
else
|
||||
{
|
||||
_quickImportUnfolded.Add( resourceNode );
|
||||
}
|
||||
unfolded = !unfolded;
|
||||
}
|
||||
if( debugMode )
|
||||
{
|
||||
ImGuiUtil.HoverTooltip( $"Resource Type: {resourceNode.Type}\nSource Address: 0x{resourceNode.SourceAddress.ToString("X" + nint.Size * 2)}" );
|
||||
}
|
||||
}
|
||||
ImGui.TableNextColumn();
|
||||
var hasGamePaths = resourceNode.PossibleGamePaths.Length > 0;
|
||||
ImGui.Selectable( resourceNode.PossibleGamePaths.Length switch
|
||||
{
|
||||
0 => "(none)",
|
||||
1 => resourceNode.GamePath.ToString(),
|
||||
_ => "(multiple)",
|
||||
}, false, hasGamePaths ? 0 : ImGuiSelectableFlags.Disabled, new Vector2( ImGui.GetContentRegionAvail().X, frameHeight ) );
|
||||
if( hasGamePaths )
|
||||
{
|
||||
var allPaths = string.Join( '\n', resourceNode.PossibleGamePaths );
|
||||
if( ImGui.IsItemClicked() )
|
||||
{
|
||||
ImGui.SetClipboardText( allPaths );
|
||||
}
|
||||
ImGuiUtil.HoverTooltip( $"{allPaths}\n\nClick to copy to clipboard." );
|
||||
}
|
||||
ImGui.TableNextColumn();
|
||||
var hasFullPath = resourceNode.FullPath.FullName.Length > 0;
|
||||
if( hasFullPath )
|
||||
{
|
||||
ImGui.Selectable( resourceNode.FullPath.ToString(), false, 0, new Vector2( ImGui.GetContentRegionAvail().X, frameHeight ) );
|
||||
if( ImGui.IsItemClicked() )
|
||||
{
|
||||
ImGui.SetClipboardText( resourceNode.FullPath.ToString() );
|
||||
}
|
||||
ImGuiUtil.HoverTooltip( $"{resourceNode.FullPath}\n\nClick to copy to clipboard." );
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.Selectable( "(unavailable)", false, ImGuiSelectableFlags.Disabled, new Vector2( ImGui.GetContentRegionAvail().X, frameHeight ) );
|
||||
ImGuiUtil.HoverTooltip( "The actual path to this file is unavailable.\nIt may be managed by another plug-in." );
|
||||
}
|
||||
ImGui.TableNextColumn();
|
||||
using( var spacing = ImRaii.PushStyle( ImGuiStyleVar.ItemSpacing, ImGui.GetStyle().ItemSpacing with { X = 3 * ImGuiHelpers.GlobalScale } ) )
|
||||
{
|
||||
if( !_quickImportWritables!.TryGetValue( resourceNode.FullPath, out var writable ) )
|
||||
{
|
||||
var path = resourceNode.FullPath.ToPath();
|
||||
if( resourceNode.FullPath.IsRooted )
|
||||
{
|
||||
writable = new RawFileWritable( path );
|
||||
}
|
||||
else
|
||||
{
|
||||
var file = Dalamud.GameData.GetFile( path );
|
||||
writable = ( file == null ) ? null : new RawGameFileWritable( file );
|
||||
}
|
||||
_quickImportWritables.Add( resourceNode.FullPath, writable );
|
||||
}
|
||||
if( ImGuiUtil.DrawDisabledButton( FontAwesomeIcon.Save.ToIconString(), new Vector2( frameHeight ), "Export this file.", !hasFullPath || writable == null, true ) )
|
||||
{
|
||||
var fullPathStr = resourceNode.FullPath.FullName;
|
||||
var ext = ( resourceNode.PossibleGamePaths.Length == 1 ) ? Path.GetExtension( resourceNode.GamePath.ToString() ) : Path.GetExtension( fullPathStr );
|
||||
_quickImportFileDialog.SaveFileDialog( $"Export {Path.GetFileName( fullPathStr )} to...", ext, Path.GetFileNameWithoutExtension( fullPathStr ), ext, ( success, name ) =>
|
||||
{
|
||||
if( !success )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes( name, writable!.Write() );
|
||||
}
|
||||
catch( Exception e )
|
||||
{
|
||||
Penumbra.Log.Error( $"Could not export {fullPathStr}:\n{e}" );
|
||||
}
|
||||
} );
|
||||
}
|
||||
ImGui.SameLine();
|
||||
if( !_quickImportActions!.TryGetValue( (resourceNode.GamePath, writable), out var quickImport ) )
|
||||
{
|
||||
quickImport = QuickImportAction.Prepare( this, resourceNode.GamePath, writable );
|
||||
_quickImportActions.Add( (resourceNode.GamePath, writable), quickImport );
|
||||
}
|
||||
if( ImGuiUtil.DrawDisabledButton( FontAwesomeIcon.FileImport.ToIconString(), new Vector2( frameHeight ), $"Add a copy of this file to {quickImport.OptionName}.", !quickImport.CanExecute, true ) )
|
||||
{
|
||||
quickImport.Execute();
|
||||
_quickImportActions.Remove( (resourceNode.GamePath, writable) );
|
||||
}
|
||||
}
|
||||
if( unfolded )
|
||||
{
|
||||
DrawQuickImportNodes( resourceNode.Children, level + 1 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record class RawFileWritable( string Path ) : IWritable
|
||||
{
|
||||
public bool Valid => true;
|
||||
|
||||
public byte[] Write()
|
||||
=> File.ReadAllBytes( Path );
|
||||
}
|
||||
|
||||
private record class RawGameFileWritable( FileResource FileResource ) : IWritable
|
||||
{
|
||||
public bool Valid => true;
|
||||
|
||||
public byte[] Write()
|
||||
=> FileResource.Data;
|
||||
}
|
||||
|
||||
private class QuickImportAction
|
||||
{
|
||||
public const string FallbackOptionName = "the current option";
|
||||
|
||||
private readonly string _optionName;
|
||||
private readonly Utf8GamePath _gamePath;
|
||||
private readonly Mod.Editor? _editor;
|
||||
private readonly IWritable? _file;
|
||||
private readonly string? _targetPath;
|
||||
private readonly int _subDirs;
|
||||
|
||||
public string OptionName => _optionName;
|
||||
public Utf8GamePath GamePath => _gamePath;
|
||||
public bool CanExecute => !_gamePath.IsEmpty && _editor != null && _file != null && _targetPath != null;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a non-executable QuickImportAction.
|
||||
/// </summary>
|
||||
private QuickImportAction( string optionName, Utf8GamePath gamePath )
|
||||
{
|
||||
_optionName = optionName;
|
||||
_gamePath = gamePath;
|
||||
_editor = null;
|
||||
_file = null;
|
||||
_targetPath = null;
|
||||
_subDirs = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an executable QuickImportAction.
|
||||
/// </summary>
|
||||
private QuickImportAction( string optionName, Utf8GamePath gamePath, Mod.Editor editor, IWritable file, string targetPath, int subDirs )
|
||||
{
|
||||
_optionName = optionName;
|
||||
_gamePath = gamePath;
|
||||
_editor = editor;
|
||||
_file = file;
|
||||
_targetPath = targetPath;
|
||||
_subDirs = subDirs;
|
||||
}
|
||||
|
||||
public static QuickImportAction Prepare( ModEditWindow owner, Utf8GamePath gamePath, IWritable? file )
|
||||
{
|
||||
var editor = owner._editor;
|
||||
if( editor == null )
|
||||
{
|
||||
return new QuickImportAction( FallbackOptionName, gamePath );
|
||||
}
|
||||
var subMod = editor.CurrentOption;
|
||||
var optionName = subMod.FullName;
|
||||
if( gamePath.IsEmpty || file == null || editor.FileChanges )
|
||||
{
|
||||
return new QuickImportAction( optionName, gamePath );
|
||||
}
|
||||
if( subMod.Files.ContainsKey( gamePath ) || subMod.FileSwaps.ContainsKey( gamePath ) )
|
||||
{
|
||||
return new QuickImportAction( optionName, gamePath );
|
||||
}
|
||||
var mod = owner._mod;
|
||||
if( mod == null )
|
||||
{
|
||||
return new QuickImportAction( optionName, gamePath );
|
||||
}
|
||||
var ( preferredPath, subDirs ) = GetPreferredPath( mod, subMod );
|
||||
var targetPath = new FullPath( Path.Combine( preferredPath.FullName, gamePath.ToString() ) ).FullName;
|
||||
if( File.Exists( targetPath ) )
|
||||
{
|
||||
return new QuickImportAction( optionName, gamePath );
|
||||
}
|
||||
|
||||
return new QuickImportAction( optionName, gamePath, editor, file, targetPath, subDirs );
|
||||
}
|
||||
|
||||
public Mod.Editor.FileRegistry Execute()
|
||||
{
|
||||
if( !CanExecute )
|
||||
{
|
||||
throw new InvalidOperationException();
|
||||
}
|
||||
var directory = Path.GetDirectoryName( _targetPath );
|
||||
if( directory != null )
|
||||
{
|
||||
Directory.CreateDirectory( directory );
|
||||
}
|
||||
File.WriteAllBytes( _targetPath!, _file!.Write() );
|
||||
_editor!.RevertFiles();
|
||||
var fileRegistry = _editor.AvailableFiles.First( file => file.File.FullName == _targetPath );
|
||||
_editor.AddPathsToSelected( new Mod.Editor.FileRegistry[] { fileRegistry }, _subDirs );
|
||||
_editor.ApplyFiles();
|
||||
|
||||
return fileRegistry;
|
||||
}
|
||||
|
||||
private static (DirectoryInfo, int) GetPreferredPath( Mod mod, ISubMod subMod )
|
||||
{
|
||||
var path = mod.ModPath;
|
||||
var subDirs = 0;
|
||||
if( subMod != mod.Default )
|
||||
{
|
||||
var name = subMod.Name;
|
||||
var fullName = subMod.FullName;
|
||||
if( fullName.EndsWith( ": " + name ) )
|
||||
{
|
||||
path = Mod.Creator.NewOptionDirectory( path, fullName[..^( name.Length + 2 )] );
|
||||
path = Mod.Creator.NewOptionDirectory( path, name );
|
||||
subDirs = 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
path = Mod.Creator.NewOptionDirectory( path, fullName );
|
||||
subDirs = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return (path, subDirs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -153,6 +153,7 @@ public partial class ModEditWindow : Window, IDisposable
|
|||
DrawSwapTab();
|
||||
DrawMissingFilesTab();
|
||||
DrawDuplicatesTab();
|
||||
DrawQuickImportTab();
|
||||
DrawMaterialReassignmentTab();
|
||||
_modelTab.Draw();
|
||||
_materialTab.Draw();
|
||||
|
|
@ -570,17 +571,17 @@ public partial class ModEditWindow : Window, IDisposable
|
|||
public ModEditWindow()
|
||||
: base( WindowBaseLabel )
|
||||
{
|
||||
_materialTab = new FileEditor< MtrlTab >( "Materials", ".mtrl",
|
||||
_materialTab = new FileEditor< MtrlTab >( this, "Materials", ".mtrl",
|
||||
() => _editor?.MtrlFiles ?? Array.Empty< Editor.FileRegistry >(),
|
||||
DrawMaterialPanel,
|
||||
() => _mod?.ModPath.FullName ?? string.Empty,
|
||||
bytes => new MtrlTab( this, new MtrlFile( bytes ) ) );
|
||||
_modelTab = new FileEditor< MdlFile >( "Models", ".mdl",
|
||||
_modelTab = new FileEditor< MdlFile >( this, "Models", ".mdl",
|
||||
() => _editor?.MdlFiles ?? Array.Empty< Editor.FileRegistry >(),
|
||||
DrawModelPanel,
|
||||
() => _mod?.ModPath.FullName ?? string.Empty,
|
||||
null );
|
||||
_shaderPackageTab = new FileEditor< ShpkTab >( "Shader Packages", ".shpk",
|
||||
_shaderPackageTab = new FileEditor< ShpkTab >( this, "Shader Packages", ".shpk",
|
||||
() => _editor?.ShpkFiles ?? Array.Empty< Editor.FileRegistry >(),
|
||||
DrawShaderPackagePanel,
|
||||
() => _mod?.ModPath.FullName ?? string.Empty,
|
||||
|
|
|
|||
150
Penumbra/UI/ConfigWindow.OnScreenTab.cs
Normal file
150
Penumbra/UI/ConfigWindow.OnScreenTab.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using ImGuiNET;
|
||||
using OtterGui;
|
||||
using OtterGui.Raii;
|
||||
using OtterGui.Widgets;
|
||||
using Penumbra.Interop;
|
||||
|
||||
namespace Penumbra.UI;
|
||||
|
||||
public partial class ConfigWindow
|
||||
{
|
||||
private class OnScreenTab : ITab
|
||||
{
|
||||
public ReadOnlySpan<byte> Label
|
||||
=> "On-Screen"u8;
|
||||
|
||||
public void DrawContent()
|
||||
{
|
||||
_unfolded ??= new();
|
||||
|
||||
if( ImGui.Button( "Refresh Character List" ) )
|
||||
{
|
||||
try
|
||||
{
|
||||
_trees = ResourceTree.FromObjectTable();
|
||||
}
|
||||
catch( Exception e )
|
||||
{
|
||||
Penumbra.Log.Error( $"Could not get character list for On-Screen tab:\n{e}" );
|
||||
_trees = Array.Empty<ResourceTree>();
|
||||
}
|
||||
_unfolded.Clear();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_trees ??= ResourceTree.FromObjectTable();
|
||||
}
|
||||
catch( Exception e )
|
||||
{
|
||||
Penumbra.Log.Error( $"Could not get character list for On-Screen tab:\n{e}" );
|
||||
_trees ??= Array.Empty<ResourceTree>();
|
||||
}
|
||||
|
||||
foreach( var (tree, index) in _trees.WithIndex() )
|
||||
{
|
||||
if( !ImGui.CollapsingHeader( $"{tree.Name}##{index}", ( index == 0 ) ? ImGuiTreeNodeFlags.DefaultOpen : 0 ) )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
using var id = ImRaii.PushId( index );
|
||||
|
||||
ImGui.Text( $"Collection: {tree.CollectionName}" );
|
||||
|
||||
using var table = ImRaii.Table( "##ResourceTree", 3,
|
||||
ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg );
|
||||
if( !table )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ImGui.TableSetupColumn( string.Empty, ImGuiTableColumnFlags.WidthStretch, 0.2f );
|
||||
ImGui.TableSetupColumn( "Game Path", ImGuiTableColumnFlags.WidthStretch, 0.3f );
|
||||
ImGui.TableSetupColumn( "Actual Path", ImGuiTableColumnFlags.WidthStretch, 0.5f );
|
||||
ImGui.TableHeadersRow();
|
||||
|
||||
DrawNodes( tree.Nodes, 0 );
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawNodes( IEnumerable<ResourceTree.Node> resourceNodes, int level )
|
||||
{
|
||||
var debugMode = Penumbra.Config.DebugMode;
|
||||
var frameHeight = ImGui.GetFrameHeight();
|
||||
foreach( var (resourceNode, index) in resourceNodes.WithIndex() )
|
||||
{
|
||||
if( resourceNode.Internal && !debugMode )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
using var id = ImRaii.PushId( index );
|
||||
ImGui.TableNextColumn();
|
||||
var unfolded = _unfolded!.Contains( resourceNode );
|
||||
using( var indent = ImRaii.PushIndent( level ) )
|
||||
{
|
||||
ImGui.TableHeader( ( ( resourceNode.Children.Count > 0 ) ? ( unfolded ? "[-] " : "[+] " ) : string.Empty ) + resourceNode.Name );
|
||||
if( ImGui.IsItemClicked() && resourceNode.Children.Count > 0 )
|
||||
{
|
||||
if( unfolded )
|
||||
{
|
||||
_unfolded.Remove( resourceNode );
|
||||
}
|
||||
else
|
||||
{
|
||||
_unfolded.Add( resourceNode );
|
||||
}
|
||||
unfolded = !unfolded;
|
||||
}
|
||||
if( debugMode )
|
||||
{
|
||||
ImGuiUtil.HoverTooltip( $"Resource Type: {resourceNode.Type}\nSource Address: 0x{resourceNode.SourceAddress.ToString( "X" + nint.Size * 2 )}" );
|
||||
}
|
||||
}
|
||||
ImGui.TableNextColumn();
|
||||
var hasGamePaths = resourceNode.PossibleGamePaths.Length > 0;
|
||||
ImGui.Selectable( resourceNode.PossibleGamePaths.Length switch
|
||||
{
|
||||
0 => "(none)",
|
||||
1 => resourceNode.GamePath.ToString(),
|
||||
_ => "(multiple)",
|
||||
}, false, hasGamePaths ? 0 : ImGuiSelectableFlags.Disabled, new Vector2( ImGui.GetContentRegionAvail().X, frameHeight ) );
|
||||
if( hasGamePaths )
|
||||
{
|
||||
var allPaths = string.Join( '\n', resourceNode.PossibleGamePaths );
|
||||
if( ImGui.IsItemClicked() )
|
||||
{
|
||||
ImGui.SetClipboardText( allPaths );
|
||||
}
|
||||
ImGuiUtil.HoverTooltip( $"{allPaths}\n\nClick to copy to clipboard." );
|
||||
}
|
||||
ImGui.TableNextColumn();
|
||||
var hasFullPath = resourceNode.FullPath.FullName.Length > 0;
|
||||
if( hasFullPath )
|
||||
{
|
||||
ImGui.Selectable( resourceNode.FullPath.ToString(), false, 0, new Vector2( ImGui.GetContentRegionAvail().X, frameHeight ) );
|
||||
if( ImGui.IsItemClicked() )
|
||||
{
|
||||
ImGui.SetClipboardText( resourceNode.FullPath.ToString() );
|
||||
}
|
||||
ImGuiUtil.HoverTooltip( $"{resourceNode.FullPath}\n\nClick to copy to clipboard." );
|
||||
}
|
||||
else
|
||||
{
|
||||
ImGui.Selectable( "(unavailable)", false, ImGuiSelectableFlags.Disabled, new Vector2( ImGui.GetContentRegionAvail().X, frameHeight ) );
|
||||
ImGuiUtil.HoverTooltip( "The actual path to this file is unavailable.\nIt may be managed by another plug-in." );
|
||||
}
|
||||
if( unfolded )
|
||||
{
|
||||
DrawNodes( resourceNode.Children, level + 1 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ResourceTree[]? _trees;
|
||||
private HashSet<ResourceTree.Node>? _unfolded;
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ public sealed partial class ConfigWindow : Window, IDisposable
|
|||
private readonly ModsTab _modsTab;
|
||||
private readonly ChangedItemsTab _changedItemsTab;
|
||||
private readonly EffectiveTab _effectiveTab;
|
||||
private readonly OnScreenTab _onScreenTab;
|
||||
private readonly DebugTab _debugTab;
|
||||
private readonly ResourceTab _resourceTab;
|
||||
private readonly ResourceWatcher _resourceWatcher;
|
||||
|
|
@ -47,6 +48,7 @@ public sealed partial class ConfigWindow : Window, IDisposable
|
|||
_collectionsTab = new CollectionsTab( this );
|
||||
_changedItemsTab = new ChangedItemsTab( this );
|
||||
_effectiveTab = new EffectiveTab();
|
||||
_onScreenTab = new OnScreenTab();
|
||||
_debugTab = new DebugTab( this );
|
||||
_resourceTab = new ResourceTab();
|
||||
if( Penumbra.Config.FixMainWindow )
|
||||
|
|
@ -74,6 +76,7 @@ public sealed partial class ConfigWindow : Window, IDisposable
|
|||
TabType.Collections => _collectionsTab.Label,
|
||||
TabType.ChangedItems => _changedItemsTab.Label,
|
||||
TabType.EffectiveChanges => _effectiveTab.Label,
|
||||
TabType.OnScreen => _onScreenTab.Label,
|
||||
TabType.ResourceWatcher => _resourceWatcher.Label,
|
||||
TabType.Debug => _debugTab.Label,
|
||||
TabType.ResourceManager => _resourceTab.Label,
|
||||
|
|
@ -120,7 +123,7 @@ public sealed partial class ConfigWindow : Window, IDisposable
|
|||
{
|
||||
SetupSizes();
|
||||
if( TabBar.Draw( string.Empty, ImGuiTabBarFlags.NoTooltip, ToLabel( SelectTab ), _settingsTab, _modsTab, _collectionsTab,
|
||||
_changedItemsTab, _effectiveTab, _resourceWatcher, _debugTab, _resourceTab ) )
|
||||
_changedItemsTab, _effectiveTab, _onScreenTab, _resourceWatcher, _debugTab, _resourceTab ) )
|
||||
{
|
||||
SelectTab = TabType.None;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue