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

@ -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.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 ) )
{