From 5b3d5d1e67bacf56d2c95626d5d9976211c791f4 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Mon, 12 Dec 2022 23:14:50 +0100 Subject: [PATCH] Add basic version of item swap, seemingly working for hair, tail and ears. --- Penumbra.GameData/Data/GamePaths.cs | 118 ++++- Penumbra.GameData/Data/MaterialHandling.cs | 31 ++ Penumbra.GameData/Enums/BodySlot.cs | 11 + Penumbra.GameData/Enums/Race.cs | 4 +- Penumbra.GameData/Files/IWritable.cs | 3 +- Penumbra.GameData/Files/MdlFile.cs | 5 +- Penumbra.GameData/Files/MtrlFile.cs | 5 + Penumbra.GameData/Structs/EqpEntry.cs | 73 +-- Penumbra/Interop/Structs/MtrlResource.cs | 3 + .../Meta/Manipulations/MetaManipulation.cs | 23 +- Penumbra/Mods/ItemSwap/CustomizationSwap.cs | 214 ++++++++ .../Mods/ItemSwap/EquipmentDataContainer.cs | 230 ++++++++ Penumbra/Mods/ItemSwap/ItemSwap.cs | 112 ++++ Penumbra/Mods/ItemSwap/ItemSwapContainer.cs | 134 +++++ Penumbra/Mods/ItemSwap/Swaps.cs | 185 +++++++ Penumbra/Mods/Subclasses/ModSettings.cs | 77 ++- Penumbra/UI/Classes/Combos.cs | 45 ++ Penumbra/UI/Classes/ItemSwapWindow.cs | 490 ++++++++++++++++++ .../UI/Classes/ModEditWindow.FileEditor.cs | 5 - .../UI/Classes/ModEditWindow.Materials.cs | 11 + Penumbra/UI/Classes/ModEditWindow.Meta.cs | 56 +- Penumbra/UI/Classes/ModEditWindow.cs | 15 +- 22 files changed, 1730 insertions(+), 120 deletions(-) create mode 100644 Penumbra.GameData/Data/MaterialHandling.cs create mode 100644 Penumbra/Mods/ItemSwap/CustomizationSwap.cs create mode 100644 Penumbra/Mods/ItemSwap/EquipmentDataContainer.cs create mode 100644 Penumbra/Mods/ItemSwap/ItemSwap.cs create mode 100644 Penumbra/Mods/ItemSwap/ItemSwapContainer.cs create mode 100644 Penumbra/Mods/ItemSwap/Swaps.cs create mode 100644 Penumbra/UI/Classes/Combos.cs create mode 100644 Penumbra/UI/Classes/ItemSwapWindow.cs diff --git a/Penumbra.GameData/Data/GamePaths.cs b/Penumbra.GameData/Data/GamePaths.cs index 8e2076a4..89f2f58e 100644 --- a/Penumbra.GameData/Data/GamePaths.cs +++ b/Penumbra.GameData/Data/GamePaths.cs @@ -1,3 +1,5 @@ +using System.Text.RegularExpressions; +using Lumina.Excel.GeneratedSheets; using Penumbra.GameData.Enums; using Penumbra.GameData.Structs; @@ -5,6 +7,23 @@ namespace Penumbra.GameData.Data; public static partial class GamePaths { + private static readonly Regex RaceCodeRegex = new(@"c(?'racecode'\d{4})", RegexOptions.Compiled); + + //[GeneratedRegex(@"c(?'racecode'\d{4})")] + public static partial Regex RaceCodeParser(); + + public static partial Regex RaceCodeParser() + => RaceCodeRegex; + + public static GenderRace ParseRaceCode(string path) + { + var match = RaceCodeParser().Match(path); + return match.Success + ? Names.GenderRaceFromCode(match.Groups["racecode"].Value) + : GenderRace.Unknown; + } + + public static partial class Monster { public static partial class Imc @@ -139,7 +158,7 @@ public static partial class GamePaths // public static partial Regex Regex(); public static string Path(SetId equipId, GenderRace raceCode, EquipSlot slot) - => $"chara/equipment/e{equipId.Value:D4}/model/c{(ushort)raceCode:D4}e{equipId.Value:D4}_{slot.ToSuffix()}.mdl"; + => $"chara/equipment/e{equipId.Value:D4}/model/c{raceCode.ToRaceCode()}e{equipId.Value:D4}_{slot.ToSuffix()}.mdl"; } public static partial class Mtrl @@ -148,7 +167,7 @@ public static partial class GamePaths // public static partial Regex Regex(); public static string Path(SetId equipId, GenderRace raceCode, EquipSlot slot, byte variant, string suffix) - => $"{FolderPath(equipId, variant)}/mt_c{(ushort)raceCode:D4}e{equipId.Value:D4}_{slot.ToSuffix()}_{suffix}.mtrl"; + => $"{FolderPath(equipId, variant)}/mt_c{raceCode.ToRaceCode()}e{equipId.Value:D4}_{slot.ToSuffix()}_{suffix}.mtrl"; public static string FolderPath(SetId equipId, byte variant) => $"chara/equipment/e{equipId.Value:D4}/material/v{variant:D4}"; @@ -160,7 +179,25 @@ public static partial class GamePaths // public static partial Regex Regex(); public static string Path(SetId equipId, GenderRace raceCode, EquipSlot slot, byte variant, char suffix1, char suffix2 = '\0') - => $"chara/equipment/e{equipId.Value:D4}/texture/v{variant:D2}_c{(ushort)raceCode:D4}e{equipId.Value:D4}_{slot.ToSuffix()}{(suffix2 != '\0' ? $"_{suffix2}" : string.Empty)}_{suffix1}.tex"; + => $"chara/equipment/e{equipId.Value:D4}/texture/v{variant:D2}_c{raceCode.ToRaceCode()}e{equipId.Value:D4}_{slot.ToSuffix()}{(suffix2 != '\0' ? $"_{suffix2}" : string.Empty)}_{suffix1}.tex"; + } + + public static partial class Avfx + { + //[GeneratedRegex(@"chara/equipment/e(?'id'\d{4})/vfx/eff/ve(?'variant'\d{4})\.avfx")] + //public static partial Regex Regex(); + + public static string Path(SetId equipId, byte effectId) + => $"chara/equipment/e{equipId.Value:D4}/vfx/eff/ve{effectId:D4}.avfx"; + } + + public static partial class Decal + { + //[GeneratedRegex(@"chara/common/texture/decal_equip/-decal_(?'decalId'\d{3})\.tex")] + //public static partial Regex Regex(); + + public static string Path(byte decalId) + => $"chara/common/texture/decal_equip/-decal_{decalId:D3}.tex"; } } @@ -181,7 +218,7 @@ public static partial class GamePaths // public static partial Regex Regex(); public static string Path(SetId accessoryId, GenderRace raceCode, EquipSlot slot) - => $"chara/accessory/a{accessoryId.Value:D4}/model/c{(ushort)raceCode:D4}a{accessoryId.Value:D4}_{slot.ToSuffix()}.mdl"; + => $"chara/accessory/a{accessoryId.Value:D4}/model/c{raceCode.ToRaceCode()}a{accessoryId.Value:D4}_{slot.ToSuffix()}.mdl"; } public static partial class Mtrl @@ -190,7 +227,7 @@ public static partial class GamePaths // public static partial Regex Regex(); public static string Path(SetId accessoryId, GenderRace raceCode, EquipSlot slot, byte variant, string suffix) - => $"{FolderPath(accessoryId, variant)}/c{(ushort)raceCode:D4}a{accessoryId.Value:D4}_{slot.ToSuffix()}_{suffix}.mtrl"; + => $"{FolderPath(accessoryId, variant)}/c{raceCode.ToRaceCode()}a{accessoryId.Value:D4}_{slot.ToSuffix()}_{suffix}.mtrl"; public static string FolderPath(SetId accessoryId, byte variant) => $"chara/accessory/a{accessoryId.Value:D4}/material/v{variant:D4}"; @@ -202,7 +239,7 @@ public static partial class GamePaths // public static partial Regex Regex(); public static string Path(SetId accessoryId, GenderRace raceCode, EquipSlot slot, byte variant, char suffix1, char suffix2 = '\0') - => $"chara/accessory/a{accessoryId.Value:D4}/texture/v{variant:D2}_c{(ushort)raceCode:D4}a{accessoryId.Value:D4}_{slot.ToSuffix()}{(suffix2 != '\0' ? $"_{suffix2}" : string.Empty)}_{suffix1}.tex"; + => $"chara/accessory/a{accessoryId.Value:D4}/texture/v{variant:D2}_c{raceCode.ToRaceCode()}a{accessoryId.Value:D4}_{slot.ToSuffix()}{(suffix2 != '\0' ? $"_{suffix2}" : string.Empty)}_{suffix1}.tex"; } } @@ -214,7 +251,19 @@ public static partial class GamePaths // public static partial Regex Regex(); public static string Path(GenderRace raceCode, BodySlot slot, SetId slotId, CustomizationType type) - => $"chara/human/c{(ushort)raceCode:D4}/obj/{slot.ToSuffix()}/{slot.ToAbbreviation()}{slotId.Value:D4}/model/c{(ushort)raceCode:D4}{slot.ToAbbreviation()}{slotId.Value:D4}_{type.ToSuffix()}.mdl"; + => $"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 @@ -222,11 +271,52 @@ public static partial class GamePaths // [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")] // public static partial Regex Regex(); - public static string Path(GenderRace raceCode, BodySlot slot, SetId slotId, string suffix, - CustomizationType type = CustomizationType.Unknown, byte variant = byte.MaxValue) - => $"chara/human/c{(ushort)raceCode:D4}/obj/{slot.ToSuffix()}/{slot.ToAbbreviation()}{slotId.Value:D4}/material/" - + (variant != byte.MaxValue ? $"v{variant:D4}/" : string.Empty) - + $"mt_c{(ushort)raceCode:D4}{slot.ToAbbreviation()}{slotId.Value:D4}{(type != CustomizationType.Unknown ? $"_{type.ToSuffix()}" : string.Empty)}_{suffix}.mtrl"; + public static string FolderPath(GenderRace raceCode, BodySlot slot, SetId slotId, byte variant = byte.MaxValue) + => $"chara/human/c{raceCode.ToRaceCode()}/obj/{slot.ToSuffix()}/{slot.ToAbbreviation()}{slotId.Value:D4}/material{(variant != byte.MaxValue ? $"/v{variant:D4}" : string.Empty)}"; + + public static string HairPath(GenderRace raceCode, SetId slotId, string fileName, out GenderRace actualGr) + { + actualGr = MaterialHandling.GetGameGenderRace(raceCode, slotId); + var folder = FolderPath(actualGr, BodySlot.Hair, slotId, 1); + return actualGr == raceCode + ? $"{folder}{fileName}" + : $"{folder}/mt_c{actualGr.ToRaceCode()}{fileName[9..]}"; + } + + public static string TailPath(GenderRace raceCode, SetId slotId, string fileName, byte variant, out SetId actualSlotId) + { + switch (raceCode) + { + case GenderRace.HrothgarMale: + case GenderRace.HrothgarFemale: + case GenderRace.HrothgarMaleNpc: + case GenderRace.HrothgarFemaleNpc: + var folder = FolderPath(raceCode, BodySlot.Tail, 1, variant == byte.MaxValue ? (byte)1 : variant); + actualSlotId = 1; + return $"{folder}{fileName}"; + default: + actualSlotId = slotId; + return $"{FolderPath(raceCode, BodySlot.Tail, slotId, variant)}{fileName}"; + } + } + + public static string Path(GenderRace raceCode, BodySlot slot, SetId slotId, string fileName, + out GenderRace actualGr, out SetId actualSlotId, byte variant = byte.MaxValue) + { + switch (slot) + { + case BodySlot.Hair: + actualSlotId = slotId; + return HairPath(raceCode, slotId, fileName, out actualGr); + case BodySlot.Tail: + actualGr = raceCode; + return TailPath(raceCode, slotId, fileName, variant, out actualSlotId); + default: + actualSlotId = slotId; + actualGr = raceCode; + return $"{FolderPath(raceCode, slot, slotId, variant)}{fileName}"; + } + } } public static partial class Tex @@ -236,10 +326,10 @@ public static partial class GamePaths public static string Path(GenderRace raceCode, BodySlot slot, SetId slotId, char suffix1, bool minus = false, CustomizationType type = CustomizationType.Unknown, byte variant = byte.MaxValue, char suffix2 = '\0') - => $"chara/human/c{(ushort)raceCode:D4}/obj/{slot.ToSuffix()}/{slot.ToAbbreviation()}{slotId.Value:D4}/texture/" + => $"chara/human/c{raceCode.ToRaceCode()}/obj/{slot.ToSuffix()}/{slot.ToAbbreviation()}{slotId.Value:D4}/texture/" + (minus ? "--" : string.Empty) + (variant != byte.MaxValue ? $"v{variant:D2}_" : string.Empty) - + $"c{(ushort)raceCode:D4}{slot.ToAbbreviation()}{slotId.Value:D4}{(type != CustomizationType.Unknown ? $"_{type.ToSuffix()}" : string.Empty)}{(suffix2 != '\0' ? $"_{suffix2}" : string.Empty)}_{suffix1}.tex"; + + $"c{raceCode.ToRaceCode()}{slot.ToAbbreviation()}{slotId.Value:D4}{(type != CustomizationType.Unknown ? $"_{type.ToSuffix()}" : string.Empty)}{(suffix2 != '\0' ? $"_{suffix2}" : string.Empty)}_{suffix1}.tex"; // [GeneratedRegex(@"chara/common/texture/(?'catchlight'catchlight)(.*)\.tex")] diff --git a/Penumbra.GameData/Data/MaterialHandling.cs b/Penumbra.GameData/Data/MaterialHandling.cs new file mode 100644 index 00000000..09bbab51 --- /dev/null +++ b/Penumbra.GameData/Data/MaterialHandling.cs @@ -0,0 +1,31 @@ +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; + +namespace Penumbra.GameData.Data; + +public static class MaterialHandling +{ + public static GenderRace GetGameGenderRace(GenderRace actualGr, SetId hairId) + { + // Hrothgar do not share hairstyles. + if (actualGr is GenderRace.HrothgarFemale or GenderRace.HrothgarMale) + return actualGr; + + // Some hairstyles are miqo'te specific but otherwise shared. + if (hairId.Value is >= 101 and <= 115) + { + if (actualGr is GenderRace.MiqoteFemale or GenderRace.MiqoteMale) + return actualGr; + + return actualGr.Split().Item1 == Gender.Female ? GenderRace.MidlanderFemale : GenderRace.MidlanderMale; + } + + // All hairstyles above 116 are shared except for Hrothgar + if (hairId.Value is >= 116 and <= 200) + { + return actualGr.Split().Item1 == Gender.Female ? GenderRace.MidlanderFemale : GenderRace.MidlanderMale; + } + + return actualGr; + } +} diff --git a/Penumbra.GameData/Enums/BodySlot.cs b/Penumbra.GameData/Enums/BodySlot.cs index 8eb6513b..92b4c6ce 100644 --- a/Penumbra.GameData/Enums/BodySlot.cs +++ b/Penumbra.GameData/Enums/BodySlot.cs @@ -37,6 +37,17 @@ public static class BodySlotEnumExtension BodySlot.Zear => 'z', _ => throw new InvalidEnumArgumentException(), }; + + public static CustomizationType ToCustomizationType(this BodySlot value) + => value switch + { + BodySlot.Hair => CustomizationType.Hair, + BodySlot.Face => CustomizationType.Face, + BodySlot.Tail => CustomizationType.Tail, + BodySlot.Body => CustomizationType.Body, + BodySlot.Zear => CustomizationType.Zear, + _ => throw new ArgumentOutOfRangeException(nameof(value), value, null) + }; } public static partial class Names diff --git a/Penumbra.GameData/Enums/Race.cs b/Penumbra.GameData/Enums/Race.cs index 1cf4f1ff..7f86cb6c 100644 --- a/Penumbra.GameData/Enums/Race.cs +++ b/Penumbra.GameData/Enums/Race.cs @@ -328,7 +328,7 @@ public static class RaceEnumExtensions VieraFemaleNpc => "1804", UnknownMaleNpc => "9104", UnknownFemaleNpc => "9204", - _ => throw new InvalidEnumArgumentException(), + _ => string.Empty, }; } @@ -427,7 +427,7 @@ public static partial class Names "1804" => VieraFemaleNpc, "9104" => UnknownMaleNpc, "9204" => UnknownFemaleNpc, - _ => throw new KeyNotFoundException(), + _ => Unknown, }; } diff --git a/Penumbra.GameData/Files/IWritable.cs b/Penumbra.GameData/Files/IWritable.cs index afad2e94..0a170af9 100644 --- a/Penumbra.GameData/Files/IWritable.cs +++ b/Penumbra.GameData/Files/IWritable.cs @@ -1,6 +1,7 @@ namespace Penumbra.GameData.Files; public interface IWritable -{ +{ + public bool Valid { get; } public byte[] Write(); } \ No newline at end of file diff --git a/Penumbra.GameData/Files/MdlFile.cs b/Penumbra.GameData/Files/MdlFile.cs index 09efb624..a7d65ee8 100644 --- a/Penumbra.GameData/Files/MdlFile.cs +++ b/Penumbra.GameData/Files/MdlFile.cs @@ -83,7 +83,9 @@ public partial class MdlFile : IWritable public Shape[] Shapes; // Raw, unparsed data. - public byte[] RemainingData; + public byte[] RemainingData; + + public bool Valid { get; } public MdlFile(byte[] data) { @@ -180,6 +182,7 @@ public partial class MdlFile : IWritable BoneBoundingBoxes[i] = MdlStructs.BoundingBoxStruct.Read(r); RemainingData = r.ReadBytes((int)(r.BaseStream.Length - r.BaseStream.Position)); + Valid = true; } private MdlStructs.ModelFileHeader LoadModelFileHeader(LuminaBinaryReader r) diff --git a/Penumbra.GameData/Files/MtrlFile.cs b/Penumbra.GameData/Files/MtrlFile.cs index b9f46c1f..7508688f 100644 --- a/Penumbra.GameData/Files/MtrlFile.cs +++ b/Penumbra.GameData/Files/MtrlFile.cs @@ -231,6 +231,9 @@ public partial class MtrlFile : IWritable { public string Path; public ushort Flags; + + public bool DX11 + => (Flags & 0x8000) != 0; } public struct Constant @@ -251,6 +254,7 @@ public partial class MtrlFile : IWritable public readonly uint Version; + public bool Valid { get; } public Texture[] Textures; public UvSet[] UvSets; @@ -368,6 +372,7 @@ public partial class MtrlFile : IWritable ShaderPackage.Constants = r.ReadStructuresAsArray(constantCount); ShaderPackage.Samplers = r.ReadStructuresAsArray(samplerCount); ShaderPackage.ShaderValues = r.ReadStructuresAsArray(shaderValueListSize / 4); + Valid = true; } private static Texture[] ReadTextureOffsets(BinaryReader r, int count, out ushort[] offsets) diff --git a/Penumbra.GameData/Structs/EqpEntry.cs b/Penumbra.GameData/Structs/EqpEntry.cs index b628aa63..49b2f66f 100644 --- a/Penumbra.GameData/Structs/EqpEntry.cs +++ b/Penumbra.GameData/Structs/EqpEntry.cs @@ -87,39 +87,42 @@ public enum EqpEntry : ulong public static class Eqp { // cf. Client::Graphics::Scene::CharacterUtility.GetSlotEqpFlags - public const EqpEntry DefaultEntry = ( EqpEntry )0x3fe00070603f00; + public const EqpEntry DefaultEntry = (EqpEntry)0x3fe00070603f00; - public static (int, int) BytesAndOffset( EquipSlot slot ) + public static (int, int) BytesAndOffset(EquipSlot slot) { return slot switch { - EquipSlot.Body => ( 2, 0 ), - EquipSlot.Legs => ( 1, 2 ), - EquipSlot.Hands => ( 1, 3 ), - EquipSlot.Feet => ( 1, 4 ), - EquipSlot.Head => ( 3, 5 ), + EquipSlot.Body => (2, 0), + EquipSlot.Legs => (1, 2), + EquipSlot.Hands => (1, 3), + EquipSlot.Feet => (1, 4), + EquipSlot.Head => (3, 5), _ => throw new InvalidEnumArgumentException(), }; } - public static EqpEntry FromSlotAndBytes( EquipSlot slot, byte[] value ) + public static EqpEntry ShiftAndMask(this EqpEntry entry, EquipSlot slot) + { + var (_, offset) = BytesAndOffset(slot); + var mask = Mask(slot); + return (EqpEntry)((ulong)(entry & mask) >> (offset * 8)); + } + + public static EqpEntry FromSlotAndBytes(EquipSlot slot, byte[] value) { EqpEntry ret = 0; - var (bytes, offset) = BytesAndOffset( slot ); - if( bytes != value.Length ) - { + var (bytes, offset) = BytesAndOffset(slot); + if (bytes != value.Length) throw new ArgumentException(); - } - for( var i = 0; i < bytes; ++i ) - { - ret |= ( EqpEntry )( ( ulong )value[ i ] << ( ( offset + i ) * 8 ) ); - } + for (var i = 0; i < bytes; ++i) + ret |= (EqpEntry)((ulong)value[i] << ((offset + i) * 8)); return ret; } - public static EqpEntry Mask( EquipSlot slot ) + public static EqpEntry Mask(EquipSlot slot) { return slot switch { @@ -132,7 +135,7 @@ public static class Eqp }; } - public static EquipSlot ToEquipSlot( this EqpEntry entry ) + public static EquipSlot ToEquipSlot(this EqpEntry entry) { return entry switch { @@ -211,7 +214,7 @@ public static class Eqp }; } - public static string ToLocalName( this EqpEntry entry ) + public static string ToLocalName(this EqpEntry entry) { return entry switch { @@ -289,25 +292,25 @@ public static class Eqp }; } - private static EqpEntry[] GetEntriesForSlot( EquipSlot slot ) + private static EqpEntry[] GetEntriesForSlot(EquipSlot slot) { - return ( ( EqpEntry[] )Enum.GetValues( typeof( EqpEntry ) ) ) - .Where( e => e.ToEquipSlot() == slot ) - .ToArray(); + return ((EqpEntry[])Enum.GetValues(typeof(EqpEntry))) + .Where(e => e.ToEquipSlot() == slot) + .ToArray(); } - public static readonly EqpEntry[] EqpAttributesBody = GetEntriesForSlot( EquipSlot.Body ); - public static readonly EqpEntry[] EqpAttributesLegs = GetEntriesForSlot( EquipSlot.Legs ); - public static readonly EqpEntry[] EqpAttributesHands = GetEntriesForSlot( EquipSlot.Hands ); - public static readonly EqpEntry[] EqpAttributesFeet = GetEntriesForSlot( EquipSlot.Feet ); - public static readonly EqpEntry[] EqpAttributesHead = GetEntriesForSlot( EquipSlot.Head ); + public static readonly EqpEntry[] EqpAttributesBody = GetEntriesForSlot(EquipSlot.Body); + public static readonly EqpEntry[] EqpAttributesLegs = GetEntriesForSlot(EquipSlot.Legs); + public static readonly EqpEntry[] EqpAttributesHands = GetEntriesForSlot(EquipSlot.Hands); + public static readonly EqpEntry[] EqpAttributesFeet = GetEntriesForSlot(EquipSlot.Feet); + public static readonly EqpEntry[] EqpAttributesHead = GetEntriesForSlot(EquipSlot.Head); - public static readonly IReadOnlyDictionary< EquipSlot, EqpEntry[] > EqpAttributes = new Dictionary< EquipSlot, EqpEntry[] >() + public static readonly IReadOnlyDictionary EqpAttributes = new Dictionary() { - [ EquipSlot.Body ] = EqpAttributesBody, - [ EquipSlot.Legs ] = EqpAttributesLegs, - [ EquipSlot.Hands ] = EqpAttributesHands, - [ EquipSlot.Feet ] = EqpAttributesFeet, - [ EquipSlot.Head ] = EqpAttributesHead, + [EquipSlot.Body] = EqpAttributesBody, + [EquipSlot.Legs] = EqpAttributesLegs, + [EquipSlot.Hands] = EqpAttributesHands, + [EquipSlot.Feet] = EqpAttributesFeet, + [EquipSlot.Head] = EqpAttributesHead, }; -} \ No newline at end of file +} diff --git a/Penumbra/Interop/Structs/MtrlResource.cs b/Penumbra/Interop/Structs/MtrlResource.cs index ff5b6abf..28756877 100644 --- a/Penumbra/Interop/Structs/MtrlResource.cs +++ b/Penumbra/Interop/Structs/MtrlResource.cs @@ -25,4 +25,7 @@ public unsafe struct MtrlResource public byte* TexString( int idx ) => StringList + *( TexSpace + 4 + idx * 8 ); + + public bool TexIsDX11( int idx ) + => *(TexSpace + 5 + idx * 8) >= 0x8000; } \ No newline at end of file diff --git a/Penumbra/Meta/Manipulations/MetaManipulation.cs b/Penumbra/Meta/Manipulations/MetaManipulation.cs index f6ff2d94..5cc96f98 100644 --- a/Penumbra/Meta/Manipulations/MetaManipulation.cs +++ b/Penumbra/Meta/Manipulations/MetaManipulation.cs @@ -198,6 +198,25 @@ public readonly struct MetaManipulation : IEquatable< MetaManipulation >, ICompa }; } + public MetaManipulation WithEntryOf( MetaManipulation other ) + { + if( ManipulationType != other.ManipulationType ) + { + return this; + } + + return ManipulationType switch + { + Type.Eqp => Eqp.Copy( other.Eqp.Entry ), + Type.Gmp => Gmp.Copy( other.Gmp.Entry ), + Type.Eqdp => Eqdp.Copy( other.Eqdp.Entry ), + Type.Est => Est.Copy( other.Est.Entry ), + Type.Rsp => Rsp.Copy( other.Rsp.Entry ), + Type.Imc => Imc.Copy( other.Imc.Entry ), + _ => throw new ArgumentOutOfRangeException(), + }; + } + public override bool Equals( object? obj ) => obj is MetaManipulation other && Equals( other ); @@ -237,8 +256,8 @@ public readonly struct MetaManipulation : IEquatable< MetaManipulation >, ICompa => ManipulationType switch { Type.Imc => $"{Imc.Entry.DecalId}-{Imc.Entry.MaterialId}-{Imc.Entry.VfxId}-{Imc.Entry.SoundId}-{Imc.Entry.MaterialAnimationId}-{Imc.Entry.AttributeMask}", - Type.Eqdp => $"{(ushort) Eqdp.Entry:X}", - Type.Eqp => $"{(ulong)Eqp.Entry:X}", + Type.Eqdp => $"{( ushort )Eqdp.Entry:X}", + Type.Eqp => $"{( ulong )Eqp.Entry:X}", Type.Est => $"{Est.Entry}", Type.Gmp => $"{Gmp.Entry.Value}", Type.Rsp => $"{Rsp.Entry}", diff --git a/Penumbra/Mods/ItemSwap/CustomizationSwap.cs b/Penumbra/Mods/ItemSwap/CustomizationSwap.cs new file mode 100644 index 00000000..46b85f00 --- /dev/null +++ b/Penumbra/Mods/ItemSwap/CustomizationSwap.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +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; + +public static class CustomizationSwap +{ + /// The .mdl file for customizations is unique per racecode, slot and id, thus the .mdl redirection itself is independent of the mode. + public static bool CreateMdl( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, BodySlot slot, GenderRace race, SetId idFrom, SetId idTo, out FileSwap mdl ) + { + if( idFrom.Value > byte.MaxValue ) + { + mdl = new FileSwap(); + return false; + } + + var mdlPathFrom = GamePaths.Character.Mdl.Path( race, slot, idFrom, slot.ToCustomizationType() ); + var mdlPathTo = GamePaths.Character.Mdl.Path( race, slot, idTo, slot.ToCustomizationType() ); + + if( !FileSwap.CreateSwap( ResourceType.Mdl, redirections, mdlPathFrom, mdlPathTo, out mdl ) ) + { + return false; + } + + var range = slot == BodySlot.Tail && race is GenderRace.HrothgarMale or GenderRace.HrothgarFemale or GenderRace.HrothgarMaleNpc or GenderRace.HrothgarMaleNpc ? 5 : 1; + + foreach( ref var materialFileName in mdl.AsMdl()!.Materials.AsSpan() ) + { + var name = materialFileName; + foreach( var variant in Enumerable.Range( 1, range ) ) + { + name = materialFileName; + if( !CreateMtrl( redirections, slot, race, idFrom, idTo, ( byte )variant, ref name, ref mdl.DataWasChanged, out var mtrl ) ) + { + return false; + } + + mdl.ChildSwaps.Add( mtrl ); + } + + materialFileName = name; + } + + 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 ) + { + variant = slot is BodySlot.Face or BodySlot.Zear ? byte.MaxValue : variant; + var mtrlFromPath = GamePaths.Character.Mtrl.Path( race, slot, idFrom, fileName, out var gameRaceFrom, out var gameSetIdFrom, variant ); + 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 ); + + var actualMtrlFromPath = mtrlFromPath; + if( newFileName != fileName ) + { + actualMtrlFromPath = GamePaths.Character.Mtrl.Path( race, slot, idFrom, newFileName, out _, out _, variant ); + fileName = newFileName; + dataWasChanged = true; + } + + if( !FileSwap.CreateSwap( ResourceType.Mtrl, redirections, actualMtrlFromPath, mtrlToPath, out mtrl, actualMtrlFromPath ) ) + { + return false; + } + + if( !CreateShader( redirections, ref mtrl.AsMtrl()!.ShaderPackage.Name, ref mtrl.DataWasChanged, out var shpk ) ) + { + return false; + } + + mtrl.ChildSwaps.Add( shpk ); + + foreach( ref var texture in mtrl.AsMtrl()!.Textures.AsSpan() ) + { + if( !CreateTex( redirections, slot, race, idFrom, ref texture, ref mtrl.DataWasChanged, out var tex ) ) + { + return false; + } + + mtrl.ChildSwaps.Add( tex ); + } + + return true; + } + + public static bool CreateTex( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, BodySlot slot, GenderRace race, SetId idFrom, 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 = ReplaceAnyRace( path, race ); + newPath = ReplaceAnyBody( newPath, slot, idFrom ); + 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 ); + } + + /// 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/EquipmentDataContainer.cs b/Penumbra/Mods/ItemSwap/EquipmentDataContainer.cs new file mode 100644 index 00000000..70847af8 --- /dev/null +++ b/Penumbra/Mods/ItemSwap/EquipmentDataContainer.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +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; + +namespace Penumbra.Mods.ItemSwap; + +public class EquipmentDataContainer +{ + public Item Item; + public EquipSlot Slot; + public SetId ModelId; + public byte Variant; + + public ImcManipulation ImcData; + + public EqpManipulation EqpData; + public GmpManipulation GmpData; + + // Example: Abyssos Helm / Body + public string AvfxPath = string.Empty; + + // Example: Dodore Doublet, but unknown what it does? + public string SoundPath = string.Empty; + + // Example: Crimson Standard Bracelet + public string DecalPath = string.Empty; + + // Example: The Howling Spirit and The Wailing Spirit, but unknown what it does. + public string AnimationPath = string.Empty; + + public Dictionary< GenderRace, GenderRaceContainer > Files = new(); + + public struct GenderRaceContainer + { + public EqdpManipulation Eqdp; + public GenderRace ModelRace; + public GenderRace MaterialRace; + public EstManipulation Est; + public string MdlPath; + public MtrlContainer[] MtrlPaths; + } + + public struct MtrlContainer + { + public string MtrlPath; + public string[] Textures; + public string Shader; + + public MtrlContainer( string mtrlPath ) + { + MtrlPath = mtrlPath; + var file = Dalamud.GameData.GetFile( mtrlPath ); + if( file != null ) + { + var mtrl = new MtrlFile( file.Data ); + Textures = mtrl.Textures.Select( t => t.Path ).ToArray(); + Shader = $"shader/sm5/shpk/{mtrl.ShaderPackage.Name}"; + } + else + { + Textures = Array.Empty< string >(); + Shader = string.Empty; + } + } + } + + + private static EstManipulation GetEstEntry( GenderRace genderRace, SetId setId, EquipSlot slot ) + { + if( slot == EquipSlot.Head ) + { + var entry = EstFile.GetDefault( EstManipulation.EstType.Head, genderRace, setId.Value ); + return new EstManipulation( genderRace.Split().Item1, genderRace.Split().Item2, EstManipulation.EstType.Head, setId.Value, entry ); + } + + if( slot == EquipSlot.Body ) + { + var entry = EstFile.GetDefault( EstManipulation.EstType.Body, genderRace, setId.Value ); + return new EstManipulation( genderRace.Split().Item1, genderRace.Split().Item2, EstManipulation.EstType.Body, setId.Value, entry ); + } + + return default; + } + + private static GenderRaceContainer GetGenderRace( GenderRace genderRace, SetId modelId, EquipSlot slot, ushort materialId ) + { + var ret = new GenderRaceContainer() + { + Eqdp = GetEqdpEntry( genderRace, modelId, slot ), + Est = GetEstEntry( genderRace, modelId, slot ), + }; + ( ret.ModelRace, ret.MaterialRace ) = TraverseEqdpTree( genderRace, modelId, slot ); + ret.MdlPath = GamePaths.Equipment.Mdl.Path( modelId, ret.ModelRace, slot ); + ret.MtrlPaths = MtrlPaths( ret.MdlPath, ret.MaterialRace, modelId, materialId ); + return ret; + } + + private static EqdpManipulation GetEqdpEntry( GenderRace genderRace, SetId modelId, EquipSlot slot ) + { + var entry = ExpandedEqdpFile.GetDefault( genderRace, slot.IsAccessory(), modelId.Value ); + return new EqdpManipulation( entry, slot, genderRace.Split().Item1, genderRace.Split().Item2, modelId.Value ); + } + + private static MtrlContainer[] MtrlPaths( string mdlPath, GenderRace mtrlRace, SetId modelId, ushort materialId ) + { + var file = Dalamud.GameData.GetFile( mdlPath ); + if( file == null ) + { + return Array.Empty< MtrlContainer >(); + } + + var mdl = new MdlFile( Dalamud.GameData.GetFile( mdlPath )!.Data ); + var basePath = GamePaths.Equipment.Mtrl.FolderPath( modelId, ( byte )materialId ); + var equipPart = $"e{modelId.Value:D4}"; + var racePart = $"c{mtrlRace.ToRaceCode()}"; + + return mdl.Materials + .Where( m => m.Contains( equipPart ) ) + .Select( m => new MtrlContainer( $"{basePath}{m.Replace( "c0101", racePart )}" ) ) + .ToArray(); + } + + 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 ); + } + + + public EquipmentDataContainer( Item i ) + { + Item = i; + LookupItem( i, out Slot, out ModelId, out Variant ); + LookupImc( ModelId, Variant, Slot ); + EqpData = new EqpManipulation( ExpandedEqpFile.GetDefault( ModelId.Value ), Slot, ModelId.Value ); + GmpData = Slot == EquipSlot.Head ? new GmpManipulation( ExpandedGmpFile.GetDefault( ModelId.Value ), ModelId.Value ) : default; + + + foreach( var genderRace in Enum.GetValues< GenderRace >() ) + { + if( CharacterUtility.EqdpIdx( genderRace, Slot.IsAccessory() ) < 0 ) + { + continue; + } + + Files[ genderRace ] = GetGenderRace( genderRace, ModelId, Slot, ImcData.Entry.MaterialId ); + } + } + + + private static void LookupItem( Item i, out EquipSlot slot, out SetId modelId, out byte variant ) + { + slot = ( ( EquipSlot )i.EquipSlotCategory.Row ).ToSlot(); + if( !slot.IsEquipment() ) + { + throw new ItemSwap.InvalidItemTypeException(); + } + + modelId = ( ( Quad )i.ModelMain ).A; + variant = ( byte )( ( Quad )i.ModelMain ).B; + } + + + + private void LookupImc( SetId modelId, byte variant, EquipSlot slot ) + { + var imc = ImcFile.GetDefault( GamePaths.Equipment.Imc.Path( modelId ), slot, variant, out var exists ); + if( !exists ) + { + throw new ItemSwap.InvalidImcException(); + } + + ImcData = new ImcManipulation( slot, variant, modelId.Value, imc ); + if( imc.DecalId != 0 ) + { + DecalPath = GamePaths.Equipment.Decal.Path( imc.DecalId ); + } + + // TODO: Figure out how this works. + if( imc.SoundId != 0 ) + { + SoundPath = string.Empty; + } + + if( imc.VfxId != 0 ) + { + AvfxPath = GamePaths.Equipment.Avfx.Path( modelId, imc.VfxId ); + } + + // TODO: Figure out how this works. + if( imc.MaterialAnimationId != 0 ) + { + AnimationPath = string.Empty; + } + } +} \ No newline at end of file diff --git a/Penumbra/Mods/ItemSwap/ItemSwap.cs b/Penumbra/Mods/ItemSwap/ItemSwap.cs new file mode 100644 index 00000000..2e6585c3 --- /dev/null +++ b/Penumbra/Mods/ItemSwap/ItemSwap.cs @@ -0,0 +1,112 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Penumbra.GameData.Files; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.ItemSwap; + +public static class ItemSwap +{ + public class InvalidItemTypeException : Exception + { } + + public class InvalidImcException : Exception + { } + + public class IdUnavailableException : Exception + { } + + private static bool LoadFile( FullPath path, out byte[] data ) + { + if( path.FullName.Length > 0 ) + { + try + { + if( path.IsRooted ) + { + data = File.ReadAllBytes( path.FullName ); + return true; + } + + var file = Dalamud.GameData.GetFile( path.InternalName.ToString() ); + if( file != null ) + { + data = file.Data; + return true; + } + } + catch( Exception e ) + { + Penumbra.Log.Debug( $"Could not load file {path}:\n{e}" ); + } + } + + data = Array.Empty< byte >(); + return false; + } + + public class GenericFile : IWritable + { + public readonly byte[] Data; + public bool Valid { get; } + + public GenericFile( FullPath path ) + => Valid = LoadFile( path, out Data ); + + public byte[] Write() + => Data; + + public static readonly GenericFile Invalid = new(FullPath.Empty); + } + + public static bool LoadFile( FullPath path, [NotNullWhen( true )] out GenericFile? file ) + { + file = new GenericFile( path ); + if( file.Valid ) + { + return true; + } + + file = null; + return false; + } + + public static bool LoadMdl( FullPath path, [NotNullWhen( true )] out MdlFile? file ) + { + try + { + if( LoadFile( path, out byte[] data ) ) + { + file = new MdlFile( data ); + return true; + } + } + catch( Exception e ) + { + Penumbra.Log.Debug( $"Could not parse file {path} to Mdl:\n{e}" ); + } + + file = null; + return false; + } + + public static bool LoadMtrl( FullPath path, [NotNullWhen( true )] out MtrlFile? file ) + { + try + { + if( LoadFile( path, out byte[] data ) ) + { + file = new MtrlFile( data ); + return true; + } + } + catch( Exception e ) + { + Penumbra.Log.Debug( $"Could not parse file {path} to Mtrl:\n{e}" ); + } + + file = null; + return false; + } +} \ No newline at end of file diff --git a/Penumbra/Mods/ItemSwap/ItemSwapContainer.cs b/Penumbra/Mods/ItemSwap/ItemSwapContainer.cs new file mode 100644 index 00000000..4fc07cd4 --- /dev/null +++ b/Penumbra/Mods/ItemSwap/ItemSwapContainer.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta.Manipulations; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.ItemSwap; + +public class ItemSwapContainer +{ + private Dictionary< Utf8GamePath, FullPath > _modRedirections = new(); + private HashSet< MetaManipulation > _modManipulations = new(); + + public IReadOnlyDictionary< Utf8GamePath, FullPath > ModRedirections + => _modRedirections; + + public IReadOnlySet< MetaManipulation > ModManipulations + => _modManipulations; + + public readonly List< Swap > Swaps = new(); + public bool Loaded { get; private set; } + + public void Clear() + { + Swaps.Clear(); + Loaded = false; + } + + public enum WriteType + { + UseSwaps, + NoSwaps, + } + + public bool WriteMod( Mod mod, WriteType writeType = WriteType.NoSwaps ) + { + var convertedManips = new HashSet< MetaManipulation >( Swaps.Count ); + var convertedFiles = new Dictionary< Utf8GamePath, FullPath >( Swaps.Count ); + var convertedSwaps = new Dictionary< Utf8GamePath, FullPath >( Swaps.Count ); + try + { + foreach( var swap in Swaps.SelectMany( s => s.WithChildren() ) ) + { + switch( swap ) + { + case FileSwap file: + // Skip, nothing to do + if( file.SwapToModdedEqualsOriginal ) + { + continue; + } + + + if( writeType == WriteType.UseSwaps && file.SwapToModdedExistsInGame && !file.DataWasChanged ) + { + convertedSwaps.TryAdd( file.SwapFromRequestPath, file.SwapToModded ); + } + else + { + var path = file.GetNewPath( mod.ModPath.FullName ); + var bytes = file.FileData.Write(); + Directory.CreateDirectory( Path.GetDirectoryName( path )! ); + File.WriteAllBytes( path, bytes ); + convertedFiles.TryAdd( file.SwapFromRequestPath, new FullPath( path ) ); + } + + break; + case MetaSwap meta: + if( !meta.SwapAppliedIsDefault ) + { + convertedManips.Add( meta.SwapApplied ); + } + + break; + } + } + + Penumbra.ModManager.OptionSetFiles( mod, -1, 0, convertedFiles ); + Penumbra.ModManager.OptionSetFileSwaps( mod, -1, 0, convertedSwaps ); + Penumbra.ModManager.OptionSetManipulations( mod, -1, 0, convertedManips ); + return true; + } + catch( Exception e ) + { + Penumbra.Log.Error( $"Could not write FileSwapContainer to {mod.ModPath}:\n{e}" ); + return false; + } + } + + public void LoadMod( Mod? mod, ModSettings? settings ) + { + Clear(); + if( mod == null ) + { + _modRedirections = new Dictionary< Utf8GamePath, FullPath >(); + _modManipulations = new HashSet< MetaManipulation >(); + } + else + { + ( _modRedirections, _modManipulations ) = ModSettings.GetResolveData( mod, settings ); + } + } + + public ItemSwapContainer() + { + LoadMod( null, null ); + } + + + public bool LoadCustomization( BodySlot slot, GenderRace race, SetId from, SetId to ) + { + if( !CustomizationSwap.CreateMdl( ModRedirections, slot, race, from, to, out var mdl ) ) + { + return false; + } + + if( !CustomizationSwap.CreateEst( ModRedirections, _modManipulations, slot, race, from, to, out var est ) ) + { + return false; + } + + Swaps.Add( mdl ); + if( est != null ) + { + Swaps.Add( est ); + } + + Loaded = true; + return true; + } +} \ No newline at end of file diff --git a/Penumbra/Mods/ItemSwap/Swaps.cs b/Penumbra/Mods/ItemSwap/Swaps.cs new file mode 100644 index 00000000..d425e476 --- /dev/null +++ b/Penumbra/Mods/ItemSwap/Swaps.cs @@ -0,0 +1,185 @@ +using System; +using Penumbra.GameData.Files; +using Penumbra.Meta.Manipulations; +using Penumbra.String.Classes; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using Penumbra.GameData.Enums; + +namespace Penumbra.Mods.ItemSwap; + +public class Swap +{ + /// Any further swaps belonging specifically to this tree of changes. + public List< Swap > ChildSwaps = new(); + + public IEnumerable< Swap > WithChildren() + => ChildSwaps.SelectMany( c => c.WithChildren() ).Prepend( this ); +} + +public sealed class MetaSwap : Swap +{ + /// The default value of a specific meta manipulation that needs to be redirected. + public MetaManipulation SwapFrom; + + /// The default value of the same Meta entry of the redirected item. + public MetaManipulation SwapToDefault; + + /// The modded value of the same Meta entry of the redirected item, or the same as SwapToDefault if unmodded. + public MetaManipulation SwapToModded; + + /// The modded value applied to the specific meta manipulation target before redirection. + public MetaManipulation SwapApplied; + + /// Whether SwapToModded equals SwapToDefault. + public bool SwapToIsDefault; + + /// Whether the applied meta manipulation does not change anything against the default. + public bool SwapAppliedIsDefault; + + /// + /// Create a new MetaSwap from the original meta identifier and the target meta identifier. + /// + /// A set of modded meta manipulations to consider. This is not manipulated, but can not be IReadOnly because TryGetValue is not available for that. + /// The original meta identifier with its default value. + /// The target meta identifier with its default value. + public MetaSwap( HashSet< MetaManipulation > manipulations, MetaManipulation manipFrom, MetaManipulation manipTo ) + { + SwapFrom = manipFrom; + SwapToDefault = manipTo; + + if( manipulations.TryGetValue( manipTo, out var actual ) ) + { + SwapToModded = actual; + SwapToIsDefault = false; + } + else + { + SwapToModded = manipTo; + SwapToIsDefault = true; + } + + SwapApplied = SwapFrom.WithEntryOf( SwapToModded ); + SwapAppliedIsDefault = SwapApplied.EntryEquals( SwapFrom ); + } +} + +public sealed class FileSwap : Swap +{ + /// The file type, used for bookkeeping. + public ResourceType Type; + + /// The binary or parsed data of the file at SwapToModded. + public IWritable FileData = ItemSwap.GenericFile.Invalid; + + /// The path that would be requested without manipulated parent files. + public string SwapFromPreChangePath = string.Empty; + + /// The Path that needs to be redirected. + public Utf8GamePath SwapFromRequestPath; + + /// The path that the game should request instead, if no mods are involved. + public Utf8GamePath SwapToRequestPath; + + /// The path to the actual file that should be loaded. This can be the same as SwapToRequestPath or a file on the drive. + public FullPath SwapToModded; + + /// Whether the target file is an actual game file. + public bool SwapToModdedExistsInGame; + + /// Whether the target file could be read either from the game or the drive. + public bool SwapToModdedExists + => FileData.Valid; + + /// Whether SwapToModded is a path to a game file that equals SwapFromGamePath. + public bool SwapToModdedEqualsOriginal; + + /// Whether the data in FileData was manipulated from the original file. + public bool DataWasChanged; + + /// Whether SwapFromPreChangePath equals SwapFromRequest. + public bool SwapFromChanged; + + public string GetNewPath( string newMod ) + => Path.Combine( newMod, new Utf8RelPath( SwapFromRequestPath ).ToString() ); + + public MdlFile? AsMdl() + => FileData as MdlFile; + + public MtrlFile? AsMtrl() + => FileData as MtrlFile; + + /// + /// 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. + /// + /// The file type. Mdl and Mtrl have special file loading treatment. + /// The set of redirections that need to be considered. + /// The path the game is going to request when loading the file. + /// The unmodded path to the file the game is supposed to load instead. + /// A full swap container with the actual file in memory. + /// True if everything could be read correctly, false otherwise. + public static bool CreateSwap( ResourceType type, IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, string swapFromRequest, string swapToRequest, out FileSwap swap, + string? swapFromPreChange = null ) + { + swap = new FileSwap + { + Type = type, + FileData = ItemSwap.GenericFile.Invalid, + DataWasChanged = false, + SwapFromPreChangePath = swapFromPreChange ?? swapFromRequest, + SwapFromChanged = swapFromPreChange != swapFromRequest, + SwapFromRequestPath = Utf8GamePath.Empty, + SwapToRequestPath = Utf8GamePath.Empty, + SwapToModded = FullPath.Empty, + }; + + if( swapFromRequest.Length == 0 + || swapToRequest.Length == 0 + || !Utf8GamePath.FromString( swapToRequest, out swap.SwapToRequestPath ) + || !Utf8GamePath.FromString( swapFromRequest, out swap.SwapFromRequestPath ) ) + { + return false; + } + + swap.SwapToModded = redirections.TryGetValue( swap.SwapToRequestPath, out var p ) ? p : new FullPath( swap.SwapToRequestPath ); + swap.SwapToModdedExistsInGame = !swap.SwapToModded.IsRooted && Dalamud.GameData.FileExists( swap.SwapToModded.InternalName.ToString() ); + swap.SwapToModdedEqualsOriginal = !swap.SwapToModded.IsRooted && swap.SwapToModded.InternalName.Equals( swap.SwapFromRequestPath.Path ); + + swap.FileData = type switch + { + 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, + _ => ItemSwap.LoadFile( swap.SwapToModded, out var f ) ? f : ItemSwap.GenericFile.Invalid, + }; + + return swap.SwapToModdedExists; + } + + + /// + /// Convert a single file redirection to use the file name and extension given by type and the files SHA256 hash, if possible. + /// + /// The set of redirections that need to be considered. + /// The in- and output path for a file + /// Will be set to true if was changed. + /// Will be updated. + public static bool CreateShaRedirection( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, ref string path, ref bool dataWasChanged, ref FileSwap swap ) + { + var oldFilename = Path.GetFileName( path ); + var hash = SHA256.HashData( swap.FileData.Write() ); + var name = + $"{( oldFilename.StartsWith( "--" ) ? "--" : string.Empty )}{string.Join( null, hash.Select( c => c.ToString( "x2" ) ) )}.{swap.Type.ToString().ToLowerInvariant()}"; + var newPath = path.Replace( oldFilename, name ); + if( !CreateSwap( swap.Type, redirections, newPath, swap.SwapToRequestPath.ToString(), out var newSwap ) ) + { + return false; + } + + path = newPath; + dataWasChanged = true; + swap = newSwap; + return true; + } +} \ No newline at end of file diff --git a/Penumbra/Mods/Subclasses/ModSettings.cs b/Penumbra/Mods/Subclasses/ModSettings.cs index 7b2a23ab..845456ae 100644 --- a/Penumbra/Mods/Subclasses/ModSettings.cs +++ b/Penumbra/Mods/Subclasses/ModSettings.cs @@ -5,6 +5,8 @@ using System.Numerics; using OtterGui; using OtterGui.Filesystem; using Penumbra.Api.Enums; +using Penumbra.Meta.Manipulations; +using Penumbra.String.Classes; namespace Penumbra.Mods; @@ -34,6 +36,56 @@ public class ModSettings Settings = mod.Groups.Select( g => g.DefaultSettings ).ToList(), }; + // Return everything required to resolve things for a single mod with given settings (which can be null, in which case the default is used. + public static (Dictionary< Utf8GamePath, FullPath >, HashSet< MetaManipulation >) GetResolveData( Mod mod, ModSettings? settings ) + { + if( settings == null ) + { + settings = DefaultSettings( mod ); + } + else + { + settings.AddMissingSettings( mod ); + } + + var dict = new Dictionary< Utf8GamePath, FullPath >(); + var set = new HashSet< MetaManipulation >(); + + void AddOption( ISubMod option ) + { + foreach( var (path, file) in option.Files.Concat( option.FileSwaps ) ) + { + dict.TryAdd( path, file ); + } + + foreach( var manip in option.Manipulations ) + { + set.Add( manip ); + } + } + + foreach( var (group, index) in mod.Groups.WithIndex().OrderByDescending( g => g.Value.Priority ) ) + { + if( group.Type is GroupType.Single ) + { + AddOption( group[ ( int )settings.Settings[ index ] ] ); + } + else + { + foreach( var (option, optionIdx) in group.WithIndex().OrderByDescending( o => group.OptionPriority( o.Index ) ) ) + { + if( ( ( settings.Settings[ index ] >> optionIdx ) & 1 ) == 1 ) + { + AddOption( option ); + } + } + } + } + + AddOption( mod.Default ); + return ( dict, set ); + } + // Automatically react to changes in a mods available options. public bool HandleChanges( ModOptionChangeType type, Mod mod, int groupIdx, int optionIdx, int movedToIdx ) { @@ -42,7 +94,7 @@ public class ModSettings case ModOptionChangeType.GroupRenamed: return true; case ModOptionChangeType.GroupAdded: // Add new empty setting for new mod. - Settings.Insert( groupIdx, mod.Groups[groupIdx].DefaultSettings ); + Settings.Insert( groupIdx, mod.Groups[ groupIdx ].DefaultSettings ); return true; case ModOptionChangeType.GroupDeleted: // Remove setting for deleted mod. @@ -59,7 +111,7 @@ public class ModSettings { GroupType.Single => ( uint )Math.Max( Math.Min( group.Count - 1, BitOperations.TrailingZeroCount( config ) ), 0 ), GroupType.Multi => 1u << ( int )config, - _ => config, + _ => config, }; return config != Settings[ groupIdx ]; } @@ -73,7 +125,7 @@ public class ModSettings { GroupType.Single => config >= optionIdx ? config > 1 ? config - 1 : 0 : config, GroupType.Multi => Functions.RemoveBit( config, optionIdx ), - _ => config, + _ => config, }; return config != Settings[ groupIdx ]; } @@ -90,7 +142,7 @@ public class ModSettings { GroupType.Single => config == optionIdx ? ( uint )movedToIdx : config, GroupType.Multi => Functions.MoveBit( config, optionIdx, movedToIdx ), - _ => config, + _ => config, }; return config != Settings[ groupIdx ]; } @@ -104,27 +156,28 @@ public class ModSettings { GroupType.Single => ( uint )Math.Min( value, group.Count - 1 ), GroupType.Multi => ( uint )( value & ( ( 1ul << group.Count ) - 1 ) ), - _ => value, + _ => value, }; // Set a setting. Ensures that there are enough settings and fixes the setting beforehand. public void SetValue( Mod mod, int groupIdx, uint newValue ) { - AddMissingSettings( groupIdx + 1 ); + AddMissingSettings( mod ); var group = mod.Groups[ groupIdx ]; Settings[ groupIdx ] = FixSetting( group, newValue ); } // Add defaulted settings up to the required count. - private bool AddMissingSettings( int totalCount ) + private bool AddMissingSettings( Mod mod ) { - if( totalCount <= Settings.Count ) + var changes = false; + for( var i = Settings.Count; i < mod.Groups.Count; ++i ) { - return false; + Settings.Add( mod.Groups[ i ].DefaultSettings ); + changes = true; } - Settings.AddRange( Enumerable.Repeat( 0u, totalCount - Settings.Count ) ); - return true; + return changes; } // A simple struct conversion to easily save settings by name instead of value. @@ -147,7 +200,7 @@ public class ModSettings Priority = settings.Priority; Enabled = settings.Enabled; Settings = new Dictionary< string, long >( mod.Groups.Count ); - settings.AddMissingSettings( mod.Groups.Count ); + settings.AddMissingSettings( mod ); foreach( var (group, setting) in mod.Groups.Zip( settings.Settings ) ) { diff --git a/Penumbra/UI/Classes/Combos.cs b/Penumbra/UI/Classes/Combos.cs new file mode 100644 index 00000000..0f56cd77 --- /dev/null +++ b/Penumbra/UI/Classes/Combos.cs @@ -0,0 +1,45 @@ +using Dalamud.Interface; +using OtterGui; +using Penumbra.GameData.Enums; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.UI.Classes; + +public static class Combos +{ + // Different combos to use with enums. + public static bool Race( string label, ModelRace current, out ModelRace race ) + => Race( label, 100, current, out race ); + + public static bool Race( string label, float unscaledWidth, ModelRace current, out ModelRace race ) + => ImGuiUtil.GenericEnumCombo( label, unscaledWidth * ImGuiHelpers.GlobalScale, current, out race, RaceEnumExtensions.ToName, 1 ); + + public static bool Gender( string label, Gender current, out Gender gender ) + => Gender( label, 120, current, out gender ); + + public static bool Gender( string label, float unscaledWidth, Gender current, out Gender gender ) + => ImGuiUtil.GenericEnumCombo( label, unscaledWidth * ImGuiHelpers.GlobalScale, current, out gender, RaceEnumExtensions.ToName, 1 ); + + public static bool EqdpEquipSlot( string label, EquipSlot current, out EquipSlot slot ) + => ImGuiUtil.GenericEnumCombo( label, 100 * ImGuiHelpers.GlobalScale, current, out slot, EquipSlotExtensions.EqdpSlots, EquipSlotExtensions.ToName ); + + public static bool EqpEquipSlot( string label, float width, EquipSlot current, out EquipSlot slot ) + => ImGuiUtil.GenericEnumCombo( label, width * ImGuiHelpers.GlobalScale, current, out slot, EquipSlotExtensions.EquipmentSlots, EquipSlotExtensions.ToName ); + + public static bool AccessorySlot( string label, EquipSlot current, out EquipSlot slot ) + => ImGuiUtil.GenericEnumCombo( label, 100 * ImGuiHelpers.GlobalScale, current, out slot, EquipSlotExtensions.AccessorySlots, EquipSlotExtensions.ToName ); + + public static bool SubRace( string label, SubRace current, out SubRace subRace ) + => ImGuiUtil.GenericEnumCombo( label, 150 * ImGuiHelpers.GlobalScale, current, out subRace, RaceEnumExtensions.ToName, 1 ); + + public static bool RspAttribute( string label, RspAttribute current, out RspAttribute attribute ) + => ImGuiUtil.GenericEnumCombo( label, 200 * ImGuiHelpers.GlobalScale, current, out attribute, + RspAttributeExtensions.ToFullString, 0, 1 ); + + public static bool EstSlot( string label, EstManipulation.EstType current, out EstManipulation.EstType attribute ) + => ImGuiUtil.GenericEnumCombo( label, 200 * ImGuiHelpers.GlobalScale, current, out attribute ); + + public static bool ImcType( string label, ObjectType current, out ObjectType type ) + => ImGuiUtil.GenericEnumCombo( label, 110 * ImGuiHelpers.GlobalScale, current, out type, ObjectTypeExtensions.ValidImcTypes, + ObjectTypeExtensions.ToName ); +} \ No newline at end of file diff --git a/Penumbra/UI/Classes/ItemSwapWindow.cs b/Penumbra/UI/Classes/ItemSwapWindow.cs new file mode 100644 index 00000000..76967dfe --- /dev/null +++ b/Penumbra/UI/Classes/ItemSwapWindow.cs @@ -0,0 +1,490 @@ +using System; +using System.Linq; +using System.Numerics; +using Dalamud.Interface; +using ImGuiNET; +using Lumina.Excel.GeneratedSheets; +using OtterGui; +using OtterGui.Raii; +using OtterGui.Widgets; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Mods; +using Penumbra.Mods.ItemSwap; + +namespace Penumbra.UI.Classes; + +public class ItemSwapWindow : IDisposable +{ + private class ItemSelector : FilterComboCache< Item > + { + public ItemSelector() + : base( Dalamud.GameData.GetExcelSheet< Item >()!.Where( i + => ( ( EquipSlot )i.EquipSlotCategory.Row ).IsEquipmentPiece() && i.ModelMain != 0 && i.Name.RawData.Length > 0 ) ) + { } + + protected override string ToString( Item obj ) + => obj.Name.ToString(); + } + + public ItemSwapWindow() + { + Penumbra.CollectionManager.CollectionChanged += OnCollectionChange; + Penumbra.CollectionManager.Current.ModSettingChanged += OnSettingChange; + } + + public void Dispose() + { + Penumbra.CollectionManager.CollectionChanged += OnCollectionChange; + Penumbra.CollectionManager.Current.ModSettingChanged -= OnSettingChange; + } + + private readonly ItemSelector _itemSelector = new(); + private readonly ItemSwapContainer _swapData = new(); + + private Mod? _mod; + private ModSettings? _modSettings; + private bool _dirty; + + private SwapType _lastTab = SwapType.Equipment; + private Gender _currentGender = Gender.Male; + 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; + + + public void UpdateMod( Mod mod, ModSettings? settings ) + { + if( mod == _mod && settings == _modSettings ) + { + return; + } + + var oldDefaultName = $"{_mod?.Name.Text ?? "Unknown"} (Swapped)"; + if( _newModName.Length == 0 || oldDefaultName == _newModName ) + { + _newModName = $"{mod.Name.Text} (Swapped)"; + } + + _mod = mod; + _modSettings = settings; + _swapData.LoadMod( _mod, _modSettings ); + _dirty = true; + } + + private void UpdateState() + { + if( !_dirty ) + { + return; + } + + _swapData.Clear(); + _loadException = null; + if( _targetId > 0 && _sourceId > 0 ) + { + try + { + 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; + } + } + } + + private static string SwapToString( Swap swap ) + { + return swap switch + { + MetaSwap meta => $"{meta.SwapFrom}: {meta.SwapFrom.EntryToString()} -> {meta.SwapApplied.EntryToString()}", + FileSwap file => $"{file.Type}: {file.SwapFromRequestPath} -> {file.SwapToModded.FullName}{( file.DataWasChanged ? " (EDITED)" : string.Empty )}", + _ => string.Empty, + }; + } + + private string CreateDescription() + => $"Created by swapping {_lastTab} {_sourceId} onto {_lastTab} {_targetId} for {_currentRace.ToName()} {_currentGender.ToName()}s in {_mod!.Name}."; + + private void DrawHeaderLine( float width ) + { + var newModAvailable = _loadException == null && _swapData.Loaded; + + ImGui.SetNextItemWidth( width ); + if( ImGui.InputTextWithHint( "##newModName", "New Mod Name...", ref _newModName, 64 ) ) + { } + + ImGui.SameLine(); + var tt = "Create a new mod of the given name containing only the swap."; + if( ImGuiUtil.DrawDisabledButton( "Create New Mod", new Vector2( width / 2, 0 ), tt, !newModAvailable || _newModName.Length == 0 ) ) + { + var newDir = Mod.CreateModFolder( Penumbra.ModManager.BasePath, _newModName ); + Mod.CreateMeta( newDir, _newModName, Penumbra.Config.DefaultModAuthor, CreateDescription(), "1.0", string.Empty ); + Mod.CreateDefaultFiles( newDir ); + Penumbra.ModManager.AddMod( newDir ); + if( !_swapData.WriteMod( Penumbra.ModManager.Last(), _useFileSwaps ? ItemSwapContainer.WriteType.UseSwaps : ItemSwapContainer.WriteType.NoSwaps ) ) + { + Penumbra.ModManager.DeleteMod( Penumbra.ModManager.Count - 1 ); + } + } + + + ImGui.SetNextItemWidth( ( width - ImGui.GetStyle().ItemSpacing.X ) / 2 ); + if( ImGui.InputTextWithHint( "##groupName", "Group Name...", ref _newGroupName, 32 ) ) + { } + + ImGui.SameLine(); + ImGui.SetNextItemWidth( ( width - ImGui.GetStyle().ItemSpacing.X ) / 2 ); + if( ImGui.InputTextWithHint( "##optionName", "New Option Name...", ref _newOptionName, 32 ) ) + { } + + 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 ) )) ) + { } + + ImGui.SameLine(); + var newPos = new Vector2( ImGui.GetCursorPosX() + 10 * ImGuiHelpers.GlobalScale, ImGui.GetCursorPosY() - ( ImGui.GetFrameHeight() + ImGui.GetStyle().ItemSpacing.Y ) / 2 ); + ImGui.SetCursorPos( newPos ); + ImGui.Checkbox( "Use File Swaps", ref _useFileSwaps ); + ImGuiUtil.HoverTooltip( "Use File Swaps." ); + } + + private enum SwapType + { + Equipment, + Accessory, + Hair, + Face, + Ears, + Tail, + Weapon, + Minion, + Mount, + } + + private void DrawSwapBar() + { + using var bar = ImRaii.TabBar( "##swapBar", ImGuiTabBarFlags.None ); + + DrawHairSwap(); + DrawFaceSwap(); + DrawEarSwap(); + DrawTailSwap(); + DrawArmorSwap(); + DrawAccessorySwap(); + DrawWeaponSwap(); + DrawMinionSwap(); + DrawMountSwap(); + } + + private ImRaii.IEndObject DrawTab( SwapType newTab ) + { + using var tab = ImRaii.TabItem( newTab.ToString() ); + if( tab ) + { + _dirty = _lastTab != newTab; + _lastTab = newTab; + } + + UpdateState(); + + return tab; + } + + private void DrawArmorSwap() + { + using var disabled = ImRaii.Disabled(); + using var tab = DrawTab( SwapType.Equipment ); + if( !tab ) + { + return; + } + } + + private void DrawAccessorySwap() + { + using var disabled = ImRaii.Disabled(); + using var tab = DrawTab( SwapType.Accessory ); + if( !tab ) + { + return; + } + } + + private void DrawHairSwap() + { + using var tab = DrawTab( SwapType.Hair ); + if( !tab ) + { + return; + } + + using var table = ImRaii.Table( "##settings", 2, ImGuiTableFlags.SizingFixedFit ); + DrawTargetIdInput( "Take this Hairstyle" ); + DrawSourceIdInput(); + DrawGenderInput(); + } + + private void DrawFaceSwap() + { + using var disabled = ImRaii.Disabled(); + using var tab = DrawTab( SwapType.Face ); + if( !tab ) + { + return; + } + + using var table = ImRaii.Table( "##settings", 2, ImGuiTableFlags.SizingFixedFit ); + DrawTargetIdInput( "Take this Face Type" ); + DrawSourceIdInput(); + DrawGenderInput(); + } + + private void DrawTailSwap() + { + using var tab = DrawTab( SwapType.Tail ); + if( !tab ) + { + return; + } + + using var table = ImRaii.Table( "##settings", 2, ImGuiTableFlags.SizingFixedFit ); + DrawTargetIdInput( "Take this Tail Type" ); + DrawSourceIdInput(); + DrawGenderInput("for all", 2); + } + + + private void DrawEarSwap() + { + using var tab = DrawTab( SwapType.Ears ); + if( !tab ) + { + return; + } + + using var table = ImRaii.Table( "##settings", 2, ImGuiTableFlags.SizingFixedFit ); + DrawTargetIdInput( "Take this Ear Type" ); + DrawSourceIdInput(); + DrawGenderInput( "for all Viera", 0 ); + } + + + private void DrawWeaponSwap() + { + using var disabled = ImRaii.Disabled(); + using var tab = DrawTab( SwapType.Weapon ); + if( !tab ) + { + return; + } + } + + 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" ) + { + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted( text ); + + 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(); + } + + private void DrawSourceIdInput( string text = "and put it on this one" ) + { + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted( text ); + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth( InputWidth * ImGuiHelpers.GlobalScale ); + 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(); + } + + private void DrawGenderInput( string text = "for all", int drawRace = 1 ) + { + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted( text ); + + ImGui.TableNextColumn(); + _dirty |= Combos.Gender( "##Gender", InputWidth, _currentGender, out _currentGender ); + if( drawRace == 1 ) + { + ImGui.SameLine(); + _dirty |= Combos.Race( "##Race", InputWidth, _currentRace, out _currentRace ); + } + else if( drawRace == 2 ) + { + ImGui.SameLine(); + if( _currentRace is not ModelRace.Miqote and not ModelRace.AuRa and not ModelRace.Hrothgar ) + { + _currentRace = ModelRace.Miqote; + } + + _dirty |= ImGuiUtil.GenericEnumCombo( "##Race", InputWidth, _currentRace, out _currentRace, new[] { ModelRace.Miqote, ModelRace.AuRa, ModelRace.Hrothgar }, + RaceEnumExtensions.ToName ); + } + } + + private string NonExistentText() + => _lastTab switch + { + SwapType.Equipment => "One of the selected pieces of equipment does not seem to exist.", + SwapType.Accessory => "One of the selected accessories does not seem to exist.", + SwapType.Hair => "One of the selected hairstyles does not seem to exist for this gender and race combo.", + SwapType.Face => "One of the selected faces does not seem to exist for this gender and race combo.", + SwapType.Ears => "One of the selected ear types does not seem to exist for this gender and race combo.", + SwapType.Tail => "One of the selected tails does not seem to exist for this gender and race combo.", + SwapType.Weapon => "One of the selected weapons does not seem to exist.", + SwapType.Minion => "One of the selected minions does not seem to exist.", + SwapType.Mount => "One of the selected mounts does not seem to exist.", + _ => string.Empty, + }; + + + public void DrawItemSwapPanel() + { + using var tab = ImRaii.TabItem( "Item Swap (WIP)" ); + if( !tab ) + { + return; + } + + ImGui.NewLine(); + DrawHeaderLine( 300 * ImGuiHelpers.GlobalScale ); + ImGui.NewLine(); + + DrawSwapBar(); + + using var table = ImRaii.ListBox( "##swaps", -Vector2.One ); + if( _loadException != null ) + { + ImGuiUtil.TextWrapped( $"Could not load Customization Swap:\n{_loadException}" ); + } + else if( _swapData.Loaded ) + { + foreach( var swap in _swapData.Swaps ) + { + DrawSwap( swap ); + } + } + else + { + ImGui.TextUnformatted( NonExistentText() ); + } + } + + private static void DrawSwap( Swap swap ) + { + var flags = swap.ChildSwaps.Count == 0 ? ImGuiTreeNodeFlags.Bullet | ImGuiTreeNodeFlags.Leaf : ImGuiTreeNodeFlags.DefaultOpen; + using var tree = ImRaii.TreeNode( SwapToString( swap ), flags ); + if( !tree ) + { + return; + } + + foreach( var child in swap.ChildSwaps ) + { + DrawSwap( child ); + } + } + + private void OnCollectionChange( CollectionType collectionType, ModCollection? oldCollection, + ModCollection? newCollection, string _ ) + { + if( collectionType != CollectionType.Current || _mod == null || newCollection == null ) + { + return; + } + + UpdateMod( _mod, newCollection.Settings[ _mod.Index ] ); + newCollection.ModSettingChanged += OnSettingChange; + if( oldCollection != null ) + { + oldCollection.ModSettingChanged -= OnSettingChange; + } + } + + private void OnSettingChange( ModSettingChange type, int modIdx, int oldValue, int groupIdx, bool inherited ) + { + if( modIdx == _mod?.Index ) + { + _swapData.LoadMod( _mod, _modSettings ); + _dirty = true; + } + } +} \ No newline at end of file diff --git a/Penumbra/UI/Classes/ModEditWindow.FileEditor.cs b/Penumbra/UI/Classes/ModEditWindow.FileEditor.cs index 22cb1398..343ef0ce 100644 --- a/Penumbra/UI/Classes/ModEditWindow.FileEditor.cs +++ b/Penumbra/UI/Classes/ModEditWindow.FileEditor.cs @@ -51,11 +51,6 @@ public partial class ModEditWindow public void Draw() { _list = _getFiles(); - if( _list.Count == 0 ) - { - return; - } - using var tab = ImRaii.TabItem( _tabName ); if( !tab ) { diff --git a/Penumbra/UI/Classes/ModEditWindow.Materials.cs b/Penumbra/UI/Classes/ModEditWindow.Materials.cs index ab63ac79..3cc27aac 100644 --- a/Penumbra/UI/Classes/ModEditWindow.Materials.cs +++ b/Penumbra/UI/Classes/ModEditWindow.Materials.cs @@ -147,6 +147,17 @@ public partial class ModEditWindow return false; } + using( var textures = ImRaii.TreeNode( "Textures", ImGuiTreeNodeFlags.DefaultOpen ) ) + { + if( textures ) + { + foreach( var tex in file.Textures ) + { + ImRaii.TreeNode( $"{tex.Path} - {tex.Flags:X4}", ImGuiTreeNodeFlags.Leaf ).Dispose(); + } + } + } + using( var sets = ImRaii.TreeNode( "UV Sets", ImGuiTreeNodeFlags.DefaultOpen ) ) { if( sets ) diff --git a/Penumbra/UI/Classes/ModEditWindow.Meta.cs b/Penumbra/UI/Classes/ModEditWindow.Meta.cs index d1a4159f..9707fb6f 100644 --- a/Penumbra/UI/Classes/ModEditWindow.Meta.cs +++ b/Penumbra/UI/Classes/ModEditWindow.Meta.cs @@ -143,7 +143,7 @@ public partial class ModEditWindow ImGuiUtil.HoverTooltip( ModelSetIdTooltip ); ImGui.TableNextColumn(); - if( EqpEquipSlotCombo( "##eqpSlot", 100, _new.Slot, out var slot ) ) + if( Combos.EqpEquipSlot( "##eqpSlot", 100, _new.Slot, out var slot ) ) { _new = new EqpManipulation( ExpandedEqpFile.GetDefault( setId ), slot, _new.SetId ); } @@ -241,7 +241,7 @@ public partial class ModEditWindow ImGuiUtil.HoverTooltip( ModelSetIdTooltip ); ImGui.TableNextColumn(); - if( RaceCombo( "##eqdpRace", _new.Race, out var race ) ) + if( Combos.Race( "##eqdpRace", _new.Race, out var race ) ) { var newDefaultEntry = ExpandedEqdpFile.GetDefault( Names.CombinedRace( _new.Gender, race ), _new.Slot.IsAccessory(), _new.SetId ); _new = new EqdpManipulation( newDefaultEntry, _new.Slot, _new.Gender, race, _new.SetId ); @@ -250,7 +250,7 @@ public partial class ModEditWindow ImGuiUtil.HoverTooltip( ModelRaceTooltip ); ImGui.TableNextColumn(); - if( GenderCombo( "##eqdpGender", _new.Gender, out var gender ) ) + if( Combos.Gender( "##eqdpGender", _new.Gender, out var gender ) ) { var newDefaultEntry = ExpandedEqdpFile.GetDefault( Names.CombinedRace( gender, _new.Race ), _new.Slot.IsAccessory(), _new.SetId ); _new = new EqdpManipulation( newDefaultEntry, _new.Slot, gender, _new.Race, _new.SetId ); @@ -259,7 +259,7 @@ public partial class ModEditWindow ImGuiUtil.HoverTooltip( GenderTooltip ); ImGui.TableNextColumn(); - if( EqdpEquipSlotCombo( "##eqdpSlot", _new.Slot, out var slot ) ) + if( Combos.EqdpEquipSlot( "##eqdpSlot", _new.Slot, out var slot ) ) { var newDefaultEntry = ExpandedEqdpFile.GetDefault( Names.CombinedRace( _new.Gender, _new.Race ), slot.IsAccessory(), _new.SetId ); _new = new EqdpManipulation( newDefaultEntry, slot, _new.Gender, _new.Race, _new.SetId ); @@ -356,7 +356,7 @@ public partial class ModEditWindow // Identifier ImGui.TableNextColumn(); - if( ImcTypeCombo( "##imcType", _new.ObjectType, out var type ) ) + if( Combos.ImcType( "##imcType", _new.ObjectType, out var type ) ) { var equipSlot = type switch { @@ -386,7 +386,7 @@ public partial class ModEditWindow // Equipment and accessories are slightly different imcs than other types. if( _new.ObjectType is ObjectType.Equipment ) { - if( EqpEquipSlotCombo( "##imcSlot", 100, _new.EquipSlot, out var slot ) ) + if( Combos.EqpEquipSlot( "##imcSlot", 100, _new.EquipSlot, out var slot ) ) { _new = new ImcManipulation( _new.ObjectType, _new.BodySlot, _new.PrimaryId, _new.SecondaryId, _new.Variant, slot, _new.Entry ).Copy( GetDefault( _new ) ?? new ImcEntry() ); @@ -396,7 +396,7 @@ public partial class ModEditWindow } else if( _new.ObjectType is ObjectType.Accessory ) { - if( AccessorySlotCombo( "##imcSlot", _new.EquipSlot, out var slot ) ) + if( Combos.AccessorySlot( "##imcSlot", _new.EquipSlot, out var slot ) ) { _new = new ImcManipulation( _new.ObjectType, _new.BodySlot, _new.PrimaryId, _new.SecondaryId, _new.Variant, slot, _new.Entry ).Copy( GetDefault( _new ) ?? new ImcEntry() ); @@ -425,7 +425,7 @@ public partial class ModEditWindow ImGui.TableNextColumn(); if( _new.ObjectType is ObjectType.DemiHuman ) { - if( EqpEquipSlotCombo( "##imcSlot", 70, _new.EquipSlot, out var slot ) ) + if( Combos.EqpEquipSlot( "##imcSlot", 70, _new.EquipSlot, out var slot ) ) { _new = new ImcManipulation( _new.ObjectType, _new.BodySlot, _new.PrimaryId, _new.SecondaryId, _new.Variant, slot, _new.Entry ).Copy( GetDefault( _new ) ?? new ImcEntry() ); @@ -599,7 +599,7 @@ public partial class ModEditWindow ImGuiUtil.HoverTooltip( ModelSetIdTooltip ); ImGui.TableNextColumn(); - if( RaceCombo( "##estRace", _new.Race, out var race ) ) + if( Combos.Race( "##estRace", _new.Race, out var race ) ) { var newDefaultEntry = EstFile.GetDefault( _new.Slot, Names.CombinedRace( _new.Gender, race ), _new.SetId ); _new = new EstManipulation( _new.Gender, race, _new.Slot, _new.SetId, newDefaultEntry ); @@ -608,7 +608,7 @@ public partial class ModEditWindow ImGuiUtil.HoverTooltip( ModelRaceTooltip ); ImGui.TableNextColumn(); - if( GenderCombo( "##estGender", _new.Gender, out var gender ) ) + if( Combos.Gender( "##estGender", _new.Gender, out var gender ) ) { var newDefaultEntry = EstFile.GetDefault( _new.Slot, Names.CombinedRace( gender, _new.Race ), _new.SetId ); _new = new EstManipulation( gender, _new.Race, _new.Slot, _new.SetId, newDefaultEntry ); @@ -617,7 +617,7 @@ public partial class ModEditWindow ImGuiUtil.HoverTooltip( GenderTooltip ); ImGui.TableNextColumn(); - if( EstSlotCombo( "##estSlot", _new.Slot, out var slot ) ) + if( Combos.EstSlot( "##estSlot", _new.Slot, out var slot ) ) { var newDefaultEntry = EstFile.GetDefault( slot, Names.CombinedRace( _new.Gender, _new.Race ), _new.SetId ); _new = new EstManipulation( _new.Gender, _new.Race, slot, _new.SetId, newDefaultEntry ); @@ -805,7 +805,7 @@ public partial class ModEditWindow // Identifier ImGui.TableNextColumn(); - if( SubRaceCombo( "##rspSubRace", _new.SubRace, out var subRace ) ) + if( Combos.SubRace( "##rspSubRace", _new.SubRace, out var subRace ) ) { _new = new RspManipulation( subRace, _new.Attribute, CmpFile.GetDefault( subRace, _new.Attribute ) ); } @@ -813,7 +813,7 @@ public partial class ModEditWindow ImGuiUtil.HoverTooltip( RacialTribeTooltip ); ImGui.TableNextColumn(); - if( RspAttributeCombo( "##rspAttribute", _new.Attribute, out var attribute ) ) + if( Combos.RspAttribute( "##rspAttribute", _new.Attribute, out var attribute ) ) { _new = new RspManipulation( _new.SubRace, attribute, CmpFile.GetDefault( subRace, attribute ) ); } @@ -858,36 +858,6 @@ public partial class ModEditWindow } } - // Different combos to use with enums. - private static bool RaceCombo( string label, ModelRace current, out ModelRace race ) - => ImGuiUtil.GenericEnumCombo( label, 100 * ImGuiHelpers.GlobalScale, current, out race, RaceEnumExtensions.ToName, 1 ); - - private static bool GenderCombo( string label, Gender current, out Gender gender ) - => ImGuiUtil.GenericEnumCombo( label, 120 * ImGuiHelpers.GlobalScale, current, out gender, RaceEnumExtensions.ToName, 1 ); - - private static bool EqdpEquipSlotCombo( string label, EquipSlot current, out EquipSlot slot ) - => ImGuiUtil.GenericEnumCombo( label, 100 * ImGuiHelpers.GlobalScale, current, out slot, EquipSlotExtensions.EqdpSlots, EquipSlotExtensions.ToName ); - - private static bool EqpEquipSlotCombo( string label, float width, EquipSlot current, out EquipSlot slot ) - => ImGuiUtil.GenericEnumCombo( label, width * ImGuiHelpers.GlobalScale, current, out slot, EquipSlotExtensions.EquipmentSlots, EquipSlotExtensions.ToName ); - - private static bool AccessorySlotCombo( string label, EquipSlot current, out EquipSlot slot ) - => ImGuiUtil.GenericEnumCombo( label, 100 * ImGuiHelpers.GlobalScale, current, out slot, EquipSlotExtensions.AccessorySlots, EquipSlotExtensions.ToName ); - - private static bool SubRaceCombo( string label, SubRace current, out SubRace subRace ) - => ImGuiUtil.GenericEnumCombo( label, 150 * ImGuiHelpers.GlobalScale, current, out subRace, RaceEnumExtensions.ToName, 1 ); - - private static bool RspAttributeCombo( string label, RspAttribute current, out RspAttribute attribute ) - => ImGuiUtil.GenericEnumCombo( label, 200 * ImGuiHelpers.GlobalScale, current, out attribute, - RspAttributeExtensions.ToFullString, 0, 1 ); - - private static bool EstSlotCombo( string label, EstManipulation.EstType current, out EstManipulation.EstType attribute ) - => ImGuiUtil.GenericEnumCombo( label, 200 * ImGuiHelpers.GlobalScale, current, out attribute ); - - private static bool ImcTypeCombo( string label, ObjectType current, out ObjectType type ) - => ImGuiUtil.GenericEnumCombo( label, 110 * ImGuiHelpers.GlobalScale, current, out type, ObjectTypeExtensions.ValidImcTypes, - ObjectTypeExtensions.ToName ); - // A number input for ids with a optional max id of given width. // Returns true if newId changed against currentId. private static bool IdInput( string label, float width, ushort currentId, out ushort newId, int minId, int maxId, bool border ) diff --git a/Penumbra/UI/Classes/ModEditWindow.cs b/Penumbra/UI/Classes/ModEditWindow.cs index f527c555..886a47d2 100644 --- a/Penumbra/UI/Classes/ModEditWindow.cs +++ b/Penumbra/UI/Classes/ModEditWindow.cs @@ -20,11 +20,13 @@ namespace Penumbra.UI.Classes; public partial class ModEditWindow : Window, IDisposable { - private const string WindowBaseLabel = "###SubModEdit"; - private Editor? _editor; - private Mod? _mod; - private Vector2 _iconSize = Vector2.Zero; - private bool _allowReduplicate = false; + private const string WindowBaseLabel = "###SubModEdit"; + internal readonly ItemSwapWindow _swapWindow = new(); + + private Editor? _editor; + private Mod? _mod; + private Vector2 _iconSize = Vector2.Zero; + private bool _allowReduplicate = false; public void ChangeMod( Mod mod ) { @@ -45,6 +47,7 @@ public partial class ModEditWindow : Window, IDisposable _selectedFiles.Clear(); _modelTab.Reset(); _materialTab.Reset(); + _swapWindow.UpdateMod( mod, Penumbra.CollectionManager.Current[ mod.Index ].Settings ); } public void ChangeOption( ISubMod? subMod ) @@ -148,6 +151,7 @@ public partial class ModEditWindow : Window, IDisposable _modelTab.Draw(); _materialTab.Draw(); DrawTextureTab(); + _swapWindow.DrawItemSwapPanel(); } // A row of three buttonSizes and a help marker that can be used for material suffix changing. @@ -544,5 +548,6 @@ public partial class ModEditWindow : Window, IDisposable _left.Dispose(); _right.Dispose(); _center.Dispose(); + _swapWindow.Dispose(); } } \ No newline at end of file