On-Screen resource tree & quick import

This commit is contained in:
Exter-N 2023-03-23 02:52:51 +01:00
parent 3c564add0e
commit 045c84512f
11 changed files with 1194 additions and 18 deletions

View 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 );
}
}

View 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;
}

View file

@ -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];
}

View file

@ -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;

View 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;
}

View file

@ -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();
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using Dalamud.Interface;

View 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);
}
}
}

View file

@ -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,

View 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;
}
}

View file

@ -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;
}