diff --git a/Penumbra.GameData/Data/GamePaths.cs b/Penumbra.GameData/Data/GamePaths.cs index 89f2f58e..3d70c13d 100644 --- a/Penumbra.GameData/Data/GamePaths.cs +++ b/Penumbra.GameData/Data/GamePaths.cs @@ -61,6 +61,24 @@ public static partial class GamePaths public static string Path(SetId monsterId, SetId bodyId, byte variant, char suffix1, char suffix2 = '\0') => $"chara/monster/m{monsterId.Value:D4}/obj/body/b{bodyId.Value:D4}/texture/v{variant:D2}_m{monsterId.Value:D4}b{bodyId.Value:D4}{(suffix2 != '\0' ? $"_{suffix2}" : string.Empty)}_{suffix1}.tex"; } + + public static partial class Sklb + { + public static string Path(SetId monsterId) + => $"chara/monster/m{monsterId.Value:D4}/skeleton/base/b0001/skl_m{monsterId.Value:D4}b0001.sklb"; + } + + public static partial class Skp + { + public static string Path(SetId monsterId) + => $"chara/monster/m{monsterId.Value:D4}/skeleton/base/b0001/skl_m{monsterId.Value:D4}b0001.skp"; + } + + public static partial class Eid + { + public static string Path(SetId monsterId) + => $"chara/monster/m{monsterId.Value:D4}/skeleton/base/b0001/eid_m{monsterId.Value:D4}b0001.eid"; + } } public static partial class Weapon @@ -243,6 +261,21 @@ public static partial class GamePaths } } + public static partial class Skeleton + { + public static partial class Phyb + { + public static string Path(GenderRace raceCode, string slot, SetId slotId) + => $"chara/human/c{raceCode.ToRaceCode()}/skeleton/{slot}/{slot[0]}{slotId.Value:D4}/phy_c{raceCode.ToRaceCode()}{slot[0]}{slotId.Value:D4}.phyb"; + } + + public static partial class Sklb + { + public static string Path(GenderRace raceCode, string slot, SetId slotId) + => $"chara/human/c{raceCode.ToRaceCode()}/skeleton/{slot}/{slot[0]}{slotId.Value:D4}/skl_c{raceCode.ToRaceCode()}{slot[0]}{slotId.Value:D4}.sklb"; + } + } + public static partial class Character { public static partial class Mdl @@ -254,18 +287,6 @@ public static partial class GamePaths => $"chara/human/c{raceCode.ToRaceCode()}/obj/{slot.ToSuffix()}/{slot.ToAbbreviation()}{slotId.Value:D4}/model/c{raceCode.ToRaceCode()}{slot.ToAbbreviation()}{slotId.Value:D4}_{type.ToSuffix()}.mdl"; } - public static partial class Phyb - { - public static string Path(GenderRace raceCode, BodySlot slot, SetId slotId) - => $"chara/human/c{raceCode.ToRaceCode()}/skeleton/{slot.ToSuffix()}/{slot.ToAbbreviation()}{slotId.Value:D4}/phy_c{raceCode.ToRaceCode()}{slot.ToAbbreviation()}{slotId.Value:D4}.phyb"; - } - - public static partial class Sklb - { - public static string Path(GenderRace raceCode, BodySlot slot, SetId slotId) - => $"chara/human/c{raceCode.ToRaceCode()}/skeleton/{slot.ToSuffix()}/{slot.ToAbbreviation()}{slotId.Value:D4}/skl_c{raceCode.ToRaceCode()}{slot.ToAbbreviation()}{slotId.Value:D4}.sklb"; - } - public static partial class Mtrl { // [GeneratedRegex(@"chara/human/c(?'race'\d{4})/obj/(?'type'[a-z]+)/(?'typeabr'[a-z])(?'id'\d{4})/material(/v(?'variant'\d{4}))?/mt_c\k'race'\k'typeabr'\k'id'(_(?'slot'[a-z]{3}))?_[a-z]+\.mtrl")] diff --git a/Penumbra/Meta/Manipulations/EstManipulation.cs b/Penumbra/Meta/Manipulations/EstManipulation.cs index b21148ef..3891cc6f 100644 --- a/Penumbra/Meta/Manipulations/EstManipulation.cs +++ b/Penumbra/Meta/Manipulations/EstManipulation.cs @@ -19,6 +19,16 @@ public readonly struct EstManipulation : IMetaManipulation< EstManipulation > Head = CharacterUtility.Index.HeadEst, } + public static string ToName( EstType type ) + => type switch + { + EstType.Hair => "hair", + EstType.Face => "face", + EstType.Body => "top", + EstType.Head => "met", + _ => "unk", + }; + public ushort Entry { get; private init; } // SkeletonIdx. [JsonConverter( typeof( StringEnumConverter ) )] diff --git a/Penumbra/Mods/ItemSwap/CustomizationSwap.cs b/Penumbra/Mods/ItemSwap/CustomizationSwap.cs index e442ae8e..b9e9a94f 100644 --- a/Penumbra/Mods/ItemSwap/CustomizationSwap.cs +++ b/Penumbra/Mods/ItemSwap/CustomizationSwap.cs @@ -7,8 +7,6 @@ using Penumbra.GameData.Data; using Penumbra.GameData.Enums; using Penumbra.GameData.Files; using Penumbra.GameData.Structs; -using Penumbra.Meta.Files; -using Penumbra.Meta.Manipulations; using Penumbra.String.Classes; namespace Penumbra.Mods.ItemSwap; @@ -54,33 +52,6 @@ public static class CustomizationSwap return true; } - public static string ReplaceAnyId( string path, char idType, SetId id, bool condition = true ) - => condition - ? Regex.Replace( path, $"{idType}\\d{{4}}", $"{idType}{id.Value:D4}" ) - : path; - - public static string ReplaceAnyRace( string path, GenderRace to, bool condition = true ) - => ReplaceAnyId( path, 'c', ( ushort )to, condition ); - - public static string ReplaceAnyBody( string path, BodySlot slot, SetId to, bool condition = true ) - => ReplaceAnyId( path, slot.ToAbbreviation(), to, condition ); - - public static string ReplaceId( string path, char type, SetId idFrom, SetId idTo, bool condition = true ) - => condition - ? path.Replace( $"{type}{idFrom.Value:D4}", $"{type}{idTo.Value:D4}" ) - : path; - - public static string ReplaceRace( string path, GenderRace from, GenderRace to, bool condition = true ) - => ReplaceId( path, 'c', ( ushort )from, ( ushort )to, condition ); - - public static string ReplaceBody( string path, BodySlot slot, SetId idFrom, SetId idTo, bool condition = true ) - => ReplaceId( path, slot.ToAbbreviation(), idFrom, idTo, condition ); - - public static string AddSuffix( string path, string ext, string suffix, bool condition = true ) - => condition - ? path.Replace( ext, suffix + ext ) - : path; - public static bool CreateMtrl( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, BodySlot slot, GenderRace race, SetId idFrom, SetId idTo, byte variant, ref string fileName, ref bool dataWasChanged, out FileSwap mtrl ) { @@ -89,10 +60,10 @@ public static class CustomizationSwap var mtrlToPath = GamePaths.Character.Mtrl.Path( race, slot, idTo, fileName, out var gameRaceTo, out var gameSetIdTo, variant ); var newFileName = fileName; - newFileName = ReplaceRace( newFileName, gameRaceTo, race, gameRaceTo != race ); - newFileName = ReplaceBody( newFileName, slot, idTo, idFrom, idFrom.Value != idTo.Value ); - newFileName = AddSuffix( newFileName, ".mtrl", $"_c{race.ToRaceCode()}", gameRaceFrom != race ); - newFileName = AddSuffix( newFileName, ".mtrl", $"_{slot.ToAbbreviation()}{idFrom.Value:D4}", gameSetIdFrom.Value != idFrom.Value ); + newFileName = ItemSwap.ReplaceRace( newFileName, gameRaceTo, race, gameRaceTo != race ); + newFileName = ItemSwap.ReplaceBody( newFileName, slot, idTo, idFrom, idFrom.Value != idTo.Value ); + newFileName = ItemSwap.AddSuffix( newFileName, ".mtrl", $"_c{race.ToRaceCode()}", gameRaceFrom != race ); + newFileName = ItemSwap.AddSuffix( newFileName, ".mtrl", $"_{slot.ToAbbreviation()}{idFrom.Value:D4}", gameSetIdFrom.Value != idFrom.Value ); var actualMtrlFromPath = mtrlFromPath; if( newFileName != fileName ) @@ -142,9 +113,9 @@ public static class CustomizationSwap } } - var newPath = ReplaceAnyRace( path, race ); - newPath = ReplaceAnyBody( newPath, slot, idFrom ); - newPath = AddSuffix( newPath, ".tex", $"_{Path.GetFileName(texture.Path).GetStableHashCode():x8}", true ); + var newPath = ItemSwap.ReplaceAnyRace( path, race ); + newPath = ItemSwap.ReplaceAnyBody( newPath, slot, idFrom ); + newPath = ItemSwap.AddSuffix( newPath, ".tex", $"_{Path.GetFileName( texture.Path ).GetStableHashCode():x8}", true ); if( newPath != path ) { texture.Path = addedDashes ? newPath.Replace( "--", string.Empty ) : newPath; @@ -160,56 +131,4 @@ public static class CustomizationSwap var path = $"shader/sm5/shpk/{shaderName}"; return FileSwap.CreateSwap( ResourceType.Shpk, redirections, path, path, out shpk ); } - - /// metaChanges is not manipulated, but IReadOnlySet does not support TryGetValue. - public static bool CreateEst( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, HashSet< MetaManipulation > metaChanges, BodySlot slot, GenderRace gr, SetId idFrom, - SetId idTo, out MetaSwap? est ) - { - var (gender, race) = gr.Split(); - var estSlot = slot switch - { - BodySlot.Hair => EstManipulation.EstType.Hair, - BodySlot.Body => EstManipulation.EstType.Body, - _ => ( EstManipulation.EstType )0, - }; - if( estSlot == 0 ) - { - est = null; - return true; - } - - var fromDefault = new EstManipulation( gender, race, estSlot, idFrom.Value, EstFile.GetDefault( estSlot, gr, idFrom.Value ) ); - var toDefault = new EstManipulation( gender, race, estSlot, idTo.Value, EstFile.GetDefault( estSlot, gr, idTo.Value ) ); - est = new MetaSwap( metaChanges, fromDefault, toDefault ); - - if( est.SwapApplied.Est.Entry >= 2 ) - { - if( !CreatePhyb( redirections, slot, gr, est.SwapApplied.Est.Entry, out var phyb ) ) - { - return false; - } - - if( !CreateSklb( redirections, slot, gr, est.SwapApplied.Est.Entry, out var sklb ) ) - { - return false; - } - - est.ChildSwaps.Add( phyb ); - est.ChildSwaps.Add( sklb ); - } - - return true; - } - - public static bool CreatePhyb( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, BodySlot slot, GenderRace race, ushort estEntry, out FileSwap phyb ) - { - var phybPath = GamePaths.Character.Phyb.Path( race, slot, estEntry ); - return FileSwap.CreateSwap( ResourceType.Phyb, redirections, phybPath, phybPath, out phyb ); - } - - public static bool CreateSklb( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, BodySlot slot, GenderRace race, ushort estEntry, out FileSwap sklb ) - { - var sklbPath = GamePaths.Character.Sklb.Path( race, slot, estEntry ); - return FileSwap.CreateSwap( ResourceType.Sklb, redirections, sklbPath, sklbPath, out sklb ); - } } \ No newline at end of file diff --git a/Penumbra/Mods/ItemSwap/EquipmentSwap.cs b/Penumbra/Mods/ItemSwap/EquipmentSwap.cs new file mode 100644 index 00000000..f065d6be --- /dev/null +++ b/Penumbra/Mods/ItemSwap/EquipmentSwap.cs @@ -0,0 +1,406 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Lumina.Data.Parsing; +using Lumina.Excel.GeneratedSheets; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Files; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Structs; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.ItemSwap; + +public static class EquipmentSwap +{ + public static Item[] CreateItemSwap( List< Swap > swaps, IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, HashSet< MetaManipulation > manips, Item itemFrom, + Item itemTo ) + { + // Check actual ids, variants and slots. We only support using the same slot. + LookupItem( itemFrom, out var slotFrom, out var idFrom, out var variantFrom ); + LookupItem( itemTo, out var slotTo, out var idTo, out var variantTo ); + if( slotFrom != slotTo ) + { + throw new ItemSwap.InvalidItemTypeException(); + } + + if( !CreateEqp( manips, slotFrom, idFrom, idTo, out var eqp ) ) + { + throw new Exception( "Could not get Eqp Entry for Swap." ); + } + + if( eqp != null ) + { + swaps.Add( eqp ); + } + + if( !CreateGmp( manips, slotFrom, idFrom, idTo, out var gmp ) ) + { + throw new Exception( "Could not get Gmp Entry for Swap." ); + } + + if( gmp != null ) + { + swaps.Add( gmp ); + } + + + var (imcFileFrom, variants, affectedItems) = GetVariants( slotFrom, idFrom, idTo, variantFrom ); + var imcFileTo = new ImcFile( new ImcManipulation( slotFrom, variantTo, idTo.Value, default ) ); + + var isAccessory = slotFrom.IsAccessory(); + var estType = slotFrom switch + { + EquipSlot.Head => EstManipulation.EstType.Head, + EquipSlot.Body => EstManipulation.EstType.Body, + _ => ( EstManipulation.EstType )0, + }; + + var mtrlVariantTo = imcFileTo.GetEntry( ImcFile.PartIndex( slotFrom ), variantTo ).MaterialId; + foreach( var gr in Enum.GetValues< GenderRace >() ) + { + if( CharacterUtility.EqdpIdx( gr, isAccessory ) < 0 ) + { + continue; + } + + if( !ItemSwap.CreateEst( redirections, manips, estType, gr, idFrom, idTo, out var est ) ) + { + throw new Exception( "Could not get Est Entry for Swap." ); + } + + if( est != null ) + { + swaps.Add( est ); + } + + if( !CreateEqdp( redirections, manips, slotFrom, gr, idFrom, idTo, mtrlVariantTo, out var eqdp ) ) + { + throw new Exception( "Could not get Eqdp Entry for Swap." ); + } + + if( eqdp != null ) + { + swaps.Add( eqdp ); + } + } + + foreach( var variant in variants ) + { + if( !CreateImc( redirections, manips, slotFrom, idFrom, idTo, variant, variantTo, imcFileFrom, imcFileTo, out var imc ) ) + { + throw new Exception( "Could not get IMC Entry for Swap." ); + } + + swaps.Add( imc ); + } + + + return affectedItems; + } + + public static bool CreateEqdp( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, HashSet< MetaManipulation > manips, EquipSlot slot, GenderRace gr, SetId idFrom, + SetId idTo, byte mtrlTo, out MetaSwap? meta ) + { + var (gender, race) = gr.Split(); + var eqdpFrom = new EqdpManipulation( ExpandedEqdpFile.GetDefault( gr, slot.IsAccessory(), idFrom.Value ), slot, gender, race, idFrom.Value ); + var eqdpTo = new EqdpManipulation( ExpandedEqdpFile.GetDefault( gr, slot.IsAccessory(), idTo.Value ), slot, gender, race, idTo.Value ); + meta = new MetaSwap( manips, eqdpFrom, eqdpTo ); + var (ownMtrl, ownMdl) = meta.SwapApplied.Eqdp.Entry.ToBits( slot ); + if( ownMdl ) + { + if( !CreateMdl( redirections, slot, gr, idFrom, idTo, mtrlTo, out var mdl ) ) + { + return false; + } + + meta.ChildSwaps.Add( mdl ); + } + else if( !ownMtrl && meta.SwapAppliedIsDefault ) + { + meta = null; + } + + return true; + } + + public static bool CreateMdl( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, EquipSlot slot, GenderRace gr, SetId idFrom, SetId idTo, byte mtrlTo, + out FileSwap mdl ) + { + var mdlPathFrom = GamePaths.Equipment.Mdl.Path( idFrom, gr, slot ); + var mdlPathTo = GamePaths.Equipment.Mdl.Path( idTo, gr, slot ); + if( !FileSwap.CreateSwap( ResourceType.Mdl, redirections, mdlPathFrom, mdlPathTo, out mdl ) ) + { + return false; + } + + foreach( ref var fileName in mdl.AsMdl()!.Materials.AsSpan() ) + { + if( !CreateMtrl( redirections, slot, idFrom, idTo, mtrlTo, ref fileName, ref mdl.DataWasChanged, out var mtrl ) ) + { + return false; + } + + if( mtrl != null ) + { + mdl.ChildSwaps.Add( mtrl ); + } + } + + return true; + } + + private static (GenderRace, GenderRace) TraverseEqdpTree( GenderRace genderRace, SetId modelId, EquipSlot slot ) + { + var model = GenderRace.Unknown; + var material = GenderRace.Unknown; + var accessory = slot.IsAccessory(); + foreach( var gr in genderRace.Dependencies() ) + { + var entry = ExpandedEqdpFile.GetDefault( gr, accessory, modelId.Value ); + var (b1, b2) = entry.ToBits( slot ); + if( b1 && material == GenderRace.Unknown ) + { + material = gr; + if( model != GenderRace.Unknown ) + { + return ( model, material ); + } + } + + if( b2 && model == GenderRace.Unknown ) + { + model = gr; + if( material != GenderRace.Unknown ) + { + return ( model, material ); + } + } + } + + return ( GenderRace.MidlanderMale, GenderRace.MidlanderMale ); + } + + private static void LookupItem( Item i, out EquipSlot slot, out SetId modelId, out byte variant ) + { + slot = ( ( EquipSlot )i.EquipSlotCategory.Row ).ToSlot(); + if( !slot.IsEquipmentPiece() ) + { + throw new ItemSwap.InvalidItemTypeException(); + } + + modelId = ( ( Quad )i.ModelMain ).A; + variant = ( byte )( ( Quad )i.ModelMain ).B; + } + + private static (ImcFile, byte[], Item[]) GetVariants( EquipSlot slot, SetId idFrom, SetId idTo, byte variantFrom ) + { + var entry = new ImcManipulation( slot, variantFrom, idFrom.Value, default ); + var imc = new ImcFile( entry ); + Item[] items; + byte[] variants; + if( idFrom.Value == idTo.Value ) + { + items = Penumbra.Identifier.Identify( idFrom, variantFrom, slot ).ToArray(); + variants = new[] { variantFrom }; + } + else + { + items = Penumbra.Identifier.Identify( slot.IsEquipment() + ? GamePaths.Equipment.Mdl.Path( idFrom, GenderRace.MidlanderMale, slot ) + : GamePaths.Accessory.Mdl.Path( idFrom, GenderRace.MidlanderMale, slot ) ).Select( kvp => kvp.Value ).OfType< Item >().ToArray(); + variants = Enumerable.Range( 0, imc.Count + 1 ).Select( i => ( byte )i ).ToArray(); + } + + return ( imc, variants, items ); + } + + public static bool CreateGmp( HashSet< MetaManipulation > manips, EquipSlot slot, SetId idFrom, SetId idTo, out MetaSwap? gmp ) + { + if( slot is not EquipSlot.Head ) + { + gmp = null; + return true; + } + + var manipFrom = new GmpManipulation( ExpandedGmpFile.GetDefault( idFrom.Value ), idFrom.Value ); + var manipTo = new GmpManipulation( ExpandedGmpFile.GetDefault( idTo.Value ), idTo.Value ); + gmp = new MetaSwap( manips, manipFrom, manipTo ); + return true; + } + + public static bool CreateImc( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, HashSet< MetaManipulation > manips, EquipSlot slot, SetId idFrom, SetId idTo, + byte variantFrom, byte variantTo, ImcFile imcFileFrom, ImcFile imcFileTo, out MetaSwap imc ) + { + var entryFrom = imcFileFrom.GetEntry( ImcFile.PartIndex( slot ), variantFrom ); + var entryTo = imcFileTo.GetEntry( ImcFile.PartIndex( slot ), variantTo ); + var manipulationFrom = new ImcManipulation( slot, variantFrom, idFrom.Value, entryFrom ); + var manipulationTo = new ImcManipulation( slot, variantTo, idTo.Value, entryTo ); + imc = new MetaSwap( manips, manipulationFrom, manipulationTo ); + + if( !AddDecal( redirections, imc.SwapToModded.Imc.Entry.DecalId, imc ) ) + { + return false; + } + + if( !AddAvfx( redirections, idFrom, idTo, imc.SwapToModded.Imc.Entry.VfxId, imc ) ) + { + return false; + } + + return true; + } + + public static bool AddDecal( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, byte decalId, MetaSwap imc ) + { + if( decalId != 0 ) + { + var decalPath = GamePaths.Equipment.Decal.Path( decalId ); + if( !FileSwap.CreateSwap( ResourceType.Tex, redirections, decalPath, decalPath, out var swap ) ) + { + return false; + } + + imc.ChildSwaps.Add( swap ); + } + + return true; + } + + public static bool AddAvfx( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, SetId idFrom, SetId idTo, byte vfxId, MetaSwap imc ) + { + if( vfxId != 0 ) + { + var vfxPathFrom = GamePaths.Equipment.Avfx.Path( idFrom.Value, vfxId ); + var vfxPathTo = GamePaths.Equipment.Avfx.Path( idTo.Value, vfxId ); + if( !FileSwap.CreateSwap( ResourceType.Avfx, redirections, vfxPathFrom, vfxPathTo, out var swap ) ) + { + return false; + } + + foreach( ref var filePath in swap.AsAvfx()!.Textures.AsSpan() ) + { + if( !CreateAtex( redirections, ref filePath, ref swap.DataWasChanged, out var atex ) ) + { + return false; + } + + swap.ChildSwaps.Add( atex ); + } + + imc.ChildSwaps.Add( swap ); + } + + return true; + } + + public static bool CreateEqp( HashSet< MetaManipulation > manips, EquipSlot slot, SetId idFrom, SetId idTo, out MetaSwap? eqp ) + { + if( slot.IsAccessory() ) + { + eqp = null; + return true; + } + + var eqpValueFrom = ExpandedEqpFile.GetDefault( idFrom.Value ); + var eqpValueTo = ExpandedEqpFile.GetDefault( idTo.Value ); + var eqpFrom = new EqpManipulation( eqpValueFrom, slot, idFrom.Value ); + var eqpTo = new EqpManipulation( eqpValueTo, slot, idFrom.Value ); + eqp = new MetaSwap( manips, eqpFrom, eqpTo ); + return true; + } + + public static bool CreateMtrl( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, EquipSlot slot, SetId idFrom, SetId idTo, byte variantTo, ref string fileName, + ref bool dataWasChanged, out FileSwap? mtrl ) + { + var prefix = slot.IsAccessory() ? 'a' : 'e'; + if( !fileName.Contains( $"{prefix}{idTo.Value:D4}" ) ) + { + mtrl = null; + return true; + } + + var folderTo = slot.IsAccessory() ? GamePaths.Accessory.Mtrl.FolderPath( idTo, variantTo ) : GamePaths.Equipment.Mtrl.FolderPath( idTo, variantTo ); + var pathTo = $"{folderTo}{fileName}"; + + var folderFrom = slot.IsAccessory() ? GamePaths.Accessory.Mtrl.FolderPath( idFrom, variantTo ) : GamePaths.Equipment.Mtrl.FolderPath( idFrom, variantTo ); + var newFileName = ItemSwap.ReplaceId( fileName, prefix, idTo, idFrom ); + var pathFrom = $"{folderFrom}{newFileName}"; + + if( newFileName != fileName ) + { + fileName = newFileName; + dataWasChanged = true; + } + + if( !FileSwap.CreateSwap( ResourceType.Mtrl, redirections, pathFrom, pathTo, out mtrl ) ) + { + return false; + } + + if( !CreateShader( redirections, ref mtrl.AsMtrl()!.ShaderPackage.Name, ref mtrl.DataWasChanged, out var shader ) ) + { + return false; + } + + mtrl.ChildSwaps.Add( shader ); + + foreach( ref var texture in mtrl.AsMtrl()!.Textures.AsSpan() ) + { + if( !CreateTex( redirections, prefix, idFrom, idTo, ref texture, ref mtrl.DataWasChanged, out var swap ) ) + { + return false; + } + + mtrl.ChildSwaps.Add( swap ); + } + + return true; + } + + public static bool CreateTex( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, char prefix, SetId idFrom, SetId idTo, ref MtrlFile.Texture texture, + ref bool dataWasChanged, + out FileSwap tex ) + { + var path = texture.Path; + var addedDashes = false; + if( texture.DX11 ) + { + var fileName = Path.GetFileName( path ); + if( !fileName.StartsWith( "--" ) ) + { + path = path.Replace( fileName, $"--{fileName}" ); + addedDashes = true; + } + } + + var newPath = ItemSwap.ReplaceAnyId( path, prefix, idFrom ); + newPath = ItemSwap.AddSuffix( newPath, ".tex", $"_{Path.GetFileName( texture.Path ).GetStableHashCode():x8}", true ); + if( newPath != path ) + { + texture.Path = addedDashes ? newPath.Replace( "--", string.Empty ) : newPath; + dataWasChanged = true; + } + + return FileSwap.CreateSwap( ResourceType.Tex, redirections, newPath, path, out tex, path ); + } + + public static bool CreateShader( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, ref string shaderName, ref bool dataWasChanged, out FileSwap shpk ) + { + var path = $"shader/sm5/shpk/{shaderName}"; + return FileSwap.CreateSwap( ResourceType.Shpk, redirections, path, path, out shpk ); + } + + public static bool CreateAtex( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, ref string filePath, ref bool dataWasChanged, out FileSwap atex ) + { + var oldPath = filePath; + filePath = ItemSwap.AddSuffix( filePath, ".atex", $"_{Path.GetFileName( filePath ).GetStableHashCode():x8}", true ); + dataWasChanged = true; + + return FileSwap.CreateSwap( ResourceType.Atex, redirections, filePath, oldPath, out atex, oldPath ); + } +} \ No newline at end of file diff --git a/Penumbra/Mods/ItemSwap/ItemSwap.cs b/Penumbra/Mods/ItemSwap/ItemSwap.cs index 3771cd6d..d32e2073 100644 --- a/Penumbra/Mods/ItemSwap/ItemSwap.cs +++ b/Penumbra/Mods/ItemSwap/ItemSwap.cs @@ -1,7 +1,14 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Text.RegularExpressions; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; using Penumbra.GameData.Files; +using Penumbra.GameData.Structs; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; using Penumbra.String.Classes; namespace Penumbra.Mods.ItemSwap; @@ -110,6 +117,76 @@ public static class ItemSwap return false; } + public static bool LoadAvfx( FullPath path, [NotNullWhen( true )] out AvfxFile? file ) + { + try + { + if( LoadFile( path, out byte[] data ) ) + { + file = new AvfxFile( data ); + return true; + } + } + catch( Exception e ) + { + Penumbra.Log.Debug( $"Could not parse file {path} to Avfx:\n{e}" ); + } + + file = null; + return false; + } + + + public static bool CreatePhyb( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, EstManipulation.EstType type, GenderRace race, ushort estEntry, out FileSwap phyb ) + { + var phybPath = GamePaths.Skeleton.Phyb.Path( race, EstManipulation.ToName( type ), estEntry ); + return FileSwap.CreateSwap( ResourceType.Phyb, redirections, phybPath, phybPath, out phyb ); + } + + public static bool CreateSklb( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, EstManipulation.EstType type, GenderRace race, ushort estEntry, out FileSwap sklb ) + { + var sklbPath = GamePaths.Skeleton.Sklb.Path( race, EstManipulation.ToName( type ), estEntry ); + return FileSwap.CreateSwap( ResourceType.Sklb, redirections, sklbPath, sklbPath, out sklb ); + } + + /// metaChanges is not manipulated, but IReadOnlySet does not support TryGetValue. + public static bool CreateEst( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, HashSet< MetaManipulation > manips, EstManipulation.EstType type, + GenderRace genderRace, SetId idFrom, SetId idTo, out MetaSwap? est ) + { + if( type == 0 ) + { + est = null; + return true; + } + + var (gender, race) = genderRace.Split(); + var fromDefault = new EstManipulation( gender, race, type, idFrom.Value, EstFile.GetDefault( type, genderRace, idFrom.Value ) ); + var toDefault = new EstManipulation( gender, race, type, idTo.Value, EstFile.GetDefault( type, genderRace, idTo.Value ) ); + est = new MetaSwap( manips, fromDefault, toDefault ); + + if( est.SwapApplied.Est.Entry >= 2 ) + { + if( !CreatePhyb( redirections, type, genderRace, est.SwapApplied.Est.Entry, out var phyb ) ) + { + return false; + } + + if( !CreateSklb( redirections, type, genderRace, est.SwapApplied.Est.Entry, out var sklb ) ) + { + return false; + } + + est.ChildSwaps.Add( phyb ); + est.ChildSwaps.Add( sklb ); + } + else if( est.SwapAppliedIsDefault ) + { + est = null; + } + + return true; + } + public static int GetStableHashCode( this string str ) { unchecked @@ -131,4 +208,31 @@ public static class ItemSwap return hash1 + hash2 * 1566083941; } } + + public static string ReplaceAnyId( string path, char idType, SetId id, bool condition = true ) + => condition + ? Regex.Replace( path, $"{idType}\\d{{4}}", $"{idType}{id.Value:D4}" ) + : path; + + public static string ReplaceAnyRace( string path, GenderRace to, bool condition = true ) + => ReplaceAnyId( path, 'c', ( ushort )to, condition ); + + public static string ReplaceAnyBody( string path, BodySlot slot, SetId to, bool condition = true ) + => ReplaceAnyId( path, slot.ToAbbreviation(), to, condition ); + + public static string ReplaceId( string path, char type, SetId idFrom, SetId idTo, bool condition = true ) + => condition + ? path.Replace( $"{type}{idFrom.Value:D4}", $"{type}{idTo.Value:D4}" ) + : path; + + public static string ReplaceRace( string path, GenderRace from, GenderRace to, bool condition = true ) + => ReplaceId( path, 'c', ( ushort )from, ( ushort )to, condition ); + + public static string ReplaceBody( string path, BodySlot slot, SetId idFrom, SetId idTo, bool condition = true ) + => ReplaceId( path, slot.ToAbbreviation(), idFrom, idTo, condition ); + + public static string AddSuffix( string path, string ext, string suffix, bool condition = true ) + => condition + ? path.Replace( ext, suffix + ext ) + : path; } \ No newline at end of file diff --git a/Penumbra/Mods/ItemSwap/ItemSwapContainer.cs b/Penumbra/Mods/ItemSwap/ItemSwapContainer.cs index 4fc07cd4..8027e7d1 100644 --- a/Penumbra/Mods/ItemSwap/ItemSwapContainer.cs +++ b/Penumbra/Mods/ItemSwap/ItemSwapContainer.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using Lumina.Excel.GeneratedSheets; using Penumbra.GameData.Enums; using Penumbra.GameData.Structs; using Penumbra.Meta.Manipulations; @@ -21,6 +22,7 @@ public class ItemSwapContainer => _modManipulations; public readonly List< Swap > Swaps = new(); + public bool Loaded { get; private set; } public void Clear() @@ -109,6 +111,22 @@ public class ItemSwapContainer LoadMod( null, null ); } + public Item[] LoadEquipment( Item from, Item to ) + { + try + { + Swaps.Clear(); + var ret = EquipmentSwap.CreateItemSwap( Swaps, ModRedirections, _modManipulations, from, to ); + Loaded = true; + return ret; + } + catch( Exception e ) + { + Swaps.Clear(); + Loaded = false; + return Array.Empty< Item >(); + } + } public bool LoadCustomization( BodySlot slot, GenderRace race, SetId from, SetId to ) { @@ -117,7 +135,13 @@ public class ItemSwapContainer return false; } - if( !CustomizationSwap.CreateEst( ModRedirections, _modManipulations, slot, race, from, to, out var est ) ) + var type = slot switch + { + BodySlot.Hair => EstManipulation.EstType.Hair, + BodySlot.Face => EstManipulation.EstType.Face, + _ => ( EstManipulation.EstType )0, + }; + if( !ItemSwap.CreateEst( ModRedirections, _modManipulations, type, race, from, to, out var est ) ) { return false; } diff --git a/Penumbra/Mods/ItemSwap/Swaps.cs b/Penumbra/Mods/ItemSwap/Swaps.cs index d425e476..5018f2a7 100644 --- a/Penumbra/Mods/ItemSwap/Swaps.cs +++ b/Penumbra/Mods/ItemSwap/Swaps.cs @@ -1,4 +1,3 @@ -using System; using Penumbra.GameData.Files; using Penumbra.Meta.Manipulations; using Penumbra.String.Classes; @@ -13,7 +12,7 @@ namespace Penumbra.Mods.ItemSwap; public class Swap { /// Any further swaps belonging specifically to this tree of changes. - public List< Swap > ChildSwaps = new(); + public readonly List< Swap > ChildSwaps = new(); public IEnumerable< Swap > WithChildren() => ChildSwaps.SelectMany( c => c.WithChildren() ).Prepend( this ); @@ -111,6 +110,9 @@ public sealed class FileSwap : Swap public MtrlFile? AsMtrl() => FileData as MtrlFile; + public AvfxFile? AsAvfx() + => FileData as AvfxFile; + /// /// Create a full swap container for a specific file type using a modded redirection set, the actually requested path and the game file it should load instead after the swap. /// @@ -151,6 +153,7 @@ public sealed class FileSwap : Swap { ResourceType.Mdl => ItemSwap.LoadMdl( swap.SwapToModded, out var f ) ? f : ItemSwap.GenericFile.Invalid, ResourceType.Mtrl => ItemSwap.LoadMtrl( swap.SwapToModded, out var f ) ? f : ItemSwap.GenericFile.Invalid, + ResourceType.Avfx => ItemSwap.LoadAvfx( swap.SwapToModded, out var f ) ? f : ItemSwap.GenericFile.Invalid, _ => ItemSwap.LoadFile( swap.SwapToModded, out var f ) ? f : ItemSwap.GenericFile.Invalid, }; diff --git a/Penumbra/UI/Classes/ItemSwapWindow.cs b/Penumbra/UI/Classes/ItemSwapWindow.cs index a1329a6d..2d23abc4 100644 --- a/Penumbra/UI/Classes/ItemSwapWindow.cs +++ b/Penumbra/UI/Classes/ItemSwapWindow.cs @@ -5,6 +5,7 @@ using Dalamud.Interface; using ImGuiNET; using Lumina.Excel.GeneratedSheets; using OtterGui; +using OtterGui.Classes; using OtterGui.Raii; using OtterGui.Widgets; using Penumbra.Api.Enums; @@ -18,17 +19,43 @@ namespace Penumbra.UI.Classes; public class ItemSwapWindow : IDisposable { - private class ItemSelector : FilterComboCache< Item > + private class EquipSelector : FilterComboCache< Item > { - public ItemSelector() + public EquipSelector() : base( Dalamud.GameData.GetExcelSheet< Item >()!.Where( i - => ( ( EquipSlot )i.EquipSlotCategory.Row ).IsEquipmentPiece() && i.ModelMain != 0 && i.Name.RawData.Length > 0 ) ) + => ( ( EquipSlot )i.EquipSlotCategory.Row ).IsEquipment() && i.ModelMain != 0 && i.Name.RawData.Length > 0 ) ) { } protected override string ToString( Item obj ) => obj.Name.ToString(); } + private class AccessorySelector : FilterComboCache< Item > + { + public AccessorySelector() + : base( Dalamud.GameData.GetExcelSheet< Item >()!.Where( i + => ( ( EquipSlot )i.EquipSlotCategory.Row ).IsAccessory() && i.ModelMain != 0 && i.Name.RawData.Length > 0 ) ) + { } + + protected override string ToString( Item obj ) + => obj.Name.ToString(); + } + + private class SlotSelector : FilterComboCache< Item > + { + public readonly EquipSlot CurrentSlot; + + public SlotSelector( EquipSlot slot ) + : base( () => Dalamud.GameData.GetExcelSheet< Item >()!.Where( i + => ( ( EquipSlot )i.EquipSlotCategory.Row ).ToSlot() == slot && i.ModelMain != 0 && i.Name.RawData.Length > 0 ).ToList() ) + { + CurrentSlot = slot; + } + + protected override string ToString( Item obj ) + => obj.Name.ToString(); + } + public ItemSwapWindow() { Penumbra.CollectionManager.CollectionChanged += OnCollectionChange; @@ -41,8 +68,10 @@ public class ItemSwapWindow : IDisposable Penumbra.CollectionManager.Current.ModSettingChanged -= OnSettingChange; } - private readonly ItemSelector _itemSelector = new(); - private readonly ItemSwapContainer _swapData = new(); + private readonly EquipSelector _equipSelector = new(); + private readonly AccessorySelector _accessorySelector = new(); + private SlotSelector? _slotSelector; + private readonly ItemSwapContainer _swapData = new(); private Mod? _mod; private ModSettings? _modSettings; @@ -53,13 +82,14 @@ public class ItemSwapWindow : IDisposable private ModelRace _currentRace = ModelRace.Midlander; private int _targetId = 0; private int _sourceId = 0; - private int _currentVariant = 1; private Exception? _loadException = null; private string _newModName = string.Empty; private string _newGroupName = "Swaps"; private string _newOptionName = string.Empty; - private bool _useFileSwaps = false; + private bool _useFileSwaps = true; + + private Item[]? _affectedItems; public void UpdateMod( Mod mod, ModSettings? settings ) @@ -90,38 +120,38 @@ public class ItemSwapWindow : IDisposable _swapData.Clear(); _loadException = null; - if( _targetId > 0 && _sourceId > 0 ) + try { - try + switch( _lastTab ) { - switch( _lastTab ) - { - case SwapType.Equipment: break; - case SwapType.Accessory: break; - case SwapType.Hair: - - _swapData.LoadCustomization( BodySlot.Hair, Names.CombinedRace( _currentGender, _currentRace ), ( SetId )_sourceId, ( SetId )_targetId ); - break; - case SwapType.Face: - _swapData.LoadCustomization( BodySlot.Face, Names.CombinedRace( _currentGender, _currentRace ), ( SetId )_sourceId, ( SetId )_targetId ); - break; - case SwapType.Ears: - _swapData.LoadCustomization( BodySlot.Zear, Names.CombinedRace( _currentGender, ModelRace.Viera ), ( SetId )_sourceId, ( SetId )_targetId ); - break; - case SwapType.Tail: - _swapData.LoadCustomization( BodySlot.Tail, Names.CombinedRace( _currentGender, _currentRace ), ( SetId )_sourceId, ( SetId )_targetId ); - break; - case SwapType.Weapon: break; - case SwapType.Minion: break; - case SwapType.Mount: break; - } - } - catch( Exception e ) - { - Penumbra.Log.Error( $"Could not get Customization Data container for {_lastTab}:\n{e}" ); - _loadException = e; + case SwapType.Equipment when _slotSelector?.CurrentSelection != null && _equipSelector.CurrentSelection != null: + _affectedItems = _swapData.LoadEquipment( _equipSelector.CurrentSelection, _slotSelector.CurrentSelection ); + break; + case SwapType.Accessory when _slotSelector?.CurrentSelection != null && _accessorySelector.CurrentSelection != null: + _affectedItems = _swapData.LoadEquipment( _accessorySelector.CurrentSelection, _slotSelector.CurrentSelection ); + break; + case SwapType.Hair when _targetId > 0 && _sourceId > 0: + _swapData.LoadCustomization( BodySlot.Hair, Names.CombinedRace( _currentGender, _currentRace ), ( SetId )_sourceId, ( SetId )_targetId ); + break; + case SwapType.Face when _targetId > 0 && _sourceId > 0: + _swapData.LoadCustomization( BodySlot.Face, Names.CombinedRace( _currentGender, _currentRace ), ( SetId )_sourceId, ( SetId )_targetId ); + break; + case SwapType.Ears when _targetId > 0 && _sourceId > 0: + _swapData.LoadCustomization( BodySlot.Zear, Names.CombinedRace( _currentGender, ModelRace.Viera ), ( SetId )_sourceId, ( SetId )_targetId ); + break; + case SwapType.Tail when _targetId > 0 && _sourceId > 0: + _swapData.LoadCustomization( BodySlot.Tail, Names.CombinedRace( _currentGender, _currentRace ), ( SetId )_sourceId, ( SetId )_targetId ); + break; + case SwapType.Weapon: break; + case SwapType.Minion: break; + case SwapType.Mount: break; } } + catch( Exception e ) + { + Penumbra.Log.Error( $"Could not get Customization Data container for {_lastTab}:\n{e}" ); + _loadException = e; + } _dirty = false; } @@ -174,7 +204,7 @@ public class ItemSwapWindow : IDisposable ImGui.SameLine(); tt = "Create a new option inside this mod containing only the swap."; if( ImGuiUtil.DrawDisabledButton( "Create New Option (WIP)", new Vector2( width / 2, 0 ), tt, - true || (!newModAvailable || _newGroupName.Length == 0 || _newOptionName.Length == 0 || _mod == null || _mod.AllSubMods.Any( m => m.Name == _newOptionName ) )) ) + true || !newModAvailable || _newGroupName.Length == 0 || _newOptionName.Length == 0 || _mod == null || _mod.AllSubMods.Any( m => m.Name == _newOptionName ) ) ) { } ImGui.SameLine(); @@ -201,15 +231,13 @@ public class ItemSwapWindow : IDisposable { using var bar = ImRaii.TabBar( "##swapBar", ImGuiTabBarFlags.None ); + DrawArmorSwap(); + DrawAccessorySwap(); DrawHairSwap(); DrawFaceSwap(); DrawEarSwap(); DrawTailSwap(); - DrawArmorSwap(); - DrawAccessorySwap(); DrawWeaponSwap(); - DrawMinionSwap(); - DrawMountSwap(); } private ImRaii.IEndObject DrawTab( SwapType newTab ) @@ -218,7 +246,7 @@ public class ItemSwapWindow : IDisposable if( tab ) { _dirty |= _lastTab != newTab; - _lastTab = newTab; + _lastTab = newTab; } UpdateState(); @@ -228,22 +256,60 @@ public class ItemSwapWindow : IDisposable private void DrawArmorSwap() { - using var disabled = ImRaii.Disabled(); - using var tab = DrawTab( SwapType.Equipment ); + using var tab = DrawTab( SwapType.Equipment ); if( !tab ) { return; } + + using var table = ImRaii.Table( "##settings", 2, ImGuiTableFlags.SizingFixedFit ); + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted( "Take this piece of equipment" ); + ImGui.TableNextColumn(); + if( _equipSelector.Draw( "##itemTarget", _equipSelector.CurrentSelection?.Name.ToString() ?? string.Empty, InputWidth * 2, ImGui.GetTextLineHeightWithSpacing() ) ) + { + var slot = ( ( EquipSlot )( _equipSelector.CurrentSelection?.EquipSlotCategory.Row ?? 0 ) ).ToSlot(); + if( slot != _slotSelector?.CurrentSlot ) + _slotSelector = new SlotSelector( slot ); + _dirty = true; + } + + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted( "And put it on this one" ); + ImGui.TableNextColumn(); + _slotSelector ??= new SlotSelector( EquipSlot.Unknown ); + _dirty |= _slotSelector.Draw( "##itemSource", _slotSelector.CurrentSelection?.Name.ToString() ?? string.Empty, InputWidth * 2, ImGui.GetTextLineHeightWithSpacing() ); } private void DrawAccessorySwap() { - using var disabled = ImRaii.Disabled(); using var tab = DrawTab( SwapType.Accessory ); if( !tab ) { return; } + + using var table = ImRaii.Table( "##settings", 2, ImGuiTableFlags.SizingFixedFit ); + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted( "Take this accessory" ); + ImGui.TableNextColumn(); + if( _accessorySelector.Draw( "##itemTarget", _accessorySelector.CurrentSelection?.Name.ToString() ?? string.Empty, InputWidth * 2, ImGui.GetTextLineHeightWithSpacing() ) ) + { + var slot = ( ( EquipSlot )( _accessorySelector.CurrentSelection?.EquipSlotCategory.Row ?? 0 ) ).ToSlot(); + if( slot != _slotSelector?.CurrentSlot ) + _slotSelector = new SlotSelector( slot ); + _dirty = true; + } + + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted( "And put it on this one" ); + ImGui.TableNextColumn(); + _slotSelector ??= new SlotSelector( EquipSlot.Unknown ); + _dirty |= _slotSelector.Draw( "##itemSource", _slotSelector.CurrentSelection?.Name.ToString() ?? string.Empty, InputWidth * 2, ImGui.GetTextLineHeightWithSpacing() ); } private void DrawHairSwap() @@ -286,7 +352,7 @@ public class ItemSwapWindow : IDisposable using var table = ImRaii.Table( "##settings", 2, ImGuiTableFlags.SizingFixedFit ); DrawTargetIdInput( "Take this Tail Type" ); DrawSourceIdInput(); - DrawGenderInput("for all", 2); + DrawGenderInput( "for all", 2 ); } @@ -315,26 +381,6 @@ public class ItemSwapWindow : IDisposable } } - private void DrawMinionSwap() - { - using var disabled = ImRaii.Disabled(); - using var tab = DrawTab( SwapType.Minion ); - if( !tab ) - { - return; - } - } - - private void DrawMountSwap() - { - using var disabled = ImRaii.Disabled(); - using var tab = DrawTab( SwapType.Mount ); - if( !tab ) - { - return; - } - } - private const float InputWidth = 120; private void DrawTargetIdInput( string text = "Take this ID" ) @@ -346,7 +392,10 @@ public class ItemSwapWindow : IDisposable ImGui.TableNextColumn(); ImGui.SetNextItemWidth( InputWidth * ImGuiHelpers.GlobalScale ); if( ImGui.InputInt( "##targetId", ref _targetId, 0, 0 ) ) + { _targetId = Math.Clamp( _targetId, 0, byte.MaxValue ); + } + _dirty |= ImGui.IsItemDeactivatedAfterEdit(); } @@ -358,21 +407,11 @@ public class ItemSwapWindow : IDisposable ImGui.TableNextColumn(); ImGui.SetNextItemWidth( InputWidth * ImGuiHelpers.GlobalScale ); - if (ImGui.InputInt( "##sourceId", ref _sourceId, 0, 0 )) + if( ImGui.InputInt( "##sourceId", ref _sourceId, 0, 0 ) ) + { _sourceId = Math.Clamp( _sourceId, 0, byte.MaxValue ); - _dirty |= ImGui.IsItemDeactivatedAfterEdit(); - } + } - private void DrawVariantInput( string text ) - { - ImGui.TableNextColumn(); - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted( text ); - - ImGui.TableNextColumn(); - ImGui.SetNextItemWidth( InputWidth * ImGuiHelpers.GlobalScale ); - if( ImGui.InputInt( "##variantId", ref _currentVariant, 0, 0 ) ) - _currentVariant = Math.Clamp( _currentVariant, 0, byte.MaxValue ); _dirty |= ImGui.IsItemDeactivatedAfterEdit(); } diff --git a/Penumbra/UI/Classes/ModEditWindow.Textures.cs b/Penumbra/UI/Classes/ModEditWindow.Textures.cs index 03f984c0..c3ab2a14 100644 --- a/Penumbra/UI/Classes/ModEditWindow.Textures.cs +++ b/Penumbra/UI/Classes/ModEditWindow.Textures.cs @@ -184,7 +184,7 @@ public partial class ModEditWindow { _dialogManager.Draw(); - using var tab = ImRaii.TabItem( "Texture Import/Export (WIP)" ); + using var tab = ImRaii.TabItem( "Texture Import/Export" ); if( !tab ) { return; diff --git a/Penumbra/UI/Classes/ModEditWindow.cs b/Penumbra/UI/Classes/ModEditWindow.cs index 886a47d2..f7a09c88 100644 --- a/Penumbra/UI/Classes/ModEditWindow.cs +++ b/Penumbra/UI/Classes/ModEditWindow.cs @@ -531,11 +531,11 @@ public partial class ModEditWindow : Window, IDisposable public ModEditWindow() : base( WindowBaseLabel ) { - _materialTab = new FileEditor< MtrlFile >( "Materials (WIP)", ".mtrl", + _materialTab = new FileEditor< MtrlFile >( "Materials", ".mtrl", () => _editor?.MtrlFiles ?? Array.Empty< Editor.FileRegistry >(), DrawMaterialPanel, () => _mod?.ModPath.FullName ?? string.Empty ); - _modelTab = new FileEditor< MdlFile >( "Models (WIP)", ".mdl", + _modelTab = new FileEditor< MdlFile >( "Models", ".mdl", () => _editor?.MdlFiles ?? Array.Empty< Editor.FileRegistry >(), DrawModelPanel, () => _mod?.ModPath.FullName ?? string.Empty );