Merge branch 'pr/n292_feature/on-screen' into services

# Conflicts:
#	Penumbra/UI/Classes/ModEditWindow.FileEditor.cs
#	Penumbra/UI/Classes/ModEditWindow.Files.cs
#	Penumbra/UI/Classes/ModEditWindow.cs
#	Penumbra/UI/ConfigWindow.cs
This commit is contained in:
Ottermandias 2023-03-23 17:50:00 +01:00
commit d28299f699
7 changed files with 1133 additions and 8 deletions

View file

@ -0,0 +1,678 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.ClientState.Objects.SubKinds;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using FFXIVClientStructs.FFXIV.Client.System.Resource;
using OtterGui;
using Penumbra.Collections;
using Penumbra.GameData.Actors;
using Penumbra.GameData.Enums;
using Penumbra.GameData.Files;
using Penumbra.Interop.Resolver;
using Penumbra.Interop.Structs;
using Penumbra.String;
using Penumbra.String.Classes;
using Objects = Dalamud.Game.ClientState.Objects.Types;
using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
namespace Penumbra.Interop;
public class ResourceTree
{
public readonly string Name;
public readonly nint SourceAddress;
public readonly bool PlayerRelated;
public readonly string CollectionName;
public readonly List<Node> Nodes;
public ResourceTree( string name, nint sourceAddress, bool playerRelated, string collectionName )
{
Name = name;
SourceAddress = sourceAddress;
PlayerRelated = playerRelated;
CollectionName = collectionName;
Nodes = new();
}
public static ResourceTree[] FromObjectTable( bool withNames = true )
{
var cache = new TreeBuildCache();
return cache.Characters
.Select( chara => FromCharacter( chara, cache, withNames ) )
.OfType<ResourceTree>()
.ToArray();
}
public static IEnumerable<(Objects.Character Character, ResourceTree ResourceTree)> FromCharacters( IEnumerable<Objects.Character> characters, bool withNames = true )
{
var cache = new TreeBuildCache();
foreach( var chara in characters )
{
var tree = FromCharacter( chara, cache, withNames );
if( tree != null )
{
yield return (chara, tree);
}
}
}
public static unsafe ResourceTree? FromCharacter( Objects.Character chara, bool withNames = true )
{
return FromCharacter( chara, new TreeBuildCache(), withNames );
}
private static unsafe ResourceTree? FromCharacter( Objects.Character chara, TreeBuildCache cache, bool withNames = true )
{
if( !chara.IsValid() )
{
return null;
}
var charaStruct = ( Character* )chara.Address;
var gameObjStruct = &charaStruct->GameObject;
var model = ( CharacterBase* )gameObjStruct->GetDrawObject();
if( model == null )
{
return null;
}
var equipment = new ReadOnlySpan<EquipmentRecord>( charaStruct->EquipSlotData, 10 );
// var customize = new ReadOnlySpan<byte>( charaStruct->CustomizeData, 26 );
var collectionResolveData = PathResolver.IdentifyCollection( gameObjStruct, true );
if( !collectionResolveData.Valid )
{
return null;
}
var collection = collectionResolveData.ModCollection;
var name = GetCharacterName( chara, cache );
var tree = new ResourceTree( name.Name, new nint( charaStruct ), name.PlayerRelated, collection.Name );
var globalContext = new GlobalResolveContext(
FileCache: cache,
Collection: collection,
Skeleton: charaStruct->ModelSkeletonId,
WithNames: withNames
);
for( var i = 0; i < model->SlotCount; ++i )
{
var context = globalContext.CreateContext(
Slot: ( i < equipment.Length ) ? ( ( uint )i ).ToEquipSlot() : EquipSlot.Unknown,
Equipment: ( i < equipment.Length ) ? equipment[i] : default
);
var imc = ( ResourceHandle* )model->IMCArray[i];
var imcNode = context.CreateNodeFromImc( imc );
if( imcNode != null )
{
tree.Nodes.Add( withNames ? imcNode.WithName( imcNode.Name ?? $"IMC #{i}" ) : imcNode );
}
var mdl = ( RenderModel* )model->ModelArray[i];
var mdlNode = context.CreateNodeFromRenderModel( mdl );
if( mdlNode != null )
{
tree.Nodes.Add( withNames ? mdlNode.WithName(mdlNode.Name ?? $"Model #{i}") : mdlNode );
}
}
if( chara is PlayerCharacter )
{
AddHumanResources( tree, globalContext, ( HumanExt* )model );
}
return tree;
}
private static unsafe (string Name, bool PlayerRelated) GetCharacterName( Objects.Character chara, TreeBuildCache cache )
{
var identifier = Penumbra.Actors.FromObject( ( GameObject* )chara.Address, out var owner, true, false, false );
var name = chara.Name.ToString().Trim();
var playerRelated = true;
if( chara.ObjectKind != ObjectKind.Player )
{
name = $"{name} ({chara.ObjectKind.ToName()})".Trim();
playerRelated = false;
}
if( identifier.Type == IdentifierType.Owned && cache.CharactersById.TryGetValue( owner->ObjectID, out var ownerChara ) )
{
var ownerName = GetCharacterName( ownerChara, cache );
name = $"[{ownerName.Name}] {name}".Trim();
playerRelated |= ownerName.PlayerRelated;
}
return (name, playerRelated);
}
private static unsafe void AddHumanResources( ResourceTree tree, GlobalResolveContext globalContext, HumanExt* human )
{
var firstSubObject = ( CharacterBase* )human->Human.CharacterBase.DrawObject.Object.ChildObject;
if( firstSubObject != null )
{
var subObjectNodes = new List<Node>();
var subObject = firstSubObject;
var subObjectIndex = 0;
do
{
var weapon = ( subObject->GetModelType() == CharacterBase.ModelType.Weapon ) ? ( Weapon* )subObject : null;
var subObjectNamePrefix = ( weapon != null ) ? "Weapon" : "Fashion Acc.";
var subObjectContext = globalContext.CreateContext(
Slot: ( weapon != null ) ? EquipSlot.MainHand : EquipSlot.Unknown,
Equipment: ( weapon != null ) ? new EquipmentRecord( weapon->ModelSetId, ( byte )weapon->Variant, ( byte )weapon->ModelUnknown ) : default
);
for( var i = 0; i < subObject->SlotCount; ++i )
{
var imc = ( ResourceHandle* )subObject->IMCArray[i];
var imcNode = subObjectContext.CreateNodeFromImc( imc );
if( imcNode != null )
{
subObjectNodes.Add( globalContext.WithNames ? imcNode.WithName( imcNode.Name ?? $"{subObjectNamePrefix} #{subObjectIndex}, IMC #{i}" ) : imcNode );
}
var mdl = ( RenderModel* )subObject->ModelArray[i];
var mdlNode = subObjectContext.CreateNodeFromRenderModel( mdl );
if( mdlNode != null )
{
subObjectNodes.Add( globalContext.WithNames ? mdlNode.WithName( mdlNode.Name ?? $"{subObjectNamePrefix} #{subObjectIndex}, Model #{i}" ) : mdlNode );
}
}
subObject = ( CharacterBase* )subObject->DrawObject.Object.NextSiblingObject;
++subObjectIndex;
} while( subObject != null && subObject != firstSubObject );
tree.Nodes.InsertRange( 0, subObjectNodes );
}
var context = globalContext.CreateContext(
Slot: EquipSlot.Unknown,
Equipment: default
);
var skeletonNode = context.CreateHumanSkeletonNode( human->Human.RaceSexId );
if( skeletonNode != null )
{
tree.Nodes.Add( globalContext.WithNames ? skeletonNode.WithName( skeletonNode.Name ?? "Skeleton" ) : skeletonNode );
}
var decalNode = context.CreateNodeFromTex( human->Decal );
if( decalNode != null )
{
tree.Nodes.Add( globalContext.WithNames ? decalNode.WithName( decalNode.Name ?? "Face Decal" ) : decalNode );
}
var legacyDecalNode = context.CreateNodeFromTex( human->LegacyBodyDecal );
if( legacyDecalNode != null )
{
tree.Nodes.Add( globalContext.WithNames ? legacyDecalNode.WithName( legacyDecalNode.Name ?? "Legacy Body Decal" ) : legacyDecalNode );
}
}
private static unsafe bool CreateOwnedGamePath( byte* rawGamePath, out Utf8GamePath gamePath, bool addDx11Prefix = false, bool isShader = false )
{
if( rawGamePath == null )
{
gamePath = default;
return false;
}
if( isShader )
{
var path = $"shader/sm5/shpk/{new ByteString( rawGamePath )}";
return Utf8GamePath.FromString( path, out gamePath );
}
if( addDx11Prefix )
{
var unprefixed = MemoryMarshal.CreateReadOnlySpanFromNullTerminated( rawGamePath );
var lastDirectorySeparator = unprefixed.LastIndexOf( ( byte )'/' );
if( unprefixed[lastDirectorySeparator + 1] != ( byte )'-' || unprefixed[lastDirectorySeparator + 2] != ( byte )'-' )
{
Span<byte> prefixed = stackalloc byte[unprefixed.Length + 2];
unprefixed[..( lastDirectorySeparator + 1 )].CopyTo( prefixed );
prefixed[lastDirectorySeparator + 1] = ( byte )'-';
prefixed[lastDirectorySeparator + 2] = ( byte )'-';
unprefixed[( lastDirectorySeparator + 1 )..].CopyTo( prefixed[( lastDirectorySeparator + 3 )..] );
if( !Utf8GamePath.FromSpan( prefixed, out gamePath ) )
{
return false;
}
gamePath = gamePath.Clone();
return true;
}
}
if( !Utf8GamePath.FromPointer( rawGamePath, out gamePath ) )
{
return false;
}
gamePath = gamePath.Clone();
return true;
}
[StructLayout( LayoutKind.Sequential, Pack = 1, Size = 4 )]
private readonly record struct EquipmentRecord( ushort SetId, byte Variant, byte Dye );
private class TreeBuildCache
{
private readonly Dictionary<FullPath, MtrlFile?> Materials = new();
private readonly Dictionary<FullPath, ShpkFile?> ShaderPackages = new();
public readonly List<Objects.Character> Characters;
public readonly Dictionary<uint, Objects.Character> CharactersById;
public TreeBuildCache()
{
Characters = new();
CharactersById = new();
foreach( var chara in Dalamud.Objects.OfType<Objects.Character>() )
{
if( chara.IsValid() )
{
Characters.Add( chara );
if( chara.ObjectId != Objects.GameObject.InvalidGameObjectId && !CharactersById.TryAdd( chara.ObjectId, chara ) )
{
var existingChara = CharactersById[chara.ObjectId];
Penumbra.Log.Warning( $"Duplicate character ID {chara.ObjectId:X8} (old: {existingChara.Name} {existingChara.ObjectKind}, new: {chara.Name} {chara.ObjectKind})" );
}
}
}
}
public MtrlFile? ReadMaterial( FullPath path )
{
return ReadFile( path, Materials, bytes => new MtrlFile( bytes ) );
}
public ShpkFile? ReadShaderPackage( FullPath path )
{
return ReadFile( path, ShaderPackages, bytes => new ShpkFile( bytes ) );
}
private static T? ReadFile<T>( FullPath path, Dictionary<FullPath, T?> cache, Func<byte[], T> parseFile ) where T : class
{
if( path.FullName.Length == 0 )
{
return null;
}
if( cache.TryGetValue( path, out var cached ) )
{
return cached;
}
var pathStr = path.ToPath();
T? parsed;
try
{
if( path.IsRooted )
{
parsed = parseFile( File.ReadAllBytes( pathStr ) );
}
else
{
var bytes = Dalamud.GameData.GetFile( pathStr )?.Data;
parsed = ( bytes != null ) ? parseFile( bytes ) : null;
}
}
catch( Exception e )
{
Penumbra.Log.Error( $"Could not read file {pathStr}:\n{e}" );
parsed = null;
}
cache.Add( path, parsed );
return parsed;
}
}
private record class GlobalResolveContext( TreeBuildCache FileCache, ModCollection Collection, int Skeleton, bool WithNames )
{
public ResolveContext CreateContext( EquipSlot Slot, EquipmentRecord Equipment )
=> new( FileCache, Collection, Skeleton, WithNames, Slot, Equipment );
}
private record class ResolveContext( TreeBuildCache FileCache, ModCollection Collection, int Skeleton, bool WithNames, EquipSlot Slot, EquipmentRecord Equipment )
{
private unsafe Node? CreateNodeFromGamePath( ResourceType type, nint sourceAddress, byte* rawGamePath, bool @internal, bool addDx11Prefix = false, bool isShader = false )
{
if( !CreateOwnedGamePath( rawGamePath, out var gamePath, addDx11Prefix, isShader ) )
{
return null;
}
return CreateNodeFromGamePath( type, sourceAddress, gamePath, @internal );
}
private unsafe Node CreateNodeFromGamePath( ResourceType type, nint sourceAddress, Utf8GamePath gamePath, bool @internal )
=> new( null, type, sourceAddress, gamePath, FilterFullPath( Collection.ResolvePath( gamePath ) ?? new FullPath( gamePath ) ), @internal );
private unsafe Node? CreateNodeFromResourceHandle( ResourceType type, nint sourceAddress, ResourceHandle* handle, bool @internal, bool withName )
{
if( handle == null )
{
return null;
}
var name = handle->FileNameAsSpan();
if( name.Length == 0 )
{
return null;
}
if( name[0] == ( byte )'|' )
{
name = name[1..];
var pos = name.IndexOf( ( byte )'|' );
if( pos < 0 )
{
return null;
}
name = name[( pos + 1 )..];
}
var fullPath = new FullPath( Encoding.UTF8.GetString( name ) );
var gamePaths = Collection.ReverseResolvePath( fullPath ).ToList();
fullPath = FilterFullPath( fullPath );
if( gamePaths.Count > 1 )
{
gamePaths = Filter( gamePaths );
}
if( gamePaths.Count == 1 )
{
return new Node( withName ? GuessNameFromPath( gamePaths[0] ) : null, type, sourceAddress, gamePaths[0], fullPath, @internal );
}
else
{
Penumbra.Log.Information( $"Found {gamePaths.Count} game paths while reverse-resolving {fullPath} in {Collection.Name}:" );
foreach( var gamePath in gamePaths )
{
Penumbra.Log.Information( $"Game path: {gamePath}" );
}
return new Node( null, type, sourceAddress, gamePaths.ToArray(), fullPath, @internal );
}
}
public unsafe Node? CreateHumanSkeletonNode( ushort raceSexId )
{
var raceSexIdStr = raceSexId.ToString( "D4" );
var path = $"chara/human/c{raceSexIdStr}/skeleton/base/b0001/skl_c{raceSexIdStr}b0001.sklb";
if( !Utf8GamePath.FromString( path, out var gamePath ) )
{
return null;
}
return CreateNodeFromGamePath( ResourceType.Sklb, 0, gamePath, false );
}
public unsafe Node? CreateNodeFromImc( ResourceHandle* imc )
{
var node = CreateNodeFromResourceHandle( ResourceType.Imc, new nint( imc ), imc, true, false );
if( node == null )
{
return null;
}
if( WithNames )
{
var name = GuessModelName( node.GamePath );
node = node.WithName( ( name != null ) ? $"IMC: {name}" : null );
}
return node;
}
public unsafe Node? CreateNodeFromTex( ResourceHandle* tex )
{
return CreateNodeFromResourceHandle( ResourceType.Tex, new nint( tex ), tex, false, WithNames );
}
public unsafe Node? CreateNodeFromRenderModel( RenderModel* mdl )
{
if( mdl == null || mdl->ResourceHandle == null || mdl->ResourceHandle->Category != ResourceCategory.Chara )
{
return null;
}
var node = CreateNodeFromResourceHandle( ResourceType.Mdl, new nint( mdl ), mdl->ResourceHandle, false, false );
if( node == null )
{
return null;
}
if( WithNames )
{
node = node.WithName( GuessModelName( node.GamePath ) );
}
for( var i = 0; i < mdl->MaterialCount; i++ )
{
var mtrl = ( Material* )mdl->Materials[i];
var mtrlNode = CreateNodeFromMaterial( mtrl );
if( mtrlNode != null )
{
// Don't keep the material's name if it's redundant with the model's name.
node.Children.Add( WithNames ? mtrlNode.WithName( ( string.Equals( mtrlNode.Name, node.Name, StringComparison.Ordinal ) ? null : mtrlNode.Name ) ?? $"Material #{i}" ) : mtrlNode );
}
}
return node;
}
private unsafe Node? CreateNodeFromMaterial( Material* mtrl )
{
if( mtrl == null )
{
return null;
}
var resource = ( MtrlResource* )mtrl->ResourceHandle;
var node = CreateNodeFromResourceHandle( ResourceType.Mtrl, new nint( mtrl ), &resource->Handle, false, WithNames );
if( node == null )
{
return null;
}
var mtrlFile = WithNames ? FileCache.ReadMaterial( node.FullPath ) : null;
var shpkNode = CreateNodeFromGamePath( ResourceType.Shpk, 0, resource->ShpkString, false, isShader: true );
if( shpkNode != null )
{
node.Children.Add( WithNames ? shpkNode.WithName( "Shader Package" ) : shpkNode );
}
var shpkFile = ( WithNames && shpkNode != null ) ? FileCache.ReadShaderPackage( shpkNode.FullPath ) : null;
var samplers = WithNames ? mtrlFile?.GetSamplersByTexture(shpkFile) : null;
for( var i = 0; i < resource->NumTex; i++ )
{
var texNode = CreateNodeFromGamePath( ResourceType.Tex, 0, resource->TexString( i ), false, addDx11Prefix: resource->TexIsDX11( i ) );
if( texNode != null )
{
if( WithNames )
{
var name = ( samplers != null && i < samplers.Count ) ? samplers[i].Item2?.Name : null;
node.Children.Add( texNode.WithName( name ?? $"Texture #{i}" ) );
}
else
{
node.Children.Add( texNode );
}
}
}
return node;
}
private static FullPath FilterFullPath( FullPath fullPath )
{
if( !fullPath.IsRooted )
{
return fullPath;
}
var relPath = Path.GetRelativePath( Penumbra.Config.ModDirectory, fullPath.FullName );
if( relPath == "." || !relPath.StartsWith( '.' ) && !Path.IsPathRooted( relPath ) )
{
return fullPath.Exists ? fullPath : FullPath.Empty;
}
return FullPath.Empty;
}
private List<Utf8GamePath> Filter( List<Utf8GamePath> gamePaths )
{
var filtered = new List<Utf8GamePath>( gamePaths.Count );
foreach( var path in gamePaths )
{
// In doubt, keep the paths.
if( IsMatch( path.ToString().Split( new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries ) ) ?? true )
{
filtered.Add( path );
}
}
return filtered;
}
private bool? IsMatch( ReadOnlySpan<string> path )
=> SafeGet( path, 0 ) switch
{
"chara" => SafeGet( path, 1 ) switch
{
"accessory" => IsMatchEquipment( path[2..], $"a{Equipment.SetId:D4}" ),
"equipment" => IsMatchEquipment( path[2..], $"e{Equipment.SetId:D4}" ),
"monster" => SafeGet( path, 2 ) == $"m{Skeleton:D4}",
"weapon" => IsMatchEquipment( path[2..], $"w{Equipment.SetId:D4}" ),
_ => null,
},
_ => null,
};
private bool? IsMatchEquipment( ReadOnlySpan<string> path, string equipmentDir )
=> SafeGet( path, 0 ) == equipmentDir
? SafeGet( path, 1 ) switch
{
"material" => SafeGet( path, 2 ) == $"v{Equipment.Variant:D4}",
_ => null,
}
: false;
private string? GuessModelName( Utf8GamePath gamePath )
{
var path = gamePath.ToString().Split( new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries );
// Weapons intentionally left out.
var isEquipment = SafeGet( path, 0 ) == "chara" && SafeGet( path, 1 ) is "accessory" or "equipment";
if( isEquipment )
{
foreach( var item in Penumbra.Identifier.Identify( Equipment.SetId, Equipment.Variant, Slot.ToSlot() ) )
{
return Slot switch
{
EquipSlot.RFinger => "R: ",
EquipSlot.LFinger => "L: ",
_ => string.Empty,
} + item.Name.ToString();
}
}
var nameFromPath = GuessNameFromPath( gamePath );
if( nameFromPath != null )
{
return nameFromPath;
}
if( isEquipment )
{
return Slot.ToName();
}
return null;
}
private static string? GuessNameFromPath( Utf8GamePath gamePath )
{
foreach( var obj in Penumbra.Identifier.Identify( gamePath.ToString() ) )
{
var name = obj.Key;
if( name.StartsWith( "Customization:" ) )
{
name = name[14..].Trim();
}
if( name != "Unknown" )
{
return name;
}
}
return null;
}
private static string? SafeGet( ReadOnlySpan<string> array, Index index )
{
var i = index.GetOffset( array.Length );
return ( i >= 0 && i < array.Length ) ? array[i] : null;
}
}
public class Node
{
public readonly string? Name;
public readonly ResourceType Type;
public readonly nint SourceAddress;
public readonly Utf8GamePath GamePath;
public readonly Utf8GamePath[] PossibleGamePaths;
public readonly FullPath FullPath;
public readonly bool Internal;
public readonly List<Node> Children;
public Node( string? name, ResourceType type, nint sourceAddress, Utf8GamePath gamePath, FullPath fullPath, bool @internal )
{
Name = name;
Type = type;
SourceAddress = sourceAddress;
GamePath = gamePath;
PossibleGamePaths = new[] { gamePath };
FullPath = fullPath;
Internal = @internal;
Children = new();
}
public Node( string? name, ResourceType type, nint sourceAddress, Utf8GamePath[] possibleGamePaths, FullPath fullPath, bool @internal )
{
Name = name;
Type = type;
SourceAddress = sourceAddress;
GamePath = ( possibleGamePaths.Length == 1 ) ? possibleGamePaths[0] : Utf8GamePath.Empty;
PossibleGamePaths = possibleGamePaths;
FullPath = fullPath;
Internal = @internal;
Children = new();
}
private Node( string? name, Node originalNode )
{
Name = name;
Type = originalNode.Type;
SourceAddress = originalNode.SourceAddress;
GamePath = originalNode.GamePath;
PossibleGamePaths = originalNode.PossibleGamePaths;
FullPath = originalNode.FullPath;
Internal = originalNode.Internal;
Children = originalNode.Children;
}
public Node WithName( string? name )
=> string.Equals( Name, name, StringComparison.Ordinal ) ? this : new Node( name, this );
}
}

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,234 @@
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 ResourceTreeViewer? _quickImportViewer;
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;
}
_quickImportViewer ??= new( "Import from Screen tab", 2, OnQuickImportRefresh, DrawQuickImportActions );
_quickImportWritables ??= new();
_quickImportActions ??= new();
_quickImportViewer.Draw();
_quickImportFileDialog.Draw();
}
private void OnQuickImportRefresh()
{
_quickImportWritables?.Clear();
_quickImportActions?.Clear();
}
private void DrawQuickImportActions( ResourceTree.Node resourceNode, Vector2 buttonSize )
{
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(), buttonSize, "Export this file.", resourceNode.FullPath.FullName.Length == 0 || 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(), buttonSize, $"Add a copy of this file to {quickImport.OptionName}.", !quickImport.CanExecute, true ) )
{
quickImport.Execute();
_quickImportActions.Remove( (resourceNode.GamePath, writable) );
}
}
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

@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using Dalamud.Interface;
using ImGuiNET;
using OtterGui.Raii;
using OtterGui;
using Penumbra.Interop;
namespace Penumbra.UI.Classes;
public class ResourceTreeViewer
{
private readonly string _name;
private readonly int _actionCapacity;
private readonly Action _onRefresh;
private readonly Action<ResourceTree.Node, Vector2> _drawActions;
private readonly HashSet<ResourceTree.Node> _unfolded;
private ResourceTree[]? _trees;
public ResourceTreeViewer( string name, int actionCapacity, Action onRefresh, Action<ResourceTree.Node, Vector2> drawActions )
{
_name = name;
_actionCapacity = actionCapacity;
_onRefresh = onRefresh;
_drawActions = drawActions;
_unfolded = new();
_trees = null;
}
public void Draw()
{
if( ImGui.Button( "Refresh Character List" ) )
{
try
{
_trees = ResourceTree.FromObjectTable();
}
catch( Exception e )
{
Penumbra.Log.Error( $"Could not get character list for {_name}:\n{e}" );
_trees = Array.Empty<ResourceTree>();
}
_unfolded.Clear();
_onRefresh();
}
try
{
_trees ??= ResourceTree.FromObjectTable();
}
catch( Exception e )
{
Penumbra.Log.Error( $"Could not get character list for {_name}:\n{e}" );
_trees ??= Array.Empty<ResourceTree>();
}
var textColorNonPlayer = ImGui.GetColorU32( ImGuiCol.Text );
var textColorPlayer = ( textColorNonPlayer & 0xFF000000u ) | ( ( textColorNonPlayer & 0x00FEFEFE ) >> 1 ) | 0x8000u; // Half green
foreach( var (tree, index) in _trees.WithIndex() )
{
using( var c = ImRaii.PushColor( ImGuiCol.Text, tree.PlayerRelated ? textColorPlayer : textColorNonPlayer ) )
{
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", ( _actionCapacity > 0 ) ? 4 : 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 );
if( _actionCapacity > 0 )
{
ImGui.TableSetupColumn( string.Empty, ImGuiTableColumnFlags.WidthFixed, (_actionCapacity - 1) * 3 * ImGuiHelpers.GlobalScale + _actionCapacity * ImGui.GetFrameHeight() );
}
ImGui.TableHeadersRow();
DrawNodes( tree.Nodes, 0 );
}
}
private void DrawNodes( IEnumerable<ResourceTree.Node> resourceNodes, int level )
{
var debugMode = Penumbra.Config.DebugMode;
var frameHeight = ImGui.GetFrameHeight();
var cellHeight = ( _actionCapacity > 0 ) ? frameHeight : 0.0f;
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, cellHeight ) );
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();
if( resourceNode.FullPath.FullName.Length > 0 )
{
ImGui.Selectable( resourceNode.FullPath.ToString(), false, 0, new Vector2( ImGui.GetContentRegionAvail().X, cellHeight ) );
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, cellHeight ) );
ImGuiUtil.HoverTooltip( "The actual path to this file is unavailable.\nIt may be managed by another plug-in." );
}
if( _actionCapacity > 0 )
{
ImGui.TableNextColumn();
using( var spacing = ImRaii.PushStyle( ImGuiStyleVar.ItemSpacing, ImGui.GetStyle().ItemSpacing with { X = 3 * ImGuiHelpers.GlobalScale } ) )
{
_drawActions( resourceNode, new Vector2( frameHeight ) );
}
}
if( unfolded )
{
DrawNodes( resourceNode.Children, level + 1 );
}
}
}
}

View file

@ -0,0 +1,23 @@
using System;
using OtterGui.Widgets;
using Penumbra.UI.Classes;
namespace Penumbra.UI;
public partial class ConfigWindow
{
private class OnScreenTab : ITab
{
private ResourceTreeViewer? _viewer;
public ReadOnlySpan<byte> Label
=> "On-Screen"u8;
public void DrawContent()
{
_viewer ??= new( "On-Screen tab", 0, delegate { }, delegate { } );
_viewer.Draw();
}
}
}