mirror of
https://github.com/xivdev/Penumbra.git
synced 2025-12-12 18:27:24 +01:00
Change Item Swaps to use exceptions for actual error messages.
This commit is contained in:
parent
29d01e698b
commit
45ec212b78
7 changed files with 149 additions and 285 deletions
|
|
@ -2,7 +2,6 @@ using System;
|
|||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using OtterGui;
|
||||
using Penumbra.Collections;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
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;
|
||||
|
|
@ -14,22 +12,17 @@ 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 )
|
||||
public static FileSwap CreateMdl( Func< Utf8GamePath, FullPath > redirections, BodySlot slot, GenderRace race, SetId idFrom, SetId idTo )
|
||||
{
|
||||
if( idFrom.Value > byte.MaxValue )
|
||||
{
|
||||
mdl = new FileSwap();
|
||||
return false;
|
||||
throw new Exception( $"The Customization ID {idFrom} is too large for {slot}." );
|
||||
}
|
||||
|
||||
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 mdl = FileSwap.CreateSwap( ResourceType.Mdl, redirections, mdlPathFrom, mdlPathTo );
|
||||
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() )
|
||||
|
|
@ -38,22 +31,18 @@ public static class CustomizationSwap
|
|||
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;
|
||||
}
|
||||
|
||||
var mtrl = CreateMtrl( redirections, slot, race, idFrom, idTo, ( byte )variant, ref name, ref mdl.DataWasChanged );
|
||||
mdl.ChildSwaps.Add( mtrl );
|
||||
}
|
||||
|
||||
materialFileName = name;
|
||||
}
|
||||
|
||||
return true;
|
||||
return mdl;
|
||||
}
|
||||
|
||||
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 )
|
||||
public static FileSwap CreateMtrl( Func< Utf8GamePath, FullPath > redirections, BodySlot slot, GenderRace race, SetId idFrom, SetId idTo, byte variant,
|
||||
ref string fileName, ref bool dataWasChanged )
|
||||
{
|
||||
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 );
|
||||
|
|
@ -73,33 +62,21 @@ public static class CustomizationSwap
|
|||
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;
|
||||
}
|
||||
|
||||
var mtrl = FileSwap.CreateSwap( ResourceType.Mtrl, redirections, actualMtrlFromPath, mtrlToPath, actualMtrlFromPath );
|
||||
var shpk = CreateShader( redirections, ref mtrl.AsMtrl()!.ShaderPackage.Name, ref mtrl.DataWasChanged );
|
||||
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;
|
||||
}
|
||||
|
||||
var tex = CreateTex( redirections, slot, race, idFrom, ref texture, ref mtrl.DataWasChanged );
|
||||
mtrl.ChildSwaps.Add( tex );
|
||||
}
|
||||
|
||||
return true;
|
||||
return mtrl;
|
||||
}
|
||||
|
||||
public static bool CreateTex( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, BodySlot slot, GenderRace race, SetId idFrom, ref MtrlFile.Texture texture,
|
||||
ref bool dataWasChanged, out FileSwap tex )
|
||||
public static FileSwap CreateTex( Func< Utf8GamePath, FullPath > redirections, BodySlot slot, GenderRace race, SetId idFrom, ref MtrlFile.Texture texture,
|
||||
ref bool dataWasChanged )
|
||||
{
|
||||
var path = texture.Path;
|
||||
var addedDashes = false;
|
||||
|
|
@ -122,13 +99,13 @@ public static class CustomizationSwap
|
|||
dataWasChanged = true;
|
||||
}
|
||||
|
||||
return FileSwap.CreateSwap( ResourceType.Tex, redirections, newPath, path, out tex, path );
|
||||
return FileSwap.CreateSwap( ResourceType.Tex, redirections, newPath, path, path );
|
||||
}
|
||||
|
||||
|
||||
public static bool CreateShader( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, ref string shaderName, ref bool dataWasChanged, out FileSwap shpk )
|
||||
public static FileSwap CreateShader( Func< Utf8GamePath, FullPath > redirections, ref string shaderName, ref bool dataWasChanged )
|
||||
{
|
||||
var path = $"shader/sm5/shpk/{shaderName}";
|
||||
return FileSwap.CreateSwap( ResourceType.Shpk, redirections, path, path, out shpk );
|
||||
return FileSwap.CreateSwap( ResourceType.Shpk, redirections, path, path );
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ namespace Penumbra.Mods.ItemSwap;
|
|||
|
||||
public static class EquipmentSwap
|
||||
{
|
||||
public static Item[] CreateItemSwap( List< Swap > swaps, IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, HashSet< MetaManipulation > manips, Item itemFrom,
|
||||
public static Item[] CreateItemSwap( List< Swap > swaps, Func< Utf8GamePath, FullPath > redirections, Func< MetaManipulation, MetaManipulation > manips, Item itemFrom,
|
||||
Item itemTo )
|
||||
{
|
||||
// Check actual ids, variants and slots. We only support using the same slot.
|
||||
|
|
@ -28,21 +28,13 @@ public static class EquipmentSwap
|
|||
throw new ItemSwap.InvalidItemTypeException();
|
||||
}
|
||||
|
||||
if( !CreateEqp( manips, slotFrom, idFrom, idTo, out var eqp ) )
|
||||
{
|
||||
throw new Exception( "Could not get Eqp Entry for Swap." );
|
||||
}
|
||||
|
||||
var eqp = CreateEqp( manips, slotFrom, idFrom, idTo );
|
||||
if( eqp != null )
|
||||
{
|
||||
swaps.Add( eqp );
|
||||
}
|
||||
|
||||
if( !CreateGmp( manips, slotFrom, idFrom, idTo, out var gmp ) )
|
||||
{
|
||||
throw new Exception( "Could not get Gmp Entry for Swap." );
|
||||
}
|
||||
|
||||
var gmp = CreateGmp( manips, slotFrom, idFrom, idTo);
|
||||
if( gmp != null )
|
||||
{
|
||||
swaps.Add( gmp );
|
||||
|
|
@ -68,21 +60,13 @@ public static class EquipmentSwap
|
|||
continue;
|
||||
}
|
||||
|
||||
if( !ItemSwap.CreateEst( redirections, manips, estType, gr, idFrom, idTo, out var est ) )
|
||||
{
|
||||
throw new Exception( "Could not get Est Entry for Swap." );
|
||||
}
|
||||
|
||||
var est = ItemSwap.CreateEst( redirections, manips, estType, gr, idFrom, idTo );
|
||||
if( est != null )
|
||||
{
|
||||
swaps.Add( est );
|
||||
}
|
||||
|
||||
if( !CreateEqdp( redirections, manips, slotFrom, gr, idFrom, idTo, mtrlVariantTo, out var eqdp ) )
|
||||
{
|
||||
throw new Exception( "Could not get Eqdp Entry for Swap." );
|
||||
}
|
||||
|
||||
var eqdp = CreateEqdp( redirections, manips, slotFrom, gr, idFrom, idTo, mtrlVariantTo );
|
||||
if( eqdp != null )
|
||||
{
|
||||
swaps.Add( eqdp );
|
||||
|
|
@ -91,11 +75,7 @@ public static class EquipmentSwap
|
|||
|
||||
foreach( var variant in variants )
|
||||
{
|
||||
if( !CreateImc( redirections, manips, slotFrom, idFrom, idTo, variant, variantTo, imcFileFrom, imcFileTo, out var imc ) )
|
||||
{
|
||||
throw new Exception( "Could not get IMC Entry for Swap." );
|
||||
}
|
||||
|
||||
var imc = CreateImc( redirections, manips, slotFrom, idFrom, idTo, variant, variantTo, imcFileFrom, imcFileTo );
|
||||
swaps.Add( imc );
|
||||
}
|
||||
|
||||
|
|
@ -103,21 +83,17 @@ public static class EquipmentSwap
|
|||
return affectedItems;
|
||||
}
|
||||
|
||||
public static bool CreateEqdp( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, HashSet< MetaManipulation > manips, EquipSlot slot, GenderRace gr, SetId idFrom,
|
||||
SetId idTo, byte mtrlTo, out MetaSwap? meta )
|
||||
public static MetaSwap? CreateEqdp( Func< Utf8GamePath, FullPath > redirections, Func< MetaManipulation, MetaManipulation > manips, EquipSlot slot, GenderRace gr, SetId idFrom,
|
||||
SetId idTo, byte mtrlTo )
|
||||
{
|
||||
var (gender, race) = gr.Split();
|
||||
var eqdpFrom = new EqdpManipulation( ExpandedEqdpFile.GetDefault( gr, slot.IsAccessory(), idFrom.Value ), slot, gender, race, idFrom.Value );
|
||||
var eqdpTo = new EqdpManipulation( ExpandedEqdpFile.GetDefault( gr, slot.IsAccessory(), idTo.Value ), slot, gender, race, idTo.Value );
|
||||
meta = new MetaSwap( manips, eqdpFrom, eqdpTo );
|
||||
var meta = new MetaSwap( manips, eqdpFrom, eqdpTo );
|
||||
var (ownMtrl, ownMdl) = meta.SwapApplied.Eqdp.Entry.ToBits( slot );
|
||||
if( ownMdl )
|
||||
{
|
||||
if( !CreateMdl( redirections, slot, gr, idFrom, idTo, mtrlTo, out var mdl ) )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var mdl = CreateMdl( redirections, slot, gr, idFrom, idTo, mtrlTo );
|
||||
meta.ChildSwaps.Add( mdl );
|
||||
}
|
||||
else if( !ownMtrl && meta.SwapAppliedIsDefault )
|
||||
|
|
@ -125,64 +101,25 @@ public static class EquipmentSwap
|
|||
meta = null;
|
||||
}
|
||||
|
||||
return true;
|
||||
return meta;
|
||||
}
|
||||
|
||||
public static bool CreateMdl( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, EquipSlot slot, GenderRace gr, SetId idFrom, SetId idTo, byte mtrlTo,
|
||||
out FileSwap mdl )
|
||||
public static FileSwap CreateMdl( Func< Utf8GamePath, FullPath > redirections, EquipSlot slot, GenderRace gr, SetId idFrom, SetId idTo, byte mtrlTo )
|
||||
{
|
||||
var mdlPathFrom = GamePaths.Equipment.Mdl.Path( idFrom, gr, slot );
|
||||
var mdlPathTo = GamePaths.Equipment.Mdl.Path( idTo, gr, slot );
|
||||
if( !FileSwap.CreateSwap( ResourceType.Mdl, redirections, mdlPathFrom, mdlPathTo, out mdl ) )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var mdl = FileSwap.CreateSwap( ResourceType.Mdl, redirections, mdlPathFrom, mdlPathTo );
|
||||
|
||||
foreach( ref var fileName in mdl.AsMdl()!.Materials.AsSpan() )
|
||||
{
|
||||
if( !CreateMtrl( redirections, slot, idFrom, idTo, mtrlTo, ref fileName, ref mdl.DataWasChanged, out var mtrl ) )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var mtrl = CreateMtrl( redirections, slot, idFrom, idTo, mtrlTo, ref fileName, ref mdl.DataWasChanged );
|
||||
if( mtrl != null )
|
||||
{
|
||||
mdl.ChildSwaps.Add( mtrl );
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static (GenderRace, GenderRace) TraverseEqdpTree( GenderRace genderRace, SetId modelId, EquipSlot slot )
|
||||
{
|
||||
var model = GenderRace.Unknown;
|
||||
var material = GenderRace.Unknown;
|
||||
var accessory = slot.IsAccessory();
|
||||
foreach( var gr in genderRace.Dependencies() )
|
||||
{
|
||||
var entry = ExpandedEqdpFile.GetDefault( gr, accessory, modelId.Value );
|
||||
var (b1, b2) = entry.ToBits( slot );
|
||||
if( b1 && material == GenderRace.Unknown )
|
||||
{
|
||||
material = gr;
|
||||
if( model != GenderRace.Unknown )
|
||||
{
|
||||
return ( model, material );
|
||||
}
|
||||
}
|
||||
|
||||
if( b2 && model == GenderRace.Unknown )
|
||||
{
|
||||
model = gr;
|
||||
if( material != GenderRace.Unknown )
|
||||
{
|
||||
return ( model, material );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ( GenderRace.MidlanderMale, GenderRace.MidlanderMale );
|
||||
return mdl;
|
||||
}
|
||||
|
||||
private static void LookupItem( Item i, out EquipSlot slot, out SetId modelId, out byte variant )
|
||||
|
|
@ -219,115 +156,99 @@ public static class EquipmentSwap
|
|||
return ( imc, variants, items );
|
||||
}
|
||||
|
||||
public static bool CreateGmp( HashSet< MetaManipulation > manips, EquipSlot slot, SetId idFrom, SetId idTo, out MetaSwap? gmp )
|
||||
public static MetaSwap? CreateGmp( Func< MetaManipulation, MetaManipulation > manips, EquipSlot slot, SetId idFrom, SetId idTo )
|
||||
{
|
||||
if( slot is not EquipSlot.Head )
|
||||
{
|
||||
gmp = null;
|
||||
return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
var manipFrom = new GmpManipulation( ExpandedGmpFile.GetDefault( idFrom.Value ), idFrom.Value );
|
||||
var manipTo = new GmpManipulation( ExpandedGmpFile.GetDefault( idTo.Value ), idTo.Value );
|
||||
gmp = new MetaSwap( manips, manipFrom, manipTo );
|
||||
return true;
|
||||
return new MetaSwap( manips, manipFrom, manipTo );
|
||||
}
|
||||
|
||||
public static bool CreateImc( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, HashSet< MetaManipulation > manips, EquipSlot slot, SetId idFrom, SetId idTo,
|
||||
byte variantFrom, byte variantTo, ImcFile imcFileFrom, ImcFile imcFileTo, out MetaSwap imc )
|
||||
public static MetaSwap CreateImc( Func< Utf8GamePath, FullPath > redirections, Func< MetaManipulation, MetaManipulation > manips, EquipSlot slot, SetId idFrom, SetId idTo,
|
||||
byte variantFrom, byte variantTo, ImcFile imcFileFrom, ImcFile imcFileTo )
|
||||
{
|
||||
var entryFrom = imcFileFrom.GetEntry( ImcFile.PartIndex( slot ), variantFrom );
|
||||
var entryTo = imcFileTo.GetEntry( ImcFile.PartIndex( slot ), variantTo );
|
||||
var manipulationFrom = new ImcManipulation( slot, variantFrom, idFrom.Value, entryFrom );
|
||||
var manipulationTo = new ImcManipulation( slot, variantTo, idTo.Value, entryTo );
|
||||
imc = new MetaSwap( manips, manipulationFrom, manipulationTo );
|
||||
var imc = new MetaSwap( manips, manipulationFrom, manipulationTo );
|
||||
|
||||
if( !AddDecal( redirections, imc.SwapToModded.Imc.Entry.DecalId, imc ) )
|
||||
var decal = CreateDecal( redirections, imc.SwapToModded.Imc.Entry.DecalId );
|
||||
if( decal != null )
|
||||
{
|
||||
return false;
|
||||
imc.ChildSwaps.Add( decal );
|
||||
}
|
||||
|
||||
if( !AddAvfx( redirections, idFrom, idTo, imc.SwapToModded.Imc.Entry.VfxId, imc ) )
|
||||
var avfx = CreateAvfx( redirections, idFrom, idTo, imc.SwapToModded.Imc.Entry.VfxId );
|
||||
if( avfx != null )
|
||||
{
|
||||
return false;
|
||||
imc.ChildSwaps.Add( avfx );
|
||||
}
|
||||
|
||||
// IMC also controls sound, Example: Dodore Doublet, but unknown what it does?
|
||||
// IMC also controls some material animation, Example: The Howling Spirit and The Wailing Spirit, but unknown what it does.
|
||||
|
||||
return true;
|
||||
return imc;
|
||||
}
|
||||
|
||||
|
||||
// Example: Crimson Standard Bracelet
|
||||
public static bool AddDecal( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, byte decalId, MetaSwap imc )
|
||||
public static FileSwap? CreateDecal( Func< Utf8GamePath, FullPath > redirections, byte decalId )
|
||||
{
|
||||
if( decalId != 0 )
|
||||
if( decalId == 0 )
|
||||
{
|
||||
var decalPath = GamePaths.Equipment.Decal.Path( decalId );
|
||||
if( !FileSwap.CreateSwap( ResourceType.Tex, redirections, decalPath, decalPath, out var swap ) )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
imc.ChildSwaps.Add( swap );
|
||||
return null;
|
||||
}
|
||||
|
||||
return true;
|
||||
var decalPath = GamePaths.Equipment.Decal.Path( decalId );
|
||||
return FileSwap.CreateSwap( ResourceType.Tex, redirections, decalPath, decalPath );
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Example: Abyssos Helm / Body
|
||||
public static bool AddAvfx( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, SetId idFrom, SetId idTo, byte vfxId, MetaSwap imc )
|
||||
public static FileSwap? CreateAvfx( Func< Utf8GamePath, FullPath > redirections, SetId idFrom, SetId idTo, byte vfxId )
|
||||
{
|
||||
if( vfxId != 0 )
|
||||
if( vfxId == 0 )
|
||||
{
|
||||
var vfxPathFrom = GamePaths.Equipment.Avfx.Path( idFrom.Value, vfxId );
|
||||
var vfxPathTo = GamePaths.Equipment.Avfx.Path( idTo.Value, vfxId );
|
||||
if( !FileSwap.CreateSwap( ResourceType.Avfx, redirections, vfxPathFrom, vfxPathTo, out var swap ) )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach( ref var filePath in swap.AsAvfx()!.Textures.AsSpan() )
|
||||
{
|
||||
if( !CreateAtex( redirections, ref filePath, ref swap.DataWasChanged, out var atex ) )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
swap.ChildSwaps.Add( atex );
|
||||
}
|
||||
|
||||
imc.ChildSwaps.Add( swap );
|
||||
return null;
|
||||
}
|
||||
|
||||
return true;
|
||||
var vfxPathFrom = GamePaths.Equipment.Avfx.Path( idFrom.Value, vfxId );
|
||||
var vfxPathTo = GamePaths.Equipment.Avfx.Path( idTo.Value, vfxId );
|
||||
var avfx = FileSwap.CreateSwap( ResourceType.Avfx, redirections, vfxPathFrom, vfxPathTo );
|
||||
|
||||
foreach( ref var filePath in avfx.AsAvfx()!.Textures.AsSpan() )
|
||||
{
|
||||
var atex = CreateAtex( redirections, ref filePath, ref avfx.DataWasChanged );
|
||||
avfx.ChildSwaps.Add( atex );
|
||||
}
|
||||
|
||||
return avfx;
|
||||
}
|
||||
|
||||
public static bool CreateEqp( HashSet< MetaManipulation > manips, EquipSlot slot, SetId idFrom, SetId idTo, out MetaSwap? eqp )
|
||||
public static MetaSwap? CreateEqp( Func< MetaManipulation, MetaManipulation > manips, EquipSlot slot, SetId idFrom, SetId idTo )
|
||||
{
|
||||
if( slot.IsAccessory() )
|
||||
{
|
||||
eqp = null;
|
||||
return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
var eqpValueFrom = ExpandedEqpFile.GetDefault( idFrom.Value );
|
||||
var eqpValueTo = ExpandedEqpFile.GetDefault( idTo.Value );
|
||||
var eqpFrom = new EqpManipulation( eqpValueFrom, slot, idFrom.Value );
|
||||
var eqpTo = new EqpManipulation( eqpValueTo, slot, idFrom.Value );
|
||||
eqp = new MetaSwap( manips, eqpFrom, eqpTo );
|
||||
return true;
|
||||
return new MetaSwap( manips, eqpFrom, eqpTo );
|
||||
}
|
||||
|
||||
public static bool CreateMtrl( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, EquipSlot slot, SetId idFrom, SetId idTo, byte variantTo, ref string fileName,
|
||||
ref bool dataWasChanged, out FileSwap? mtrl )
|
||||
public static FileSwap? CreateMtrl( Func< Utf8GamePath, FullPath > redirections, EquipSlot slot, SetId idFrom, SetId idTo, byte variantTo, ref string fileName,
|
||||
ref bool dataWasChanged )
|
||||
{
|
||||
var prefix = slot.IsAccessory() ? 'a' : 'e';
|
||||
if( !fileName.Contains( $"{prefix}{idTo.Value:D4}" ) )
|
||||
{
|
||||
mtrl = null;
|
||||
return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
var folderTo = slot.IsAccessory() ? GamePaths.Accessory.Mtrl.FolderPath( idTo, variantTo ) : GamePaths.Equipment.Mtrl.FolderPath( idTo, variantTo );
|
||||
|
|
@ -343,34 +264,20 @@ public static class EquipmentSwap
|
|||
dataWasChanged = true;
|
||||
}
|
||||
|
||||
if( !FileSwap.CreateSwap( ResourceType.Mtrl, redirections, pathFrom, pathTo, out mtrl ) )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if( !CreateShader( redirections, ref mtrl.AsMtrl()!.ShaderPackage.Name, ref mtrl.DataWasChanged, out var shader ) )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
mtrl.ChildSwaps.Add( shader );
|
||||
var mtrl = FileSwap.CreateSwap( ResourceType.Mtrl, redirections, pathFrom, pathTo );
|
||||
var shpk = CreateShader( redirections, ref mtrl.AsMtrl()!.ShaderPackage.Name, ref mtrl.DataWasChanged );
|
||||
mtrl.ChildSwaps.Add( shpk );
|
||||
|
||||
foreach( ref var texture in mtrl.AsMtrl()!.Textures.AsSpan() )
|
||||
{
|
||||
if( !CreateTex( redirections, prefix, idFrom, idTo, ref texture, ref mtrl.DataWasChanged, out var swap ) )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
mtrl.ChildSwaps.Add( swap );
|
||||
var tex = CreateTex( redirections, prefix, idFrom, idTo, ref texture, ref mtrl.DataWasChanged );
|
||||
mtrl.ChildSwaps.Add( tex );
|
||||
}
|
||||
|
||||
return true;
|
||||
return mtrl;
|
||||
}
|
||||
|
||||
public static bool CreateTex( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, char prefix, SetId idFrom, SetId idTo, ref MtrlFile.Texture texture,
|
||||
ref bool dataWasChanged,
|
||||
out FileSwap tex )
|
||||
public static FileSwap CreateTex( Func< Utf8GamePath, FullPath > redirections, char prefix, SetId idFrom, SetId idTo, ref MtrlFile.Texture texture, ref bool dataWasChanged )
|
||||
{
|
||||
var path = texture.Path;
|
||||
var addedDashes = false;
|
||||
|
|
@ -392,21 +299,21 @@ public static class EquipmentSwap
|
|||
dataWasChanged = true;
|
||||
}
|
||||
|
||||
return FileSwap.CreateSwap( ResourceType.Tex, redirections, newPath, path, out tex, path );
|
||||
return FileSwap.CreateSwap( ResourceType.Tex, redirections, newPath, path, path );
|
||||
}
|
||||
|
||||
public static bool CreateShader( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, ref string shaderName, ref bool dataWasChanged, out FileSwap shpk )
|
||||
public static FileSwap CreateShader( Func< Utf8GamePath, FullPath > redirections, ref string shaderName, ref bool dataWasChanged )
|
||||
{
|
||||
var path = $"shader/sm5/shpk/{shaderName}";
|
||||
return FileSwap.CreateSwap( ResourceType.Shpk, redirections, path, path, out shpk );
|
||||
return FileSwap.CreateSwap( ResourceType.Shpk, redirections, path, path );
|
||||
}
|
||||
|
||||
public static bool CreateAtex( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, ref string filePath, ref bool dataWasChanged, out FileSwap atex )
|
||||
public static FileSwap CreateAtex( Func< Utf8GamePath, FullPath > redirections, ref string filePath, ref bool dataWasChanged )
|
||||
{
|
||||
var oldPath = filePath;
|
||||
filePath = ItemSwap.AddSuffix( filePath, ".atex", $"_{Path.GetFileName( filePath ).GetStableHashCode():x8}", true );
|
||||
dataWasChanged = true;
|
||||
|
||||
return FileSwap.CreateSwap( ResourceType.Atex, redirections, filePath, oldPath, out atex, oldPath );
|
||||
return FileSwap.CreateSwap( ResourceType.Atex, redirections, filePath, oldPath, oldPath );
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
|
|
@ -137,54 +136,45 @@ public static class ItemSwap
|
|||
}
|
||||
|
||||
|
||||
public static bool CreatePhyb( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, EstManipulation.EstType type, GenderRace race, ushort estEntry, out FileSwap phyb )
|
||||
public static FileSwap CreatePhyb( Func< Utf8GamePath, FullPath > redirections, EstManipulation.EstType type, GenderRace race, ushort estEntry )
|
||||
{
|
||||
var phybPath = GamePaths.Skeleton.Phyb.Path( race, EstManipulation.ToName( type ), estEntry );
|
||||
return FileSwap.CreateSwap( ResourceType.Phyb, redirections, phybPath, phybPath, out phyb );
|
||||
return FileSwap.CreateSwap( ResourceType.Phyb, redirections, phybPath, phybPath );
|
||||
}
|
||||
|
||||
public static bool CreateSklb( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, EstManipulation.EstType type, GenderRace race, ushort estEntry, out FileSwap sklb )
|
||||
public static FileSwap CreateSklb( Func< Utf8GamePath, FullPath > redirections, EstManipulation.EstType type, GenderRace race, ushort estEntry )
|
||||
{
|
||||
var sklbPath = GamePaths.Skeleton.Sklb.Path( race, EstManipulation.ToName( type ), estEntry );
|
||||
return FileSwap.CreateSwap( ResourceType.Sklb, redirections, sklbPath, sklbPath, out sklb );
|
||||
return FileSwap.CreateSwap( ResourceType.Sklb, redirections, sklbPath, sklbPath );
|
||||
}
|
||||
|
||||
/// <remarks> metaChanges is not manipulated, but IReadOnlySet does not support TryGetValue. </remarks>
|
||||
public static bool CreateEst( IReadOnlyDictionary< Utf8GamePath, FullPath > redirections, HashSet< MetaManipulation > manips, EstManipulation.EstType type,
|
||||
GenderRace genderRace, SetId idFrom, SetId idTo, out MetaSwap? est )
|
||||
public static MetaSwap? CreateEst( Func< Utf8GamePath, FullPath > redirections, Func< MetaManipulation, MetaManipulation > manips, EstManipulation.EstType type,
|
||||
GenderRace genderRace, SetId idFrom, SetId idTo )
|
||||
{
|
||||
if( type == 0 )
|
||||
{
|
||||
est = null;
|
||||
return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
var (gender, race) = genderRace.Split();
|
||||
var fromDefault = new EstManipulation( gender, race, type, idFrom.Value, EstFile.GetDefault( type, genderRace, idFrom.Value ) );
|
||||
var toDefault = new EstManipulation( gender, race, type, idTo.Value, EstFile.GetDefault( type, genderRace, idTo.Value ) );
|
||||
est = new MetaSwap( manips, fromDefault, toDefault );
|
||||
var est = new MetaSwap( manips, fromDefault, toDefault );
|
||||
|
||||
if( est.SwapApplied.Est.Entry >= 2 )
|
||||
{
|
||||
if( !CreatePhyb( redirections, type, genderRace, est.SwapApplied.Est.Entry, out var phyb ) )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if( !CreateSklb( redirections, type, genderRace, est.SwapApplied.Est.Entry, out var sklb ) )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var phyb = CreatePhyb( redirections, type, genderRace, est.SwapApplied.Est.Entry );
|
||||
est.ChildSwaps.Add( phyb );
|
||||
var sklb = CreateSklb( redirections, type, genderRace, est.SwapApplied.Est.Entry );
|
||||
est.ChildSwaps.Add( sklb );
|
||||
}
|
||||
else if( est.SwapAppliedIsDefault )
|
||||
{
|
||||
est = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
return true;
|
||||
return est;
|
||||
}
|
||||
|
||||
public static int GetStableHashCode( this string str )
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using Lumina.Excel.GeneratedSheets;
|
||||
using Penumbra.Collections;
|
||||
using Penumbra.GameData.Enums;
|
||||
using Penumbra.GameData.Structs;
|
||||
using Penumbra.Meta.Manipulations;
|
||||
|
|
@ -112,40 +113,39 @@ public class ItemSwapContainer
|
|||
LoadMod( null, null );
|
||||
}
|
||||
|
||||
public Item[] LoadEquipment( Item from, Item to )
|
||||
private Func< Utf8GamePath, FullPath > PathResolver( ModCollection? collection )
|
||||
=> collection != null
|
||||
? p => collection.ResolvePath( p ) ?? new FullPath( p )
|
||||
: p => ModRedirections.TryGetValue( p, out var path ) ? path : new FullPath( p );
|
||||
|
||||
private Func< MetaManipulation, MetaManipulation > MetaResolver( ModCollection? collection )
|
||||
{
|
||||
try
|
||||
{
|
||||
Swaps.Clear();
|
||||
var ret = EquipmentSwap.CreateItemSwap( Swaps, ModRedirections, _modManipulations, from, to );
|
||||
Loaded = true;
|
||||
return ret;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Swaps.Clear();
|
||||
Loaded = false;
|
||||
return Array.Empty< Item >();
|
||||
}
|
||||
var set = collection?.MetaCache?.Manipulations.ToHashSet() ?? _modManipulations;
|
||||
return m => set.TryGetValue( m, out var a ) ? a : m;
|
||||
}
|
||||
|
||||
public bool LoadCustomization( BodySlot slot, GenderRace race, SetId from, SetId to )
|
||||
public Item[] LoadEquipment( Item from, Item to, ModCollection? collection = null )
|
||||
{
|
||||
if( !CustomizationSwap.CreateMdl( ModRedirections, slot, race, from, to, out var mdl ) )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Swaps.Clear();
|
||||
Loaded = false;
|
||||
var ret = EquipmentSwap.CreateItemSwap( Swaps, PathResolver( collection ), MetaResolver( collection ), from, to );
|
||||
Loaded = true;
|
||||
return ret;
|
||||
}
|
||||
|
||||
public bool LoadCustomization( BodySlot slot, GenderRace race, SetId from, SetId to, ModCollection? collection = null )
|
||||
{
|
||||
var pathResolver = PathResolver( collection );
|
||||
var mdl = CustomizationSwap.CreateMdl( pathResolver, slot, race, from, to );
|
||||
var type = slot switch
|
||||
{
|
||||
BodySlot.Hair => EstManipulation.EstType.Hair,
|
||||
BodySlot.Face => EstManipulation.EstType.Face,
|
||||
_ => ( EstManipulation.EstType )0,
|
||||
};
|
||||
if( !ItemSwap.CreateEst( ModRedirections, _modManipulations, type, race, from, to, out var est ) )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var metaResolver = MetaResolver( collection );
|
||||
var est = ItemSwap.CreateEst( pathResolver, metaResolver, type, race, from, to );
|
||||
|
||||
Swaps.Add( mdl );
|
||||
if( est != null )
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System;
|
||||
using Penumbra.GameData.Files;
|
||||
using Penumbra.Meta.Manipulations;
|
||||
using Penumbra.String.Classes;
|
||||
|
|
@ -41,25 +42,16 @@ public sealed class MetaSwap : Swap
|
|||
/// <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="manipulations">A function that converts the given manipulation to the modded one.</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 )
|
||||
public MetaSwap( Func< MetaManipulation, 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;
|
||||
}
|
||||
|
||||
SwapToModded = manipulations( manipTo );
|
||||
SwapToIsDefault = manipTo.EntryEquals( SwapToModded );
|
||||
SwapApplied = SwapFrom.WithEntryOf( SwapToModded );
|
||||
SwapAppliedIsDefault = SwapApplied.EntryEquals( SwapFrom );
|
||||
}
|
||||
|
|
@ -117,15 +109,14 @@ public sealed class FileSwap : Swap
|
|||
/// 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="redirections">A function either returning the path after mod application.</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 )
|
||||
public static FileSwap CreateSwap( ResourceType type, Func< Utf8GamePath, FullPath > redirections, string swapFromRequest, string swapToRequest, string? swapFromPreChange = null )
|
||||
{
|
||||
swap = new FileSwap
|
||||
var swap = new FileSwap
|
||||
{
|
||||
Type = type,
|
||||
FileData = ItemSwap.GenericFile.Invalid,
|
||||
|
|
@ -142,22 +133,22 @@ public sealed class FileSwap : Swap
|
|||
|| !Utf8GamePath.FromString( swapToRequest, out swap.SwapToRequestPath )
|
||||
|| !Utf8GamePath.FromString( swapFromRequest, out swap.SwapFromRequestPath ) )
|
||||
{
|
||||
return false;
|
||||
throw new Exception( $"Could not create UTF8 String for \"{swapFromRequest}\" or \"{swapToRequest}\"." );
|
||||
}
|
||||
|
||||
swap.SwapToModded = redirections.TryGetValue( swap.SwapToRequestPath, out var p ) ? p : new FullPath( swap.SwapToRequestPath );
|
||||
swap.SwapToModded = redirections( 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,
|
||||
ResourceType.Avfx => ItemSwap.LoadAvfx( swap.SwapToModded, out var f ) ? f : ItemSwap.GenericFile.Invalid,
|
||||
_ => ItemSwap.LoadFile( swap.SwapToModded, out var f ) ? f : ItemSwap.GenericFile.Invalid,
|
||||
ResourceType.Mdl => ItemSwap.LoadMdl( swap.SwapToModded, out var f ) ? f : throw new Exception( $"Could not load file data for {swap.SwapToModded}." ),
|
||||
ResourceType.Mtrl => ItemSwap.LoadMtrl( swap.SwapToModded, out var f ) ? f : throw new Exception( $"Could not load file data for {swap.SwapToModded}." ),
|
||||
ResourceType.Avfx => ItemSwap.LoadAvfx( swap.SwapToModded, out var f ) ? f : throw new Exception( $"Could not load file data for {swap.SwapToModded}." ),
|
||||
_ => ItemSwap.LoadFile( swap.SwapToModded, out var f ) ? f : throw new Exception( $"Could not load file data for {swap.SwapToModded}." ),
|
||||
};
|
||||
|
||||
return swap.SwapToModdedExists;
|
||||
return swap;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -168,17 +159,14 @@ public sealed class FileSwap : Swap
|
|||
/// <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 )
|
||||
public static bool CreateShaRedirection( Func< 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;
|
||||
}
|
||||
var newSwap = CreateSwap( swap.Type, redirections, newPath, swap.SwapToRequestPath.ToString());
|
||||
|
||||
path = newPath;
|
||||
dataWasChanged = true;
|
||||
|
|
|
|||
|
|
@ -102,12 +102,13 @@ public class ItemSwapWindow : IDisposable
|
|||
private int _sourceId = 0;
|
||||
private Exception? _loadException = null;
|
||||
|
||||
private string _newModName = string.Empty;
|
||||
private string _newGroupName = "Swaps";
|
||||
private string _newOptionName = string.Empty;
|
||||
private IModGroup? _selectedGroup = null;
|
||||
private bool _subModValid = false;
|
||||
private bool _useFileSwaps = true;
|
||||
private string _newModName = string.Empty;
|
||||
private string _newGroupName = "Swaps";
|
||||
private string _newOptionName = string.Empty;
|
||||
private IModGroup? _selectedGroup = null;
|
||||
private bool _subModValid = false;
|
||||
private bool _useFileSwaps = true;
|
||||
private bool _useCurrentCollection = false;
|
||||
|
||||
private Item[]? _affectedItems;
|
||||
|
||||
|
|
@ -157,21 +158,21 @@ public class ItemSwapWindow : IDisposable
|
|||
var values = _selectors[ _lastTab ];
|
||||
if( values.Source.CurrentSelection.Item2 != null && values.Target.CurrentSelection.Item2 != null )
|
||||
{
|
||||
_affectedItems = _swapData.LoadEquipment( values.Target.CurrentSelection.Item2, values.Source.CurrentSelection.Item2 );
|
||||
_affectedItems = _swapData.LoadEquipment( values.Target.CurrentSelection.Item2, values.Source.CurrentSelection.Item2, _useCurrentCollection ? Penumbra.CollectionManager.Current : null );
|
||||
}
|
||||
|
||||
break;
|
||||
case SwapType.Hair when _targetId > 0 && _sourceId > 0:
|
||||
_swapData.LoadCustomization( BodySlot.Hair, Names.CombinedRace( _currentGender, _currentRace ), ( SetId )_sourceId, ( SetId )_targetId );
|
||||
_swapData.LoadCustomization( BodySlot.Hair, Names.CombinedRace( _currentGender, _currentRace ), ( SetId )_sourceId, ( SetId )_targetId, _useCurrentCollection ? Penumbra.CollectionManager.Current : null );
|
||||
break;
|
||||
case SwapType.Face when _targetId > 0 && _sourceId > 0:
|
||||
_swapData.LoadCustomization( BodySlot.Face, Names.CombinedRace( _currentGender, _currentRace ), ( SetId )_sourceId, ( SetId )_targetId );
|
||||
_swapData.LoadCustomization( BodySlot.Face, Names.CombinedRace( _currentGender, _currentRace ), ( SetId )_sourceId, ( SetId )_targetId, _useCurrentCollection ? Penumbra.CollectionManager.Current : null );
|
||||
break;
|
||||
case SwapType.Ears when _targetId > 0 && _sourceId > 0:
|
||||
_swapData.LoadCustomization( BodySlot.Zear, Names.CombinedRace( _currentGender, ModelRace.Viera ), ( SetId )_sourceId, ( SetId )_targetId );
|
||||
_swapData.LoadCustomization( BodySlot.Zear, Names.CombinedRace( _currentGender, ModelRace.Viera ), ( SetId )_sourceId, ( SetId )_targetId, _useCurrentCollection ? Penumbra.CollectionManager.Current : null );
|
||||
break;
|
||||
case SwapType.Tail when _targetId > 0 && _sourceId > 0:
|
||||
_swapData.LoadCustomization( BodySlot.Tail, Names.CombinedRace( _currentGender, _currentRace ), ( SetId )_sourceId, ( SetId )_targetId );
|
||||
_swapData.LoadCustomization( BodySlot.Tail, Names.CombinedRace( _currentGender, _currentRace ), ( SetId )_sourceId, ( SetId )_targetId, _useCurrentCollection ? Penumbra.CollectionManager.Current : null );
|
||||
break;
|
||||
case SwapType.Weapon: break;
|
||||
}
|
||||
|
|
@ -180,6 +181,8 @@ public class ItemSwapWindow : IDisposable
|
|||
{
|
||||
Penumbra.Log.Error( $"Could not get Customization Data container for {_lastTab}:\n{e}" );
|
||||
_loadException = e;
|
||||
_affectedItems = null;
|
||||
_swapData.Clear();
|
||||
}
|
||||
|
||||
_dirty = false;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue