Why is this so much work?

This commit is contained in:
Ottermandias 2023-03-20 17:30:09 +01:00
parent 651c7410ac
commit b92a3161b5
53 changed files with 3054 additions and 2936 deletions

View file

@ -0,0 +1,258 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Penumbra.String.Classes;
namespace Penumbra.Mods;
public class DuplicateManager
{
private readonly Mod.Manager _modManager;
private readonly SHA256 _hasher = SHA256.Create();
private readonly ModFileCollection _files;
private readonly List<(FullPath[] Paths, long Size, byte[] Hash)> _duplicates = new();
public DuplicateManager(ModFileCollection files, Mod.Manager modManager)
{
_files = files;
_modManager = modManager;
}
public IReadOnlyList<(FullPath[] Paths, long Size, byte[] Hash)> Duplicates
=> _duplicates;
public long SavedSpace { get; private set; } = 0;
public bool Finished { get; private set; } = true;
public void StartDuplicateCheck(IEnumerable<FileRegistry> files)
{
if (!Finished)
return;
Finished = false;
var filesTmp = files.OrderByDescending(f => f.FileSize).ToArray();
Task.Run(() => CheckDuplicates(filesTmp));
}
public void DeleteDuplicates(Mod mod, ISubMod option, bool useModManager)
{
if (!Finished || _duplicates.Count == 0)
return;
foreach (var (set, _, _) in _duplicates)
{
if (set.Length < 2)
continue;
var remaining = set[0];
foreach (var duplicate in set.Skip(1))
HandleDuplicate(mod, duplicate, remaining, useModManager);
}
_duplicates.Clear();
DeleteEmptyDirectories(mod.ModPath);
_files.UpdateAll(mod, option);
}
public void Clear()
{
Finished = true;
SavedSpace = 0;
}
private void HandleDuplicate(Mod mod, FullPath duplicate, FullPath remaining, bool useModManager)
{
void HandleSubMod(ISubMod subMod, int groupIdx, int optionIdx)
{
var changes = false;
var dict = subMod.Files.ToDictionary(kvp => kvp.Key,
kvp => ChangeDuplicatePath(mod, kvp.Value, duplicate, remaining, kvp.Key, ref changes));
if (!changes)
return;
if (useModManager)
{
_modManager.OptionSetFiles(mod, groupIdx, optionIdx, dict);
}
else
{
var sub = (Mod.SubMod)subMod;
sub.FileData = dict;
if (groupIdx == -1)
mod.SaveDefaultMod();
else
IModGroup.Save(mod.Groups[groupIdx], mod.ModPath, groupIdx);
}
}
ModEditor.ApplyToAllOptions(mod, HandleSubMod);
try
{
File.Delete(duplicate.FullName);
}
catch (Exception e)
{
Penumbra.Log.Error($"[DeleteDuplicates] Could not delete duplicate {duplicate.FullName} of {remaining.FullName}:\n{e}");
}
}
private static FullPath ChangeDuplicatePath(Mod mod, FullPath value, FullPath from, FullPath to, Utf8GamePath key, ref bool changes)
{
if (!value.Equals(from))
return value;
changes = true;
Penumbra.Log.Debug($"[DeleteDuplicates] Changing {key} for {mod.Name}\n : {from}\n -> {to}");
return to;
}
private void CheckDuplicates(IReadOnlyList<FileRegistry> files)
{
_duplicates.Clear();
SavedSpace = 0;
var list = new List<FullPath>();
var lastSize = -1L;
foreach (var file in files)
{
// Skip any UI Files because deduplication causes weird crashes for those.
if (file.SubModUsage.Any(f => f.Item2.Path.StartsWith("ui/"u8)))
continue;
if (Finished)
return;
if (file.FileSize == lastSize)
{
list.Add(file.File);
continue;
}
if (list.Count >= 2)
CheckMultiDuplicates(list, lastSize);
lastSize = file.FileSize;
list.Clear();
list.Add(file.File);
}
if (list.Count >= 2)
CheckMultiDuplicates(list, lastSize);
_duplicates.Sort((a, b) => a.Size != b.Size ? b.Size.CompareTo(a.Size) : a.Paths[0].CompareTo(b.Paths[0]));
Finished = true;
}
private void CheckMultiDuplicates(IReadOnlyList<FullPath> list, long size)
{
var hashes = list.Select(f => (f, ComputeHash(f))).ToList();
while (hashes.Count > 0)
{
if (Finished)
return;
var set = new HashSet<FullPath> { hashes[0].Item1 };
var hash = hashes[0];
for (var j = 1; j < hashes.Count; ++j)
{
if (Finished)
return;
if (CompareHashes(hash.Item2, hashes[j].Item2) && CompareFilesDirectly(hashes[0].Item1, hashes[j].Item1))
set.Add(hashes[j].Item1);
}
hashes.RemoveAll(p => set.Contains(p.Item1));
if (set.Count > 1)
{
_duplicates.Add((set.OrderBy(f => f.FullName.Length).ToArray(), size, hash.Item2));
SavedSpace += (set.Count - 1) * size;
}
}
}
private static unsafe bool CompareFilesDirectly(FullPath f1, FullPath f2)
{
if (!f1.Exists || !f2.Exists)
return false;
using var s1 = File.OpenRead(f1.FullName);
using var s2 = File.OpenRead(f2.FullName);
var buffer1 = stackalloc byte[256];
var buffer2 = stackalloc byte[256];
var span1 = new Span<byte>(buffer1, 256);
var span2 = new Span<byte>(buffer2, 256);
while (true)
{
var bytes1 = s1.Read(span1);
var bytes2 = s2.Read(span2);
if (bytes1 != bytes2)
return false;
if (!span1[..bytes1].SequenceEqual(span2[..bytes2]))
return false;
if (bytes1 < 256)
return true;
}
}
public static bool CompareHashes(byte[] f1, byte[] f2)
=> StructuralComparisons.StructuralEqualityComparer.Equals(f1, f2);
public byte[] ComputeHash(FullPath f)
{
using var stream = File.OpenRead(f.FullName);
return _hasher.ComputeHash(stream);
}
/// <summary>
/// Recursively delete all empty directories starting from the given directory.
/// Deletes inner directories first, so that a tree of empty directories is actually deleted.
/// </summary>
private static void DeleteEmptyDirectories(DirectoryInfo baseDir)
{
try
{
if (!baseDir.Exists)
return;
foreach (var dir in baseDir.EnumerateDirectories("*", SearchOption.TopDirectoryOnly))
DeleteEmptyDirectories(dir);
baseDir.Refresh();
if (!baseDir.EnumerateFileSystemInfos().Any())
Directory.Delete(baseDir.FullName, false);
}
catch (Exception e)
{
Penumbra.Log.Error($"Could not delete empty directories in {baseDir.FullName}:\n{e}");
}
}
/// <summary> Deduplicate a mod simply by its directory without any confirmation or waiting time. </summary>
internal void DeduplicateMod(DirectoryInfo modDirectory)
{
try
{
var mod = new Mod(modDirectory);
mod.Reload(true, out _);
Finished = false;
_files.UpdateAll(mod, mod.Default);
CheckDuplicates(_files.Available.OrderByDescending(f => f.FileSize).ToArray());
DeleteDuplicates(mod, mod.Default, false);
}
catch (Exception e)
{
Penumbra.Log.Warning($"Could not deduplicate mod {modDirectory.Name}:\n{e}");
}
}
}

View file

@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using Penumbra.String.Classes;
namespace Penumbra.Mods;
public class FileRegistry : IEquatable<FileRegistry>
{
public readonly List<(ISubMod, Utf8GamePath)> SubModUsage = new();
public FullPath File { get; private init; }
public Utf8RelPath RelPath { get; private init; }
public long FileSize { get; private init; }
public int CurrentUsage;
public static bool FromFile(DirectoryInfo modPath, FileInfo file, [NotNullWhen(true)] out FileRegistry? registry)
{
var fullPath = new FullPath(file.FullName);
if (!fullPath.ToRelPath(modPath, out var relPath))
{
registry = null;
return false;
}
registry = new FileRegistry
{
File = fullPath,
RelPath = relPath,
FileSize = file.Length,
CurrentUsage = 0,
};
return true;
}
public bool Equals(FileRegistry? other)
{
if (other is null)
return false;
return ReferenceEquals(this, other) || File.Equals(other.File);
}
public override bool Equals(object? obj)
{
if (obj is null)
return false;
if (ReferenceEquals(this, obj))
return true;
return obj.GetType() == GetType() && Equals((FileRegistry)obj);
}
public override int GetHashCode()
=> File.GetHashCode();
}

View file

@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using OtterGui;
using Penumbra.GameData.Enums;
using Penumbra.GameData.Files;
namespace Penumbra.Mods;
public partial class MdlMaterialEditor
{
[GeneratedRegex(@"/mt_c(?'RaceCode'\d{4})b0001_(?'Suffix'.*?)\.mtrl", RegexOptions.ExplicitCapture | RegexOptions.NonBacktracking)]
private static partial Regex MaterialRegex();
private readonly ModFileCollection _files;
private readonly List<ModelMaterialInfo> _modelFiles = new();
public IReadOnlyList<ModelMaterialInfo> ModelFiles
=> _modelFiles;
public MdlMaterialEditor(ModFileCollection files)
=> _files = files;
public void SaveAllModels()
{
foreach (var info in _modelFiles)
info.Save();
}
public void RestoreAllModels()
{
foreach (var info in _modelFiles)
info.Restore();
}
public void Clear()
{
_modelFiles.Clear();
}
/// <summary>
/// Go through the currently loaded files and replace all appropriate suffices.
/// Does nothing if toSuffix is invalid.
/// If raceCode is Unknown, apply to all raceCodes.
/// If fromSuffix is empty, apply to all suffices.
/// </summary>
public void ReplaceAllMaterials(string toSuffix, string fromSuffix = "", GenderRace raceCode = GenderRace.Unknown)
{
if (!ValidString(toSuffix))
return;
foreach (var info in _modelFiles)
{
for (var i = 0; i < info.Count; ++i)
{
var (_, def) = info[i];
var match = MaterialRegex().Match(def);
if (match.Success
&& (raceCode == GenderRace.Unknown || raceCode.ToRaceCode() == match.Groups["RaceCode"].Value)
&& (fromSuffix.Length == 0 || fromSuffix == match.Groups["Suffix"].Value))
info.SetMaterial($"/mt_c{match.Groups["RaceCode"].Value}b0001_{toSuffix}.mtrl", i);
}
}
}
/// Non-ASCII encoding is not supported.
public static bool ValidString(string to)
=> to.Length != 0
&& to.Length < 16
&& Encoding.UTF8.GetByteCount(to) == to.Length;
/// <summary> Find all model files in the mod that contain skin materials. </summary>
public void ScanModels(Mod mod)
{
_modelFiles.Clear();
foreach (var file in _files.Mdl)
{
try
{
var bytes = File.ReadAllBytes(file.File.FullName);
var mdlFile = new MdlFile(bytes);
var materials = mdlFile.Materials.WithIndex().Where(p => MaterialRegex().IsMatch((string)p.Item1))
.Select(p => p.Item2).ToArray();
if (materials.Length > 0)
_modelFiles.Add(new ModelMaterialInfo(file.File, mdlFile, materials));
}
catch (Exception e)
{
Penumbra.Log.Error($"Unexpected error scanning {mod.Name}'s {file.File.FullName} for materials:\n{e}");
}
}
}
}

View file

@ -1,288 +0,0 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Penumbra.String.Classes;
namespace Penumbra.Mods;
public partial class Mod
{
public partial class Editor
{
private readonly SHA256 _hasher = SHA256.Create();
private readonly List< (FullPath[] Paths, long Size, byte[] Hash) > _duplicates = new();
public IReadOnlyList< (FullPath[] Paths, long Size, byte[] Hash) > Duplicates
=> _duplicates;
public long SavedSpace { get; private set; } = 0;
public bool DuplicatesFinished { get; private set; } = true;
public void DeleteDuplicates( bool useModManager = true )
{
if( !DuplicatesFinished || _duplicates.Count == 0 )
{
return;
}
foreach( var (set, _, _) in _duplicates )
{
if( set.Length < 2 )
{
continue;
}
var remaining = set[ 0 ];
foreach( var duplicate in set.Skip( 1 ) )
{
HandleDuplicate( duplicate, remaining, useModManager );
}
}
_duplicates.Clear();
DeleteEmptyDirectories( _mod.ModPath );
UpdateFiles();
}
private void HandleDuplicate( FullPath duplicate, FullPath remaining, bool useModManager )
{
void HandleSubMod( ISubMod subMod, int groupIdx, int optionIdx )
{
var changes = false;
var dict = subMod.Files.ToDictionary( kvp => kvp.Key,
kvp => ChangeDuplicatePath( kvp.Value, duplicate, remaining, kvp.Key, ref changes ) );
if( changes )
{
if( useModManager )
{
Penumbra.ModManager.OptionSetFiles( _mod, groupIdx, optionIdx, dict );
}
else
{
var sub = ( SubMod )subMod;
sub.FileData = dict;
if( groupIdx == -1 )
{
_mod.SaveDefaultMod();
}
else
{
IModGroup.Save( _mod.Groups[ groupIdx ], _mod.ModPath, groupIdx );
}
}
}
}
ApplyToAllOptions( _mod, HandleSubMod );
try
{
File.Delete( duplicate.FullName );
}
catch( Exception e )
{
Penumbra.Log.Error( $"[DeleteDuplicates] Could not delete duplicate {duplicate.FullName} of {remaining.FullName}:\n{e}" );
}
}
private FullPath ChangeDuplicatePath( FullPath value, FullPath from, FullPath to, Utf8GamePath key, ref bool changes )
{
if( !value.Equals( from ) )
{
return value;
}
changes = true;
Penumbra.Log.Debug( $"[DeleteDuplicates] Changing {key} for {_mod.Name}\n : {from}\n -> {to}" );
return to;
}
public void StartDuplicateCheck()
{
if( DuplicatesFinished )
{
DuplicatesFinished = false;
UpdateFiles();
var files = _availableFiles.OrderByDescending( f => f.FileSize ).ToArray();
Task.Run( () => CheckDuplicates( files ) );
}
}
private void CheckDuplicates( IReadOnlyList< FileRegistry > files )
{
_duplicates.Clear();
SavedSpace = 0;
var list = new List< FullPath >();
var lastSize = -1L;
foreach( var file in files )
{
// Skip any UI Files because deduplication causes weird crashes for those.
if( file.SubModUsage.Any( f => f.Item2.Path.StartsWith( "ui/"u8 ) ) )
{
continue;
}
if( DuplicatesFinished )
{
return;
}
if( file.FileSize == lastSize )
{
list.Add( file.File );
continue;
}
if( list.Count >= 2 )
{
CheckMultiDuplicates( list, lastSize );
}
lastSize = file.FileSize;
list.Clear();
list.Add( file.File );
}
if( list.Count >= 2 )
{
CheckMultiDuplicates( list, lastSize );
}
_duplicates.Sort( ( a, b ) => a.Size != b.Size ? b.Size.CompareTo( a.Size ) : a.Paths[ 0 ].CompareTo( b.Paths[ 0 ] ) );
DuplicatesFinished = true;
}
private void CheckMultiDuplicates( IReadOnlyList< FullPath > list, long size )
{
var hashes = list.Select( f => ( f, ComputeHash( f ) ) ).ToList();
while( hashes.Count > 0 )
{
if( DuplicatesFinished )
{
return;
}
var set = new HashSet< FullPath > { hashes[ 0 ].Item1 };
var hash = hashes[ 0 ];
for( var j = 1; j < hashes.Count; ++j )
{
if( DuplicatesFinished )
{
return;
}
if( CompareHashes( hash.Item2, hashes[ j ].Item2 ) && CompareFilesDirectly( hashes[ 0 ].Item1, hashes[ j ].Item1 ) )
{
set.Add( hashes[ j ].Item1 );
}
}
hashes.RemoveAll( p => set.Contains( p.Item1 ) );
if( set.Count > 1 )
{
_duplicates.Add( ( set.OrderBy( f => f.FullName.Length ).ToArray(), size, hash.Item2 ) );
SavedSpace += ( set.Count - 1 ) * size;
}
}
}
private static unsafe bool CompareFilesDirectly( FullPath f1, FullPath f2 )
{
if( !f1.Exists || !f2.Exists )
{
return false;
}
using var s1 = File.OpenRead( f1.FullName );
using var s2 = File.OpenRead( f2.FullName );
var buffer1 = stackalloc byte[256];
var buffer2 = stackalloc byte[256];
var span1 = new Span< byte >( buffer1, 256 );
var span2 = new Span< byte >( buffer2, 256 );
while( true )
{
var bytes1 = s1.Read( span1 );
var bytes2 = s2.Read( span2 );
if( bytes1 != bytes2 )
{
return false;
}
if( !span1[ ..bytes1 ].SequenceEqual( span2[ ..bytes2 ] ) )
{
return false;
}
if( bytes1 < 256 )
{
return true;
}
}
}
public static bool CompareHashes( byte[] f1, byte[] f2 )
=> StructuralComparisons.StructuralEqualityComparer.Equals( f1, f2 );
public byte[] ComputeHash( FullPath f )
{
using var stream = File.OpenRead( f.FullName );
return _hasher.ComputeHash( stream );
}
// Recursively delete all empty directories starting from the given directory.
// Deletes inner directories first, so that a tree of empty directories is actually deleted.
private static void DeleteEmptyDirectories( DirectoryInfo baseDir )
{
try
{
if( !baseDir.Exists )
{
return;
}
foreach( var dir in baseDir.EnumerateDirectories( "*", SearchOption.TopDirectoryOnly ) )
{
DeleteEmptyDirectories( dir );
}
baseDir.Refresh();
if( !baseDir.EnumerateFileSystemInfos().Any() )
{
Directory.Delete( baseDir.FullName, false );
}
}
catch( Exception e )
{
Penumbra.Log.Error( $"Could not delete empty directories in {baseDir.FullName}:\n{e}" );
}
}
// Deduplicate a mod simply by its directory without any confirmation or waiting time.
internal static void DeduplicateMod( DirectoryInfo modDirectory )
{
try
{
var mod = new Mod( modDirectory );
mod.Reload( true, out _ );
var editor = new Editor( mod, mod.Default );
editor.DuplicatesFinished = false;
editor.CheckDuplicates( editor.AvailableFiles.OrderByDescending( f => f.FileSize ).ToArray() );
editor.DeleteDuplicates( false );
}
catch( Exception e )
{
Penumbra.Log.Warning( $"Could not deduplicate mod {modDirectory.Name}:\n{e}" );
}
}
}
}

View file

@ -1,58 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using Penumbra.String.Classes;
using Penumbra.Util;
namespace Penumbra.Mods;
public partial class Mod
{
public partial class Editor
{
private SubMod _subMod;
public ISubMod CurrentOption
=> _subMod;
public readonly Dictionary< Utf8GamePath, FullPath > CurrentSwaps = new();
public void SetSubMod( ISubMod? subMod )
{
_subMod = subMod as SubMod ?? _mod._default;
UpdateFiles();
RevertSwaps();
RevertManipulations();
}
public int ApplyFiles()
{
var dict = new Dictionary< Utf8GamePath, FullPath >();
var num = 0;
foreach( var file in _availableFiles )
{
foreach( var path in file.SubModUsage.Where( p => p.Item1 == CurrentOption ) )
{
num += dict.TryAdd( path.Item2, file.File ) ? 0 : 1;
}
}
Penumbra.ModManager.OptionSetFiles( _mod, _subMod.GroupIdx, _subMod.OptionIdx, dict );
UpdateFiles();
return num;
}
public void RevertFiles()
=> UpdateFiles();
public void ApplySwaps()
{
Penumbra.ModManager.OptionSetFileSwaps( _mod, _subMod.GroupIdx, _subMod.OptionIdx, CurrentSwaps.ToDictionary( kvp => kvp.Key, kvp => kvp.Value ) );
}
public void RevertSwaps()
{
CurrentSwaps.SetTo( _subMod.FileSwaps );
}
}
}

View file

@ -1,290 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using Penumbra.String.Classes;
namespace Penumbra.Mods;
public partial class Mod
{
public partial class Editor
{
public class FileRegistry : IEquatable< FileRegistry >
{
public readonly List< (ISubMod, Utf8GamePath) > SubModUsage = new();
public FullPath File { get; private init; }
public Utf8RelPath RelPath { get; private init; }
public long FileSize { get; private init; }
public int CurrentUsage;
public static bool FromFile( Mod mod, FileInfo file, [NotNullWhen( true )] out FileRegistry? registry )
{
var fullPath = new FullPath( file.FullName );
if( !fullPath.ToRelPath( mod.ModPath, out var relPath ) )
{
registry = null;
return false;
}
registry = new FileRegistry
{
File = fullPath,
RelPath = relPath,
FileSize = file.Length,
CurrentUsage = 0,
};
return true;
}
public bool Equals( FileRegistry? other )
{
if( other is null )
{
return false;
}
return ReferenceEquals( this, other ) || File.Equals( other.File );
}
public override bool Equals( object? obj )
{
if( obj is null )
{
return false;
}
if( ReferenceEquals( this, obj ) )
{
return true;
}
return obj.GetType() == GetType() && Equals( ( FileRegistry )obj );
}
public override int GetHashCode()
=> File.GetHashCode();
}
// All files in subdirectories of the mod directory.
public IReadOnlyList< FileRegistry > AvailableFiles
=> _availableFiles;
public bool FileChanges { get; private set; }
private List< FileRegistry > _availableFiles = null!;
private List< FileRegistry > _mtrlFiles = null!;
private List< FileRegistry > _mdlFiles = null!;
private List< FileRegistry > _texFiles = null!;
private List< FileRegistry > _shpkFiles = null!;
private readonly HashSet< Utf8GamePath > _usedPaths = new();
// All paths that are used in
private readonly SortedSet< FullPath > _missingFiles = new();
public IReadOnlySet< FullPath > MissingFiles
=> _missingFiles;
public IReadOnlyList< FileRegistry > MtrlFiles
=> _mtrlFiles;
public IReadOnlyList< FileRegistry > MdlFiles
=> _mdlFiles;
public IReadOnlyList< FileRegistry > TexFiles
=> _texFiles;
public IReadOnlyList< FileRegistry > ShpkFiles
=> _shpkFiles;
// Remove all path redirections where the pointed-to file does not exist.
public void RemoveMissingPaths()
{
void HandleSubMod( ISubMod mod, int groupIdx, int optionIdx )
{
var newDict = mod.Files.Where( kvp => CheckAgainstMissing( kvp.Value, kvp.Key, mod == _subMod ) )
.ToDictionary( kvp => kvp.Key, kvp => kvp.Value );
if( newDict.Count != mod.Files.Count )
{
Penumbra.ModManager.OptionSetFiles( _mod, groupIdx, optionIdx, newDict );
}
}
ApplyToAllOptions( _mod, HandleSubMod );
_missingFiles.Clear();
}
private bool CheckAgainstMissing( FullPath file, Utf8GamePath key, bool removeUsed )
{
if( !_missingFiles.Contains( file ) )
{
return true;
}
if( removeUsed )
{
_usedPaths.Remove( key );
}
Penumbra.Log.Debug( $"[RemoveMissingPaths] Removing {key} -> {file} from {_mod.Name}." );
return false;
}
// Fetch all files inside subdirectories of the main mod directory.
// Then check which options use them and how often.
private void UpdateFiles()
{
_availableFiles = _mod.ModPath.EnumerateDirectories()
.SelectMany( d => d.EnumerateFiles( "*.*", SearchOption.AllDirectories )
.Select( f => FileRegistry.FromFile( _mod, f, out var r ) ? r : null )
.OfType< FileRegistry >() )
.ToList();
_usedPaths.Clear();
_mtrlFiles = _availableFiles.Where( f => f.File.FullName.EndsWith( ".mtrl", StringComparison.OrdinalIgnoreCase ) ).ToList();
_mdlFiles = _availableFiles.Where( f => f.File.FullName.EndsWith( ".mdl", StringComparison.OrdinalIgnoreCase ) ).ToList();
_texFiles = _availableFiles.Where( f => f.File.FullName.EndsWith( ".tex", StringComparison.OrdinalIgnoreCase ) ).ToList();
_shpkFiles = _availableFiles.Where( f => f.File.FullName.EndsWith( ".shpk", StringComparison.OrdinalIgnoreCase ) ).ToList();
FileChanges = false;
foreach( var subMod in _mod.AllSubMods )
{
foreach( var (gamePath, file) in subMod.Files )
{
if( !file.Exists )
{
_missingFiles.Add( file );
if( subMod == _subMod )
{
_usedPaths.Add( gamePath );
}
}
else
{
var registry = _availableFiles.Find( x => x.File.Equals( file ) );
if( registry != null )
{
if( subMod == _subMod )
{
++registry.CurrentUsage;
_usedPaths.Add( gamePath );
}
registry.SubModUsage.Add( ( subMod, gamePath ) );
}
}
}
}
}
// Return whether the given path is already used in the current option.
public bool CanAddGamePath( Utf8GamePath path )
=> !_usedPaths.Contains( path );
// Try to set a given path for a given file.
// Returns false if this is not possible.
// If path is empty, it will be deleted instead.
// If pathIdx is equal to the total number of paths, path will be added, otherwise replaced.
public bool SetGamePath( int fileIdx, int pathIdx, Utf8GamePath path )
{
if( _usedPaths.Contains( path ) || fileIdx < 0 || fileIdx > _availableFiles.Count )
{
return false;
}
var registry = _availableFiles[ fileIdx ];
if( pathIdx > registry.SubModUsage.Count )
{
return false;
}
if( ( pathIdx == -1 || pathIdx == registry.SubModUsage.Count ) && !path.IsEmpty )
{
registry.SubModUsage.Add( ( CurrentOption, path ) );
++registry.CurrentUsage;
_usedPaths.Add( path );
}
else
{
_usedPaths.Remove( registry.SubModUsage[ pathIdx ].Item2 );
if( path.IsEmpty )
{
registry.SubModUsage.RemoveAt( pathIdx );
--registry.CurrentUsage;
}
else
{
registry.SubModUsage[ pathIdx ] = ( registry.SubModUsage[ pathIdx ].Item1, path );
}
}
FileChanges = true;
return true;
}
// Transform a set of files to the appropriate game paths with the given number of folders skipped,
// and add them to the given option.
public int AddPathsToSelected( IEnumerable< FileRegistry > files, int skipFolders = 0 )
{
var failed = 0;
foreach( var file in files )
{
var gamePath = file.RelPath.ToGamePath( skipFolders );
if( gamePath.IsEmpty )
{
++failed;
continue;
}
if( CanAddGamePath( gamePath ) )
{
++file.CurrentUsage;
file.SubModUsage.Add( ( CurrentOption, gamePath ) );
_usedPaths.Add( gamePath );
FileChanges = true;
}
else
{
++failed;
}
}
return failed;
}
// Remove all paths in the current option from the given files.
public void RemovePathsFromSelected( IEnumerable< FileRegistry > files )
{
foreach( var file in files )
{
file.CurrentUsage = 0;
FileChanges |= file.SubModUsage.RemoveAll( p => p.Item1 == CurrentOption && _usedPaths.Remove( p.Item2 ) ) > 0;
}
}
// Delete all given files from your filesystem
public void DeleteFiles( IEnumerable< FileRegistry > files )
{
var deletions = 0;
foreach( var file in files )
{
try
{
File.Delete( file.File.FullName );
Penumbra.Log.Debug( $"[DeleteFiles] Deleted {file.File.FullName} from {_mod.Name}." );
++deletions;
}
catch( Exception e )
{
Penumbra.Log.Error( $"[DeleteFiles] Could not delete {file.File.FullName} from {_mod.Name}:\n{e}" );
}
}
if( deletions > 0 )
{
_mod.Reload( false, out _ );
UpdateFiles();
}
}
}
}

View file

@ -1,183 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using OtterGui;
using Penumbra.GameData.Enums;
using Penumbra.GameData.Files;
using Penumbra.String.Classes;
namespace Penumbra.Mods;
public partial class Mod
{
public partial class Editor
{
private static readonly Regex MaterialRegex = new(@"/mt_c(?'RaceCode'\d{4})b0001_(?'Suffix'.*?)\.mtrl", RegexOptions.Compiled);
private readonly List< ModelMaterialInfo > _modelFiles = new();
public IReadOnlyList< ModelMaterialInfo > ModelFiles
=> _modelFiles;
// Non-ASCII encoding can not be used.
public static bool ValidString( string to )
=> to.Length != 0
&& to.Length < 16
&& Encoding.UTF8.GetByteCount( to ) == to.Length;
public void SaveAllModels()
{
foreach( var info in _modelFiles )
{
info.Save();
}
}
public void RestoreAllModels()
{
foreach( var info in _modelFiles )
{
info.Restore();
}
}
// Go through the currently loaded files and replace all appropriate suffices.
// Does nothing if toSuffix is invalid.
// If raceCode is Unknown, apply to all raceCodes.
// If fromSuffix is empty, apply to all suffices.
public void ReplaceAllMaterials( string toSuffix, string fromSuffix = "", GenderRace raceCode = GenderRace.Unknown )
{
if( !ValidString( toSuffix ) )
{
return;
}
foreach( var info in _modelFiles )
{
for( var i = 0; i < info.Count; ++i )
{
var (_, def) = info[ i ];
var match = MaterialRegex.Match( def );
if( match.Success
&& ( raceCode == GenderRace.Unknown || raceCode.ToRaceCode() == match.Groups[ "RaceCode" ].Value )
&& ( fromSuffix.Length == 0 || fromSuffix == match.Groups[ "Suffix" ].Value ) )
{
info.SetMaterial( $"/mt_c{match.Groups[ "RaceCode" ].Value}b0001_{toSuffix}.mtrl", i );
}
}
}
}
// Find all model files in the mod that contain skin materials.
public void ScanModels()
{
_modelFiles.Clear();
foreach( var file in _mdlFiles.Where( f => f.File.Extension == ".mdl" ) )
{
try
{
var bytes = File.ReadAllBytes( file.File.FullName );
var mdlFile = new MdlFile( bytes );
var materials = mdlFile.Materials.WithIndex().Where( p => MaterialRegex.IsMatch( p.Item1 ) )
.Select( p => p.Item2 ).ToArray();
if( materials.Length > 0 )
{
_modelFiles.Add( new ModelMaterialInfo( file.File, mdlFile, materials ) );
}
}
catch( Exception e )
{
Penumbra.Log.Error( $"Unexpected error scanning {_mod.Name}'s {file.File.FullName} for materials:\n{e}" );
}
}
}
// A class that collects information about skin materials in a model file and handle changes on them.
public class ModelMaterialInfo
{
public readonly FullPath Path;
public readonly MdlFile File;
private readonly string[] _currentMaterials;
private readonly IReadOnlyList< int > _materialIndices;
public bool Changed { get; private set; }
public IReadOnlyList< string > CurrentMaterials
=> _currentMaterials;
private IEnumerable< string > DefaultMaterials
=> _materialIndices.Select( i => File.Materials[ i ] );
public (string Current, string Default) this[ int idx ]
=> ( _currentMaterials[ idx ], File.Materials[ _materialIndices[ idx ] ] );
public int Count
=> _materialIndices.Count;
// Set the skin material to a new value and flag changes appropriately.
public void SetMaterial( string value, int materialIdx )
{
var mat = File.Materials[ _materialIndices[ materialIdx ] ];
_currentMaterials[ materialIdx ] = value;
if( mat != value )
{
Changed = true;
}
else
{
Changed = !_currentMaterials.SequenceEqual( DefaultMaterials );
}
}
// Save a changed .mdl file.
public void Save()
{
if( !Changed )
{
return;
}
foreach( var (idx, i) in _materialIndices.WithIndex() )
{
File.Materials[ idx ] = _currentMaterials[ i ];
}
try
{
System.IO.File.WriteAllBytes( Path.FullName, File.Write() );
Changed = false;
}
catch( Exception e )
{
Restore();
Penumbra.Log.Error( $"Could not write manipulated .mdl file {Path.FullName}:\n{e}" );
}
}
// Revert all current changes.
public void Restore()
{
if( !Changed )
{
return;
}
foreach( var (idx, i) in _materialIndices.WithIndex() )
{
_currentMaterials[ i ] = File.Materials[ idx ];
}
Changed = false;
}
public ModelMaterialInfo( FullPath path, MdlFile file, IReadOnlyList< int > indices )
{
Path = path;
File = file;
_materialIndices = indices;
_currentMaterials = DefaultMaterials.ToArray();
}
}
}
}

View file

@ -1,165 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using Penumbra.Meta.Manipulations;
namespace Penumbra.Mods;
public partial class Mod
{
public partial class Editor
{
public struct Manipulations
{
private readonly HashSet< ImcManipulation > _imc = new();
private readonly HashSet< EqpManipulation > _eqp = new();
private readonly HashSet< EqdpManipulation > _eqdp = new();
private readonly HashSet< GmpManipulation > _gmp = new();
private readonly HashSet< EstManipulation > _est = new();
private readonly HashSet< RspManipulation > _rsp = new();
public bool Changes { get; private set; } = false;
public IReadOnlySet< ImcManipulation > Imc
=> _imc;
public IReadOnlySet< EqpManipulation > Eqp
=> _eqp;
public IReadOnlySet< EqdpManipulation > Eqdp
=> _eqdp;
public IReadOnlySet< GmpManipulation > Gmp
=> _gmp;
public IReadOnlySet< EstManipulation > Est
=> _est;
public IReadOnlySet< RspManipulation > Rsp
=> _rsp;
public Manipulations()
{ }
public bool CanAdd( MetaManipulation m )
{
return m.ManipulationType switch
{
MetaManipulation.Type.Imc => !_imc.Contains( m.Imc ),
MetaManipulation.Type.Eqdp => !_eqdp.Contains( m.Eqdp ),
MetaManipulation.Type.Eqp => !_eqp.Contains( m.Eqp ),
MetaManipulation.Type.Est => !_est.Contains( m.Est ),
MetaManipulation.Type.Gmp => !_gmp.Contains( m.Gmp ),
MetaManipulation.Type.Rsp => !_rsp.Contains( m.Rsp ),
_ => false,
};
}
public bool Add( MetaManipulation m )
{
var added = m.ManipulationType switch
{
MetaManipulation.Type.Imc => _imc.Add( m.Imc ),
MetaManipulation.Type.Eqdp => _eqdp.Add( m.Eqdp ),
MetaManipulation.Type.Eqp => _eqp.Add( m.Eqp ),
MetaManipulation.Type.Est => _est.Add( m.Est ),
MetaManipulation.Type.Gmp => _gmp.Add( m.Gmp ),
MetaManipulation.Type.Rsp => _rsp.Add( m.Rsp ),
_ => false,
};
Changes |= added;
return added;
}
public bool Delete( MetaManipulation m )
{
var deleted = m.ManipulationType switch
{
MetaManipulation.Type.Imc => _imc.Remove( m.Imc ),
MetaManipulation.Type.Eqdp => _eqdp.Remove( m.Eqdp ),
MetaManipulation.Type.Eqp => _eqp.Remove( m.Eqp ),
MetaManipulation.Type.Est => _est.Remove( m.Est ),
MetaManipulation.Type.Gmp => _gmp.Remove( m.Gmp ),
MetaManipulation.Type.Rsp => _rsp.Remove( m.Rsp ),
_ => false,
};
Changes |= deleted;
return deleted;
}
public bool Change( MetaManipulation m )
=> Delete( m ) && Add( m );
public bool Set( MetaManipulation m )
=> Delete( m ) | Add( m );
public void Clear()
{
_imc.Clear();
_eqp.Clear();
_eqdp.Clear();
_gmp.Clear();
_est.Clear();
_rsp.Clear();
Changes = true;
}
public void Split( IEnumerable< MetaManipulation > manips )
{
Clear();
foreach( var manip in manips )
{
switch( manip.ManipulationType )
{
case MetaManipulation.Type.Imc:
_imc.Add( manip.Imc );
break;
case MetaManipulation.Type.Eqdp:
_eqdp.Add( manip.Eqdp );
break;
case MetaManipulation.Type.Eqp:
_eqp.Add( manip.Eqp );
break;
case MetaManipulation.Type.Est:
_est.Add( manip.Est );
break;
case MetaManipulation.Type.Gmp:
_gmp.Add( manip.Gmp );
break;
case MetaManipulation.Type.Rsp:
_rsp.Add( manip.Rsp );
break;
}
}
Changes = false;
}
public IEnumerable< MetaManipulation > Recombine()
=> _imc.Select( m => ( MetaManipulation )m )
.Concat( _eqdp.Select( m => ( MetaManipulation )m ) )
.Concat( _eqp.Select( m => ( MetaManipulation )m ) )
.Concat( _est.Select( m => ( MetaManipulation )m ) )
.Concat( _gmp.Select( m => ( MetaManipulation )m ) )
.Concat( _rsp.Select( m => ( MetaManipulation )m ) );
public void Apply( Mod mod, int groupIdx, int optionIdx )
{
if( Changes )
{
Penumbra.ModManager.OptionSetManipulations( mod, groupIdx, optionIdx, Recombine().ToHashSet() );
Changes = false;
}
}
}
public Manipulations Meta = new();
public void RevertManipulations()
=> Meta.Split( _subMod.Manipulations );
public void ApplyManipulations()
{
Meta.Apply( _mod, _subMod.GroupIdx, _subMod.OptionIdx );
}
}
}

View file

@ -1,56 +0,0 @@
using System;
using System.IO;
using OtterGui;
namespace Penumbra.Mods;
public partial class Mod : IMod
{
public partial class Editor : IDisposable
{
private readonly Mod _mod;
public Editor( Mod mod, ISubMod? option )
{
_mod = mod;
_subMod = null!;
SetSubMod( option );
UpdateFiles();
ScanModels();
}
public void Cancel()
{
DuplicatesFinished = true;
}
public void Dispose()
=> Cancel();
// Does not delete the base directory itself even if it is completely empty at the end.
private static void ClearEmptySubDirectories( DirectoryInfo baseDir )
{
foreach( var subDir in baseDir.GetDirectories() )
{
ClearEmptySubDirectories( subDir );
if( subDir.GetFiles().Length == 0 && subDir.GetDirectories().Length == 0 )
{
subDir.Delete();
}
}
}
// Apply a option action to all available option in a mod, including the default option.
private static void ApplyToAllOptions( Mod mod, Action< ISubMod, int, int > action )
{
action( mod.Default, -1, 0 );
foreach( var (group, groupIdx) in mod.Groups.WithIndex() )
{
for( var optionIdx = 0; optionIdx < group.Count; ++optionIdx )
{
action( group[ optionIdx ], groupIdx, optionIdx );
}
}
}
}
}

View file

@ -1,262 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dalamud.Interface.Internal.Notifications;
using OtterGui;
using Penumbra.String.Classes;
using Penumbra.Util;
namespace Penumbra.Mods;
public partial class Mod
{
public void Normalize( Manager manager )
=> ModNormalizer.Normalize( manager, this );
private struct ModNormalizer
{
private readonly Mod _mod;
private readonly string _normalizationDirName;
private readonly string _oldDirName;
private Dictionary< Utf8GamePath, FullPath >[][]? _redirections = null;
private ModNormalizer( Mod mod )
{
_mod = mod;
_normalizationDirName = Path.Combine( _mod.ModPath.FullName, "TmpNormalization" );
_oldDirName = Path.Combine( _mod.ModPath.FullName, "TmpNormalizationOld" );
}
public static void Normalize( Manager manager, Mod mod )
{
var normalizer = new ModNormalizer( mod );
try
{
Penumbra.Log.Debug( $"[Normalization] Starting Normalization of {mod.ModPath.Name}..." );
if( !normalizer.CheckDirectories() )
{
return;
}
Penumbra.Log.Debug( "[Normalization] Copying files to temporary directory structure..." );
if( !normalizer.CopyNewFiles() )
{
return;
}
Penumbra.Log.Debug( "[Normalization] Moving old files out of the way..." );
if( !normalizer.MoveOldFiles() )
{
return;
}
Penumbra.Log.Debug( "[Normalization] Moving new directory structure in place..." );
if( !normalizer.MoveNewFiles() )
{
return;
}
Penumbra.Log.Debug( "[Normalization] Applying new redirections..." );
normalizer.ApplyRedirections( manager );
}
catch( Exception e )
{
Penumbra.ChatService.NotificationMessage( $"Could not normalize mod:\n{e}", "Failure", NotificationType.Error );
}
finally
{
Penumbra.Log.Debug( "[Normalization] Cleaning up remaining directories..." );
normalizer.Cleanup();
}
}
private bool CheckDirectories()
{
if( Directory.Exists( _normalizationDirName ) )
{
Penumbra.ChatService.NotificationMessage( "Could not normalize mod:\n"
+ "The directory TmpNormalization may not already exist when normalizing a mod.", "Failure",
NotificationType.Error );
return false;
}
if( Directory.Exists( _oldDirName ) )
{
Penumbra.ChatService.NotificationMessage( "Could not normalize mod:\n"
+ "The directory TmpNormalizationOld may not already exist when normalizing a mod.", "Failure",
NotificationType.Error );
return false;
}
return true;
}
private void Cleanup()
{
if( Directory.Exists( _normalizationDirName ) )
{
try
{
Directory.Delete( _normalizationDirName, true );
}
catch
{
// ignored
}
}
if( Directory.Exists( _oldDirName ) )
{
try
{
foreach( var dir in new DirectoryInfo( _oldDirName ).EnumerateDirectories() )
{
dir.MoveTo( Path.Combine( _mod.ModPath.FullName, dir.Name ) );
}
Directory.Delete( _oldDirName, true );
}
catch
{
// ignored
}
}
}
private bool CopyNewFiles()
{
// We copy all files to a temporary folder to ensure that we can revert the operation on failure.
try
{
var directory = Directory.CreateDirectory( _normalizationDirName );
_redirections = new Dictionary< Utf8GamePath, FullPath >[_mod.Groups.Count + 1][];
_redirections[ 0 ] = new Dictionary< Utf8GamePath, FullPath >[] { new(_mod.Default.Files.Count) };
// Normalize the default option.
var newDict = new Dictionary< Utf8GamePath, FullPath >( _mod.Default.Files.Count );
_redirections[ 0 ][ 0 ] = newDict;
foreach( var (gamePath, fullPath) in _mod._default.FileData )
{
var relPath = new Utf8RelPath( gamePath ).ToString();
var newFullPath = Path.Combine( directory.FullName, relPath );
var redirectPath = new FullPath( Path.Combine( _mod.ModPath.FullName, relPath ) );
Directory.CreateDirectory( Path.GetDirectoryName( newFullPath )! );
File.Copy( fullPath.FullName, newFullPath, true );
newDict.Add( gamePath, redirectPath );
}
// Normalize all other options.
foreach( var (group, groupIdx) in _mod.Groups.WithIndex() )
{
_redirections[ groupIdx + 1 ] = new Dictionary< Utf8GamePath, FullPath >[group.Count];
var groupDir = Creator.CreateModFolder( directory, group.Name );
foreach( var option in group.OfType< SubMod >() )
{
var optionDir = Creator.CreateModFolder( groupDir, option.Name );
newDict = new Dictionary< Utf8GamePath, FullPath >( option.FileData.Count );
_redirections[ groupIdx + 1 ][ option.OptionIdx ] = newDict;
foreach( var (gamePath, fullPath) in option.FileData )
{
var relPath = new Utf8RelPath( gamePath ).ToString();
var newFullPath = Path.Combine( optionDir.FullName, relPath );
var redirectPath = new FullPath( Path.Combine( _mod.ModPath.FullName, groupDir.Name, optionDir.Name, relPath ) );
Directory.CreateDirectory( Path.GetDirectoryName( newFullPath )! );
File.Copy( fullPath.FullName, newFullPath, true );
newDict.Add( gamePath, redirectPath );
}
}
}
return true;
}
catch( Exception e )
{
Penumbra.ChatService.NotificationMessage( $"Could not normalize mod:\n{e}", "Failure", NotificationType.Error );
_redirections = null;
}
return false;
}
private bool MoveOldFiles()
{
try
{
// Clean old directories and files.
var oldDirectory = Directory.CreateDirectory( _oldDirName );
foreach( var dir in _mod.ModPath.EnumerateDirectories() )
{
if( dir.FullName.Equals( _oldDirName, StringComparison.OrdinalIgnoreCase )
|| dir.FullName.Equals( _normalizationDirName, StringComparison.OrdinalIgnoreCase ) )
{
continue;
}
dir.MoveTo( Path.Combine( oldDirectory.FullName, dir.Name ) );
}
return true;
}
catch( Exception e )
{
Penumbra.ChatService.NotificationMessage( $"Could not move old files out of the way while normalizing mod mod:\n{e}", "Failure", NotificationType.Error );
}
return false;
}
private bool MoveNewFiles()
{
try
{
var mainDir = new DirectoryInfo( _normalizationDirName );
foreach( var dir in mainDir.EnumerateDirectories() )
{
dir.MoveTo( Path.Combine( _mod.ModPath.FullName, dir.Name ) );
}
mainDir.Delete();
Directory.Delete( _oldDirName, true );
return true;
}
catch( Exception e )
{
Penumbra.ChatService.NotificationMessage( $"Could not move new files into the mod while normalizing mod mod:\n{e}", "Failure", NotificationType.Error );
foreach( var dir in _mod.ModPath.EnumerateDirectories() )
{
if( dir.FullName.Equals( _oldDirName, StringComparison.OrdinalIgnoreCase )
|| dir.FullName.Equals( _normalizationDirName, StringComparison.OrdinalIgnoreCase ) )
{
continue;
}
try
{
dir.Delete( true );
}
catch
{
// ignored
}
}
}
return false;
}
private void ApplyRedirections( Manager manager )
{
if( _redirections == null )
{
return;
}
foreach( var option in _mod.AllSubMods.OfType< SubMod >() )
{
manager.OptionSetFiles( _mod, option.GroupIdx, option.OptionIdx, _redirections[ option.GroupIdx + 1 ][ option.OptionIdx ] );
}
}
}
}

View file

@ -14,10 +14,10 @@ public class ModBackup
public readonly string Name;
public readonly bool Exists;
public ModBackup( Mod mod )
public ModBackup( Mod.Manager modManager, Mod mod )
{
_mod = mod;
Name = Path.Combine( Penumbra.ModManager.ExportDirectory.FullName, _mod.ModPath.Name ) + ".pmp";
Name = Path.Combine( modManager.ExportDirectory.FullName, _mod.ModPath.Name ) + ".pmp";
Exists = File.Exists( Name );
}

View file

@ -0,0 +1,128 @@
using System;
using System.IO;
using OtterGui;
namespace Penumbra.Mods;
public class ModEditor : IDisposable
{
public readonly ModNormalizer ModNormalizer;
public readonly ModMetaEditor MetaEditor;
public readonly ModFileEditor FileEditor;
public readonly DuplicateManager Duplicates;
public readonly ModFileCollection Files;
public readonly ModSwapEditor SwapEditor;
public readonly MdlMaterialEditor MdlMaterialEditor;
public Mod? Mod { get; private set; }
public int GroupIdx { get; private set; }
public int OptionIdx { get; private set; }
public IModGroup? Group { get; private set; }
public ISubMod? Option { get; private set; }
public ModEditor(ModNormalizer modNormalizer, ModMetaEditor metaEditor, ModFileCollection files,
ModFileEditor fileEditor, DuplicateManager duplicates, ModSwapEditor swapEditor, MdlMaterialEditor mdlMaterialEditor)
{
ModNormalizer = modNormalizer;
MetaEditor = metaEditor;
Files = files;
FileEditor = fileEditor;
Duplicates = duplicates;
SwapEditor = swapEditor;
MdlMaterialEditor = mdlMaterialEditor;
}
public void LoadMod(Mod mod)
=> LoadMod(mod, -1, 0);
public void LoadMod(Mod mod, int groupIdx, int optionIdx)
{
Mod = mod;
LoadOption(groupIdx, optionIdx, true);
Files.UpdateAll(mod, Option!);
SwapEditor.Revert(Option!);
MetaEditor.Load(Option!);
Duplicates.Clear();
}
public void LoadOption(int groupIdx, int optionIdx)
{
LoadOption(groupIdx, optionIdx, true);
SwapEditor.Revert(Option!);
Files.UpdatePaths(Mod!, Option!);
MetaEditor.Load(Option!);
FileEditor.Clear();
Duplicates.Clear();
}
/// <summary> Load the correct option by indices for the currently loaded mod if possible, unload if not. </summary>
private void LoadOption(int groupIdx, int optionIdx, bool message)
{
if (Mod != null && Mod.Groups.Count > groupIdx)
{
if (groupIdx == -1 && optionIdx == 0)
{
Group = null;
Option = Mod.Default;
GroupIdx = groupIdx;
OptionIdx = optionIdx;
return;
}
if (groupIdx >= 0)
{
Group = Mod.Groups[groupIdx];
if (optionIdx >= 0 && optionIdx < Group.Count)
{
Option = Group[optionIdx];
GroupIdx = groupIdx;
OptionIdx = optionIdx;
return;
}
}
}
Group = null;
Option = Mod?.Default;
GroupIdx = -1;
OptionIdx = 0;
if (message)
global::Penumbra.Penumbra.Log.Error($"Loading invalid option {groupIdx} {optionIdx} for Mod {Mod?.Name ?? "Unknown"}.");
}
public void Clear()
{
Duplicates.Clear();
FileEditor.Clear();
Files.Clear();
MetaEditor.Clear();
Mod = null;
LoadOption(0, 0, false);
}
public void Dispose()
=> Clear();
/// <summary> Apply a option action to all available option in a mod, including the default option. </summary>
public static void ApplyToAllOptions(Mod mod, Action<ISubMod, int, int> action)
{
action(mod.Default, -1, 0);
foreach (var (group, groupIdx) in mod.Groups.WithIndex())
{
for (var optionIdx = 0; optionIdx < group.Count; ++optionIdx)
action(group[optionIdx], groupIdx, optionIdx);
}
}
// Does not delete the base directory itself even if it is completely empty at the end.
public static void ClearEmptySubDirectories(DirectoryInfo baseDir)
{
foreach (var subDir in baseDir.GetDirectories())
{
ClearEmptySubDirectories(subDir);
if (subDir.GetFiles().Length == 0 && subDir.GetDirectories().Length == 0)
subDir.Delete();
}
}
}

View file

@ -0,0 +1,198 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using Microsoft.Win32;
using Penumbra.String.Classes;
namespace Penumbra.Mods;
public class ModFileCollection : IDisposable
{
private readonly List<FileRegistry> _available = new();
private readonly List<FileRegistry> _mtrl = new();
private readonly List<FileRegistry> _mdl = new();
private readonly List<FileRegistry> _tex = new();
private readonly List<FileRegistry> _shpk = new();
private readonly SortedSet<FullPath> _missing = new();
private readonly HashSet<Utf8GamePath> _usedPaths = new();
public IReadOnlySet<FullPath> Missing
=> Ready ? _missing : new HashSet<FullPath>();
public IReadOnlySet<Utf8GamePath> UsedPaths
=> Ready ? _usedPaths : new HashSet<Utf8GamePath>();
public IReadOnlyList<FileRegistry> Available
=> Ready ? _available : Array.Empty<FileRegistry>();
public IReadOnlyList<FileRegistry> Mtrl
=> Ready ? _mtrl : Array.Empty<FileRegistry>();
public IReadOnlyList<FileRegistry> Mdl
=> Ready ? _mdl : Array.Empty<FileRegistry>();
public IReadOnlyList<FileRegistry> Tex
=> Ready ? _tex : Array.Empty<FileRegistry>();
public IReadOnlyList<FileRegistry> Shpk
=> Ready ? _shpk : Array.Empty<FileRegistry>();
public bool Ready { get; private set; } = true;
public ModFileCollection()
{ }
public void UpdateAll(Mod mod, ISubMod option)
{
UpdateFiles(mod, new CancellationToken());
UpdatePaths(mod, option, false, new CancellationToken());
}
public void UpdatePaths(Mod mod, ISubMod option)
=> UpdatePaths(mod, option, true, new CancellationToken());
public void Clear()
{
ClearFiles();
ClearPaths(false, new CancellationToken());
}
public void Dispose()
=> Clear();
public void ClearMissingFiles()
=> _missing.Clear();
public void RemoveUsedPath(ISubMod option, FileRegistry? file, Utf8GamePath gamePath)
{
_usedPaths.Remove(gamePath);
if (file != null)
{
--file.CurrentUsage;
file.SubModUsage.RemoveAll(p => p.Item1 == option && p.Item2.Equals(gamePath));
}
}
public void RemoveUsedPath(ISubMod option, FullPath file, Utf8GamePath gamePath)
=> RemoveUsedPath(option, _available.FirstOrDefault(f => f.File.Equals(file)), gamePath);
public void AddUsedPath(ISubMod option, FileRegistry? file, Utf8GamePath gamePath)
{
_usedPaths.Add(gamePath);
if (file == null)
return;
++file.CurrentUsage;
file.SubModUsage.Add((option, gamePath));
}
public void AddUsedPath(ISubMod option, FullPath file, Utf8GamePath gamePath)
=> AddUsedPath(option, _available.FirstOrDefault(f => f.File.Equals(file)), gamePath);
public void ChangeUsedPath(FileRegistry file, int pathIdx, Utf8GamePath gamePath)
{
var oldPath = file.SubModUsage[pathIdx];
_usedPaths.Remove(oldPath.Item2);
if (!gamePath.IsEmpty)
{
_usedPaths.Add(gamePath);
}
else
{
--file.CurrentUsage;
file.SubModUsage.RemoveAt(pathIdx);
}
}
private void UpdateFiles(Mod mod, CancellationToken tok)
{
tok.ThrowIfCancellationRequested();
ClearFiles();
foreach (var file in mod.ModPath.EnumerateDirectories().SelectMany(d => d.EnumerateFiles("*.*", SearchOption.AllDirectories)))
{
tok.ThrowIfCancellationRequested();
if (!FileRegistry.FromFile(mod.ModPath, file, out var registry))
continue;
_available.Add(registry);
switch (Path.GetExtension(registry.File.FullName).ToLowerInvariant())
{
case ".mtrl":
_mtrl.Add(registry);
break;
case ".mdl":
_mdl.Add(registry);
break;
case ".tex":
_tex.Add(registry);
break;
case ".shpk":
_shpk.Add(registry);
break;
}
}
}
private void ClearFiles()
{
_available.Clear();
_mtrl.Clear();
_mdl.Clear();
_tex.Clear();
_shpk.Clear();
}
private void ClearPaths(bool clearRegistries, CancellationToken tok)
{
if (clearRegistries)
foreach (var reg in _available)
{
tok.ThrowIfCancellationRequested();
reg.CurrentUsage = 0;
reg.SubModUsage.Clear();
}
_missing.Clear();
_usedPaths.Clear();
}
private void UpdatePaths(Mod mod, ISubMod option, bool clearRegistries, CancellationToken tok)
{
tok.ThrowIfCancellationRequested();
ClearPaths(clearRegistries, tok);
tok.ThrowIfCancellationRequested();
foreach (var subMod in mod.AllSubMods)
{
foreach (var (gamePath, file) in subMod.Files)
{
tok.ThrowIfCancellationRequested();
if (!file.Exists)
{
_missing.Add(file);
if (subMod == option)
_usedPaths.Add(gamePath);
}
else
{
var registry = _available.Find(x => x.File.Equals(file));
if (registry == null)
continue;
if (subMod == option)
{
++registry.CurrentUsage;
_usedPaths.Add(gamePath);
}
registry.SubModUsage.Add((subMod, gamePath));
}
}
}
}
}

View file

@ -0,0 +1,173 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Penumbra.String.Classes;
namespace Penumbra.Mods;
public class ModFileEditor
{
private readonly ModFileCollection _files;
private readonly Mod.Manager _modManager;
public bool Changes { get; private set; }
public ModFileEditor(ModFileCollection files, Mod.Manager modManager)
{
_files = files;
_modManager = modManager;
}
public void Clear()
{
Changes = false;
}
public int Apply(Mod mod, Mod.SubMod option)
{
var dict = new Dictionary<Utf8GamePath, FullPath>();
var num = 0;
foreach (var file in _files.Available)
{
foreach (var path in file.SubModUsage.Where(p => p.Item1 == option))
num += dict.TryAdd(path.Item2, file.File) ? 0 : 1;
}
Penumbra.ModManager.OptionSetFiles(mod, option.GroupIdx, option.OptionIdx, dict);
_files.UpdatePaths(mod, option);
return num;
}
public void RevertFiles(Mod mod, ISubMod option)
{
_files.UpdatePaths(mod, option);
Changes = false;
}
/// <summary> Remove all path redirections where the pointed-to file does not exist. </summary>
public void RemoveMissingPaths(Mod mod, ISubMod option)
{
void HandleSubMod(ISubMod subMod, int groupIdx, int optionIdx)
{
var newDict = subMod.Files.Where(kvp => CheckAgainstMissing(mod, subMod, kvp.Value, kvp.Key, subMod == option))
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
if (newDict.Count != subMod.Files.Count)
_modManager.OptionSetFiles(mod, groupIdx, optionIdx, newDict);
}
ModEditor.ApplyToAllOptions(mod, HandleSubMod);
_files.ClearMissingFiles();
}
/// <summary> Return whether the given path is already used in the current option. </summary>
public bool CanAddGamePath(Utf8GamePath path)
=> !_files.UsedPaths.Contains(path);
/// <summary>
/// Try to set a given path for a given file.
/// Returns false if this is not possible.
/// If path is empty, it will be deleted instead.
/// If pathIdx is equal to the total number of paths, path will be added, otherwise replaced.
/// </summary>
public bool SetGamePath(ISubMod option, int fileIdx, int pathIdx, Utf8GamePath path)
{
if (!CanAddGamePath(path) || fileIdx < 0 || fileIdx > _files.Available.Count)
return false;
var registry = _files.Available[fileIdx];
if (pathIdx > registry.SubModUsage.Count)
return false;
if ((pathIdx == -1 || pathIdx == registry.SubModUsage.Count) && !path.IsEmpty)
_files.AddUsedPath(option, registry, path);
else
_files.ChangeUsedPath(registry, pathIdx, path);
Changes = true;
return true;
}
/// <summary>
/// Transform a set of files to the appropriate game paths with the given number of folders skipped,
/// and add them to the given option.
/// </summary>
public int AddPathsToSelected(ISubMod option, IEnumerable<FileRegistry> files, int skipFolders = 0)
{
var failed = 0;
foreach (var file in files)
{
var gamePath = file.RelPath.ToGamePath(skipFolders);
if (gamePath.IsEmpty)
{
++failed;
continue;
}
if (CanAddGamePath(gamePath))
{
_files.AddUsedPath(option, file, gamePath);
Changes = true;
}
else
{
++failed;
}
}
return failed;
}
/// <summary> Remove all paths in the current option from the given files. </summary>
public void RemovePathsFromSelected(ISubMod option, IEnumerable<FileRegistry> files)
{
foreach (var file in files)
{
foreach (var (_, path) in file.SubModUsage.Where(p => p.Item1 == option))
{
_files.RemoveUsedPath(option, file, path);
Changes = true;
}
}
}
/// <summary> Delete all given files from your filesystem </summary>
public void DeleteFiles(Mod mod, ISubMod option, IEnumerable<FileRegistry> files)
{
var deletions = 0;
foreach (var file in files)
{
try
{
File.Delete(file.File.FullName);
Penumbra.Log.Debug($"[DeleteFiles] Deleted {file.File.FullName} from {mod.Name}.");
++deletions;
}
catch (Exception e)
{
Penumbra.Log.Error($"[DeleteFiles] Could not delete {file.File.FullName} from {mod.Name}:\n{e}");
}
}
if (deletions <= 0)
return;
mod.Reload(false, out _);
_files.UpdateAll(mod, option);
}
private bool CheckAgainstMissing(Mod mod, ISubMod option, FullPath file, Utf8GamePath key, bool removeUsed)
{
if (!_files.Missing.Contains(file))
return true;
if (removeUsed)
_files.RemoveUsedPath(option, file, key);
Penumbra.Log.Debug($"[RemoveMissingPaths] Removing {key} -> {file} from {mod.Name}.");
return false;
}
}

View file

@ -0,0 +1,154 @@
using System.Collections.Generic;
using System.Linq;
using Penumbra.Meta.Manipulations;
namespace Penumbra.Mods;
public class ModMetaEditor
{
private readonly Mod.Manager _modManager;
private readonly HashSet<ImcManipulation> _imc = new();
private readonly HashSet<EqpManipulation> _eqp = new();
private readonly HashSet<EqdpManipulation> _eqdp = new();
private readonly HashSet<GmpManipulation> _gmp = new();
private readonly HashSet<EstManipulation> _est = new();
private readonly HashSet<RspManipulation> _rsp = new();
public ModMetaEditor(Mod.Manager modManager)
=> _modManager = modManager;
public bool Changes { get; private set; } = false;
public IReadOnlySet<ImcManipulation> Imc
=> _imc;
public IReadOnlySet<EqpManipulation> Eqp
=> _eqp;
public IReadOnlySet<EqdpManipulation> Eqdp
=> _eqdp;
public IReadOnlySet<GmpManipulation> Gmp
=> _gmp;
public IReadOnlySet<EstManipulation> Est
=> _est;
public IReadOnlySet<RspManipulation> Rsp
=> _rsp;
public bool CanAdd(MetaManipulation m)
{
return m.ManipulationType switch
{
MetaManipulation.Type.Imc => !_imc.Contains(m.Imc),
MetaManipulation.Type.Eqdp => !_eqdp.Contains(m.Eqdp),
MetaManipulation.Type.Eqp => !_eqp.Contains(m.Eqp),
MetaManipulation.Type.Est => !_est.Contains(m.Est),
MetaManipulation.Type.Gmp => !_gmp.Contains(m.Gmp),
MetaManipulation.Type.Rsp => !_rsp.Contains(m.Rsp),
_ => false,
};
}
public bool Add(MetaManipulation m)
{
var added = m.ManipulationType switch
{
MetaManipulation.Type.Imc => _imc.Add(m.Imc),
MetaManipulation.Type.Eqdp => _eqdp.Add(m.Eqdp),
MetaManipulation.Type.Eqp => _eqp.Add(m.Eqp),
MetaManipulation.Type.Est => _est.Add(m.Est),
MetaManipulation.Type.Gmp => _gmp.Add(m.Gmp),
MetaManipulation.Type.Rsp => _rsp.Add(m.Rsp),
_ => false,
};
Changes |= added;
return added;
}
public bool Delete(MetaManipulation m)
{
var deleted = m.ManipulationType switch
{
MetaManipulation.Type.Imc => _imc.Remove(m.Imc),
MetaManipulation.Type.Eqdp => _eqdp.Remove(m.Eqdp),
MetaManipulation.Type.Eqp => _eqp.Remove(m.Eqp),
MetaManipulation.Type.Est => _est.Remove(m.Est),
MetaManipulation.Type.Gmp => _gmp.Remove(m.Gmp),
MetaManipulation.Type.Rsp => _rsp.Remove(m.Rsp),
_ => false,
};
Changes |= deleted;
return deleted;
}
public bool Change(MetaManipulation m)
=> Delete(m) && Add(m);
public bool Set(MetaManipulation m)
=> Delete(m) | Add(m);
public void Clear()
{
_imc.Clear();
_eqp.Clear();
_eqdp.Clear();
_gmp.Clear();
_est.Clear();
_rsp.Clear();
Changes = true;
}
public void Load(ISubMod mod)
=> Split(mod.Manipulations);
public void Apply(Mod mod, int groupIdx, int optionIdx)
{
if (!Changes)
return;
_modManager.OptionSetManipulations(mod, groupIdx, optionIdx, Recombine().ToHashSet());
Changes = false;
}
private void Split(IEnumerable<MetaManipulation> manips)
{
Clear();
foreach (var manip in manips)
{
switch (manip.ManipulationType)
{
case MetaManipulation.Type.Imc:
_imc.Add(manip.Imc);
break;
case MetaManipulation.Type.Eqdp:
_eqdp.Add(manip.Eqdp);
break;
case MetaManipulation.Type.Eqp:
_eqp.Add(manip.Eqp);
break;
case MetaManipulation.Type.Est:
_est.Add(manip.Est);
break;
case MetaManipulation.Type.Gmp:
_gmp.Add(manip.Gmp);
break;
case MetaManipulation.Type.Rsp:
_rsp.Add(manip.Rsp);
break;
}
}
Changes = false;
}
public IEnumerable<MetaManipulation> Recombine()
=> _imc.Select(m => (MetaManipulation)m)
.Concat(_eqdp.Select(m => (MetaManipulation)m))
.Concat(_eqp.Select(m => (MetaManipulation)m))
.Concat(_est.Select(m => (MetaManipulation)m))
.Concat(_gmp.Select(m => (MetaManipulation)m))
.Concat(_rsp.Select(m => (MetaManipulation)m));
}

View file

@ -0,0 +1,289 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Dalamud.Interface.Internal.Notifications;
using OtterGui;
using Penumbra.String.Classes;
namespace Penumbra.Mods;
public class ModNormalizer
{
private readonly Mod.Manager _modManager;
private readonly List<List<Dictionary<Utf8GamePath, FullPath>>> _redirections = new();
public Mod Mod { get; private set; } = null!;
private string _normalizationDirName = null!;
private string _oldDirName = null!;
public int Step { get; private set; }
public int TotalSteps { get; private set; }
public bool Running
=> Step < TotalSteps;
public ModNormalizer(Mod.Manager modManager)
=> _modManager = modManager;
public void Normalize(Mod mod)
{
if (Step < TotalSteps)
return;
Mod = mod;
_normalizationDirName = Path.Combine(Mod.ModPath.FullName, "TmpNormalization");
_oldDirName = Path.Combine(Mod.ModPath.FullName, "TmpNormalizationOld");
Step = 0;
TotalSteps = mod.TotalFileCount + 5;
Task.Run(NormalizeSync);
}
private void NormalizeSync()
{
try
{
Penumbra.Log.Debug($"[Normalization] Starting Normalization of {Mod.ModPath.Name}...");
if (!CheckDirectories())
{
return;
}
Penumbra.Log.Debug("[Normalization] Copying files to temporary directory structure...");
if (!CopyNewFiles())
{
return;
}
Penumbra.Log.Debug("[Normalization] Moving old files out of the way...");
if (!MoveOldFiles())
{
return;
}
Penumbra.Log.Debug("[Normalization] Moving new directory structure in place...");
if (!MoveNewFiles())
{
return;
}
Penumbra.Log.Debug("[Normalization] Applying new redirections...");
ApplyRedirections();
}
catch (Exception e)
{
Penumbra.ChatService.NotificationMessage($"Could not normalize mod:\n{e}", "Failure", NotificationType.Error);
}
finally
{
Penumbra.Log.Debug("[Normalization] Cleaning up remaining directories...");
Cleanup();
}
}
private bool CheckDirectories()
{
if (Directory.Exists(_normalizationDirName))
{
Penumbra.ChatService.NotificationMessage("Could not normalize mod:\n"
+ "The directory TmpNormalization may not already exist when normalizing a mod.", "Failure",
NotificationType.Error);
return false;
}
if (Directory.Exists(_oldDirName))
{
Penumbra.ChatService.NotificationMessage("Could not normalize mod:\n"
+ "The directory TmpNormalizationOld may not already exist when normalizing a mod.", "Failure",
NotificationType.Error);
return false;
}
++Step;
return true;
}
private void Cleanup()
{
if (Directory.Exists(_normalizationDirName))
{
try
{
Directory.Delete(_normalizationDirName, true);
}
catch
{
// ignored
}
}
if (Directory.Exists(_oldDirName))
{
try
{
foreach (var dir in new DirectoryInfo(_oldDirName).EnumerateDirectories())
{
dir.MoveTo(Path.Combine(Mod.ModPath.FullName, dir.Name));
}
Directory.Delete(_oldDirName, true);
}
catch
{
// ignored
}
}
Step = TotalSteps;
}
private bool CopyNewFiles()
{
// We copy all files to a temporary folder to ensure that we can revert the operation on failure.
try
{
var directory = Directory.CreateDirectory(_normalizationDirName);
for (var i = _redirections.Count; i < Mod.Groups.Count + 1; ++i)
_redirections.Add(new List<Dictionary<Utf8GamePath, FullPath>>());
if (_redirections[0].Count == 0)
_redirections[0].Add(new Dictionary<Utf8GamePath, FullPath>(Mod.Default.Files.Count));
else
{
_redirections[0][0].Clear();
_redirections[0][0].EnsureCapacity(Mod.Default.Files.Count);
}
// Normalize the default option.
var newDict = _redirections[0][0];
foreach (var (gamePath, fullPath) in Mod.Default.Files)
{
var relPath = new Utf8RelPath(gamePath).ToString();
var newFullPath = Path.Combine(directory.FullName, relPath);
var redirectPath = new FullPath(Path.Combine(Mod.ModPath.FullName, relPath));
Directory.CreateDirectory(Path.GetDirectoryName(newFullPath)!);
File.Copy(fullPath.FullName, newFullPath, true);
newDict.Add(gamePath, redirectPath);
++Step;
}
// Normalize all other options.
foreach (var (group, groupIdx) in Mod.Groups.WithIndex())
{
_redirections[groupIdx + 1].EnsureCapacity(group.Count);
for (var i = _redirections[groupIdx + 1].Count; i < group.Count; ++i)
_redirections[groupIdx + 1].Add(new Dictionary<Utf8GamePath, FullPath>());
var groupDir = Mod.Creator.CreateModFolder(directory, group.Name);
foreach (var option in group.OfType<Mod.SubMod>())
{
var optionDir = Mod.Creator.CreateModFolder(groupDir, option.Name);
newDict = _redirections[groupIdx + 1][option.OptionIdx];
newDict.Clear();
newDict.EnsureCapacity(option.FileData.Count);
foreach (var (gamePath, fullPath) in option.FileData)
{
var relPath = new Utf8RelPath(gamePath).ToString();
var newFullPath = Path.Combine(optionDir.FullName, relPath);
var redirectPath = new FullPath(Path.Combine(Mod.ModPath.FullName, groupDir.Name, optionDir.Name, relPath));
Directory.CreateDirectory(Path.GetDirectoryName(newFullPath)!);
File.Copy(fullPath.FullName, newFullPath, true);
newDict.Add(gamePath, redirectPath);
++Step;
}
}
}
return true;
}
catch (Exception e)
{
Penumbra.ChatService.NotificationMessage($"Could not normalize mod:\n{e}", "Failure", NotificationType.Error);
}
return false;
}
private bool MoveOldFiles()
{
try
{
// Clean old directories and files.
var oldDirectory = Directory.CreateDirectory(_oldDirName);
foreach (var dir in Mod.ModPath.EnumerateDirectories())
{
if (dir.FullName.Equals(_oldDirName, StringComparison.OrdinalIgnoreCase)
|| dir.FullName.Equals(_normalizationDirName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
dir.MoveTo(Path.Combine(oldDirectory.FullName, dir.Name));
}
++Step;
return true;
}
catch (Exception e)
{
Penumbra.ChatService.NotificationMessage($"Could not move old files out of the way while normalizing mod mod:\n{e}", "Failure",
NotificationType.Error);
}
return false;
}
private bool MoveNewFiles()
{
try
{
var mainDir = new DirectoryInfo(_normalizationDirName);
foreach (var dir in mainDir.EnumerateDirectories())
{
dir.MoveTo(Path.Combine(Mod.ModPath.FullName, dir.Name));
}
mainDir.Delete();
Directory.Delete(_oldDirName, true);
++Step;
return true;
}
catch (Exception e)
{
Penumbra.ChatService.NotificationMessage($"Could not move new files into the mod while normalizing mod mod:\n{e}", "Failure",
NotificationType.Error);
foreach (var dir in Mod.ModPath.EnumerateDirectories())
{
if (dir.FullName.Equals(_oldDirName, StringComparison.OrdinalIgnoreCase)
|| dir.FullName.Equals(_normalizationDirName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
try
{
dir.Delete(true);
}
catch
{
// ignored
}
}
}
return false;
}
private void ApplyRedirections()
{
foreach (var option in Mod.AllSubMods.OfType<Mod.SubMod>())
{
_modManager.OptionSetFiles(Mod, option.GroupIdx, option.OptionIdx, _redirections[option.GroupIdx + 1][option.OptionIdx]);
}
++Step;
}
}

View file

@ -0,0 +1,51 @@
using System.Collections.Generic;
using Penumbra.Mods;
using Penumbra.String.Classes;
using Penumbra.Util;
public class ModSwapEditor
{
private readonly Mod.Manager _modManager;
private readonly Dictionary<Utf8GamePath, FullPath> _swaps = new();
public IReadOnlyDictionary<Utf8GamePath, FullPath> Swaps
=> _swaps;
public ModSwapEditor(Mod.Manager modManager)
=> _modManager = modManager;
public void Revert(ISubMod option)
{
_swaps.SetTo(option.FileSwaps);
Changes = false;
}
public void Apply(Mod mod, int groupIdx, int optionIdx)
{
if (Changes)
{
_modManager.OptionSetFileSwaps(mod, groupIdx, optionIdx, _swaps);
Changes = false;
}
}
public bool Changes { get; private set; }
public void Remove(Utf8GamePath path)
=> Changes |= _swaps.Remove(path);
public void Add(Utf8GamePath path, FullPath file)
=> Changes |= _swaps.TryAdd(path, file);
public void Change(Utf8GamePath path, Utf8GamePath newPath)
{
if (_swaps.Remove(path, out var file))
Add(newPath, file);
}
public void Change(Utf8GamePath path, FullPath file)
{
_swaps[path] = file;
Changes = true;
}
}

View file

@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OtterGui;
using Penumbra.GameData.Files;
using Penumbra.String.Classes;
namespace Penumbra.Mods;
/// <summary> A class that collects information about skin materials in a model file and handle changes on them. </summary>
public class ModelMaterialInfo
{
public readonly FullPath Path;
public readonly MdlFile File;
private readonly string[] _currentMaterials;
private readonly IReadOnlyList<int> _materialIndices;
public bool Changed { get; private set; }
public IReadOnlyList<string> CurrentMaterials
=> _currentMaterials;
private IEnumerable<string> DefaultMaterials
=> _materialIndices.Select(i => File.Materials[i]);
public (string Current, string Default) this[int idx]
=> (_currentMaterials[idx], File.Materials[_materialIndices[idx]]);
public int Count
=> _materialIndices.Count;
// Set the skin material to a new value and flag changes appropriately.
public void SetMaterial(string value, int materialIdx)
{
var mat = File.Materials[_materialIndices[materialIdx]];
_currentMaterials[materialIdx] = value;
if (mat != value)
Changed = true;
else
Changed = !_currentMaterials.SequenceEqual(DefaultMaterials);
}
// Save a changed .mdl file.
public void Save()
{
if (!Changed)
return;
foreach (var (idx, i) in _materialIndices.WithIndex())
File.Materials[idx] = _currentMaterials[i];
try
{
System.IO.File.WriteAllBytes(Path.FullName, File.Write());
Changed = false;
}
catch (Exception e)
{
Restore();
Penumbra.Log.Error($"Could not write manipulated .mdl file {Path.FullName}:\n{e}");
}
}
// Revert all current changes.
public void Restore()
{
if (!Changed)
return;
foreach (var (idx, i) in _materialIndices.WithIndex())
_currentMaterials[i] = File.Materials[idx];
Changed = false;
}
public ModelMaterialInfo(FullPath path, MdlFile file, IReadOnlyList<int> indices)
{
Path = path;
File = file;
_materialIndices = indices;
_currentMaterials = DefaultMaterials.ToArray();
}
}

View file

@ -59,7 +59,7 @@ public partial class Mod
}
MoveDataFile( oldDirectory, dir );
new ModBackup( mod ).Move( null, dir.Name );
new ModBackup( this, mod ).Move( null, dir.Name );
dir.Refresh();
mod.ModPath = dir;

View file

@ -158,7 +158,7 @@ public sealed partial class Mod
{
foreach( var mod in _mods )
{
new ModBackup( mod ).Move( dir.FullName );
new ModBackup( this, mod ).Move( dir.FullName );
}
}

View file

@ -25,7 +25,7 @@ public partial class Mod
public int Priority
=> 0;
private Mod( DirectoryInfo modPath )
internal Mod( DirectoryInfo modPath )
{
ModPath = modPath;
_default = new SubMod( this );
@ -51,7 +51,7 @@ public partial class Mod
return mod;
}
private bool Reload( bool incorporateMetaChanges, out ModDataChangeType modDataChange )
internal bool Reload( bool incorporateMetaChanges, out ModDataChangeType modDataChange )
{
modDataChange = ModDataChangeType.Deletion;
ModPath.Refresh();

View file

@ -26,7 +26,7 @@ public enum ModDataChangeType : ushort
Note = 0x0800,
}
public sealed partial class Mod
public sealed partial class Mod : IMod
{
public static readonly TemporaryMod ForcedFiles = new()
{

View file

@ -18,7 +18,7 @@ public partial class Mod
// The default mod contains setting-independent sets of file replacements, file swaps and meta changes.
// Every mod has an default mod, though it may be empty.
private void SaveDefaultMod()
public void SaveDefaultMod()
{
var defaultFile = DefaultFile;
@ -100,7 +100,7 @@ public partial class Mod
// It can be loaded and reloaded from Json.
// Nothing is checked for existence or validity when loading.
// Objects are also not checked for uniqueness, the first appearance of a game path or meta path decides.
private sealed class SubMod : ISubMod
public sealed class SubMod : ISubMod
{
public string Name { get; set; } = "Default";