Complete mod collection cleanup, initial stuff for inheritance. Some further cleanup.

This commit is contained in:
Ottermandias 2022-03-28 17:25:59 +02:00
parent 7915d516e2
commit 1861c40a4f
48 changed files with 1151 additions and 898 deletions

31
Penumbra/Mods/FullMod.cs Normal file
View file

@ -0,0 +1,31 @@
using System.Collections.Generic;
using System.IO;
using Penumbra.GameData.ByteString;
namespace Penumbra.Mods;
// A complete Mod containing settings (i.e. dependent on a collection)
// and the resulting cache.
public class FullMod
{
public ModSettings Settings { get; }
public Mod Data { get; }
public FullMod( ModSettings settings, Mod data )
{
Settings = settings;
Data = data;
}
public bool FixSettings()
=> Settings.FixInvalidSettings( Data.Meta );
public HashSet< Utf8GamePath > GetFiles( FileInfo file )
{
var relPath = Utf8RelPath.FromFile( file, Data.BasePath, out var p ) ? p : Utf8RelPath.Empty;
return ModFunctions.GetFilesForConfig( relPath, Settings, Data.Meta );
}
public override string ToString()
=> Data.Meta.Name;
}

View file

@ -0,0 +1,102 @@
using System.Collections.Generic;
using System.ComponentModel;
using Newtonsoft.Json;
using Penumbra.GameData.ByteString;
using Penumbra.Util;
namespace Penumbra.Mods;
public enum SelectType
{
Single,
Multi,
}
public struct Option
{
public string OptionName;
public string OptionDesc;
[JsonProperty( ItemConverterType = typeof( SingleOrArrayConverter< Utf8GamePath > ) )]
public Dictionary< Utf8RelPath, HashSet< Utf8GamePath > > OptionFiles;
public bool AddFile( Utf8RelPath filePath, Utf8GamePath gamePath )
{
if( OptionFiles.TryGetValue( filePath, out var set ) )
{
return set.Add( gamePath );
}
OptionFiles[ filePath ] = new HashSet< Utf8GamePath > { gamePath };
return true;
}
}
public struct OptionGroup
{
public string GroupName;
[JsonConverter( typeof( Newtonsoft.Json.Converters.StringEnumConverter ) )]
public SelectType SelectionType;
public List< Option > Options;
private bool ApplySingleGroupFiles( Utf8RelPath relPath, int selection, HashSet< Utf8GamePath > paths )
{
// Selection contains the path, merge all GamePaths for this config.
if( Options[ selection ].OptionFiles.TryGetValue( relPath, out var groupPaths ) )
{
paths.UnionWith( groupPaths );
return true;
}
// If the group contains the file in another selection, return true to skip it for default files.
for( var i = 0; i < Options.Count; ++i )
{
if( i == selection )
{
continue;
}
if( Options[ i ].OptionFiles.ContainsKey( relPath ) )
{
return true;
}
}
return false;
}
private bool ApplyMultiGroupFiles( Utf8RelPath relPath, int selection, HashSet< Utf8GamePath > paths )
{
var doNotAdd = false;
for( var i = 0; i < Options.Count; ++i )
{
if( ( selection & ( 1 << i ) ) != 0 )
{
if( Options[ i ].OptionFiles.TryGetValue( relPath, out var groupPaths ) )
{
paths.UnionWith( groupPaths );
doNotAdd = true;
}
}
else if( Options[ i ].OptionFiles.ContainsKey( relPath ) )
{
doNotAdd = true;
}
}
return doNotAdd;
}
// Adds all game paths from the given option that correspond to the given RelPath to paths, if any exist.
internal bool ApplyGroupFiles( Utf8RelPath relPath, int selection, HashSet< Utf8GamePath > paths )
{
return SelectionType switch
{
SelectType.Single => ApplySingleGroupFiles( relPath, selection, paths ),
SelectType.Multi => ApplyMultiGroupFiles( relPath, selection, paths ),
_ => throw new InvalidEnumArgumentException( "Invalid option group type." ),
};
}
}

View file

@ -0,0 +1,44 @@
using System;
namespace Penumbra.Mods;
public partial class Mod
{
public struct SortOrder : IComparable< SortOrder >
{
public ModFolder ParentFolder { get; set; }
private string _sortOrderName;
public string SortOrderName
{
get => _sortOrderName;
set => _sortOrderName = value.Replace( '/', '\\' );
}
public string SortOrderPath
=> ParentFolder.FullName;
public string FullName
{
get
{
var path = SortOrderPath;
return path.Length > 0 ? $"{path}/{SortOrderName}" : SortOrderName;
}
}
public SortOrder( ModFolder parentFolder, string name )
{
ParentFolder = parentFolder;
_sortOrderName = name.Replace( '/', '\\' );
}
public string FullPath
=> SortOrderPath.Length > 0 ? $"{SortOrderPath}/{SortOrderName}" : SortOrderName;
public int CompareTo( SortOrder other )
=> string.Compare( FullPath, other.FullPath, StringComparison.InvariantCultureIgnoreCase );
}
}

95
Penumbra/Mods/Mod.cs Normal file
View file

@ -0,0 +1,95 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dalamud.Logging;
using Penumbra.GameData.ByteString;
namespace Penumbra.Mods;
// Mod contains all permanent information about a mod,
// and is independent of collections or settings.
// It only changes when the user actively changes the mod or their filesystem.
public partial class Mod
{
public DirectoryInfo BasePath;
public ModMeta Meta;
public ModResources Resources;
public SortOrder Order;
public SortedList< string, object? > ChangedItems { get; } = new();
public string LowerChangedItemsString { get; private set; } = string.Empty;
public FileInfo MetaFile { get; set; }
public int Index { get; private set; } = -1;
private Mod( ModFolder parentFolder, DirectoryInfo basePath, ModMeta meta, ModResources resources)
{
BasePath = basePath;
Meta = meta;
Resources = resources;
MetaFile = MetaFileInfo( basePath );
Order = new SortOrder( parentFolder, Meta.Name );
Order.ParentFolder.AddMod( this );
ComputeChangedItems();
}
public void ComputeChangedItems()
{
var identifier = GameData.GameData.GetIdentifier();
ChangedItems.Clear();
foreach( var file in Resources.ModFiles.Select( f => f.ToRelPath( BasePath, out var p ) ? p : Utf8RelPath.Empty ) )
{
foreach( var path in ModFunctions.GetAllFiles( file, Meta ) )
{
identifier.Identify( ChangedItems, path.ToGamePath() );
}
}
foreach( var path in Meta.FileSwaps.Keys )
{
identifier.Identify( ChangedItems, path.ToGamePath() );
}
LowerChangedItemsString = string.Join( "\0", ChangedItems.Keys.Select( k => k.ToLowerInvariant() ) );
}
public static FileInfo MetaFileInfo( DirectoryInfo basePath )
=> new(Path.Combine( basePath.FullName, "meta.json" ));
public static Mod? LoadMod( ModFolder parentFolder, DirectoryInfo basePath )
{
basePath.Refresh();
if( !basePath.Exists )
{
PluginLog.Error( $"Supplied mod directory {basePath} does not exist." );
return null;
}
var metaFile = MetaFileInfo( basePath );
if( !metaFile.Exists )
{
PluginLog.Debug( "No mod meta found for {ModLocation}.", basePath.Name );
return null;
}
var meta = ModMeta.LoadFromFile( metaFile );
if( meta == null )
{
return null;
}
var data = new ModResources();
if( data.RefreshModFiles( basePath ).HasFlag( ResourceChange.Meta ) )
{
data.SetManipulations( meta, basePath );
}
return new Mod( parentFolder, basePath, meta, data );
}
public void SaveMeta()
=> Meta.SaveToFile( MetaFile );
public override string ToString()
=> Order.FullPath;
}

531
Penumbra/Mods/ModCleanup.cs Normal file
View file

@ -0,0 +1,531 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using Dalamud.Logging;
using Penumbra.GameData.ByteString;
using Penumbra.Importer;
using Penumbra.Util;
namespace Penumbra.Mods;
public class ModCleanup
{
private const string Duplicates = "Duplicates";
private const string Required = "Required";
private readonly DirectoryInfo _baseDir;
private readonly ModMeta _mod;
private SHA256? _hasher;
private readonly Dictionary< long, List< FileInfo > > _filesBySize = new();
private SHA256 Sha()
{
_hasher ??= SHA256.Create();
return _hasher;
}
private ModCleanup( DirectoryInfo baseDir, ModMeta mod )
{
_baseDir = baseDir;
_mod = mod;
BuildDict();
}
private void BuildDict()
{
foreach( var file in _baseDir.EnumerateFiles( "*.*", SearchOption.AllDirectories ) )
{
var fileLength = file.Length;
if( _filesBySize.TryGetValue( fileLength, out var files ) )
{
files.Add( file );
}
else
{
_filesBySize[ fileLength ] = new List< FileInfo > { file };
}
}
}
private static DirectoryInfo CreateNewModDir( Mod mod, string optionGroup, string option )
{
var newName = $"{mod.BasePath.Name}_{optionGroup}_{option}";
return TexToolsImport.CreateModFolder( new DirectoryInfo( Penumbra.Config.ModDirectory ), newName );
}
private static Mod CreateNewMod( DirectoryInfo newDir, string newSortOrder )
{
var idx = Penumbra.ModManager.AddMod( newDir );
var newMod = Penumbra.ModManager.Mods[ idx ];
newMod.Move( newSortOrder );
newMod.ComputeChangedItems();
ModFileSystem.InvokeChange();
return newMod;
}
private static ModMeta CreateNewMeta( DirectoryInfo newDir, Mod mod, string name, string optionGroup, string option )
{
var newMeta = new ModMeta
{
Author = mod.Meta.Author,
Name = name,
Description = $"Split from {mod.Meta.Name} Group {optionGroup} Option {option}.",
};
var metaFile = new FileInfo( Path.Combine( newDir.FullName, "meta.json" ) );
newMeta.SaveToFile( metaFile );
return newMeta;
}
private static void CreateModSplit( HashSet< string > unseenPaths, Mod mod, OptionGroup group, Option option )
{
try
{
var newDir = CreateNewModDir( mod, group.GroupName, option.OptionName );
var newName = group.SelectionType == SelectType.Multi ? $"{group.GroupName} - {option.OptionName}" : option.OptionName;
var newMeta = CreateNewMeta( newDir, mod, newName, group.GroupName, option.OptionName );
foreach( var (fileName, paths) in option.OptionFiles )
{
var oldPath = Path.Combine( mod.BasePath.FullName, fileName.ToString() );
unseenPaths.Remove( oldPath );
if( File.Exists( oldPath ) )
{
foreach( var path in paths )
{
var newPath = Path.Combine( newDir.FullName, path.ToString() );
Directory.CreateDirectory( Path.GetDirectoryName( newPath )! );
File.Copy( oldPath, newPath, true );
}
}
}
var newSortOrder = group.SelectionType == SelectType.Single
? $"{mod.Order.ParentFolder.FullName}/{mod.Meta.Name}/{group.GroupName}/{option.OptionName}"
: $"{mod.Order.ParentFolder.FullName}/{mod.Meta.Name}/{group.GroupName} - {option.OptionName}";
CreateNewMod( newDir, newSortOrder );
}
catch( Exception e )
{
PluginLog.Error( $"Could not split Mod:\n{e}" );
}
}
public static void SplitMod( Mod mod )
{
if( mod.Meta.Groups.Count == 0 )
{
return;
}
var unseenPaths = mod.Resources.ModFiles.Select( f => f.FullName ).ToHashSet();
foreach( var group in mod.Meta.Groups.Values )
{
foreach( var option in group.Options )
{
CreateModSplit( unseenPaths, mod, group, option );
}
}
if( unseenPaths.Count == 0 )
{
return;
}
var defaultGroup = new OptionGroup()
{
GroupName = "Default",
SelectionType = SelectType.Multi,
};
var defaultOption = new Option()
{
OptionName = "Files",
OptionFiles = unseenPaths.ToDictionary(
p => Utf8RelPath.FromFile( new FileInfo( p ), mod.BasePath, out var rel ) ? rel : Utf8RelPath.Empty,
p => new HashSet< Utf8GamePath >()
{ Utf8GamePath.FromFile( new FileInfo( p ), mod.BasePath, out var game, true ) ? game : Utf8GamePath.Empty } ),
};
CreateModSplit( unseenPaths, mod, defaultGroup, defaultOption );
}
private static Option FindOrCreateDuplicates( ModMeta meta )
{
static Option RequiredOption()
=> new()
{
OptionName = Required,
OptionDesc = "",
OptionFiles = new Dictionary< Utf8RelPath, HashSet< Utf8GamePath > >(),
};
if( meta.Groups.TryGetValue( Duplicates, out var duplicates ) )
{
var idx = duplicates.Options.FindIndex( o => o.OptionName == Required );
if( idx >= 0 )
{
return duplicates.Options[ idx ];
}
duplicates.Options.Add( RequiredOption() );
return duplicates.Options.Last();
}
meta.Groups.Add( Duplicates, new OptionGroup
{
GroupName = Duplicates,
SelectionType = SelectType.Single,
Options = new List< Option > { RequiredOption() },
} );
return meta.Groups[ Duplicates ].Options.First();
}
public static void Deduplicate( DirectoryInfo baseDir, ModMeta mod )
{
var dedup = new ModCleanup( baseDir, mod );
foreach( var (key, value) in dedup._filesBySize.Where( pair => pair.Value.Count >= 2 ) )
{
if( value.Count == 2 )
{
if( CompareFilesDirectly( value[ 0 ], value[ 1 ] ) )
{
dedup.ReplaceFile( value[ 0 ], value[ 1 ] );
}
}
else
{
var deleted = Enumerable.Repeat( false, value.Count ).ToArray();
var hashes = value.Select( dedup.ComputeHash ).ToArray();
for( var i = 0; i < value.Count; ++i )
{
if( deleted[ i ] )
{
continue;
}
for( var j = i + 1; j < value.Count; ++j )
{
if( deleted[ j ] || !CompareHashes( hashes[ i ], hashes[ j ] ) )
{
continue;
}
dedup.ReplaceFile( value[ i ], value[ j ] );
deleted[ j ] = true;
}
}
}
}
CleanUpDuplicates( mod );
ClearEmptySubDirectories( dedup._baseDir );
}
private void ReplaceFile( FileInfo f1, FileInfo f2 )
{
if( !Utf8RelPath.FromFile( f1, _baseDir, out var relName1 )
|| !Utf8RelPath.FromFile( f2, _baseDir, out var relName2 ) )
{
return;
}
var inOption1 = false;
var inOption2 = false;
foreach( var option in _mod.Groups.SelectMany( g => g.Value.Options ) )
{
if( option.OptionFiles.ContainsKey( relName1 ) )
{
inOption1 = true;
}
if( !option.OptionFiles.TryGetValue( relName2, out var values ) )
{
continue;
}
inOption2 = true;
foreach( var value in values )
{
option.AddFile( relName1, value );
}
option.OptionFiles.Remove( relName2 );
}
if( !inOption1 || !inOption2 )
{
var duplicates = FindOrCreateDuplicates( _mod );
if( !inOption1 )
{
duplicates.AddFile( relName1, relName2.ToGamePath() );
}
if( !inOption2 )
{
duplicates.AddFile( relName1, relName1.ToGamePath() );
}
}
PluginLog.Information( $"File {relName1} and {relName2} are identical. Deleting the second." );
f2.Delete();
}
public static bool CompareFilesDirectly( FileInfo f1, FileInfo f2 )
=> File.ReadAllBytes( f1.FullName ).SequenceEqual( File.ReadAllBytes( f2.FullName ) );
public static bool CompareHashes( byte[] f1, byte[] f2 )
=> StructuralComparisons.StructuralEqualityComparer.Equals( f1, f2 );
public byte[] ComputeHash( FileInfo f )
{
var stream = File.OpenRead( f.FullName );
var ret = Sha().ComputeHash( stream );
stream.Dispose();
return ret;
}
// 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();
}
}
}
private static bool FileIsInAnyGroup( ModMeta meta, Utf8RelPath relPath, bool exceptDuplicates = false )
{
var groupEnumerator = exceptDuplicates
? meta.Groups.Values.Where( g => g.GroupName != Duplicates )
: meta.Groups.Values;
return groupEnumerator.SelectMany( group => group.Options )
.Any( option => option.OptionFiles.ContainsKey( relPath ) );
}
private static void CleanUpDuplicates( ModMeta meta )
{
if( !meta.Groups.TryGetValue( Duplicates, out var info ) )
{
return;
}
var requiredIdx = info.Options.FindIndex( o => o.OptionName == Required );
if( requiredIdx >= 0 )
{
var required = info.Options[ requiredIdx ];
foreach( var (key, value) in required.OptionFiles.ToArray() )
{
if( value.Count > 1 || FileIsInAnyGroup( meta, key, true ) )
{
continue;
}
if( value.Count == 0 || value.First().CompareTo( key.ToGamePath() ) == 0 )
{
required.OptionFiles.Remove( key );
}
}
if( required.OptionFiles.Count == 0 )
{
info.Options.RemoveAt( requiredIdx );
}
}
if( info.Options.Count == 0 )
{
meta.Groups.Remove( Duplicates );
}
}
public enum GroupType
{
Both = 0,
Single = 1,
Multi = 2,
};
private static void RemoveFromGroups( ModMeta meta, Utf8RelPath relPath, Utf8GamePath gamePath, GroupType type = GroupType.Both,
bool skipDuplicates = true )
{
if( meta.Groups.Count == 0 )
{
return;
}
var enumerator = type switch
{
GroupType.Both => meta.Groups.Values,
GroupType.Single => meta.Groups.Values.Where( g => g.SelectionType == SelectType.Single ),
GroupType.Multi => meta.Groups.Values.Where( g => g.SelectionType == SelectType.Multi ),
_ => throw new InvalidEnumArgumentException( "Invalid Enum in RemoveFromGroups" ),
};
foreach( var group in enumerator )
{
var optionEnum = skipDuplicates
? group.Options.Where( o => group.GroupName != Duplicates || o.OptionName != Required )
: group.Options;
foreach( var option in optionEnum )
{
if( option.OptionFiles.TryGetValue( relPath, out var gamePaths ) && gamePaths.Remove( gamePath ) && gamePaths.Count == 0 )
{
option.OptionFiles.Remove( relPath );
}
}
}
}
public static bool MoveFile( ModMeta meta, string basePath, Utf8RelPath oldRelPath, Utf8RelPath newRelPath )
{
if( oldRelPath.Equals( newRelPath ) )
{
return true;
}
try
{
var newFullPath = Path.Combine( basePath, newRelPath.ToString() );
new FileInfo( newFullPath ).Directory!.Create();
File.Move( Path.Combine( basePath, oldRelPath.ToString() ), newFullPath );
}
catch( Exception e )
{
PluginLog.Error( $"Could not move file from {oldRelPath} to {newRelPath}:\n{e}" );
return false;
}
foreach( var option in meta.Groups.Values.SelectMany( group => group.Options ) )
{
if( option.OptionFiles.TryGetValue( oldRelPath, out var gamePaths ) )
{
option.OptionFiles.Add( newRelPath, gamePaths );
option.OptionFiles.Remove( oldRelPath );
}
}
return true;
}
private static void RemoveUselessGroups( ModMeta meta )
{
meta.Groups = meta.Groups.Where( kvp => kvp.Value.Options.Any( o => o.OptionFiles.Count > 0 ) )
.ToDictionary( kvp => kvp.Key, kvp => kvp.Value );
}
// Goes through all Single-Select options and checks if file links are in each of them.
// If they are, it moves those files to the root folder and removes them from the groups (and puts them to duplicates, if necessary).
public static void Normalize( DirectoryInfo baseDir, ModMeta meta )
{
foreach( var group in meta.Groups.Values.Where( g => g.SelectionType == SelectType.Single && g.GroupName != Duplicates ) )
{
var firstOption = true;
HashSet< (Utf8RelPath, Utf8GamePath) > groupList = new();
foreach( var option in group.Options )
{
HashSet< (Utf8RelPath, Utf8GamePath) > optionList = new();
foreach( var (file, gamePaths) in option.OptionFiles.Select( p => ( p.Key, p.Value ) ) )
{
optionList.UnionWith( gamePaths.Select( p => ( file, p ) ) );
}
if( firstOption )
{
groupList = optionList;
}
else
{
groupList.IntersectWith( optionList );
}
firstOption = false;
}
var newPath = new Dictionary< Utf8RelPath, Utf8GamePath >();
foreach( var (path, gamePath) in groupList )
{
var relPath = new Utf8RelPath( gamePath );
if( newPath.TryGetValue( path, out var usedGamePath ) )
{
var required = FindOrCreateDuplicates( meta );
var usedRelPath = new Utf8RelPath( usedGamePath );
required.AddFile( usedRelPath, gamePath );
required.AddFile( usedRelPath, usedGamePath );
RemoveFromGroups( meta, relPath, gamePath, GroupType.Single );
}
else if( MoveFile( meta, baseDir.FullName, path, relPath ) )
{
newPath[ path ] = gamePath;
if( FileIsInAnyGroup( meta, relPath ) )
{
FindOrCreateDuplicates( meta ).AddFile( relPath, gamePath );
}
RemoveFromGroups( meta, relPath, gamePath, GroupType.Single );
}
}
}
RemoveUselessGroups( meta );
ClearEmptySubDirectories( baseDir );
}
public static void AutoGenerateGroups( DirectoryInfo baseDir, ModMeta meta )
{
meta.Groups.Clear();
ClearEmptySubDirectories( baseDir );
foreach( var groupDir in baseDir.EnumerateDirectories() )
{
var group = new OptionGroup
{
GroupName = groupDir.Name,
SelectionType = SelectType.Single,
Options = new List< Option >(),
};
foreach( var optionDir in groupDir.EnumerateDirectories() )
{
var option = new Option
{
OptionDesc = string.Empty,
OptionName = optionDir.Name,
OptionFiles = new Dictionary< Utf8RelPath, HashSet< Utf8GamePath > >(),
};
foreach( var file in optionDir.EnumerateFiles( "*.*", SearchOption.AllDirectories ) )
{
if( Utf8RelPath.FromFile( file, baseDir, out var rel )
&& Utf8GamePath.FromFile( file, optionDir, out var game ) )
{
option.OptionFiles[ rel ] = new HashSet< Utf8GamePath > { game };
}
}
if( option.OptionFiles.Count > 0 )
{
group.Options.Add( option );
}
}
if( group.Options.Count > 0 )
{
meta.Groups.Add( groupDir.Name, group );
}
}
// TODO
var idx = Penumbra.ModManager.Mods.IndexOf( m => m.Meta == meta );
foreach( var collection in Penumbra.CollectionManager )
{
collection.Settings[ idx ]?.FixInvalidSettings( meta );
}
}
}

View file

@ -1,6 +1,5 @@
using System;
using System.Linq;
using Penumbra.Mod;
namespace Penumbra.Mods;
@ -37,7 +36,7 @@ public static partial class ModFileSystem
// Rename the SortOrderName of a single mod. Slashes are replaced by Backslashes.
// Saves and returns true if anything changed.
public static bool Rename( this Mod.Mod mod, string newName )
public static bool Rename( this global::Penumbra.Mods.Mod mod, string newName )
{
if( RenameNoSave( mod, newName ) )
{
@ -63,7 +62,7 @@ public static partial class ModFileSystem
// Move a single mod to the target folder.
// Returns true and saves if anything changed.
public static bool Move( this Mod.Mod mod, ModFolder target )
public static bool Move( this global::Penumbra.Mods.Mod mod, ModFolder target )
{
if( MoveNoSave( mod, target ) )
{
@ -76,7 +75,7 @@ public static partial class ModFileSystem
// Move a mod to the filesystem location specified by sortOrder and rename its SortOrderName.
// Creates all necessary Subfolders.
public static void Move( this Mod.Mod mod, string sortOrder )
public static void Move( this global::Penumbra.Mods.Mod mod, string sortOrder )
{
var split = sortOrder.Split( new[] { '/' }, StringSplitOptions.RemoveEmptyEntries );
var folder = Root;
@ -137,10 +136,10 @@ public static partial class ModFileSystem
}
// Sets and saves the sort order of a single mod, removing the entry if it is unnecessary.
private static void SaveMod( Mod.Mod mod )
private static void SaveMod( global::Penumbra.Mods.Mod mod )
{
if( ReferenceEquals( mod.Order.ParentFolder, Root )
&& string.Equals( mod.Order.SortOrderName, mod.Meta.Name.Replace( '/', '\\' ), StringComparison.InvariantCultureIgnoreCase ) )
&& string.Equals( mod.Order.SortOrderName, mod.Meta.Name.Text.Replace( '/', '\\' ), StringComparison.InvariantCultureIgnoreCase ) )
{
Penumbra.Config.ModSortOrder.Remove( mod.BasePath.Name );
}
@ -184,7 +183,7 @@ public static partial class ModFileSystem
return true;
}
private static bool RenameNoSave( Mod.Mod mod, string newName )
private static bool RenameNoSave( global::Penumbra.Mods.Mod mod, string newName )
{
newName = newName.Replace( '/', '\\' );
if( mod.Order.SortOrderName == newName )
@ -193,12 +192,12 @@ public static partial class ModFileSystem
}
mod.Order.ParentFolder.RemoveModIgnoreEmpty( mod );
mod.Order = new Mod.Mod.SortOrder( mod.Order.ParentFolder, newName );
mod.Order = new global::Penumbra.Mods.Mod.SortOrder( mod.Order.ParentFolder, newName );
mod.Order.ParentFolder.AddMod( mod );
return true;
}
private static bool MoveNoSave( Mod.Mod mod, ModFolder target )
private static bool MoveNoSave( global::Penumbra.Mods.Mod mod, ModFolder target )
{
var oldParent = mod.Order.ParentFolder;
if( ReferenceEquals( target, oldParent ) )
@ -207,7 +206,7 @@ public static partial class ModFileSystem
}
oldParent.RemoveMod( mod );
mod.Order = new Mod.Mod.SortOrder( target, mod.Order.SortOrderName );
mod.Order = new global::Penumbra.Mods.Mod.SortOrder( target, mod.Order.SortOrderName );
target.AddMod( mod );
return true;
}

View file

@ -1,247 +1,245 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Penumbra.Mod;
namespace Penumbra.Mods
namespace Penumbra.Mods;
public partial class ModFolder
{
public partial class ModFolder
public ModFolder? Parent;
public string FullName
{
public ModFolder? Parent;
public string FullName
get
{
get
{
var parentPath = Parent?.FullName ?? string.Empty;
return parentPath.Any() ? $"{parentPath}/{Name}" : Name;
}
}
private string _name = string.Empty;
public string Name
{
get => _name;
set => _name = value.Replace( '/', '\\' );
}
public List< ModFolder > SubFolders { get; } = new();
public List< Mod.Mod > Mods { get; } = new();
public ModFolder( ModFolder parent, string name )
{
Parent = parent;
Name = name;
}
public override string ToString()
=> FullName;
public int TotalDescendantMods()
=> Mods.Count + SubFolders.Sum( f => f.TotalDescendantMods() );
public int TotalDescendantFolders()
=> SubFolders.Sum( f => f.TotalDescendantFolders() );
// Return all descendant mods in the specified order.
public IEnumerable< Mod.Mod > AllMods( bool foldersFirst )
{
if( foldersFirst )
{
return SubFolders.SelectMany( f => f.AllMods( foldersFirst ) ).Concat( Mods );
}
return GetSortedEnumerator().SelectMany( f =>
{
if( f is ModFolder folder )
{
return folder.AllMods( false );
}
return new[] { ( Mod.Mod )f };
} );
}
// Return all descendant subfolders.
public IEnumerable< ModFolder > AllFolders()
=> SubFolders.SelectMany( f => f.AllFolders() ).Prepend( this );
// Iterate through all descendants in the specified order, returning subfolders as well as mods.
public IEnumerable< object > GetItems( bool foldersFirst )
=> foldersFirst ? SubFolders.Cast< object >().Concat( Mods ) : GetSortedEnumerator();
// Find a subfolder by name. Returns true and sets folder to it if it exists.
public bool FindSubFolder( string name, out ModFolder folder )
{
var subFolder = new ModFolder( this, name );
var idx = SubFolders.BinarySearch( subFolder, FolderComparer );
folder = idx >= 0 ? SubFolders[ idx ] : this;
return idx >= 0;
}
// Checks if an equivalent subfolder as folder already exists and returns its index.
// If it does not exist, inserts folder as a subfolder and returns the new index.
// Also sets this as folders parent.
public int FindOrAddSubFolder( ModFolder folder )
{
var idx = SubFolders.BinarySearch( folder, FolderComparer );
if( idx >= 0 )
{
return idx;
}
idx = ~idx;
SubFolders.Insert( idx, folder );
folder.Parent = this;
return idx;
}
// Checks if a subfolder with the given name already exists and returns it and its index.
// If it does not exists, creates and inserts it and returns the new subfolder and its index.
public (ModFolder, int) FindOrCreateSubFolder( string name )
{
var subFolder = new ModFolder( this, name );
var idx = FindOrAddSubFolder( subFolder );
return ( SubFolders[ idx ], idx );
}
// Remove folder as a subfolder if it exists.
// If this folder is empty afterwards, remove it from its parent.
public void RemoveSubFolder( ModFolder folder )
{
RemoveFolderIgnoreEmpty( folder );
CheckEmpty();
}
// Add the given mod as a child, if it is not already a child.
// Returns the index of the found or inserted mod.
public int AddMod( Mod.Mod mod )
{
var idx = Mods.BinarySearch( mod, ModComparer );
if( idx >= 0 )
{
return idx;
}
idx = ~idx;
Mods.Insert( idx, mod );
return idx;
}
// Remove mod as a child if it exists.
// If this folder is empty afterwards, remove it from its parent.
public void RemoveMod( Mod.Mod mod )
{
RemoveModIgnoreEmpty( mod );
CheckEmpty();
var parentPath = Parent?.FullName ?? string.Empty;
return parentPath.Any() ? $"{parentPath}/{Name}" : Name;
}
}
// Internals
public partial class ModFolder
private string _name = string.Empty;
public string Name
{
// Create a Root folder without parent.
internal static ModFolder CreateRoot()
=> new( null!, string.Empty );
get => _name;
set => _name = value.Replace( '/', '\\' );
}
internal class ModFolderComparer : IComparer< ModFolder >
public List< ModFolder > SubFolders { get; } = new();
public List< Mod > Mods { get; } = new();
public ModFolder( ModFolder parent, string name )
{
Parent = parent;
Name = name;
}
public override string ToString()
=> FullName;
public int TotalDescendantMods()
=> Mods.Count + SubFolders.Sum( f => f.TotalDescendantMods() );
public int TotalDescendantFolders()
=> SubFolders.Sum( f => f.TotalDescendantFolders() );
// Return all descendant mods in the specified order.
public IEnumerable< Mod > AllMods( bool foldersFirst )
{
if( foldersFirst )
{
public StringComparison CompareType = StringComparison.InvariantCultureIgnoreCase;
// Compare only the direct folder names since this is only used inside an enumeration of subfolders of one folder.
public int Compare( ModFolder? x, ModFolder? y )
=> ReferenceEquals( x, y )
? 0
: string.Compare( x?.Name ?? string.Empty, y?.Name ?? string.Empty, CompareType );
return SubFolders.SelectMany( f => f.AllMods( foldersFirst ) ).Concat( Mods );
}
internal class ModDataComparer : IComparer< Mod.Mod >
return GetSortedEnumerator().SelectMany( f =>
{
public StringComparison CompareType = StringComparison.InvariantCultureIgnoreCase;
// Compare only the direct SortOrderNames since this is only used inside an enumeration of direct mod children of one folder.
// Since mod SortOrderNames do not have to be unique inside a folder, also compare their BasePaths (and thus their identity) if necessary.
public int Compare( Mod.Mod? x, Mod.Mod? y )
if( f is ModFolder folder )
{
if( ReferenceEquals( x, y ) )
{
return 0;
}
var cmp = string.Compare( x?.Order.SortOrderName, y?.Order.SortOrderName, CompareType );
if( cmp != 0 )
{
return cmp;
}
return string.Compare( x?.BasePath.Name, y?.BasePath.Name, StringComparison.InvariantCulture );
return folder.AllMods( false );
}
return new[] { ( Mod )f };
} );
}
// Return all descendant subfolders.
public IEnumerable< ModFolder > AllFolders()
=> SubFolders.SelectMany( f => f.AllFolders() ).Prepend( this );
// Iterate through all descendants in the specified order, returning subfolders as well as mods.
public IEnumerable< object > GetItems( bool foldersFirst )
=> foldersFirst ? SubFolders.Cast< object >().Concat( Mods ) : GetSortedEnumerator();
// Find a subfolder by name. Returns true and sets folder to it if it exists.
public bool FindSubFolder( string name, out ModFolder folder )
{
var subFolder = new ModFolder( this, name );
var idx = SubFolders.BinarySearch( subFolder, FolderComparer );
folder = idx >= 0 ? SubFolders[ idx ] : this;
return idx >= 0;
}
// Checks if an equivalent subfolder as folder already exists and returns its index.
// If it does not exist, inserts folder as a subfolder and returns the new index.
// Also sets this as folders parent.
public int FindOrAddSubFolder( ModFolder folder )
{
var idx = SubFolders.BinarySearch( folder, FolderComparer );
if( idx >= 0 )
{
return idx;
}
internal static readonly ModFolderComparer FolderComparer = new();
internal static readonly ModDataComparer ModComparer = new();
idx = ~idx;
SubFolders.Insert( idx, folder );
folder.Parent = this;
return idx;
}
// Get an enumerator for actually sorted objects instead of folder-first objects.
private IEnumerable< object > GetSortedEnumerator()
// Checks if a subfolder with the given name already exists and returns it and its index.
// If it does not exists, creates and inserts it and returns the new subfolder and its index.
public (ModFolder, int) FindOrCreateSubFolder( string name )
{
var subFolder = new ModFolder( this, name );
var idx = FindOrAddSubFolder( subFolder );
return ( SubFolders[ idx ], idx );
}
// Remove folder as a subfolder if it exists.
// If this folder is empty afterwards, remove it from its parent.
public void RemoveSubFolder( ModFolder folder )
{
RemoveFolderIgnoreEmpty( folder );
CheckEmpty();
}
// Add the given mod as a child, if it is not already a child.
// Returns the index of the found or inserted mod.
public int AddMod( Mod mod )
{
var idx = Mods.BinarySearch( mod, ModComparer );
if( idx >= 0 )
{
var modIdx = 0;
foreach( var folder in SubFolders )
{
var folderString = folder.Name;
for( ; modIdx < Mods.Count; ++modIdx )
{
var mod = Mods[ modIdx ];
var modString = mod.Order.SortOrderName;
if( string.Compare( folderString, modString, StringComparison.InvariantCultureIgnoreCase ) > 0 )
{
yield return mod;
}
else
{
break;
}
}
return idx;
}
yield return folder;
idx = ~idx;
Mods.Insert( idx, mod );
return idx;
}
// Remove mod as a child if it exists.
// If this folder is empty afterwards, remove it from its parent.
public void RemoveMod( Mod mod )
{
RemoveModIgnoreEmpty( mod );
CheckEmpty();
}
}
// Internals
public partial class ModFolder
{
// Create a Root folder without parent.
internal static ModFolder CreateRoot()
=> new(null!, string.Empty);
internal class ModFolderComparer : IComparer< ModFolder >
{
public StringComparison CompareType = StringComparison.InvariantCultureIgnoreCase;
// Compare only the direct folder names since this is only used inside an enumeration of subfolders of one folder.
public int Compare( ModFolder? x, ModFolder? y )
=> ReferenceEquals( x, y )
? 0
: string.Compare( x?.Name ?? string.Empty, y?.Name ?? string.Empty, CompareType );
}
internal class ModDataComparer : IComparer< Mod >
{
public StringComparison CompareType = StringComparison.InvariantCultureIgnoreCase;
// Compare only the direct SortOrderNames since this is only used inside an enumeration of direct mod children of one folder.
// Since mod SortOrderNames do not have to be unique inside a folder, also compare their BasePaths (and thus their identity) if necessary.
public int Compare( Mod? x, Mod? y )
{
if( ReferenceEquals( x, y ) )
{
return 0;
}
var cmp = string.Compare( x?.Order.SortOrderName, y?.Order.SortOrderName, CompareType );
if( cmp != 0 )
{
return cmp;
}
return string.Compare( x?.BasePath.Name, y?.BasePath.Name, StringComparison.InvariantCulture );
}
}
internal static readonly ModFolderComparer FolderComparer = new();
internal static readonly ModDataComparer ModComparer = new();
// Get an enumerator for actually sorted objects instead of folder-first objects.
private IEnumerable< object > GetSortedEnumerator()
{
var modIdx = 0;
foreach( var folder in SubFolders )
{
var folderString = folder.Name;
for( ; modIdx < Mods.Count; ++modIdx )
{
yield return Mods[ modIdx ];
var mod = Mods[ modIdx ];
var modString = mod.Order.SortOrderName;
if( string.Compare( folderString, modString, StringComparison.InvariantCultureIgnoreCase ) > 0 )
{
yield return mod;
}
else
{
break;
}
}
yield return folder;
}
private void CheckEmpty()
for( ; modIdx < Mods.Count; ++modIdx )
{
if( Mods.Count == 0 && SubFolders.Count == 0 )
{
Parent?.RemoveSubFolder( this );
}
yield return Mods[ modIdx ];
}
}
private void CheckEmpty()
{
if( Mods.Count == 0 && SubFolders.Count == 0 )
{
Parent?.RemoveSubFolder( this );
}
}
// Remove a subfolder but do not remove this folder from its parent if it is empty afterwards.
internal void RemoveFolderIgnoreEmpty( ModFolder folder )
{
var idx = SubFolders.BinarySearch( folder, FolderComparer );
if( idx < 0 )
{
return;
}
// Remove a subfolder but do not remove this folder from its parent if it is empty afterwards.
internal void RemoveFolderIgnoreEmpty( ModFolder folder )
{
var idx = SubFolders.BinarySearch( folder, FolderComparer );
if( idx < 0 )
{
return;
}
SubFolders[ idx ].Parent = null;
SubFolders.RemoveAt( idx );
}
SubFolders[ idx ].Parent = null;
SubFolders.RemoveAt( idx );
}
// Remove a mod, but do not remove this folder from its parent if it is empty afterwards.
internal void RemoveModIgnoreEmpty( Mod.Mod mod )
// Remove a mod, but do not remove this folder from its parent if it is empty afterwards.
internal void RemoveModIgnoreEmpty( Mod mod )
{
var idx = Mods.BinarySearch( mod, ModComparer );
if( idx >= 0 )
{
var idx = Mods.BinarySearch( mod, ModComparer );
if( idx >= 0 )
{
Mods.RemoveAt( idx );
}
Mods.RemoveAt( idx );
}
}
}

View file

@ -0,0 +1,100 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Penumbra.GameData.ByteString;
namespace Penumbra.Mods;
// Functions that do not really depend on only one component of a mod.
public static class ModFunctions
{
public static bool CleanUpCollection( Dictionary< string, ModSettings > settings, IEnumerable< DirectoryInfo > modPaths )
{
var hashes = modPaths.Select( p => p.Name ).ToHashSet();
var missingMods = settings.Keys.Where( k => !hashes.Contains( k ) ).ToArray();
var anyChanges = false;
foreach( var toRemove in missingMods )
{
anyChanges |= settings.Remove( toRemove );
}
return anyChanges;
}
public static HashSet< Utf8GamePath > GetFilesForConfig( Utf8RelPath relPath, ModSettings settings, ModMeta meta )
{
var doNotAdd = false;
var files = new HashSet< Utf8GamePath >();
foreach( var group in meta.Groups.Values.Where( g => g.Options.Count > 0 ) )
{
doNotAdd |= group.ApplyGroupFiles( relPath, settings.Settings[ group.GroupName ], files );
}
if( !doNotAdd )
{
files.Add( relPath.ToGamePath() );
}
return files;
}
public static HashSet< Utf8GamePath > GetAllFiles( Utf8RelPath relPath, ModMeta meta )
{
var ret = new HashSet< Utf8GamePath >();
foreach( var option in meta.Groups.Values.SelectMany( g => g.Options ) )
{
if( option.OptionFiles.TryGetValue( relPath, out var files ) )
{
ret.UnionWith( files );
}
}
if( ret.Count == 0 )
{
ret.Add( relPath.ToGamePath() );
}
return ret;
}
public static ModSettings ConvertNamedSettings( NamedModSettings namedSettings, ModMeta meta )
{
ModSettings ret = new()
{
Priority = namedSettings.Priority,
Settings = namedSettings.Settings.Keys.ToDictionary( k => k, _ => 0 ),
};
foreach( var setting in namedSettings.Settings.Keys )
{
if( !meta.Groups.TryGetValue( setting, out var info ) )
{
continue;
}
if( info.SelectionType == SelectType.Single )
{
if( namedSettings.Settings[ setting ].Count == 0 )
{
ret.Settings[ setting ] = 0;
}
else
{
var idx = info.Options.FindIndex( o => o.OptionName == namedSettings.Settings[ setting ].Last() );
ret.Settings[ setting ] = idx < 0 ? 0 : idx;
}
}
else
{
foreach( var idx in namedSettings.Settings[ setting ]
.Select( option => info.Options.FindIndex( o => o.OptionName == option ) )
.Where( idx => idx >= 0 ) )
{
ret.Settings[ setting ] |= 1 << idx;
}
}
}
return ret;
}
}

View file

@ -2,16 +2,14 @@ using System;
using System.Collections.Generic;
using System.IO;
using Dalamud.Logging;
using Penumbra.Meta.Manipulations;
using Penumbra.Mod;
namespace Penumbra.Mods;
public partial class ModManagerNew
{
private readonly List< Mod.Mod > _mods = new();
private readonly List< Mod > _mods = new();
public IReadOnlyList< Mod.Mod > Mods
public IReadOnlyList< Mod > Mods
=> _mods;
public void DiscoverMods()
@ -37,6 +35,7 @@ public partial class ModManagerNew
//Collections.RecreateCaches();
}
}
public partial class ModManagerNew
{
public DirectoryInfo BasePath { get; private set; } = null!;

285
Penumbra/Mods/ModManager.cs Normal file
View file

@ -0,0 +1,285 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dalamud.Logging;
using Penumbra.GameData.ByteString;
using Penumbra.Meta;
using Penumbra.Mods;
using Penumbra.Util;
namespace Penumbra.Mods;
public partial class Mod
{
public enum ChangeType
{
Added,
Removed,
Changed,
}
// The ModManager handles the basic mods installed to the mod directory.
// It also contains the CollectionManager that handles all collections.
public class Manager : IEnumerable< Mod >
{
public DirectoryInfo BasePath { get; private set; } = null!;
private readonly List< Mod > _mods = new();
public Mod this[ int idx ]
=> _mods[ idx ];
public IReadOnlyList< Mod > Mods
=> _mods;
public IEnumerator< Mod > GetEnumerator()
=> _mods.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator()
=> GetEnumerator();
public ModFolder StructuredMods { get; } = ModFileSystem.Root;
public delegate void ModChangeDelegate( ChangeType type, int modIndex, Mod mod );
public event ModChangeDelegate? ModChange;
public event Action? ModsRediscovered;
public bool Valid { get; private set; }
public int Count
=> _mods.Count;
public Configuration Config
=> Penumbra.Config;
public void DiscoverMods( string newDir )
{
SetBaseDirectory( newDir, false );
DiscoverMods();
}
private void SetBaseDirectory( string newPath, bool firstTime )
{
if( !firstTime && string.Equals( newPath, Config.ModDirectory, StringComparison.InvariantCultureIgnoreCase ) )
{
return;
}
if( newPath.Length == 0 )
{
Valid = false;
BasePath = new DirectoryInfo( "." );
}
else
{
var newDir = new DirectoryInfo( newPath );
if( !newDir.Exists )
{
try
{
Directory.CreateDirectory( newDir.FullName );
newDir.Refresh();
}
catch( Exception e )
{
PluginLog.Error( $"Could not create specified mod directory {newDir.FullName}:\n{e}" );
}
}
BasePath = newDir;
Valid = true;
if( Config.ModDirectory != BasePath.FullName )
{
Config.ModDirectory = BasePath.FullName;
Config.Save();
}
}
ModsRediscovered?.Invoke();
}
public Manager()
{
SetBaseDirectory( Config.ModDirectory, true );
}
private bool SetSortOrderPath( Mod mod, string path )
{
mod.Move( path );
var fixedPath = mod.Order.FullPath;
if( fixedPath.Length == 0 || string.Equals( fixedPath, mod.Meta.Name, StringComparison.InvariantCultureIgnoreCase ) )
{
Config.ModSortOrder.Remove( mod.BasePath.Name );
return true;
}
if( path != fixedPath )
{
Config.ModSortOrder[ mod.BasePath.Name ] = fixedPath;
return true;
}
return false;
}
private void SetModStructure( bool removeOldPaths = false )
{
var changes = false;
foreach( var (folder, path) in Config.ModSortOrder.ToArray() )
{
if( path.Length > 0 && _mods.FindFirst( m => m.BasePath.Name == folder, out var mod ) )
{
changes |= SetSortOrderPath( mod, path );
}
else if( removeOldPaths )
{
changes = true;
Config.ModSortOrder.Remove( folder );
}
}
if( changes )
{
Config.Save();
}
}
public void DiscoverMods()
{
_mods.Clear();
BasePath.Refresh();
StructuredMods.SubFolders.Clear();
StructuredMods.Mods.Clear();
if( Valid && BasePath.Exists )
{
foreach( var modFolder in BasePath.EnumerateDirectories() )
{
var mod = LoadMod( StructuredMods, modFolder );
if( mod == null )
{
continue;
}
mod.Index = _mods.Count;
_mods.Add( mod );
}
SetModStructure();
}
ModsRediscovered?.Invoke();
}
public void DeleteMod( DirectoryInfo modFolder )
{
if( Directory.Exists( modFolder.FullName ) )
{
try
{
Directory.Delete( modFolder.FullName, true );
}
catch( Exception e )
{
PluginLog.Error( $"Could not delete the mod {modFolder.Name}:\n{e}" );
}
}
var idx = _mods.FindIndex( m => m.BasePath.Name == modFolder.Name );
if( idx >= 0 )
{
var mod = _mods[ idx ];
mod.Order.ParentFolder.RemoveMod( mod );
_mods.RemoveAt( idx );
for( var i = idx; i < _mods.Count; ++i )
{
--_mods[ i ].Index;
}
ModChange?.Invoke( ChangeType.Removed, idx, mod );
}
}
public int AddMod( DirectoryInfo modFolder )
{
var mod = LoadMod( StructuredMods, modFolder );
if( mod == null )
{
return -1;
}
if( Config.ModSortOrder.TryGetValue( mod.BasePath.Name, out var sortOrder ) )
{
if( SetSortOrderPath( mod, sortOrder ) )
{
Config.Save();
}
}
if( _mods.Any( m => m.BasePath.Name == modFolder.Name ) )
{
return -1;
}
_mods.Add( mod );
ModChange?.Invoke( ChangeType.Added, _mods.Count - 1, mod );
return _mods.Count - 1;
}
public bool UpdateMod( int idx, bool reloadMeta = false, bool recomputeMeta = false, bool force = false )
{
var mod = Mods[ idx ];
var oldName = mod.Meta.Name;
var metaChanges = mod.Meta.RefreshFromFile( mod.MetaFile ) || force;
var fileChanges = mod.Resources.RefreshModFiles( mod.BasePath );
if( !recomputeMeta && !reloadMeta && !metaChanges && fileChanges == 0 )
{
return false;
}
if( metaChanges || fileChanges.HasFlag( ResourceChange.Files ) )
{
mod.ComputeChangedItems();
if( Config.ModSortOrder.TryGetValue( mod.BasePath.Name, out var sortOrder ) )
{
mod.Move( sortOrder );
var path = mod.Order.FullPath;
if( path != sortOrder )
{
Config.ModSortOrder[ mod.BasePath.Name ] = path;
Config.Save();
}
}
else
{
mod.Order = new SortOrder( StructuredMods, mod.Meta.Name );
}
}
var nameChange = !string.Equals( oldName, mod.Meta.Name, StringComparison.InvariantCulture );
recomputeMeta |= fileChanges.HasFlag( ResourceChange.Meta );
if( recomputeMeta )
{
mod.Resources.MetaManipulations.Update( mod.Resources.MetaFiles, mod.BasePath, mod.Meta );
mod.Resources.MetaManipulations.SaveToFile( MetaCollection.FileName( mod.BasePath ) );
}
// TODO: more specific mod changes?
ModChange?.Invoke( ChangeType.Changed, idx, mod );
return true;
}
public bool UpdateMod( Mod mod, bool reloadMeta = false, bool recomputeMeta = false, bool force = false )
=> UpdateMod( Mods.IndexOf( mod ), reloadMeta, recomputeMeta, force );
public static FullPath? ResolvePath( Utf8GamePath gameResourcePath )
=> Penumbra.CollectionManager.Default.ResolvePath( gameResourcePath );
}
}

View file

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using Dalamud.Logging;
using Penumbra.Mod;
using Penumbra.Util;
namespace Penumbra.Mods;
@ -12,7 +11,7 @@ namespace Penumbra.Mods;
// Contains all change functions on a specific mod that also require corresponding changes to collections.
public static class ModManagerEditExtensions
{
public static bool RenameMod( this Mod.Mod.Manager manager, string newName, Mod.Mod mod )
public static bool RenameMod( this Mod.Manager manager, string newName, Mod mod )
{
if( newName.Length == 0 || string.Equals( newName, mod.Meta.Name, StringComparison.InvariantCulture ) )
{
@ -25,14 +24,14 @@ public static class ModManagerEditExtensions
return true;
}
public static bool ChangeSortOrder( this Mod.Mod.Manager manager, Mod.Mod mod, string newSortOrder )
public static bool ChangeSortOrder( this Mod.Manager manager, Mod mod, string newSortOrder )
{
if( string.Equals( mod.Order.FullPath, newSortOrder, StringComparison.InvariantCultureIgnoreCase ) )
{
return false;
}
var inRoot = new Mod.Mod.SortOrder( manager.StructuredMods, mod.Meta.Name );
var inRoot = new Mod.SortOrder( manager.StructuredMods, mod.Meta.Name );
if( newSortOrder == string.Empty || newSortOrder == inRoot.SortOrderName )
{
mod.Order = inRoot;
@ -49,7 +48,7 @@ public static class ModManagerEditExtensions
return true;
}
public static bool RenameModFolder( this Mod.Mod.Manager manager, Mod.Mod mod, DirectoryInfo newDir, bool move = true )
public static bool RenameModFolder( this Mod.Manager manager, Mod mod, DirectoryInfo newDir, bool move = true )
{
if( move )
{
@ -73,7 +72,7 @@ public static class ModManagerEditExtensions
var oldBasePath = mod.BasePath;
mod.BasePath = newDir;
mod.MetaFile = Mod.Mod.MetaFileInfo( newDir );
mod.MetaFile = Mod.MetaFileInfo( newDir );
manager.UpdateMod( mod );
if( manager.Config.ModSortOrder.ContainsKey( oldBasePath.Name ) )
@ -95,7 +94,7 @@ public static class ModManagerEditExtensions
return true;
}
public static bool ChangeModGroup( this Mod.Mod.Manager manager, string oldGroupName, string newGroupName, Mod.Mod mod,
public static bool ChangeModGroup( this Mod.Manager manager, string oldGroupName, string newGroupName, Mod mod,
SelectType type = SelectType.Single )
{
if( newGroupName == oldGroupName || mod.Meta.Groups.ContainsKey( newGroupName ) )
@ -157,7 +156,7 @@ public static class ModManagerEditExtensions
return true;
}
public static bool RemoveModOption( this Mod.Mod.Manager manager, int optionIdx, OptionGroup group, Mod.Mod mod )
public static bool RemoveModOption( this Mod.Manager manager, int optionIdx, OptionGroup group, Mod mod )
{
if( optionIdx < 0 || optionIdx >= group.Options.Count )
{

108
Penumbra/Mods/ModMeta.cs Normal file
View file

@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dalamud.Logging;
using Newtonsoft.Json;
using Penumbra.GameData.ByteString;
using Penumbra.Util;
namespace Penumbra.Mods;
// Contains descriptive data about the mod as well as possible settings and fileswaps.
public class ModMeta
{
public uint FileVersion { get; set; }
public LowerString Name { get; set; } = "Mod";
public LowerString Author { get; set; } = LowerString.Empty;
public string Description { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public string Website { get; set; } = string.Empty;
[JsonProperty( ItemConverterType = typeof( FullPath.FullPathConverter ) )]
public Dictionary< Utf8GamePath, FullPath > FileSwaps { get; set; } = new();
public Dictionary< string, OptionGroup > Groups { get; set; } = new();
[JsonIgnore]
private int FileHash { get; set; }
[JsonIgnore]
public bool HasGroupsWithConfig { get; private set; }
public bool RefreshFromFile( FileInfo filePath )
{
var newMeta = LoadFromFile( filePath );
if( newMeta == null )
{
return true;
}
if( newMeta.FileHash == FileHash )
{
return false;
}
FileVersion = newMeta.FileVersion;
Name = newMeta.Name;
Author = newMeta.Author;
Description = newMeta.Description;
Version = newMeta.Version;
Website = newMeta.Website;
FileSwaps = newMeta.FileSwaps;
Groups = newMeta.Groups;
FileHash = newMeta.FileHash;
HasGroupsWithConfig = newMeta.HasGroupsWithConfig;
return true;
}
public static ModMeta? LoadFromFile( FileInfo filePath )
{
try
{
var text = File.ReadAllText( filePath.FullName );
var meta = JsonConvert.DeserializeObject< ModMeta >( text,
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore } );
if( meta != null )
{
meta.FileHash = text.GetHashCode();
meta.RefreshHasGroupsWithConfig();
}
return meta;
}
catch( Exception e )
{
PluginLog.Error( $"Could not load mod meta:\n{e}" );
return null;
}
}
public bool RefreshHasGroupsWithConfig()
{
var oldValue = HasGroupsWithConfig;
HasGroupsWithConfig = Groups.Values.Any( g => g.Options.Count > 1 || g.SelectionType == SelectType.Multi && g.Options.Count == 1 );
return oldValue != HasGroupsWithConfig;
}
public void SaveToFile( FileInfo filePath )
{
try
{
var text = JsonConvert.SerializeObject( this, Formatting.Indented );
var newHash = text.GetHashCode();
if( newHash != FileHash )
{
File.WriteAllText( filePath.FullName, text );
FileHash = newHash;
}
}
catch( Exception e )
{
PluginLog.Error( $"Could not write meta file for mod {Name} to {filePath.FullName}:\n{e}" );
}
}
}

View file

@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Penumbra.GameData.ByteString;
using Penumbra.Meta;
namespace Penumbra.Mods;
[Flags]
public enum ResourceChange
{
None = 0,
Files = 1,
Meta = 2,
}
// Contains static mod data that should only change on filesystem changes.
public class ModResources
{
public List< FullPath > ModFiles { get; private set; } = new();
public List< FullPath > MetaFiles { get; private set; } = new();
public MetaCollection MetaManipulations { get; private set; } = new();
private void ForceManipulationsUpdate( ModMeta meta, DirectoryInfo basePath )
{
MetaManipulations.Update( MetaFiles, basePath, meta );
MetaManipulations.SaveToFile( MetaCollection.FileName( basePath ) );
}
public void SetManipulations( ModMeta meta, DirectoryInfo basePath, bool validate = true )
{
var newManipulations = MetaCollection.LoadFromFile( MetaCollection.FileName( basePath ) );
if( newManipulations == null )
{
ForceManipulationsUpdate( meta, basePath );
}
else
{
MetaManipulations = newManipulations;
if( validate && !MetaManipulations.Validate( meta ) )
{
ForceManipulationsUpdate( meta, basePath );
}
}
}
// Update the current set of files used by the mod,
// returns true if anything changed.
public ResourceChange RefreshModFiles( DirectoryInfo basePath )
{
List< FullPath > tmpFiles = new(ModFiles.Count);
List< FullPath > tmpMetas = new(MetaFiles.Count);
// we don't care about any _files_ in the root dir, but any folders should be a game folder/file combo
foreach( var file in basePath.EnumerateDirectories()
.SelectMany( dir => dir.EnumerateFiles( "*.*", SearchOption.AllDirectories ) )
.Select( f => new FullPath( f ) )
.OrderBy( f => f.FullName ) )
{
switch( file.Extension.ToLowerInvariant() )
{
case ".meta":
case ".rgsp":
tmpMetas.Add( file );
break;
default:
tmpFiles.Add( file );
break;
}
}
ResourceChange changes = 0;
if( !tmpFiles.Select( f => f.FullName ).SequenceEqual( ModFiles.Select( f => f.FullName ) ) )
{
ModFiles = tmpFiles;
changes |= ResourceChange.Files;
}
if( !tmpMetas.Select( f => f.FullName ).SequenceEqual( MetaFiles.Select( f => f.FullName ) ) )
{
MetaFiles = tmpMetas;
changes |= ResourceChange.Meta;
}
return changes;
}
}

View file

@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Penumbra.Mods;
// Contains the settings for a given mod.
public class ModSettings
{
public bool Enabled { get; set; }
public int Priority { get; set; }
public Dictionary< string, int > Settings { get; set; } = new();
// For backwards compatibility
private Dictionary< string, int > Conf
{
set => Settings = value;
}
public ModSettings DeepCopy()
{
var settings = new ModSettings
{
Enabled = Enabled,
Priority = Priority,
Settings = Settings.ToDictionary( kvp => kvp.Key, kvp => kvp.Value ),
};
return settings;
}
public static ModSettings DefaultSettings( ModMeta meta )
{
return new ModSettings
{
Enabled = false,
Priority = 0,
Settings = meta.Groups.ToDictionary( kvp => kvp.Key, _ => 0 ),
};
}
public bool FixSpecificSetting( string name, ModMeta meta )
{
if( !meta.Groups.TryGetValue( name, out var group ) )
{
return Settings.Remove( name );
}
if( Settings.TryGetValue( name, out var oldSetting ) )
{
Settings[ name ] = group.SelectionType switch
{
SelectType.Single => Math.Min( Math.Max( oldSetting, 0 ), group.Options.Count - 1 ),
SelectType.Multi => Math.Min( Math.Max( oldSetting, 0 ), ( 1 << group.Options.Count ) - 1 ),
_ => Settings[ group.GroupName ],
};
return oldSetting != Settings[ group.GroupName ];
}
Settings[ name ] = 0;
return true;
}
public bool FixInvalidSettings( ModMeta meta )
{
if( meta.Groups.Count == 0 )
{
return false;
}
return Settings.Keys.ToArray().Union( meta.Groups.Keys )
.Aggregate( false, ( current, name ) => current | FixSpecificSetting( name, meta ) );
}
}

View file

@ -0,0 +1,46 @@
using System.Collections.Generic;
using System.Linq;
namespace Penumbra.Mods;
// Contains settings with the option selections stored by names instead of index.
// This is meant to make them possibly more portable when we support importing collections from other users.
// Enabled does not exist, because disabled mods would not be exported in this way.
public class NamedModSettings
{
public int Priority { get; set; }
public Dictionary< string, HashSet< string > > Settings { get; set; } = new();
public void AddFromModSetting( ModSettings s, ModMeta meta )
{
Priority = s.Priority;
Settings = s.Settings.Keys.ToDictionary( k => k, _ => new HashSet< string >() );
foreach( var kvp in Settings )
{
if( !meta.Groups.TryGetValue( kvp.Key, out var info ) )
{
continue;
}
var setting = s.Settings[ kvp.Key ];
if( info.SelectionType == SelectType.Single )
{
var name = setting < info.Options.Count
? info.Options[ setting ].OptionName
: info.Options[ 0 ].OptionName;
kvp.Value.Add( name );
}
else
{
for( var i = 0; i < info.Options.Count; ++i )
{
if( ( ( setting >> i ) & 1 ) != 0 )
{
kvp.Value.Add( info.Options[ i ].OptionName );
}
}
}
}
}
}