mirror of
https://github.com/xivdev/Penumbra.git
synced 2026-02-21 07:17:53 +01:00
Add basic version of item swap, seemingly working for hair, tail and ears.
This commit is contained in:
parent
e534ce37d5
commit
5b3d5d1e67
22 changed files with 1730 additions and 120 deletions
214
Penumbra/Mods/ItemSwap/CustomizationSwap.cs
Normal file
214
Penumbra/Mods/ItemSwap/CustomizationSwap.cs
Normal 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 );
|
||||
}
|
||||
}
|
||||
230
Penumbra/Mods/ItemSwap/EquipmentDataContainer.cs
Normal file
230
Penumbra/Mods/ItemSwap/EquipmentDataContainer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
112
Penumbra/Mods/ItemSwap/ItemSwap.cs
Normal file
112
Penumbra/Mods/ItemSwap/ItemSwap.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
134
Penumbra/Mods/ItemSwap/ItemSwapContainer.cs
Normal file
134
Penumbra/Mods/ItemSwap/ItemSwapContainer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
185
Penumbra/Mods/ItemSwap/Swaps.cs
Normal file
185
Penumbra/Mods/ItemSwap/Swaps.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ) )
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue