diff --git a/Penumbra/Interop/ResourceTree.cs b/Penumbra/Interop/ResourceTree.cs new file mode 100644 index 00000000..31c84cd6 --- /dev/null +++ b/Penumbra/Interop/ResourceTree.cs @@ -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 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() + .Select( chara => FromCharacter( chara, cache, withNames ) ) + .OfType() + .ToArray(); + } + + public static IEnumerable<(Objects.Character Character, ResourceTree ResourceTree)> FromCharacters( IEnumerable characters, bool withNames = true ) + { + var cache = new 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( charaStruct->EquipSlotData, 10 ); + // var customize = new ReadOnlySpan( charaStruct->CustomizeData, 26 ); + + var collectionResolveData = PathResolver.IdentifyCollection( gameObjStruct, true ); + if( !collectionResolveData.Valid ) + { + return null; + } + var collection = collectionResolveData.ModCollection; + + var 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 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 Materials = new(); + private readonly Dictionary 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( FullPath path, Dictionary cache, Func parseFile ) where T : class + { + if( path.FullName.Length == 0 ) + { + return null; + } + + if( cache.TryGetValue( path, out var cached ) ) + { + return cached; + } + + var pathStr = path.ToPath(); + T? parsed; + try + { + if( path.IsRooted ) + { + parsed = parseFile( File.ReadAllBytes( pathStr ) ); + } + else + { + var bytes = Dalamud.GameData.GetFile( pathStr )?.Data; + parsed = ( bytes != null ) ? parseFile( bytes ) : null; + } + } + catch( Exception e ) + { + Penumbra.Log.Error( $"Could not read file {pathStr}:\n{e}" ); + parsed = null; + } + cache.Add( path, parsed ); + + return parsed; + } + } + + private record class GlobalResolveContext( 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 Filter( List gamePaths ) + { + var filtered = new List( gamePaths.Count ); + foreach( var path in gamePaths ) + { + // In doubt, keep the paths. + if( IsMatch( path.ToString().Split( new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries ) ) ?? true ) + { + filtered.Add( path ); + } + } + + return filtered; + } + + private bool? IsMatch( ReadOnlySpan path ) + => SafeGet( path, 0 ) switch + { + "chara" => SafeGet( path, 1 ) switch + { + "accessory" => IsMatchEquipment( path[2..], $"a{Equipment.SetId:D4}" ), + "equipment" => IsMatchEquipment( path[2..], $"e{Equipment.SetId:D4}" ), + "monster" => SafeGet( path, 2 ) == $"m{Skeleton:D4}", + "weapon" => IsMatchEquipment( path[2..], $"w{Equipment.SetId:D4}" ), + _ => null, + }, + _ => null, + }; + + private bool? IsMatchEquipment( ReadOnlySpan path, string equipmentDir ) + => SafeGet( path, 0 ) == equipmentDir + ? SafeGet( path, 1 ) switch + { + "material" => SafeGet( path, 2 ) == $"v{Equipment.Variant:D4}", + _ => null, + } + : false; + + private string? GuessModelName( Utf8GamePath gamePath ) + { + var path = gamePath.ToString().Split( new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries ); + // Weapons intentionally left out. + var isEquipment = SafeGet( path, 0 ) == "chara" && SafeGet( path, 1 ) is "accessory" or "equipment"; + if( isEquipment ) + { + foreach( var item in Penumbra.Identifier.Identify( Equipment.SetId, Equipment.Variant, Slot.ToSlot() ) ) + { + return Slot switch + { + EquipSlot.RFinger => "R: ", + EquipSlot.LFinger => "L: ", + _ => string.Empty, + } + item.Name.ToString(); + } + } + var nameFromPath = GuessNameFromPath( gamePath ); + if( nameFromPath != null ) + { + return nameFromPath; + } + if( isEquipment ) + { + return Slot.ToName(); + } + + return null; + } + + private static string? GuessNameFromPath( Utf8GamePath gamePath ) + { + foreach( var obj in Penumbra.Identifier.Identify( gamePath.ToString() ) ) + { + var name = obj.Key; + if( name.StartsWith( "Customization:" ) ) + { + name = name[14..].Trim(); + } + if( name != "Unknown" ) + { + return name; + } + } + + return null; + } + + private static string? SafeGet( ReadOnlySpan array, Index index ) + { + var i = index.GetOffset( array.Length ); + return ( i >= 0 && i < array.Length ) ? array[i] : null; + } + } + + public class Node + { + public readonly string? Name; + public readonly ResourceType Type; + public readonly nint SourceAddress; + public readonly Utf8GamePath GamePath; + public readonly Utf8GamePath[] PossibleGamePaths; + public readonly FullPath FullPath; + public readonly bool Internal; + public readonly List Children; + + public Node( string? name, ResourceType type, nint sourceAddress, Utf8GamePath gamePath, FullPath fullPath, bool @internal ) + { + Name = name; + Type = type; + SourceAddress = sourceAddress; + GamePath = gamePath; + PossibleGamePaths = new[] { gamePath }; + FullPath = fullPath; + Internal = @internal; + Children = new(); + } + + public Node( string? name, ResourceType type, nint sourceAddress, Utf8GamePath[] possibleGamePaths, FullPath fullPath, bool @internal ) + { + Name = name; + Type = type; + SourceAddress = sourceAddress; + GamePath = ( possibleGamePaths.Length == 1 ) ? possibleGamePaths[0] : Utf8GamePath.Empty; + PossibleGamePaths = possibleGamePaths; + FullPath = fullPath; + Internal = @internal; + Children = new(); + } + + private Node( string? name, Node originalNode ) + { + Name = name; + Type = originalNode.Type; + SourceAddress = originalNode.SourceAddress; + GamePath = originalNode.GamePath; + PossibleGamePaths = originalNode.PossibleGamePaths; + FullPath = originalNode.FullPath; + Internal = originalNode.Internal; + Children = originalNode.Children; + } + + public Node WithName( string? name ) + => string.Equals( Name, name, StringComparison.Ordinal ) ? this : new Node( name, this ); + } +} diff --git a/Penumbra/Interop/Structs/HumanExt.cs b/Penumbra/Interop/Structs/HumanExt.cs new file mode 100644 index 00000000..7af5cee4 --- /dev/null +++ b/Penumbra/Interop/Structs/HumanExt.cs @@ -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; +} \ No newline at end of file diff --git a/Penumbra/Interop/Structs/Material.cs b/Penumbra/Interop/Structs/Material.cs index dbd7c2b0..7cee271e 100644 --- a/Penumbra/Interop/Structs/Material.cs +++ b/Penumbra/Interop/Structs/Material.cs @@ -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]; } \ No newline at end of file diff --git a/Penumbra/Interop/Structs/ResourceHandle.cs b/Penumbra/Interop/Structs/ResourceHandle.cs index 77c212ef..4de81903 100644 --- a/Penumbra/Interop/Structs/ResourceHandle.cs +++ b/Penumbra/Interop/Structs/ResourceHandle.cs @@ -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; diff --git a/Penumbra/Interop/Structs/WeaponExt.cs b/Penumbra/Interop/Structs/WeaponExt.cs new file mode 100644 index 00000000..de7038d7 --- /dev/null +++ b/Penumbra/Interop/Structs/WeaponExt.cs @@ -0,0 +1,14 @@ +using System.Runtime.InteropServices; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; + +namespace Penumbra.Interop.Structs; + +[StructLayout( LayoutKind.Explicit )] +public unsafe struct WeaponExt +{ + [FieldOffset( 0x0 )] + public Weapon Weapon; + + [FieldOffset( 0xA8 )] + public RenderModel** WeaponRenderModel; +} \ No newline at end of file diff --git a/Penumbra/UI/Classes/ModEditWindow.FileEditor.cs b/Penumbra/UI/Classes/ModEditWindow.FileEditor.cs index 19630beb..4e283773 100644 --- a/Penumbra/UI/Classes/ModEditWindow.FileEditor.cs +++ b/Penumbra/UI/Classes/ModEditWindow.FileEditor.cs @@ -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(); } diff --git a/Penumbra/UI/Classes/ModEditWindow.Files.cs b/Penumbra/UI/Classes/ModEditWindow.Files.cs index 18f17711..d7bd57d4 100644 --- a/Penumbra/UI/Classes/ModEditWindow.Files.cs +++ b/Penumbra/UI/Classes/ModEditWindow.Files.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Numerics; using Dalamud.Interface; diff --git a/Penumbra/UI/Classes/ModEditWindow.QuickImport.cs b/Penumbra/UI/Classes/ModEditWindow.QuickImport.cs new file mode 100644 index 00000000..f300bd34 --- /dev/null +++ b/Penumbra/UI/Classes/ModEditWindow.QuickImport.cs @@ -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? _quickImportUnfolded; + private Dictionary? _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(); + } + _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(); + } + + 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 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; + + /// + /// Creates a non-executable QuickImportAction. + /// + private QuickImportAction( string optionName, Utf8GamePath gamePath ) + { + _optionName = optionName; + _gamePath = gamePath; + _editor = null; + _file = null; + _targetPath = null; + _subDirs = 0; + } + + /// + /// Creates an executable QuickImportAction. + /// + 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); + } + } +} diff --git a/Penumbra/UI/Classes/ModEditWindow.cs b/Penumbra/UI/Classes/ModEditWindow.cs index 3fe48126..83b5d103 100644 --- a/Penumbra/UI/Classes/ModEditWindow.cs +++ b/Penumbra/UI/Classes/ModEditWindow.cs @@ -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, diff --git a/Penumbra/UI/ConfigWindow.OnScreenTab.cs b/Penumbra/UI/ConfigWindow.OnScreenTab.cs new file mode 100644 index 00000000..d71b44c0 --- /dev/null +++ b/Penumbra/UI/ConfigWindow.OnScreenTab.cs @@ -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 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(); + } + _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(); + } + + 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 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? _unfolded; + } +} diff --git a/Penumbra/UI/ConfigWindow.cs b/Penumbra/UI/ConfigWindow.cs index f836d7e5..9065c369 100644 --- a/Penumbra/UI/ConfigWindow.cs +++ b/Penumbra/UI/ConfigWindow.cs @@ -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; }