Add basic version of item swap, seemingly working for hair, tail and ears.

This commit is contained in:
Ottermandias 2022-12-12 23:14:50 +01:00
parent e534ce37d5
commit 5b3d5d1e67
22 changed files with 1730 additions and 120 deletions

View file

@ -1,3 +1,5 @@
using System.Text.RegularExpressions;
using Lumina.Excel.GeneratedSheets;
using Penumbra.GameData.Enums; using Penumbra.GameData.Enums;
using Penumbra.GameData.Structs; using Penumbra.GameData.Structs;
@ -5,6 +7,23 @@ namespace Penumbra.GameData.Data;
public static partial class GamePaths 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 Monster
{ {
public static partial class Imc public static partial class Imc
@ -139,7 +158,7 @@ public static partial class GamePaths
// public static partial Regex Regex(); // public static partial Regex Regex();
public static string Path(SetId equipId, GenderRace raceCode, EquipSlot slot) 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 public static partial class Mtrl
@ -148,7 +167,7 @@ public static partial class GamePaths
// public static partial Regex Regex(); // public static partial Regex Regex();
public static string Path(SetId equipId, GenderRace raceCode, EquipSlot slot, byte variant, string suffix) 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) public static string FolderPath(SetId equipId, byte variant)
=> $"chara/equipment/e{equipId.Value:D4}/material/v{variant:D4}"; => $"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 partial Regex Regex();
public static string Path(SetId equipId, GenderRace raceCode, EquipSlot slot, byte variant, char suffix1, char suffix2 = '\0') 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 partial Regex Regex();
public static string Path(SetId accessoryId, GenderRace raceCode, EquipSlot slot) 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 public static partial class Mtrl
@ -190,7 +227,7 @@ public static partial class GamePaths
// public static partial Regex Regex(); // public static partial Regex Regex();
public static string Path(SetId accessoryId, GenderRace raceCode, EquipSlot slot, byte variant, string suffix) 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) public static string FolderPath(SetId accessoryId, byte variant)
=> $"chara/accessory/a{accessoryId.Value:D4}/material/v{variant:D4}"; => $"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 partial Regex Regex();
public static string Path(SetId accessoryId, GenderRace raceCode, EquipSlot slot, byte variant, char suffix1, char suffix2 = '\0') 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 partial Regex Regex();
public static string Path(GenderRace raceCode, BodySlot slot, SetId slotId, CustomizationType type) 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 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")] // [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 partial Regex Regex();
public static string Path(GenderRace raceCode, BodySlot slot, SetId slotId, string suffix, public static string FolderPath(GenderRace raceCode, BodySlot slot, SetId slotId, byte variant = byte.MaxValue)
CustomizationType type = CustomizationType.Unknown, 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)}";
=> $"chara/human/c{(ushort)raceCode:D4}/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)
+ $"mt_c{(ushort)raceCode:D4}{slot.ToAbbreviation()}{slotId.Value:D4}{(type != CustomizationType.Unknown ? $"_{type.ToSuffix()}" : string.Empty)}_{suffix}.mtrl"; {
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 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, 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') 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) + (minus ? "--" : string.Empty)
+ (variant != byte.MaxValue ? $"v{variant:D2}_" : 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")] // [GeneratedRegex(@"chara/common/texture/(?'catchlight'catchlight)(.*)\.tex")]

View file

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

View file

@ -37,6 +37,17 @@ public static class BodySlotEnumExtension
BodySlot.Zear => 'z', BodySlot.Zear => 'z',
_ => throw new InvalidEnumArgumentException(), _ => 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 public static partial class Names

View file

@ -328,7 +328,7 @@ public static class RaceEnumExtensions
VieraFemaleNpc => "1804", VieraFemaleNpc => "1804",
UnknownMaleNpc => "9104", UnknownMaleNpc => "9104",
UnknownFemaleNpc => "9204", UnknownFemaleNpc => "9204",
_ => throw new InvalidEnumArgumentException(), _ => string.Empty,
}; };
} }
@ -427,7 +427,7 @@ public static partial class Names
"1804" => VieraFemaleNpc, "1804" => VieraFemaleNpc,
"9104" => UnknownMaleNpc, "9104" => UnknownMaleNpc,
"9204" => UnknownFemaleNpc, "9204" => UnknownFemaleNpc,
_ => throw new KeyNotFoundException(), _ => Unknown,
}; };
} }

View file

@ -1,6 +1,7 @@
namespace Penumbra.GameData.Files; namespace Penumbra.GameData.Files;
public interface IWritable public interface IWritable
{ {
public bool Valid { get; }
public byte[] Write(); public byte[] Write();
} }

View file

@ -83,7 +83,9 @@ public partial class MdlFile : IWritable
public Shape[] Shapes; public Shape[] Shapes;
// Raw, unparsed data. // Raw, unparsed data.
public byte[] RemainingData; public byte[] RemainingData;
public bool Valid { get; }
public MdlFile(byte[] data) public MdlFile(byte[] data)
{ {
@ -180,6 +182,7 @@ public partial class MdlFile : IWritable
BoneBoundingBoxes[i] = MdlStructs.BoundingBoxStruct.Read(r); BoneBoundingBoxes[i] = MdlStructs.BoundingBoxStruct.Read(r);
RemainingData = r.ReadBytes((int)(r.BaseStream.Length - r.BaseStream.Position)); RemainingData = r.ReadBytes((int)(r.BaseStream.Length - r.BaseStream.Position));
Valid = true;
} }
private MdlStructs.ModelFileHeader LoadModelFileHeader(LuminaBinaryReader r) private MdlStructs.ModelFileHeader LoadModelFileHeader(LuminaBinaryReader r)

View file

@ -231,6 +231,9 @@ public partial class MtrlFile : IWritable
{ {
public string Path; public string Path;
public ushort Flags; public ushort Flags;
public bool DX11
=> (Flags & 0x8000) != 0;
} }
public struct Constant public struct Constant
@ -251,6 +254,7 @@ public partial class MtrlFile : IWritable
public readonly uint Version; public readonly uint Version;
public bool Valid { get; }
public Texture[] Textures; public Texture[] Textures;
public UvSet[] UvSets; public UvSet[] UvSets;
@ -368,6 +372,7 @@ public partial class MtrlFile : IWritable
ShaderPackage.Constants = r.ReadStructuresAsArray<Constant>(constantCount); ShaderPackage.Constants = r.ReadStructuresAsArray<Constant>(constantCount);
ShaderPackage.Samplers = r.ReadStructuresAsArray<Sampler>(samplerCount); ShaderPackage.Samplers = r.ReadStructuresAsArray<Sampler>(samplerCount);
ShaderPackage.ShaderValues = r.ReadStructuresAsArray<float>(shaderValueListSize / 4); ShaderPackage.ShaderValues = r.ReadStructuresAsArray<float>(shaderValueListSize / 4);
Valid = true;
} }
private static Texture[] ReadTextureOffsets(BinaryReader r, int count, out ushort[] offsets) private static Texture[] ReadTextureOffsets(BinaryReader r, int count, out ushort[] offsets)

View file

@ -87,39 +87,42 @@ public enum EqpEntry : ulong
public static class Eqp public static class Eqp
{ {
// cf. Client::Graphics::Scene::CharacterUtility.GetSlotEqpFlags // 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 return slot switch
{ {
EquipSlot.Body => ( 2, 0 ), EquipSlot.Body => (2, 0),
EquipSlot.Legs => ( 1, 2 ), EquipSlot.Legs => (1, 2),
EquipSlot.Hands => ( 1, 3 ), EquipSlot.Hands => (1, 3),
EquipSlot.Feet => ( 1, 4 ), EquipSlot.Feet => (1, 4),
EquipSlot.Head => ( 3, 5 ), EquipSlot.Head => (3, 5),
_ => throw new InvalidEnumArgumentException(), _ => 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; EqpEntry ret = 0;
var (bytes, offset) = BytesAndOffset( slot ); var (bytes, offset) = BytesAndOffset(slot);
if( bytes != value.Length ) if (bytes != value.Length)
{
throw new ArgumentException(); throw new ArgumentException();
}
for( var i = 0; i < bytes; ++i ) for (var i = 0; i < bytes; ++i)
{ ret |= (EqpEntry)((ulong)value[i] << ((offset + i) * 8));
ret |= ( EqpEntry )( ( ulong )value[ i ] << ( ( offset + i ) * 8 ) );
}
return ret; return ret;
} }
public static EqpEntry Mask( EquipSlot slot ) public static EqpEntry Mask(EquipSlot slot)
{ {
return slot switch 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 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 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 ) ) ) return ((EqpEntry[])Enum.GetValues(typeof(EqpEntry)))
.Where( e => e.ToEquipSlot() == slot ) .Where(e => e.ToEquipSlot() == slot)
.ToArray(); .ToArray();
} }
public static readonly EqpEntry[] EqpAttributesBody = GetEntriesForSlot( EquipSlot.Body ); public static readonly EqpEntry[] EqpAttributesBody = GetEntriesForSlot(EquipSlot.Body);
public static readonly EqpEntry[] EqpAttributesLegs = GetEntriesForSlot( EquipSlot.Legs ); public static readonly EqpEntry[] EqpAttributesLegs = GetEntriesForSlot(EquipSlot.Legs);
public static readonly EqpEntry[] EqpAttributesHands = GetEntriesForSlot( EquipSlot.Hands ); public static readonly EqpEntry[] EqpAttributesHands = GetEntriesForSlot(EquipSlot.Hands);
public static readonly EqpEntry[] EqpAttributesFeet = GetEntriesForSlot( EquipSlot.Feet ); public static readonly EqpEntry[] EqpAttributesFeet = GetEntriesForSlot(EquipSlot.Feet);
public static readonly EqpEntry[] EqpAttributesHead = GetEntriesForSlot( EquipSlot.Head ); public static readonly EqpEntry[] EqpAttributesHead = GetEntriesForSlot(EquipSlot.Head);
public static readonly IReadOnlyDictionary< EquipSlot, EqpEntry[] > EqpAttributes = new Dictionary< EquipSlot, EqpEntry[] >() public static readonly IReadOnlyDictionary<EquipSlot, EqpEntry[]> EqpAttributes = new Dictionary<EquipSlot, EqpEntry[]>()
{ {
[ EquipSlot.Body ] = EqpAttributesBody, [EquipSlot.Body] = EqpAttributesBody,
[ EquipSlot.Legs ] = EqpAttributesLegs, [EquipSlot.Legs] = EqpAttributesLegs,
[ EquipSlot.Hands ] = EqpAttributesHands, [EquipSlot.Hands] = EqpAttributesHands,
[ EquipSlot.Feet ] = EqpAttributesFeet, [EquipSlot.Feet] = EqpAttributesFeet,
[ EquipSlot.Head ] = EqpAttributesHead, [EquipSlot.Head] = EqpAttributesHead,
}; };
} }

View file

@ -25,4 +25,7 @@ public unsafe struct MtrlResource
public byte* TexString( int idx ) public byte* TexString( int idx )
=> StringList + *( TexSpace + 4 + idx * 8 ); => StringList + *( TexSpace + 4 + idx * 8 );
public bool TexIsDX11( int idx )
=> *(TexSpace + 5 + idx * 8) >= 0x8000;
} }

View file

@ -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 ) public override bool Equals( object? obj )
=> obj is MetaManipulation other && Equals( other ); => obj is MetaManipulation other && Equals( other );
@ -237,8 +256,8 @@ public readonly struct MetaManipulation : IEquatable< MetaManipulation >, ICompa
=> ManipulationType switch => ManipulationType switch
{ {
Type.Imc => $"{Imc.Entry.DecalId}-{Imc.Entry.MaterialId}-{Imc.Entry.VfxId}-{Imc.Entry.SoundId}-{Imc.Entry.MaterialAnimationId}-{Imc.Entry.AttributeMask}", 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.Eqdp => $"{( ushort )Eqdp.Entry:X}",
Type.Eqp => $"{(ulong)Eqp.Entry:X}", Type.Eqp => $"{( ulong )Eqp.Entry:X}",
Type.Est => $"{Est.Entry}", Type.Est => $"{Est.Entry}",
Type.Gmp => $"{Gmp.Entry.Value}", Type.Gmp => $"{Gmp.Entry.Value}",
Type.Rsp => $"{Rsp.Entry}", Type.Rsp => $"{Rsp.Entry}",

View file

@ -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 );
}
/// <remarks> metaChanges is not manipulated, but IReadOnlySet does not support TryGetValue. </remarks>
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 );
}
}

View file

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

View file

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

View file

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

View file

@ -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
{
/// <summary> Any further swaps belonging specifically to this tree of changes. </summary>
public List< Swap > ChildSwaps = new();
public IEnumerable< Swap > WithChildren()
=> ChildSwaps.SelectMany( c => c.WithChildren() ).Prepend( this );
}
public sealed class MetaSwap : Swap
{
/// <summary> The default value of a specific meta manipulation that needs to be redirected. </summary>
public MetaManipulation SwapFrom;
/// <summary> The default value of the same Meta entry of the redirected item. </summary>
public MetaManipulation SwapToDefault;
/// <summary> The modded value of the same Meta entry of the redirected item, or the same as SwapToDefault if unmodded. </summary>
public MetaManipulation SwapToModded;
/// <summary> The modded value applied to the specific meta manipulation target before redirection. </summary>
public MetaManipulation SwapApplied;
/// <summary> Whether SwapToModded equals SwapToDefault. </summary>
public bool SwapToIsDefault;
/// <summary> Whether the applied meta manipulation does not change anything against the default. </summary>
public bool SwapAppliedIsDefault;
/// <summary>
/// Create a new MetaSwap from the original meta identifier and the target meta identifier.
/// </summary>
/// <param name="manipulations">A set of modded meta manipulations to consider. This is not manipulated, but can not be IReadOnly because TryGetValue is not available for that.</param>
/// <param name="manipFrom">The original meta identifier with its default value.</param>
/// <param name="manipTo">The target meta identifier with its default value.</param>
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
{
/// <summary> The file type, used for bookkeeping. </summary>
public ResourceType Type;
/// <summary> The binary or parsed data of the file at SwapToModded. </summary>
public IWritable FileData = ItemSwap.GenericFile.Invalid;
/// <summary> The path that would be requested without manipulated parent files. </summary>
public string SwapFromPreChangePath = string.Empty;
/// <summary> The Path that needs to be redirected. </summary>
public Utf8GamePath SwapFromRequestPath;
/// <summary> The path that the game should request instead, if no mods are involved. </summary>
public Utf8GamePath SwapToRequestPath;
/// <summary> The path to the actual file that should be loaded. This can be the same as SwapToRequestPath or a file on the drive. </summary>
public FullPath SwapToModded;
/// <summary> Whether the target file is an actual game file. </summary>
public bool SwapToModdedExistsInGame;
/// <summary> Whether the target file could be read either from the game or the drive. </summary>
public bool SwapToModdedExists
=> FileData.Valid;
/// <summary> Whether SwapToModded is a path to a game file that equals SwapFromGamePath. </summary>
public bool SwapToModdedEqualsOriginal;
/// <summary> Whether the data in FileData was manipulated from the original file. </summary>
public bool DataWasChanged;
/// <summary> Whether SwapFromPreChangePath equals SwapFromRequest. </summary>
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;
/// <summary>
/// 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.
/// </summary>
/// <param name="type">The file type. Mdl and Mtrl have special file loading treatment.</param>
/// <param name="redirections">The set of redirections that need to be considered.</param>
/// <param name="swapFromRequest">The path the game is going to request when loading the file.</param>
/// <param name="swapToRequest">The unmodded path to the file the game is supposed to load instead.</param>
/// <param name="swap">A full swap container with the actual file in memory.</param>
/// <returns>True if everything could be read correctly, false otherwise.</returns>
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;
}
/// <summary>
/// Convert a single file redirection to use the file name and extension given by type and the files SHA256 hash, if possible.
/// </summary>
/// <param name="redirections">The set of redirections that need to be considered.</param>
/// <param name="path">The in- and output path for a file</param>
/// <param name="dataWasChanged">Will be set to true if <paramref name="path"/> was changed.</param>
/// <param name="swap">Will be updated.</param>
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;
}
}

View file

@ -5,6 +5,8 @@ using System.Numerics;
using OtterGui; using OtterGui;
using OtterGui.Filesystem; using OtterGui.Filesystem;
using Penumbra.Api.Enums; using Penumbra.Api.Enums;
using Penumbra.Meta.Manipulations;
using Penumbra.String.Classes;
namespace Penumbra.Mods; namespace Penumbra.Mods;
@ -34,6 +36,56 @@ public class ModSettings
Settings = mod.Groups.Select( g => g.DefaultSettings ).ToList(), 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. // Automatically react to changes in a mods available options.
public bool HandleChanges( ModOptionChangeType type, Mod mod, int groupIdx, int optionIdx, int movedToIdx ) 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.GroupRenamed: return true;
case ModOptionChangeType.GroupAdded: case ModOptionChangeType.GroupAdded:
// Add new empty setting for new mod. // Add new empty setting for new mod.
Settings.Insert( groupIdx, mod.Groups[groupIdx].DefaultSettings ); Settings.Insert( groupIdx, mod.Groups[ groupIdx ].DefaultSettings );
return true; return true;
case ModOptionChangeType.GroupDeleted: case ModOptionChangeType.GroupDeleted:
// Remove setting for deleted mod. // 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.Single => ( uint )Math.Max( Math.Min( group.Count - 1, BitOperations.TrailingZeroCount( config ) ), 0 ),
GroupType.Multi => 1u << ( int )config, GroupType.Multi => 1u << ( int )config,
_ => config, _ => config,
}; };
return config != Settings[ groupIdx ]; return config != Settings[ groupIdx ];
} }
@ -73,7 +125,7 @@ public class ModSettings
{ {
GroupType.Single => config >= optionIdx ? config > 1 ? config - 1 : 0 : config, GroupType.Single => config >= optionIdx ? config > 1 ? config - 1 : 0 : config,
GroupType.Multi => Functions.RemoveBit( config, optionIdx ), GroupType.Multi => Functions.RemoveBit( config, optionIdx ),
_ => config, _ => config,
}; };
return config != Settings[ groupIdx ]; return config != Settings[ groupIdx ];
} }
@ -90,7 +142,7 @@ public class ModSettings
{ {
GroupType.Single => config == optionIdx ? ( uint )movedToIdx : config, GroupType.Single => config == optionIdx ? ( uint )movedToIdx : config,
GroupType.Multi => Functions.MoveBit( config, optionIdx, movedToIdx ), GroupType.Multi => Functions.MoveBit( config, optionIdx, movedToIdx ),
_ => config, _ => config,
}; };
return config != Settings[ groupIdx ]; return config != Settings[ groupIdx ];
} }
@ -104,27 +156,28 @@ public class ModSettings
{ {
GroupType.Single => ( uint )Math.Min( value, group.Count - 1 ), GroupType.Single => ( uint )Math.Min( value, group.Count - 1 ),
GroupType.Multi => ( uint )( value & ( ( 1ul << 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. // Set a setting. Ensures that there are enough settings and fixes the setting beforehand.
public void SetValue( Mod mod, int groupIdx, uint newValue ) public void SetValue( Mod mod, int groupIdx, uint newValue )
{ {
AddMissingSettings( groupIdx + 1 ); AddMissingSettings( mod );
var group = mod.Groups[ groupIdx ]; var group = mod.Groups[ groupIdx ];
Settings[ groupIdx ] = FixSetting( group, newValue ); Settings[ groupIdx ] = FixSetting( group, newValue );
} }
// Add defaulted settings up to the required count. // 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 changes;
return true;
} }
// A simple struct conversion to easily save settings by name instead of value. // A simple struct conversion to easily save settings by name instead of value.
@ -147,7 +200,7 @@ public class ModSettings
Priority = settings.Priority; Priority = settings.Priority;
Enabled = settings.Enabled; Enabled = settings.Enabled;
Settings = new Dictionary< string, long >( mod.Groups.Count ); 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 ) ) foreach( var (group, setting) in mod.Groups.Zip( settings.Settings ) )
{ {

View file

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

View file

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

View file

@ -51,11 +51,6 @@ public partial class ModEditWindow
public void Draw() public void Draw()
{ {
_list = _getFiles(); _list = _getFiles();
if( _list.Count == 0 )
{
return;
}
using var tab = ImRaii.TabItem( _tabName ); using var tab = ImRaii.TabItem( _tabName );
if( !tab ) if( !tab )
{ {

View file

@ -147,6 +147,17 @@ public partial class ModEditWindow
return false; 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 ) ) using( var sets = ImRaii.TreeNode( "UV Sets", ImGuiTreeNodeFlags.DefaultOpen ) )
{ {
if( sets ) if( sets )

View file

@ -143,7 +143,7 @@ public partial class ModEditWindow
ImGuiUtil.HoverTooltip( ModelSetIdTooltip ); ImGuiUtil.HoverTooltip( ModelSetIdTooltip );
ImGui.TableNextColumn(); 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 ); _new = new EqpManipulation( ExpandedEqpFile.GetDefault( setId ), slot, _new.SetId );
} }
@ -241,7 +241,7 @@ public partial class ModEditWindow
ImGuiUtil.HoverTooltip( ModelSetIdTooltip ); ImGuiUtil.HoverTooltip( ModelSetIdTooltip );
ImGui.TableNextColumn(); 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 ); 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 ); _new = new EqdpManipulation( newDefaultEntry, _new.Slot, _new.Gender, race, _new.SetId );
@ -250,7 +250,7 @@ public partial class ModEditWindow
ImGuiUtil.HoverTooltip( ModelRaceTooltip ); ImGuiUtil.HoverTooltip( ModelRaceTooltip );
ImGui.TableNextColumn(); 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 ); 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 ); _new = new EqdpManipulation( newDefaultEntry, _new.Slot, gender, _new.Race, _new.SetId );
@ -259,7 +259,7 @@ public partial class ModEditWindow
ImGuiUtil.HoverTooltip( GenderTooltip ); ImGuiUtil.HoverTooltip( GenderTooltip );
ImGui.TableNextColumn(); 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 ); 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 ); _new = new EqdpManipulation( newDefaultEntry, slot, _new.Gender, _new.Race, _new.SetId );
@ -356,7 +356,7 @@ public partial class ModEditWindow
// Identifier // Identifier
ImGui.TableNextColumn(); ImGui.TableNextColumn();
if( ImcTypeCombo( "##imcType", _new.ObjectType, out var type ) ) if( Combos.ImcType( "##imcType", _new.ObjectType, out var type ) )
{ {
var equipSlot = type switch var equipSlot = type switch
{ {
@ -386,7 +386,7 @@ public partial class ModEditWindow
// Equipment and accessories are slightly different imcs than other types. // Equipment and accessories are slightly different imcs than other types.
if( _new.ObjectType is ObjectType.Equipment ) 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 = new ImcManipulation( _new.ObjectType, _new.BodySlot, _new.PrimaryId, _new.SecondaryId, _new.Variant, slot, _new.Entry ).Copy( GetDefault( _new )
?? new ImcEntry() ); ?? new ImcEntry() );
@ -396,7 +396,7 @@ public partial class ModEditWindow
} }
else if( _new.ObjectType is ObjectType.Accessory ) 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 = new ImcManipulation( _new.ObjectType, _new.BodySlot, _new.PrimaryId, _new.SecondaryId, _new.Variant, slot, _new.Entry ).Copy( GetDefault( _new )
?? new ImcEntry() ); ?? new ImcEntry() );
@ -425,7 +425,7 @@ public partial class ModEditWindow
ImGui.TableNextColumn(); ImGui.TableNextColumn();
if( _new.ObjectType is ObjectType.DemiHuman ) 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 = new ImcManipulation( _new.ObjectType, _new.BodySlot, _new.PrimaryId, _new.SecondaryId, _new.Variant, slot, _new.Entry ).Copy( GetDefault( _new )
?? new ImcEntry() ); ?? new ImcEntry() );
@ -599,7 +599,7 @@ public partial class ModEditWindow
ImGuiUtil.HoverTooltip( ModelSetIdTooltip ); ImGuiUtil.HoverTooltip( ModelSetIdTooltip );
ImGui.TableNextColumn(); 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 ); var newDefaultEntry = EstFile.GetDefault( _new.Slot, Names.CombinedRace( _new.Gender, race ), _new.SetId );
_new = new EstManipulation( _new.Gender, race, _new.Slot, _new.SetId, newDefaultEntry ); _new = new EstManipulation( _new.Gender, race, _new.Slot, _new.SetId, newDefaultEntry );
@ -608,7 +608,7 @@ public partial class ModEditWindow
ImGuiUtil.HoverTooltip( ModelRaceTooltip ); ImGuiUtil.HoverTooltip( ModelRaceTooltip );
ImGui.TableNextColumn(); 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 ); var newDefaultEntry = EstFile.GetDefault( _new.Slot, Names.CombinedRace( gender, _new.Race ), _new.SetId );
_new = new EstManipulation( gender, _new.Race, _new.Slot, _new.SetId, newDefaultEntry ); _new = new EstManipulation( gender, _new.Race, _new.Slot, _new.SetId, newDefaultEntry );
@ -617,7 +617,7 @@ public partial class ModEditWindow
ImGuiUtil.HoverTooltip( GenderTooltip ); ImGuiUtil.HoverTooltip( GenderTooltip );
ImGui.TableNextColumn(); 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 ); var newDefaultEntry = EstFile.GetDefault( slot, Names.CombinedRace( _new.Gender, _new.Race ), _new.SetId );
_new = new EstManipulation( _new.Gender, _new.Race, slot, _new.SetId, newDefaultEntry ); _new = new EstManipulation( _new.Gender, _new.Race, slot, _new.SetId, newDefaultEntry );
@ -805,7 +805,7 @@ public partial class ModEditWindow
// Identifier // Identifier
ImGui.TableNextColumn(); 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 ) ); _new = new RspManipulation( subRace, _new.Attribute, CmpFile.GetDefault( subRace, _new.Attribute ) );
} }
@ -813,7 +813,7 @@ public partial class ModEditWindow
ImGuiUtil.HoverTooltip( RacialTribeTooltip ); ImGuiUtil.HoverTooltip( RacialTribeTooltip );
ImGui.TableNextColumn(); 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 ) ); _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. // A number input for ids with a optional max id of given width.
// Returns true if newId changed against currentId. // 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 ) private static bool IdInput( string label, float width, ushort currentId, out ushort newId, int minId, int maxId, bool border )

View file

@ -20,11 +20,13 @@ namespace Penumbra.UI.Classes;
public partial class ModEditWindow : Window, IDisposable public partial class ModEditWindow : Window, IDisposable
{ {
private const string WindowBaseLabel = "###SubModEdit"; private const string WindowBaseLabel = "###SubModEdit";
private Editor? _editor; internal readonly ItemSwapWindow _swapWindow = new();
private Mod? _mod;
private Vector2 _iconSize = Vector2.Zero; private Editor? _editor;
private bool _allowReduplicate = false; private Mod? _mod;
private Vector2 _iconSize = Vector2.Zero;
private bool _allowReduplicate = false;
public void ChangeMod( Mod mod ) public void ChangeMod( Mod mod )
{ {
@ -45,6 +47,7 @@ public partial class ModEditWindow : Window, IDisposable
_selectedFiles.Clear(); _selectedFiles.Clear();
_modelTab.Reset(); _modelTab.Reset();
_materialTab.Reset(); _materialTab.Reset();
_swapWindow.UpdateMod( mod, Penumbra.CollectionManager.Current[ mod.Index ].Settings );
} }
public void ChangeOption( ISubMod? subMod ) public void ChangeOption( ISubMod? subMod )
@ -148,6 +151,7 @@ public partial class ModEditWindow : Window, IDisposable
_modelTab.Draw(); _modelTab.Draw();
_materialTab.Draw(); _materialTab.Draw();
DrawTextureTab(); DrawTextureTab();
_swapWindow.DrawItemSwapPanel();
} }
// A row of three buttonSizes and a help marker that can be used for material suffix changing. // 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(); _left.Dispose();
_right.Dispose(); _right.Dispose();
_center.Dispose(); _center.Dispose();
_swapWindow.Dispose();
} }
} }