diff --git a/OtterGui b/OtterGui index a832fb6c..1a3cd1f8 160000 --- a/OtterGui +++ b/OtterGui @@ -1 +1 @@ -Subproject commit a832fb6ca5e7c6cb4e35a51a08d30d1800f405da +Subproject commit 1a3cd1f881f3b6c2c4d9d4b20f054d1ab5ccc014 diff --git a/Penumbra/Api/PenumbraApi.cs b/Penumbra/Api/PenumbraApi.cs index 8610ac17..cac23ecb 100644 --- a/Penumbra/Api/PenumbraApi.cs +++ b/Penumbra/Api/PenumbraApi.cs @@ -76,7 +76,7 @@ public class PenumbraApi : IDisposable, IPenumbraApi _penumbra!.ObjectReloader.RedrawAll( setting ); } - private static string ResolvePath( string path, Mods.Mod2.Manager _, ModCollection collection ) + private static string ResolvePath( string path, Mods.Mod.Manager _, ModCollection collection ) { if( !Penumbra.Config.EnableMods ) { diff --git a/Penumbra/Api/SimpleRedirectManager.cs b/Penumbra/Api/SimpleRedirectManager.cs index 1c633a65..6b4a45b3 100644 --- a/Penumbra/Api/SimpleRedirectManager.cs +++ b/Penumbra/Api/SimpleRedirectManager.cs @@ -48,7 +48,7 @@ public class SimpleRedirectManager return RedirectResult.NoPermission; } - if( Mod2.FilterFile( path ) ) + if( Mod.FilterFile( path ) ) { return RedirectResult.FilteredGamePath; } diff --git a/Penumbra/Collections/CollectionManager.Active.cs b/Penumbra/Collections/CollectionManager.Active.cs index 8674d5a3..fe396ccd 100644 --- a/Penumbra/Collections/CollectionManager.Active.cs +++ b/Penumbra/Collections/CollectionManager.Active.cs @@ -275,7 +275,7 @@ public partial class ModCollection } } - private void OnModRemovedActive( bool meta, IEnumerable< ModSettings2? > settings ) + private void OnModRemovedActive( bool meta, IEnumerable< ModSettings? > settings ) { foreach( var (collection, _) in this.Zip( settings ).Where( c => c.First.HasCache && c.Second?.Enabled == true ) ) { diff --git a/Penumbra/Collections/CollectionManager.cs b/Penumbra/Collections/CollectionManager.cs index a1766e96..03b51406 100644 --- a/Penumbra/Collections/CollectionManager.cs +++ b/Penumbra/Collections/CollectionManager.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using Dalamud.Logging; +using OtterGui.Filesystem; using Penumbra.Mods; using Penumbra.Util; @@ -27,7 +28,7 @@ public partial class ModCollection public delegate void CollectionChangeDelegate( Type type, ModCollection? oldCollection, ModCollection? newCollection, string? characterName = null ); - private readonly Mod2.Manager _modManager; + private readonly Mod.Manager _modManager; // The empty collection is always available and always has index 0. // It can not be deleted or moved. @@ -59,7 +60,7 @@ public partial class ModCollection public IEnumerable< ModCollection > GetEnumeratorWithEmpty() => _collections; - public Manager( Mod2.Manager manager ) + public Manager( Mod.Manager manager ) { _modManager = manager; @@ -207,7 +208,7 @@ public partial class ModCollection // A changed mod path forces changes for all collections, active and inactive. - private void OnModPathChanged( ModPathChangeType type, Mod2 mod, DirectoryInfo? oldDirectory, + private void OnModPathChanged( ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, DirectoryInfo? newDirectory ) { switch( type ) @@ -221,10 +222,10 @@ public partial class ModCollection OnModAddedActive( mod.TotalManipulations > 0 ); break; case ModPathChangeType.Deleted: - var settings = new List< ModSettings2? >( _collections.Count ); + var settings = new List< ModSettings? >( _collections.Count ); foreach( var collection in this ) { - settings.Add( collection[ mod.Index ].Settings ); + settings.Add( collection._settings[ mod.Index ] ); collection.RemoveMod( mod, mod.Index ); } @@ -242,26 +243,50 @@ public partial class ModCollection } } - - private void OnModOptionsChanged( ModOptionChangeType type, Mod2 mod, int groupIdx, int optionIdx ) + // Automatically update all relevant collections when a mod is changed. + // This means saving if options change in a way where the settings may change and the collection has settings for this mod. + // And also updating effective file and meta manipulation lists if necessary. + private void OnModOptionsChanged( ModOptionChangeType type, Mod mod, int groupIdx, int optionIdx, int movedToIdx ) { - if( type == ModOptionChangeType.DisplayChange ) + var (handleChanges, recomputeList, withMeta) = type switch { - return; + ModOptionChangeType.GroupRenamed => ( true, false, false ), + ModOptionChangeType.GroupAdded => ( true, false, false ), + ModOptionChangeType.GroupDeleted => ( true, true, true ), + ModOptionChangeType.GroupMoved => ( true, false, false ), + ModOptionChangeType.GroupTypeChanged => ( true, true, true ), + ModOptionChangeType.PriorityChanged => ( true, true, true ), + ModOptionChangeType.OptionAdded => ( true, true, true ), + ModOptionChangeType.OptionDeleted => ( true, true, true ), + ModOptionChangeType.OptionMoved => ( true, false, false ), + ModOptionChangeType.OptionFilesChanged => ( false, true, false ), + ModOptionChangeType.OptionSwapsChanged => ( false, true, false ), + ModOptionChangeType.OptionMetaChanged => ( false, true, true ), + ModOptionChangeType.OptionUpdated => ( false, true, true ), + ModOptionChangeType.DisplayChange => ( false, false, false ), + _ => ( false, false, false ), + }; + + if( handleChanges ) + { + foreach( var collection in this ) + { + if( collection._settings[ mod.Index ]?.HandleChanges( type, mod, groupIdx, optionIdx, movedToIdx ) ?? false ) + { + collection.Save(); + } + } } - // TODO - switch( type ) + if( recomputeList ) { - case ModOptionChangeType.GroupRenamed: - case ModOptionChangeType.GroupAdded: - case ModOptionChangeType.GroupDeleted: - case ModOptionChangeType.PriorityChanged: - case ModOptionChangeType.OptionAdded: - case ModOptionChangeType.OptionDeleted: - case ModOptionChangeType.OptionChanged: - default: - throw new ArgumentOutOfRangeException( nameof( type ), type, null ); + foreach( var collection in this.Where( c => c.HasCache ) ) + { + if( collection[ mod.Index ].Settings is { Enabled: true } ) + { + collection.CalculateEffectiveFileList( withMeta, collection == Penumbra.CollectionManager.Default ); + } + } } } diff --git a/Penumbra/Collections/ConflictCache.cs b/Penumbra/Collections/ConflictCache.cs index 1891a8bb..7ff26f76 100644 --- a/Penumbra/Collections/ConflictCache.cs +++ b/Penumbra/Collections/ConflictCache.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using System.Linq; +using OtterGui.Classes; using Penumbra.GameData.ByteString; using Penumbra.Meta.Manipulations; @@ -73,9 +73,16 @@ public struct ConflictCache } // Find all mod conflicts concerning the specified mod (in both directions). - public IEnumerable< Conflict > ModConflicts( int modIdx ) + public SubList< Conflict > ModConflicts( int modIdx ) { - return _conflicts.SkipWhile( c => c.Mod1 < modIdx ).TakeWhile( c => c.Mod1 == modIdx ); + var start = _conflicts.FindIndex( c => c.Mod1 == modIdx ); + if( start < 0 ) + { + return SubList< Conflict >.Empty; + } + + var end = _conflicts.FindIndex( start, c => c.Mod1 != modIdx ); + return new SubList< Conflict >( _conflicts, start, end - start ); } private void Sort() diff --git a/Penumbra/Collections/ModCollection.Cache.Access.cs b/Penumbra/Collections/ModCollection.Cache.Access.cs index 8e58762a..168dbed5 100644 --- a/Penumbra/Collections/ModCollection.Cache.Access.cs +++ b/Penumbra/Collections/ModCollection.Cache.Access.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using Dalamud.Logging; +using OtterGui.Classes; using Penumbra.GameData.ByteString; using Penumbra.Meta.Manager; @@ -64,8 +65,8 @@ public partial class ModCollection internal IReadOnlyList< ConflictCache.Conflict > Conflicts => _cache?.Conflicts.Conflicts ?? Array.Empty< ConflictCache.Conflict >(); - internal IEnumerable< ConflictCache.Conflict > ModConflicts( int modIdx ) - => _cache?.Conflicts.ModConflicts( modIdx ) ?? Array.Empty< ConflictCache.Conflict >(); + internal SubList< ConflictCache.Conflict > ModConflicts( int modIdx ) + => _cache?.Conflicts.ModConflicts( modIdx ) ?? SubList< ConflictCache.Conflict >.Empty; // Update the effective file list for the given cache. // Creates a cache if necessary. diff --git a/Penumbra/Collections/ModCollection.Cache.cs b/Penumbra/Collections/ModCollection.Cache.cs index 6de9072c..5283ec4f 100644 --- a/Penumbra/Collections/ModCollection.Cache.cs +++ b/Penumbra/Collections/ModCollection.Cache.cs @@ -18,7 +18,7 @@ public partial class ModCollection // Shared caches to avoid allocations. private static readonly Dictionary< Utf8GamePath, FileRegister > RegisteredFiles = new(1024); private static readonly Dictionary< MetaManipulation, FileRegister > RegisteredManipulations = new(1024); - private static readonly List< ModSettings2? > ResolvedSettings = new(128); + private static readonly List< ModSettings? > ResolvedSettings = new(128); private readonly ModCollection _collection; private readonly SortedList< string, object? > _changedItems = new(); @@ -225,7 +225,7 @@ public partial class ModCollection foreach( var (path, file) in mod.Files.Concat( mod.FileSwaps ) ) { // Skip all filtered files - if( Mod2.FilterFile( path ) ) + if( Mod.FilterFile( path ) ) { continue; } @@ -257,6 +257,11 @@ public partial class ModCollection { var config = settings.Settings[ idx ]; var group = mod.Groups[ idx ]; + if( group.Count == 0 ) + { + continue; + } + switch( group.Type ) { case SelectType.Single: diff --git a/Penumbra/Collections/ModCollection.Changes.cs b/Penumbra/Collections/ModCollection.Changes.cs index 4b350758..cbf0b09d 100644 --- a/Penumbra/Collections/ModCollection.Changes.cs +++ b/Penumbra/Collections/ModCollection.Changes.cs @@ -46,7 +46,7 @@ public partial class ModCollection } // Enable or disable the mod inheritance of every mod in mods. - public void SetMultipleModInheritances( IEnumerable< Mod2 > mods, bool inherit ) + public void SetMultipleModInheritances( IEnumerable< Mod > mods, bool inherit ) { if( mods.Aggregate( false, ( current, mod ) => current | FixInheritance( mod.Index, inherit ) ) ) { @@ -56,7 +56,7 @@ public partial class ModCollection // Set the enabled state of every mod in mods to the new value. // If the mod is currently inherited, stop the inheritance. - public void SetMultipleModStates( IEnumerable< Mod2 > mods, bool newValue ) + public void SetMultipleModStates( IEnumerable< Mod > mods, bool newValue ) { var changes = false; foreach( var mod in mods ) @@ -137,7 +137,7 @@ public partial class ModCollection return false; } - _settings[ idx ] = inherit ? null : this[ idx ].Settings?.DeepCopy() ?? ModSettings2.DefaultSettings( Penumbra.ModManager.Mods[ idx ] ); + _settings[ idx ] = inherit ? null : this[ idx ].Settings?.DeepCopy() ?? ModSettings.DefaultSettings( Penumbra.ModManager.Mods[ idx ] ); return true; } diff --git a/Penumbra/Collections/ModCollection.File.cs b/Penumbra/Collections/ModCollection.File.cs index 0311e0b0..262208b4 100644 --- a/Penumbra/Collections/ModCollection.File.cs +++ b/Penumbra/Collections/ModCollection.File.cs @@ -6,8 +6,8 @@ using System.Text; using Dalamud.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using OtterGui.Filesystem; using Penumbra.Mods; -using Penumbra.Util; namespace Penumbra.Collections; @@ -48,7 +48,7 @@ public partial class ModCollection if( settings != null ) { j.WritePropertyName( Penumbra.ModManager[ i ].BasePath.Name ); - x.Serialize( j, new ModSettings2.SavedSettings( settings, Penumbra.ModManager[ i ] ) ); + x.Serialize( j, new ModSettings.SavedSettings( settings, Penumbra.ModManager[ i ] ) ); } } @@ -111,8 +111,8 @@ public partial class ModCollection var name = obj[ nameof( Name ) ]?.ToObject< string >() ?? string.Empty; var version = obj[ nameof( Version ) ]?.ToObject< int >() ?? 0; // Custom deserialization that is converted with the constructor. - var settings = obj[ nameof( Settings ) ]?.ToObject< Dictionary< string, ModSettings2.SavedSettings > >() - ?? new Dictionary< string, ModSettings2.SavedSettings >(); + var settings = obj[ nameof( Settings ) ]?.ToObject< Dictionary< string, ModSettings.SavedSettings > >() + ?? new Dictionary< string, ModSettings.SavedSettings >(); inheritance = obj[ nameof( Inheritance ) ]?.ToObject< List< string > >() ?? ( IReadOnlyList< string > )Array.Empty< string >(); return new ModCollection( name, version, settings ); diff --git a/Penumbra/Collections/ModCollection.Inheritance.cs b/Penumbra/Collections/ModCollection.Inheritance.cs index b9c75d9f..dfa2ce0f 100644 --- a/Penumbra/Collections/ModCollection.Inheritance.cs +++ b/Penumbra/Collections/ModCollection.Inheritance.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using OtterGui.Filesystem; using Penumbra.Mods; using Penumbra.Util; @@ -119,7 +120,7 @@ public partial class ModCollection // Obtain the actual settings for a given mod via index. // Also returns the collection the settings are taken from. // If no collection provides settings for this mod, this collection is returned together with null. - public (ModSettings2? Settings, ModCollection Collection) this[ Index idx ] + public (ModSettings? Settings, ModCollection Collection) this[ Index idx ] { get { diff --git a/Penumbra/Collections/ModCollection.Migration.cs b/Penumbra/Collections/ModCollection.Migration.cs index 4bfcccda..74215dd8 100644 --- a/Penumbra/Collections/ModCollection.Migration.cs +++ b/Penumbra/Collections/ModCollection.Migration.cs @@ -45,13 +45,13 @@ public sealed partial class ModCollection } // We treat every completely defaulted setting as inheritance-ready. - private static bool SettingIsDefaultV0( ModSettings2.SavedSettings setting ) + private static bool SettingIsDefaultV0( ModSettings.SavedSettings setting ) => setting is { Enabled: false, Priority: 0 } && setting.Settings.Values.All( s => s == 0 ); - private static bool SettingIsDefaultV0( ModSettings2? setting ) + private static bool SettingIsDefaultV0( ModSettings? setting ) => setting is { Enabled: false, Priority: 0 } && setting.Settings.All( s => s == 0 ); } - internal static ModCollection MigrateFromV0( string name, Dictionary< string, ModSettings2.SavedSettings > allSettings ) + internal static ModCollection MigrateFromV0( string name, Dictionary< string, ModSettings.SavedSettings > allSettings ) => new(name, 0, allSettings); } \ No newline at end of file diff --git a/Penumbra/Collections/ModCollection.cs b/Penumbra/Collections/ModCollection.cs index ce3df492..a540272f 100644 --- a/Penumbra/Collections/ModCollection.cs +++ b/Penumbra/Collections/ModCollection.cs @@ -27,17 +27,17 @@ public partial class ModCollection // If a ModSetting is null, it can be inherited from other collections. // If no collection provides a setting for the mod, it is just disabled. - private readonly List< ModSettings2? > _settings; + private readonly List< ModSettings? > _settings; - public IReadOnlyList< ModSettings2? > Settings + public IReadOnlyList< ModSettings? > Settings => _settings; // Evaluates the settings along the whole inheritance tree. - public IEnumerable< ModSettings2? > ActualSettings + public IEnumerable< ModSettings? > ActualSettings => Enumerable.Range( 0, _settings.Count ).Select( i => this[ i ].Settings ); // Settings for deleted mods will be kept via directory name. - private readonly Dictionary< string, ModSettings2.SavedSettings > _unusedSettings; + private readonly Dictionary< string, ModSettings.SavedSettings > _unusedSettings; // Constructor for duplication. private ModCollection( string name, ModCollection duplicate ) @@ -52,13 +52,13 @@ public partial class ModCollection } // Constructor for reading from files. - private ModCollection( string name, int version, Dictionary< string, ModSettings2.SavedSettings > allSettings ) + private ModCollection( string name, int version, Dictionary< string, ModSettings.SavedSettings > allSettings ) { Name = name; Version = version; _unusedSettings = allSettings; - _settings = new List< ModSettings2? >(); + _settings = new List< ModSettings? >(); ApplyModSettings(); Migration.Migrate( this ); @@ -68,7 +68,7 @@ public partial class ModCollection // Create a new, unique empty collection of a given name. public static ModCollection CreateNewEmpty( string name ) - => new(name, CurrentVersion, new Dictionary< string, ModSettings2.SavedSettings >()); + => new(name, CurrentVersion, new Dictionary< string, ModSettings.SavedSettings >()); // Duplicate the calling collection to a new, unique collection of a given name. public ModCollection Duplicate( string name ) @@ -86,7 +86,7 @@ public partial class ModCollection } // Add settings for a new appended mod, by checking if the mod had settings from a previous deletion. - private bool AddMod( Mod2 mod ) + private bool AddMod( Mod mod ) { if( _unusedSettings.TryGetValue( mod.BasePath.Name, out var save ) ) { @@ -101,12 +101,12 @@ public partial class ModCollection } // Move settings from the current mod list to the unused mod settings. - private void RemoveMod( Mod2 mod, int idx ) + private void RemoveMod( Mod mod, int idx ) { var settings = _settings[ idx ]; if( settings != null ) { - _unusedSettings.Add( mod.BasePath.Name, new ModSettings2.SavedSettings( settings, mod ) ); + _unusedSettings.Add( mod.BasePath.Name, new ModSettings.SavedSettings( settings, mod ) ); } _settings.RemoveAt( idx ); @@ -127,7 +127,7 @@ public partial class ModCollection { foreach( var (mod, setting) in Penumbra.ModManager.Zip( _settings ).Where( s => s.Second != null ) ) { - _unusedSettings[ mod.BasePath.Name ] = new ModSettings2.SavedSettings( setting!, mod ); + _unusedSettings[ mod.BasePath.Name ] = new ModSettings.SavedSettings( setting!, mod ); } _settings.Clear(); diff --git a/Penumbra/Import/ImporterState.cs b/Penumbra/Import/ImporterState.cs new file mode 100644 index 00000000..5a9476e6 --- /dev/null +++ b/Penumbra/Import/ImporterState.cs @@ -0,0 +1,9 @@ +namespace Penumbra.Import; + +public enum ImporterState +{ + None, + WritingPackToDisk, + ExtractingModFiles, + Done, +} \ No newline at end of file diff --git a/Penumbra/Import/MetaFileInfo.cs b/Penumbra/Import/MetaFileInfo.cs new file mode 100644 index 00000000..5393fa6c --- /dev/null +++ b/Penumbra/Import/MetaFileInfo.cs @@ -0,0 +1,126 @@ +using System.Text.RegularExpressions; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Util; + +namespace Penumbra.Import; + +// Obtain information what type of object is manipulated +// by the given .meta file from TexTools, using its name. +public class MetaFileInfo +{ + private const string Pt = @"(?'PrimaryType'[a-z]*)"; // language=regex + private const string Pp = @"(?'PrimaryPrefix'[a-z])"; // language=regex + private const string Pi = @"(?'PrimaryId'\d{4})"; // language=regex + private const string Pir = @"\k'PrimaryId'"; // language=regex + private const string St = @"(?'SecondaryType'[a-z]*)"; // language=regex + private const string Sp = @"(?'SecondaryPrefix'[a-z])"; // language=regex + private const string Si = @"(?'SecondaryId'\d{4})"; // language=regex + private const string File = @"\k'PrimaryPrefix'\k'PrimaryId'(\k'SecondaryPrefix'\k'SecondaryId')?"; // language=regex + private const string Slot = @"(_(?'Slot'[a-z]{3}))?"; // language=regex + private const string Ext = @"\.meta"; + + // These are the valid regexes for .meta files that we are able to support at the moment. + private static readonly Regex HousingMeta = new($"bgcommon/hou/{Pt}/general/{Pi}/{Pir}{Ext}", RegexOptions.Compiled); + private static readonly Regex CharaMeta = new($"chara/{Pt}/{Pp}{Pi}(/obj/{St}/{Sp}{Si})?/{File}{Slot}{Ext}", RegexOptions.Compiled); + + public readonly ObjectType PrimaryType; + public readonly BodySlot SecondaryType; + public readonly ushort PrimaryId; + public readonly ushort SecondaryId; + public readonly EquipSlot EquipSlot = EquipSlot.Unknown; + public readonly CustomizationType CustomizationType = CustomizationType.Unknown; + + private static bool ValidType( ObjectType type ) + { + return type switch + { + ObjectType.Accessory => true, + ObjectType.Character => true, + ObjectType.Equipment => true, + ObjectType.DemiHuman => true, + ObjectType.Housing => true, + ObjectType.Monster => true, + ObjectType.Weapon => true, + ObjectType.Icon => false, + ObjectType.Font => false, + ObjectType.Interface => false, + ObjectType.LoadingScreen => false, + ObjectType.Map => false, + ObjectType.Vfx => false, + ObjectType.Unknown => false, + ObjectType.World => false, + _ => false, + }; + } + + public MetaFileInfo( string fileName ) + : this( new GamePath( fileName ) ) + { } + + public MetaFileInfo( GamePath fileName ) + { + // Set the primary type from the gamePath start. + PrimaryType = GameData.GameData.GetGamePathParser().PathToObjectType( fileName ); + PrimaryId = 0; + SecondaryType = BodySlot.Unknown; + SecondaryId = 0; + // Not all types of objects can have valid meta data manipulation. + if( !ValidType( PrimaryType ) ) + { + PrimaryType = ObjectType.Unknown; + return; + } + + // Housing files have a separate regex that just contains the primary id. + if( PrimaryType == ObjectType.Housing ) + { + var housingMatch = HousingMeta.Match( fileName ); + if( housingMatch.Success ) + { + PrimaryId = ushort.Parse( housingMatch.Groups[ "PrimaryId" ].Value ); + } + + return; + } + + // Non-housing is in chara/. + var match = CharaMeta.Match( fileName ); + if( !match.Success ) + { + return; + } + + // The primary ID has to be available for every object. + PrimaryId = ushort.Parse( match.Groups[ "PrimaryId" ].Value ); + + // Depending on slot, we can set equip slot or customization type. + if( match.Groups[ "Slot" ].Success ) + { + switch( PrimaryType ) + { + case ObjectType.Equipment: + case ObjectType.Accessory: + if( Names.SuffixToEquipSlot.TryGetValue( match.Groups[ "Slot" ].Value, out var tmpSlot ) ) + { + EquipSlot = tmpSlot; + } + + break; + case ObjectType.Character: + if( Names.SuffixToCustomizationType.TryGetValue( match.Groups[ "Slot" ].Value, out var tmpCustom ) ) + { + CustomizationType = tmpCustom; + } + + break; + } + } + + // Secondary type and secondary id are for weapons and demihumans. + if( match.Groups[ "SecondaryType" ].Success + && Names.StringToBodySlot.TryGetValue( match.Groups[ "SecondaryType" ].Value, out SecondaryType ) ) + { + SecondaryId = ushort.Parse( match.Groups[ "SecondaryId" ].Value ); + } + } +} \ No newline at end of file diff --git a/Penumbra/Import/StreamDisposer.cs b/Penumbra/Import/StreamDisposer.cs new file mode 100644 index 00000000..09300ed1 --- /dev/null +++ b/Penumbra/Import/StreamDisposer.cs @@ -0,0 +1,25 @@ +using System; +using System.IO; +using Penumbra.Util; + +namespace Penumbra.Import; + +// Create an automatically disposing SqPack stream. +public class StreamDisposer : PenumbraSqPackStream, IDisposable +{ + private readonly FileStream _fileStream; + + public StreamDisposer( FileStream stream ) + : base( stream ) + => _fileStream = stream; + + public new void Dispose() + { + var filePath = _fileStream.Name; + + base.Dispose(); + _fileStream.Dispose(); + + File.Delete( filePath ); + } +} \ No newline at end of file diff --git a/Penumbra/Import/TexToolsImport.cs b/Penumbra/Import/TexToolsImport.cs new file mode 100644 index 00000000..ed4e6cd1 --- /dev/null +++ b/Penumbra/Import/TexToolsImport.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Dalamud.Logging; +using ICSharpCode.SharpZipLib.Zip; +using Newtonsoft.Json; +using Penumbra.Util; +using FileMode = System.IO.FileMode; + +namespace Penumbra.Import; + +public partial class TexToolsImporter +{ + private const string TempFileName = "textools-import"; + private static readonly JsonSerializerSettings JsonSettings = new() { NullValueHandling = NullValueHandling.Ignore }; + + private readonly DirectoryInfo _baseDirectory; + private readonly string _tmpFile; + + private readonly IEnumerable< FileInfo > _modPackFiles; + private readonly int _modPackCount; + + public ImporterState State { get; private set; } + public readonly List< (FileInfo File, DirectoryInfo? Mod, Exception? Error) > ExtractedMods; + + public TexToolsImporter( DirectoryInfo baseDirectory, ICollection< FileInfo > files ) + : this( baseDirectory, files.Count, files ) + { } + + public TexToolsImporter( DirectoryInfo baseDirectory, int count, IEnumerable< FileInfo > modPackFiles ) + { + _baseDirectory = baseDirectory; + _tmpFile = Path.Combine( _baseDirectory.FullName, TempFileName ); + _modPackFiles = modPackFiles; + _modPackCount = count; + ExtractedMods = new List< (FileInfo, DirectoryInfo?, Exception?) >( count ); + Task.Run( ImportFiles ); + } + + private void ImportFiles() + { + State = ImporterState.None; + _currentModPackIdx = 0; + foreach( var file in _modPackFiles ) + { + try + { + var directory = VerifyVersionAndImport( file ); + ExtractedMods.Add( ( file, directory, null ) ); + } + catch( Exception e ) + { + ExtractedMods.Add( ( file, null, e ) ); + _currentNumOptions = 0; + _currentOptionIdx = 0; + _currentFileIdx = 0; + _currentNumFiles = 0; + } + + ++_currentModPackIdx; + } + + State = ImporterState.Done; + } + + // Rudimentary analysis of a TTMP file by extension and version. + // Puts out warnings if extension does not correspond to data. + private DirectoryInfo VerifyVersionAndImport( FileInfo modPackFile ) + { + using var zfs = modPackFile.OpenRead(); + using var extractedModPack = new ZipFile( zfs ); + + var mpl = FindZipEntry( extractedModPack, "TTMPL.mpl" ); + if( mpl == null ) + { + throw new FileNotFoundException( "ZIP does not contain a TTMPL.mpl file." ); + } + + var modRaw = GetStringFromZipEntry( extractedModPack, mpl, Encoding.UTF8 ); + + // At least a better validation than going by the extension. + if( modRaw.Contains( "\"TTMPVersion\":" ) ) + { + if( modPackFile.Extension != ".ttmp2" ) + { + PluginLog.Warning( $"File {modPackFile.FullName} seems to be a V2 TTMP, but has the wrong extension." ); + } + + return ImportV2ModPack( _: modPackFile, extractedModPack, modRaw ); + } + + if( modPackFile.Extension != ".ttmp" ) + { + PluginLog.Warning( $"File {modPackFile.FullName} seems to be a V1 TTMP, but has the wrong extension." ); + } + + return ImportV1ModPack( modPackFile, extractedModPack, modRaw ); + } + + + // You can in no way rely on any file paths in TTMPs so we need to just do this, sorry + private static ZipEntry? FindZipEntry( ZipFile file, string fileName ) + { + for( var i = 0; i < file.Count; i++ ) + { + var entry = file[ i ]; + + if( entry.Name.Contains( fileName ) ) + { + return entry; + } + } + + return null; + } + + private static Stream GetStreamFromZipEntry( ZipFile file, ZipEntry entry ) + => file.GetInputStream( entry ); + + private static string GetStringFromZipEntry( ZipFile file, ZipEntry entry, Encoding encoding ) + { + using var ms = new MemoryStream(); + using var s = GetStreamFromZipEntry( file, entry ); + s.CopyTo( ms ); + return encoding.GetString( ms.ToArray() ); + } + + private void WriteZipEntryToTempFile( Stream s ) + { + using var fs = new FileStream( _tmpFile, FileMode.Create ); + s.CopyTo( fs ); + } + + private PenumbraSqPackStream GetSqPackStreamStream( ZipFile file, string entryName ) + { + State = ImporterState.WritingPackToDisk; + + // write shitty zip garbage to disk + var entry = FindZipEntry( file, entryName ); + if( entry == null ) + { + throw new FileNotFoundException( $"ZIP does not contain a file named {entryName}." ); + } + + using var s = file.GetInputStream( entry ); + + WriteZipEntryToTempFile( s ); + + var fs = new FileStream( _tmpFile, FileMode.Open ); + return new StreamDisposer( fs ); + } +} \ No newline at end of file diff --git a/Penumbra/Import/TexToolsImporter.Gui.cs b/Penumbra/Import/TexToolsImporter.Gui.cs new file mode 100644 index 00000000..e510b149 --- /dev/null +++ b/Penumbra/Import/TexToolsImporter.Gui.cs @@ -0,0 +1,94 @@ +using System.Linq; +using System.Numerics; +using ImGuiNET; +using OtterGui; +using OtterGui.Raii; +using Penumbra.UI.Classes; + +namespace Penumbra.Import; + +public partial class TexToolsImporter +{ + // Progress Data + private int _currentModPackIdx; + private int _currentOptionIdx; + private int _currentFileIdx; + + private int _currentNumOptions; + private int _currentNumFiles; + private string _currentModName = string.Empty; + private string _currentGroupName = string.Empty; + private string _currentOptionName = string.Empty; + private string _currentFileName = string.Empty; + + + public void DrawProgressInfo( Vector2 size ) + { + if( _modPackCount == 0 ) + { + ImGuiUtil.Center( "Nothing to extract." ); + } + else if( _modPackCount == _currentModPackIdx ) + { + DrawEndState(); + } + else + { + ImGui.NewLine(); + var percentage = _modPackCount / ( float )_currentModPackIdx; + ImGui.ProgressBar( percentage, size, $"Mod {_currentModPackIdx + 1} / {_modPackCount}" ); + ImGui.NewLine(); + ImGui.Text( $"Extracting {_currentModName}..." ); + + if( _currentNumOptions > 1 ) + { + ImGui.NewLine(); + ImGui.NewLine(); + percentage = _currentNumOptions == 0 ? 1f : _currentOptionIdx / ( float )_currentNumOptions; + ImGui.ProgressBar( percentage, size, $"Option {_currentOptionIdx + 1} / {_currentNumOptions}" ); + ImGui.NewLine(); + ImGui.Text( + $"Extracting option {( _currentGroupName.Length == 0 ? string.Empty : $"{_currentGroupName} - " )}{_currentOptionName}..." ); + } + + ImGui.NewLine(); + ImGui.NewLine(); + percentage = _currentNumFiles == 0 ? 1f : _currentFileIdx / ( float )_currentNumFiles; + ImGui.ProgressBar( percentage, size, $"File {_currentFileIdx + 1} / {_currentNumFiles}" ); + ImGui.NewLine(); + ImGui.Text( $"Extracting file {_currentFileName}..." ); + } + } + + + private void DrawEndState() + { + var success = ExtractedMods.Count( t => t.Mod != null ); + + ImGui.Text( $"Successfully extracted {success} / {ExtractedMods.Count} files." ); + ImGui.NewLine(); + using var table = ImRaii.Table( "##files", 2 ); + if( !table ) + { + return; + } + + foreach( var (file, dir, ex) in ExtractedMods ) + { + ImGui.TableNextColumn(); + ImGui.Text( file.Name ); + ImGui.TableNextColumn(); + if( dir != null ) + { + using var color = ImRaii.PushColor( ImGuiCol.Text, ColorId.FolderExpanded.Value() ); + ImGui.Text( dir.FullName[ ( _baseDirectory.FullName.Length + 1 ).. ] ); + } + else + { + using var color = ImRaii.PushColor( ImGuiCol.Text, ColorId.ConflictingMod.Value() ); + ImGui.Text( ex!.Message ); + ImGuiUtil.HoverTooltip( ex.ToString() ); + } + } + } +} \ No newline at end of file diff --git a/Penumbra/Import/TexToolsImporter.ModPack.cs b/Penumbra/Import/TexToolsImporter.ModPack.cs new file mode 100644 index 00000000..5ac31a35 --- /dev/null +++ b/Penumbra/Import/TexToolsImporter.ModPack.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Dalamud.Logging; +using ICSharpCode.SharpZipLib.Zip; +using Newtonsoft.Json; +using Penumbra.Mods; +using Penumbra.Util; + +namespace Penumbra.Import; + +public partial class TexToolsImporter +{ + // Version 1 mod packs are a simple collection of files without much information. + private DirectoryInfo ImportV1ModPack( FileInfo modPackFile, ZipFile extractedModPack, string modRaw ) + { + _currentOptionIdx = 0; + _currentNumOptions = 1; + _currentModName = modPackFile.Name.Length > 0 ? modPackFile.Name : DefaultTexToolsData.Name; + _currentGroupName = string.Empty; + _currentOptionName = DefaultTexToolsData.DefaultOption; + + PluginLog.Log( " -> Importing V1 ModPack" ); + + var modListRaw = modRaw.Split( + new[] { "\r\n", "\r", "\n" }, + StringSplitOptions.RemoveEmptyEntries + ); + + var modList = modListRaw.Select( m => JsonConvert.DeserializeObject< SimpleMod >( m, JsonSettings )! ).ToList(); + + // Open the mod data file from the mod pack as a SqPackStream + using var modData = GetSqPackStreamStream( extractedModPack, "TTMPD.mpd" ); + + var ret = Mod.CreateModFolder( _baseDirectory, Path.GetFileNameWithoutExtension( modPackFile.Name ) ); + // Create a new ModMeta from the TTMP mod list info + Mod.CreateMeta( ret, _currentModName, DefaultTexToolsData.Author, DefaultTexToolsData.Description, null, null ); + + ExtractSimpleModList( ret, modList, modData ); + + return ret; + } + + // Version 2 mod packs can either be simple or extended, import accordingly. + private DirectoryInfo ImportV2ModPack( FileInfo _, ZipFile extractedModPack, string modRaw ) + { + var modList = JsonConvert.DeserializeObject< SimpleModPack >( modRaw, JsonSettings )!; + + if( modList.TtmpVersion.EndsWith( "s" ) ) + { + return ImportSimpleV2ModPack( extractedModPack, modList ); + } + + if( modList.TtmpVersion.EndsWith( "w" ) ) + { + return ImportExtendedV2ModPack( extractedModPack, modRaw ); + } + + try + { + PluginLog.Warning( $"Unknown TTMPVersion <{modList.TtmpVersion}> given, trying to export as simple mod pack." ); + return ImportSimpleV2ModPack( extractedModPack, modList ); + } + catch( Exception e1 ) + { + PluginLog.Warning( $"Exporting as simple mod pack failed with following error, retrying as extended mod pack:\n{e1}" ); + try + { + return ImportExtendedV2ModPack( extractedModPack, modRaw ); + } + catch( Exception e2 ) + { + throw new IOException( "Exporting as extended mod pack failed, too. Version unsupported or file defect.", e2 ); + } + } + } + + // Simple V2 mod packs are basically the same as V1 mod packs. + private DirectoryInfo ImportSimpleV2ModPack( ZipFile extractedModPack, SimpleModPack modList ) + { + _currentOptionIdx = 0; + _currentNumOptions = 1; + _currentModName = modList.Name; + _currentGroupName = string.Empty; + _currentOptionName = DefaultTexToolsData.DefaultOption; + PluginLog.Log( " -> Importing Simple V2 ModPack" ); + + // Open the mod data file from the mod pack as a SqPackStream + using var modData = GetSqPackStreamStream( extractedModPack, "TTMPD.mpd" ); + + var ret = Mod.CreateModFolder( _baseDirectory, _currentModName ); + Mod.CreateMeta( ret, _currentModName, modList.Author, string.IsNullOrEmpty( modList.Description ) + ? "Mod imported from TexTools mod pack" + : modList.Description, null, null ); + + ExtractSimpleModList( ret, modList.SimpleModsList, modData ); + return ret; + } + + // Obtain the number of relevant options to extract. + private static int GetOptionCount( ExtendedModPack pack ) + => ( pack.SimpleModsList.Length > 0 ? 1 : 0 ) + + pack.ModPackPages + .Sum( page => page.ModGroups + .Where( g => g.GroupName.Length > 0 && g.OptionList.Length > 0 ) + .Sum( group => group.OptionList + .Count( o => o.Name.Length > 0 && o.ModsJsons.Length > 0 ) ) ); + + // Extended V2 mod packs contain multiple options that need to be handled separately. + private DirectoryInfo ImportExtendedV2ModPack( ZipFile extractedModPack, string modRaw ) + { + _currentOptionIdx = 0; + PluginLog.Log( " -> Importing Extended V2 ModPack" ); + + var modList = JsonConvert.DeserializeObject< ExtendedModPack >( modRaw, JsonSettings )!; + _currentNumOptions = GetOptionCount( modList ); + _currentModName = modList.Name; + // Open the mod data file from the mod pack as a SqPackStream + using var modData = GetSqPackStreamStream( extractedModPack, "TTMPD.mpd" ); + + var ret = Mod.CreateModFolder( _baseDirectory, _currentModName ); + Mod.CreateMeta( ret, _currentModName, modList.Author, modList.Description, modList.Version, null ); + + if( _currentNumOptions == 0 ) + { + return ret; + } + + // It can contain a simple list, still. + if( modList.SimpleModsList.Length > 0 ) + { + _currentGroupName = string.Empty; + _currentOptionName = "Default"; + ExtractSimpleModList( ret, modList.SimpleModsList, modData ); + } + + // Iterate through all pages + var options = new List< ISubMod >(); + var groupPriority = 0; + foreach( var page in modList.ModPackPages ) + { + foreach( var group in page.ModGroups.Where( group => group.GroupName.Length > 0 && group.OptionList.Length > 0 ) ) + { + _currentGroupName = group.GroupName; + options.Clear(); + var description = new StringBuilder(); + var groupFolder = Mod.NewSubFolderName( ret, group.GroupName ) + ?? new DirectoryInfo( Path.Combine( ret.FullName, $"Group {groupPriority + 1}" ) ); + + var optionIdx = 1; + + foreach( var option in group.OptionList.Where( option => option.Name.Length > 0 && option.ModsJsons.Length > 0 ) ) + { + _currentOptionName = option.Name; + var optionFolder = Mod.NewSubFolderName( groupFolder, option.Name ) + ?? new DirectoryInfo( Path.Combine( groupFolder.FullName, $"Option {optionIdx}" ) ); + ExtractSimpleModList( optionFolder, option.ModsJsons, modData ); + options.Add( Mod.CreateSubMod( ret, optionFolder, option ) ); + description.Append( option.Description ); + if( !string.IsNullOrEmpty( option.Description ) ) + { + description.Append( '\n' ); + } + + ++optionIdx; + ++_currentOptionIdx; + } + + Mod.CreateOptionGroup( ret, group, groupPriority++, description.ToString(), options ); + } + } + + Mod.CreateDefaultFiles( ret ); + return ret; + } + + private void ExtractSimpleModList( DirectoryInfo outDirectory, ICollection< SimpleMod > mods, PenumbraSqPackStream dataStream ) + { + State = ImporterState.ExtractingModFiles; + + _currentFileIdx = 0; + _currentNumFiles = mods.Count; + + // Extract each SimpleMod into the new mod folder + foreach( var simpleMod in mods ) + { + ExtractMod( outDirectory, simpleMod, dataStream ); + ++_currentFileIdx; + } + } + + private void ExtractMod( DirectoryInfo outDirectory, SimpleMod mod, PenumbraSqPackStream dataStream ) + { + PluginLog.Log( " -> Extracting {0} at {1}", mod.FullPath, mod.ModOffset.ToString( "X" ) ); + + try + { + var data = dataStream.ReadFile< PenumbraSqPackStream.PenumbraFileResource >( mod.ModOffset ); + + _currentFileName = mod.FullPath; + var extractedFile = new FileInfo( Path.Combine( outDirectory.FullName, mod.FullPath ) ); + + extractedFile.Directory?.Create(); + + if( extractedFile.FullName.EndsWith( ".mdl" ) ) + { + ProcessMdl( data.Data ); + } + + File.WriteAllBytes( extractedFile.FullName, data.Data ); + } + catch( Exception ex ) + { + PluginLog.LogError( ex, "Could not extract mod." ); + } + } + + private static void ProcessMdl( byte[] mdl ) + { + const int modelHeaderLodOffset = 22; + + // Model file header LOD num + mdl[ 64 ] = 1; + + // Model header LOD num + var stackSize = BitConverter.ToUInt32( mdl, 4 ); + var runtimeBegin = stackSize + 0x44; + var stringsLengthOffset = runtimeBegin + 4; + var stringsLength = BitConverter.ToUInt32( mdl, ( int )stringsLengthOffset ); + var modelHeaderStart = stringsLengthOffset + stringsLength + 4; + mdl[ modelHeaderStart + modelHeaderLodOffset ] = 1; + } +} \ No newline at end of file diff --git a/Penumbra/Import/TexToolsMeta.Deserialization.cs b/Penumbra/Import/TexToolsMeta.Deserialization.cs new file mode 100644 index 00000000..5e79659b --- /dev/null +++ b/Penumbra/Import/TexToolsMeta.Deserialization.cs @@ -0,0 +1,174 @@ +using System; +using System.IO; +using Dalamud.Logging; +using Lumina.Extensions; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Import; + +public partial class TexToolsMeta +{ + // Deserialize and check Eqp Entries and add them to the list if they are non-default. + private void DeserializeEqpEntry( MetaFileInfo metaFileInfo, byte[]? data ) + { + // Eqp can only be valid for equipment. + if( data == null || !metaFileInfo.EquipSlot.IsEquipment() ) + { + return; + } + + var value = Eqp.FromSlotAndBytes( metaFileInfo.EquipSlot, data ); + var def = new EqpManipulation( ExpandedEqpFile.GetDefault( metaFileInfo.PrimaryId ), metaFileInfo.EquipSlot, metaFileInfo.PrimaryId ); + var manip = new EqpManipulation( value, metaFileInfo.EquipSlot, metaFileInfo.PrimaryId ); + if( def.Entry != manip.Entry ) + { + MetaManipulations.Add( manip ); + } + } + + // Deserialize and check Eqdp Entries and add them to the list if they are non-default. + private void DeserializeEqdpEntries( MetaFileInfo metaFileInfo, byte[]? data ) + { + if( data == null ) + { + return; + } + + var num = data.Length / 5; + using var reader = new BinaryReader( new MemoryStream( data ) ); + for( var i = 0; i < num; ++i ) + { + // Use the SE gender/race code. + var gr = ( GenderRace )reader.ReadUInt32(); + var byteValue = reader.ReadByte(); + if( !gr.IsValid() || !metaFileInfo.EquipSlot.IsEquipment() && !metaFileInfo.EquipSlot.IsAccessory() ) + { + continue; + } + + var value = Eqdp.FromSlotAndBits( metaFileInfo.EquipSlot, ( byteValue & 1 ) == 1, ( byteValue & 2 ) == 2 ); + var def = new EqdpManipulation( ExpandedEqdpFile.GetDefault( gr, metaFileInfo.EquipSlot.IsAccessory(), metaFileInfo.PrimaryId ), + metaFileInfo.EquipSlot, + gr.Split().Item1, gr.Split().Item2, metaFileInfo.PrimaryId ); + var manip = new EqdpManipulation( value, metaFileInfo.EquipSlot, gr.Split().Item1, gr.Split().Item2, metaFileInfo.PrimaryId ); + if( def.Entry != manip.Entry ) + { + MetaManipulations.Add( manip ); + } + } + } + + // Deserialize and check Gmp Entries and add them to the list if they are non-default. + private void DeserializeGmpEntry( MetaFileInfo metaFileInfo, byte[]? data ) + { + if( data == null ) + { + return; + } + + using var reader = new BinaryReader( new MemoryStream( data ) ); + var value = ( GmpEntry )reader.ReadUInt32(); + value.UnknownTotal = reader.ReadByte(); + var def = ExpandedGmpFile.GetDefault( metaFileInfo.PrimaryId ); + if( value != def ) + { + MetaManipulations.Add( new GmpManipulation( value, metaFileInfo.PrimaryId ) ); + } + } + + // Deserialize and check Est Entries and add them to the list if they are non-default. + private void DeserializeEstEntries( MetaFileInfo metaFileInfo, byte[]? data ) + { + if( data == null ) + { + return; + } + + var num = data.Length / 6; + using var reader = new BinaryReader( new MemoryStream( data ) ); + for( var i = 0; i < num; ++i ) + { + var gr = ( GenderRace )reader.ReadUInt16(); + var id = reader.ReadUInt16(); + var value = reader.ReadUInt16(); + var type = ( metaFileInfo.SecondaryType, metaFileInfo.EquipSlot ) switch + { + (BodySlot.Face, _) => EstManipulation.EstType.Face, + (BodySlot.Hair, _) => EstManipulation.EstType.Hair, + (_, EquipSlot.Head) => EstManipulation.EstType.Head, + (_, EquipSlot.Body) => EstManipulation.EstType.Body, + _ => ( EstManipulation.EstType )0, + }; + if( !gr.IsValid() || type == 0 ) + { + continue; + } + + var def = EstFile.GetDefault( type, gr, id ); + if( def != value ) + { + MetaManipulations.Add( new EstManipulation( gr.Split().Item1, gr.Split().Item2, type, id, value ) ); + } + } + } + + // Deserialize and check IMC Entries and add them to the list if they are non-default. + // This requires requesting a file from Lumina, which may fail due to TexTools corruption or just not existing. + // TexTools creates IMC files for off-hand weapon models which may not exist in the game files. + private void DeserializeImcEntries( MetaFileInfo metaFileInfo, byte[]? data ) + { + if( data == null ) + { + return; + } + + var num = data.Length / 6; + using var reader = new BinaryReader( new MemoryStream( data ) ); + var values = reader.ReadStructures< ImcEntry >( num ); + ushort i = 0; + try + { + if( metaFileInfo.PrimaryType is ObjectType.Equipment or ObjectType.Accessory ) + { + var def = new ImcFile( new ImcManipulation( metaFileInfo.EquipSlot, i, metaFileInfo.PrimaryId, new ImcEntry() ).GamePath() ); + var partIdx = ImcFile.PartIndex( metaFileInfo.EquipSlot ); + foreach( var value in values ) + { + if( !value.Equals( def.GetEntry( partIdx, i ) ) ) + { + MetaManipulations.Add( new ImcManipulation( metaFileInfo.EquipSlot, i, metaFileInfo.PrimaryId, value ) ); + } + + ++i; + } + } + else + { + var def = new ImcFile( new ImcManipulation( metaFileInfo.PrimaryType, metaFileInfo.SecondaryType, metaFileInfo.PrimaryId, + metaFileInfo.SecondaryId, i, + new ImcEntry() ).GamePath() ); + foreach( var value in values ) + { + if( !value.Equals( def.GetEntry( 0, i ) ) ) + { + MetaManipulations.Add( new ImcManipulation( metaFileInfo.PrimaryType, metaFileInfo.SecondaryType, + metaFileInfo.PrimaryId, + metaFileInfo.SecondaryId, i, + value ) ); + } + + ++i; + } + } + } + catch( Exception e ) + { + PluginLog.Warning( + $"Could not compute IMC manipulation for {metaFileInfo.PrimaryType} {metaFileInfo.PrimaryId}. This is in all likelihood due to TexTools corrupting your index files.\n" + + $"If the following error looks like Lumina is having trouble to read an IMC file, please do a do-over in TexTools:\n{e}" ); + } + } +} \ No newline at end of file diff --git a/Penumbra/Import/TexToolsMeta.Rgsp.cs b/Penumbra/Import/TexToolsMeta.Rgsp.cs new file mode 100644 index 00000000..97ce6915 --- /dev/null +++ b/Penumbra/Import/TexToolsMeta.Rgsp.cs @@ -0,0 +1,81 @@ +using System; +using System.IO; +using Dalamud.Logging; +using Penumbra.GameData.Enums; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Import; + +public partial class TexToolsMeta +{ + // Parse a single rgsp file. + public static TexToolsMeta FromRgspFile( string filePath, byte[] data ) + { + if( data.Length != 45 && data.Length != 42 ) + { + PluginLog.Error( "Error while parsing .rgsp file:\n\tInvalid number of bytes." ); + return Invalid; + } + + using var s = new MemoryStream( data ); + using var br = new BinaryReader( s ); + // The first value is a flag that signifies version. + // If it is byte.max, the following two bytes are the version, + // otherwise it is version 1 and signifies the sub race instead. + var flag = br.ReadByte(); + var version = flag != 255 ? ( uint )1 : br.ReadUInt16(); + + var ret = new TexToolsMeta( filePath, version ); + + // SubRace is offset by one due to Unknown. + var subRace = ( SubRace )( version == 1 ? flag + 1 : br.ReadByte() + 1 ); + if( !Enum.IsDefined( typeof( SubRace ), subRace ) || subRace == SubRace.Unknown ) + { + PluginLog.Error( $"Error while parsing .rgsp file:\n\t{subRace} is not a valid SubRace." ); + return Invalid; + } + + // Next byte is Gender. 1 is Female, 0 is Male. + var gender = br.ReadByte(); + if( gender != 1 && gender != 0 ) + { + PluginLog.Error( $"Error while parsing .rgsp file:\n\t{gender} is neither Male nor Female." ); + return Invalid; + } + + // Add the given values to the manipulations if they are not default. + void Add( RspAttribute attribute, float value ) + { + var def = CmpFile.GetDefault( subRace, attribute ); + if( value != def ) + { + ret.MetaManipulations.Add( new RspManipulation( subRace, attribute, value ) ); + } + } + + if( gender == 1 ) + { + Add( RspAttribute.FemaleMinSize, br.ReadSingle() ); + Add( RspAttribute.FemaleMaxSize, br.ReadSingle() ); + Add( RspAttribute.FemaleMinTail, br.ReadSingle() ); + Add( RspAttribute.FemaleMaxTail, br.ReadSingle() ); + + Add( RspAttribute.BustMinX, br.ReadSingle() ); + Add( RspAttribute.BustMinY, br.ReadSingle() ); + Add( RspAttribute.BustMinZ, br.ReadSingle() ); + Add( RspAttribute.BustMaxX, br.ReadSingle() ); + Add( RspAttribute.BustMaxY, br.ReadSingle() ); + Add( RspAttribute.BustMaxZ, br.ReadSingle() ); + } + else + { + Add( RspAttribute.MaleMinSize, br.ReadSingle() ); + Add( RspAttribute.MaleMaxSize, br.ReadSingle() ); + Add( RspAttribute.MaleMinTail, br.ReadSingle() ); + Add( RspAttribute.MaleMaxTail, br.ReadSingle() ); + } + + return ret; + } +} \ No newline at end of file diff --git a/Penumbra/Import/TexToolsMeta.cs b/Penumbra/Import/TexToolsMeta.cs new file mode 100644 index 00000000..1a0f05fd --- /dev/null +++ b/Penumbra/Import/TexToolsMeta.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Dalamud.Logging; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Import; + +// TexTools provices custom generated *.meta files for its modpacks, that contain changes to +// - imc files +// - eqp files +// - gmp files +// - est files +// - eqdp files +// made by the mod. The filename determines to what the changes are applied, and the binary file itself contains changes. +// We parse every *.meta file in a mod and combine all actual changes that do not keep data on default values and that can be applied to the game in a .json. +// TexTools may also generate files that contain non-existing changes, e.g. *.imc files for weapon offhands, which will be ignored. +// TexTools also provides .rgsp files, that contain changes to the racial scaling parameters in the human.cmp file. +public partial class TexToolsMeta +{ + // An empty TexToolsMeta. + public static readonly TexToolsMeta Invalid = new( string.Empty, 0 ); + + // The info class determines the files or table locations the changes need to apply to from the filename. + + public readonly uint Version; + public readonly string FilePath; + public readonly List< MetaManipulation > MetaManipulations = new(); + + public TexToolsMeta( byte[] data ) + { + try + { + using var reader = new BinaryReader( new MemoryStream( data ) ); + Version = reader.ReadUInt32(); + FilePath = ReadNullTerminated( reader ); + var metaInfo = new MetaFileInfo( FilePath ); + var numHeaders = reader.ReadUInt32(); + var headerSize = reader.ReadUInt32(); + var headerStart = reader.ReadUInt32(); + reader.BaseStream.Seek( headerStart, SeekOrigin.Begin ); + + List< (MetaManipulation.Type type, uint offset, int size) > entries = new(); + for( var i = 0; i < numHeaders; ++i ) + { + var currentOffset = reader.BaseStream.Position; + var type = ( MetaManipulation.Type )reader.ReadUInt32(); + var offset = reader.ReadUInt32(); + var size = reader.ReadInt32(); + entries.Add( ( type, offset, size ) ); + reader.BaseStream.Seek( currentOffset + headerSize, SeekOrigin.Begin ); + } + + byte[]? ReadEntry( MetaManipulation.Type type ) + { + var idx = entries.FindIndex( t => t.type == type ); + if( idx < 0 ) + { + return null; + } + + reader.BaseStream.Seek( entries[ idx ].offset, SeekOrigin.Begin ); + return reader.ReadBytes( entries[ idx ].size ); + } + + DeserializeEqpEntry( metaInfo, ReadEntry( MetaManipulation.Type.Eqp ) ); + DeserializeGmpEntry( metaInfo, ReadEntry( MetaManipulation.Type.Gmp ) ); + DeserializeEqdpEntries( metaInfo, ReadEntry( MetaManipulation.Type.Eqdp ) ); + DeserializeEstEntries( metaInfo, ReadEntry( MetaManipulation.Type.Est ) ); + DeserializeImcEntries( metaInfo, ReadEntry( MetaManipulation.Type.Imc ) ); + } + catch( Exception e ) + { + FilePath = ""; + PluginLog.Error( $"Error while parsing .meta file:\n{e}" ); + } + } + + private TexToolsMeta( string filePath, uint version ) + { + FilePath = filePath; + Version = version; + } + + // Read a null terminated string from a binary reader. + private static string ReadNullTerminated( BinaryReader reader ) + { + var builder = new System.Text.StringBuilder(); + for( var c = reader.ReadChar(); c != 0; c = reader.ReadChar() ) + { + builder.Append( c ); + } + + return builder.ToString(); + } +} \ No newline at end of file diff --git a/Penumbra/Import/TexToolsStructs.cs b/Penumbra/Import/TexToolsStructs.cs new file mode 100644 index 00000000..bb2ba8d9 --- /dev/null +++ b/Penumbra/Import/TexToolsStructs.cs @@ -0,0 +1,74 @@ +using System; +using Penumbra.Mods; + +namespace Penumbra.Import; + +internal static class DefaultTexToolsData +{ + public const string Name = "New Mod"; + public const string Author = "Unknown"; + public const string Description = "Mod imported from TexTools mod pack."; + public const string DefaultOption = "Default"; +} + +[Serializable] +internal class SimpleMod +{ + public string Name = string.Empty; + public string Category = string.Empty; + public string FullPath = string.Empty; + public string DatFile = string.Empty; + public long ModOffset = 0; + public long ModSize = 0; + public object? ModPackEntry = null; +} + +[Serializable] +internal class ModPackPage +{ + public int PageIndex = 0; + public ModGroup[] ModGroups = Array.Empty< ModGroup >(); +} + +[Serializable] +internal class ModGroup +{ + public string GroupName = string.Empty; + public SelectType SelectionType = SelectType.Single; + public OptionList[] OptionList = Array.Empty< OptionList >(); +} + +[Serializable] +internal class OptionList +{ + public string Name = string.Empty; + public string Description = string.Empty; + public string ImagePath = string.Empty; + public SimpleMod[] ModsJsons = Array.Empty< SimpleMod >(); + public string GroupName = string.Empty; + public SelectType SelectionType = SelectType.Single; + public bool IsChecked = false; +} + +[Serializable] +internal class ExtendedModPack +{ + public string PackVersion = string.Empty; + public string Name = DefaultTexToolsData.Name; + public string Author = DefaultTexToolsData.Author; + public string Version = string.Empty; + public string Description = DefaultTexToolsData.Description; + public ModPackPage[] ModPackPages = Array.Empty< ModPackPage >(); + public SimpleMod[] SimpleModsList = Array.Empty< SimpleMod >(); +} + +[Serializable] +internal class SimpleModPack +{ + public string TtmpVersion = string.Empty; + public string Name = DefaultTexToolsData.Name; + public string Author = DefaultTexToolsData.Author; + public string Version = string.Empty; + public string Description = DefaultTexToolsData.Description; + public SimpleMod[] SimpleModsList = Array.Empty< SimpleMod >(); +} \ No newline at end of file diff --git a/Penumbra/Importer/ImporterState.cs b/Penumbra/Importer/ImporterState.cs deleted file mode 100644 index 608976fc..00000000 --- a/Penumbra/Importer/ImporterState.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Penumbra.Importer -{ - public enum ImporterState - { - None, - WritingPackToDisk, - ExtractingModFiles, - Done, - } -} \ No newline at end of file diff --git a/Penumbra/Importer/MagicTempFileStreamManagerAndDeleter.cs b/Penumbra/Importer/MagicTempFileStreamManagerAndDeleter.cs deleted file mode 100644 index 10be9f15..00000000 --- a/Penumbra/Importer/MagicTempFileStreamManagerAndDeleter.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.IO; -using Penumbra.Util; - -namespace Penumbra.Importer -{ - public class MagicTempFileStreamManagerAndDeleter : PenumbraSqPackStream, IDisposable - { - private readonly FileStream _fileStream; - - public MagicTempFileStreamManagerAndDeleter( FileStream stream ) - : base( stream ) - => _fileStream = stream; - - public new void Dispose() - { - var filePath = _fileStream.Name; - - base.Dispose(); - _fileStream.Dispose(); - - File.Delete( filePath ); - } - } -} \ No newline at end of file diff --git a/Penumbra/Importer/Models/ExtendedModPack.cs b/Penumbra/Importer/Models/ExtendedModPack.cs deleted file mode 100644 index 45593faa..00000000 --- a/Penumbra/Importer/Models/ExtendedModPack.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Collections.Generic; -using Penumbra.Mods; - -namespace Penumbra.Importer.Models -{ - internal class OptionList - { - public string? Name { get; set; } - public string? Description { get; set; } - public string? ImagePath { get; set; } - public List< SimpleMod >? ModsJsons { get; set; } - public string? GroupName { get; set; } - public SelectType SelectionType { get; set; } - public bool IsChecked { get; set; } - } - - internal class ModGroup - { - public string? GroupName { get; set; } - public SelectType SelectionType { get; set; } - public List< OptionList >? OptionList { get; set; } - } - - internal class ModPackPage - { - public int PageIndex { get; set; } - public List< ModGroup >? ModGroups { get; set; } - } - - internal class ExtendedModPack - { - public string? TTMPVersion { get; set; } - public string? Name { get; set; } - public string? Author { get; set; } - public string? Version { get; set; } - public string? Description { get; set; } - public List< ModPackPage >? ModPackPages { get; set; } - public List< SimpleMod >? SimpleModsList { get; set; } - } -} \ No newline at end of file diff --git a/Penumbra/Importer/Models/SimpleModPack.cs b/Penumbra/Importer/Models/SimpleModPack.cs deleted file mode 100644 index 1b3f9e4e..00000000 --- a/Penumbra/Importer/Models/SimpleModPack.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; - -namespace Penumbra.Importer.Models -{ - internal class SimpleModPack - { - public string? TTMPVersion { get; set; } - public string? Name { get; set; } - public string? Author { get; set; } - public string? Version { get; set; } - public string? Description { get; set; } - public List< SimpleMod >? SimpleModsList { get; set; } - } - - internal class SimpleMod - { - public string? Name { get; set; } - public string? Category { get; set; } - public string? FullPath { get; set; } - public long ModOffset { get; set; } - public long ModSize { get; set; } - public string? DatFile { get; set; } - public object? ModPackEntry { get; set; } - } -} \ No newline at end of file diff --git a/Penumbra/Importer/TexToolsImport.cs b/Penumbra/Importer/TexToolsImport.cs deleted file mode 100644 index 0caa5a05..00000000 --- a/Penumbra/Importer/TexToolsImport.cs +++ /dev/null @@ -1,371 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using Dalamud.Logging; -using ICSharpCode.SharpZipLib.Zip; -using Newtonsoft.Json; -using Penumbra.Importer.Models; -using Penumbra.Mods; -using Penumbra.Util; -using FileMode = System.IO.FileMode; - -namespace Penumbra.Importer; - -internal class TexToolsImport -{ - private readonly DirectoryInfo _outDirectory; - - private const string TempFileName = "textools-import"; - private readonly string _resolvedTempFilePath; - - public DirectoryInfo? ExtractedDirectory { get; private set; } - - public ImporterState State { get; private set; } - - public long TotalProgress { get; private set; } - public long CurrentProgress { get; private set; } - - public float Progress - { - get - { - if( CurrentProgress != 0 ) - { - // ReSharper disable twice RedundantCast - return ( float )CurrentProgress / ( float )TotalProgress; - } - - return 0; - } - } - - public string? CurrentModPack { get; private set; } - - public TexToolsImport( DirectoryInfo outDirectory ) - { - _outDirectory = outDirectory; - _resolvedTempFilePath = Path.Combine( _outDirectory.FullName, TempFileName ); - } - - private static DirectoryInfo NewOptionDirectory( DirectoryInfo baseDir, string optionName ) - => new(Path.Combine( baseDir.FullName, optionName.ReplaceBadXivSymbols() )); - - public DirectoryInfo ImportModPack( FileInfo modPackFile ) - { - CurrentModPack = modPackFile.Name; - - var dir = VerifyVersionAndImport( modPackFile ); - - State = ImporterState.Done; - return dir; - } - - private void WriteZipEntryToTempFile( Stream s ) - { - var fs = new FileStream( _resolvedTempFilePath, FileMode.Create ); - s.CopyTo( fs ); - fs.Close(); - } - - // You can in no way rely on any file paths in TTMPs so we need to just do this, sorry - private static ZipEntry? FindZipEntry( ZipFile file, string fileName ) - { - for( var i = 0; i < file.Count; i++ ) - { - var entry = file[ i ]; - - if( entry.Name.Contains( fileName ) ) - { - return entry; - } - } - - return null; - } - - private PenumbraSqPackStream GetMagicSqPackDeleterStream( ZipFile file, string entryName ) - { - State = ImporterState.WritingPackToDisk; - - // write shitty zip garbage to disk - var entry = FindZipEntry( file, entryName ); - if( entry == null ) - { - throw new FileNotFoundException( $"ZIP does not contain a file named {entryName}." ); - } - - using var s = file.GetInputStream( entry ); - - WriteZipEntryToTempFile( s ); - - var fs = new FileStream( _resolvedTempFilePath, FileMode.Open ); - return new MagicTempFileStreamManagerAndDeleter( fs ); - } - - private DirectoryInfo VerifyVersionAndImport( FileInfo modPackFile ) - { - using var zfs = modPackFile.OpenRead(); - using var extractedModPack = new ZipFile( zfs ); - - var mpl = FindZipEntry( extractedModPack, "TTMPL.mpl" ); - if( mpl == null ) - { - throw new FileNotFoundException( "ZIP does not contain a TTMPL.mpl file." ); - } - - var modRaw = GetStringFromZipEntry( extractedModPack, mpl, Encoding.UTF8 ); - - // At least a better validation than going by the extension. - if( modRaw.Contains( "\"TTMPVersion\":" ) ) - { - if( modPackFile.Extension != ".ttmp2" ) - { - PluginLog.Warning( $"File {modPackFile.FullName} seems to be a V2 TTMP, but has the wrong extension." ); - } - - return ImportV2ModPack( modPackFile, extractedModPack, modRaw ); - } - - if( modPackFile.Extension != ".ttmp" ) - { - PluginLog.Warning( $"File {modPackFile.FullName} seems to be a V1 TTMP, but has the wrong extension." ); - } - - return ImportV1ModPack( modPackFile, extractedModPack, modRaw ); - } - - private DirectoryInfo ImportV1ModPack( FileInfo modPackFile, ZipFile extractedModPack, string modRaw ) - { - PluginLog.Log( " -> Importing V1 ModPack" ); - - var modListRaw = modRaw.Split( - new[] { "\r\n", "\r", "\n" }, - StringSplitOptions.None - ); - - var modList = modListRaw.Select( JsonConvert.DeserializeObject< SimpleMod > ); - - // Open the mod data file from the modpack as a SqPackStream - using var modData = GetMagicSqPackDeleterStream( extractedModPack, "TTMPD.mpd" ); - - ExtractedDirectory = CreateModFolder( _outDirectory, Path.GetFileNameWithoutExtension( modPackFile.Name ) ); - // Create a new ModMeta from the TTMP modlist info - Mod2.CreateMeta( ExtractedDirectory, string.IsNullOrEmpty( modPackFile.Name ) ? "New Mod" : modPackFile.Name, "Unknown", - "Mod imported from TexTools mod pack.", null, null ); - - ExtractSimpleModList( ExtractedDirectory, modList, modData ); - - return ExtractedDirectory; - } - - private DirectoryInfo ImportV2ModPack( FileInfo _, ZipFile extractedModPack, string modRaw ) - { - var modList = JsonConvert.DeserializeObject< SimpleModPack >( modRaw ); - - if( modList.TTMPVersion?.EndsWith( "s" ) ?? false ) - { - return ImportSimpleV2ModPack( extractedModPack, modList ); - } - - if( modList.TTMPVersion?.EndsWith( "w" ) ?? false ) - { - return ImportExtendedV2ModPack( extractedModPack, modRaw ); - } - - try - { - PluginLog.Warning( $"Unknown TTMPVersion {modList.TTMPVersion ?? "NULL"} given, trying to export as simple Modpack." ); - return ImportSimpleV2ModPack( extractedModPack, modList ); - } - catch( Exception e1 ) - { - PluginLog.Warning( $"Exporting as simple Modpack failed with following error, retrying as extended Modpack:\n{e1}" ); - try - { - return ImportExtendedV2ModPack( extractedModPack, modRaw ); - } - catch( Exception e2 ) - { - throw new IOException( "Exporting as extended Modpack failed, too. Version unsupported or file defect.", e2 ); - } - } - } - - public static DirectoryInfo CreateModFolder( DirectoryInfo outDirectory, string modListName ) - { - var name = Path.GetFileName( modListName ); - if( !name.Any() ) - { - name = "_"; - } - - var newModFolderBase = NewOptionDirectory( outDirectory, name ); - var newModFolder = newModFolderBase; - var i = 2; - while( newModFolder.Exists && i < 12 ) - { - newModFolder = new DirectoryInfo( newModFolderBase.FullName + $" ({i++})" ); - } - - if( newModFolder.Exists ) - { - throw new IOException( "Could not create mod folder: too many folders of the same name exist." ); - } - - newModFolder.Create(); - return newModFolder; - } - - private DirectoryInfo ImportSimpleV2ModPack( ZipFile extractedModPack, SimpleModPack modList ) - { - PluginLog.Log( " -> Importing Simple V2 ModPack" ); - - // Open the mod data file from the modpack as a SqPackStream - using var modData = GetMagicSqPackDeleterStream( extractedModPack, "TTMPD.mpd" ); - - ExtractedDirectory = CreateModFolder( _outDirectory, modList.Name ?? "New Mod" ); - Mod2.CreateMeta( ExtractedDirectory, modList.Name ?? "New Mod", modList.Author ?? "Unknown", string.IsNullOrEmpty( modList.Description ) - ? "Mod imported from TexTools mod pack" - : modList.Description, null, null ); - - ExtractSimpleModList( ExtractedDirectory, modList.SimpleModsList ?? Enumerable.Empty< SimpleMod >(), modData ); - return ExtractedDirectory; - } - - private DirectoryInfo ImportExtendedV2ModPack( ZipFile extractedModPack, string modRaw ) - { - PluginLog.Log( " -> Importing Extended V2 ModPack" ); - - var modList = JsonConvert.DeserializeObject< ExtendedModPack >( modRaw ); - - // Open the mod data file from the modpack as a SqPackStream - using var modData = GetMagicSqPackDeleterStream( extractedModPack, "TTMPD.mpd" ); - - ExtractedDirectory = CreateModFolder( _outDirectory, modList.Name ?? "New Mod" ); - Mod2.CreateMeta( ExtractedDirectory, modList.Name ?? "New Mod", modList.Author ?? "Unknown", - string.IsNullOrEmpty( modList.Description ) ? "Mod imported from TexTools mod pack" : modList.Description, modList.Version, null ); - - if( modList.SimpleModsList != null ) - { - ExtractSimpleModList( ExtractedDirectory, modList.SimpleModsList, modData ); - } - - if( modList.ModPackPages == null ) - { - return ExtractedDirectory; - } - - // Iterate through all pages - var options = new List< ISubMod >(); - var groupPriority = 0; - foreach( var page in modList.ModPackPages ) - { - if( page.ModGroups == null ) - { - continue; - } - - foreach( var group in page.ModGroups.Where( group => group.GroupName != null && group.OptionList != null ) ) - { - options.Clear(); - var description = new StringBuilder(); - var groupFolder = NewOptionDirectory( ExtractedDirectory, group.GroupName! ); - if( groupFolder.Exists ) - { - groupFolder = new DirectoryInfo( groupFolder.FullName + $" ({page.PageIndex})" ); - group.GroupName += $" ({page.PageIndex})"; - } - - foreach( var option in group.OptionList!.Where( option => option.Name != null && option.ModsJsons != null ) ) - { - var optionFolder = NewOptionDirectory( groupFolder, option.Name! ); - ExtractSimpleModList( optionFolder, option.ModsJsons!, modData ); - options.Add( Mod2.CreateSubMod( ExtractedDirectory, optionFolder, option ) ); - description.Append( option.Description ); - if( !string.IsNullOrEmpty( option.Description ) ) - { - description.Append( '\n' ); - } - } - - Mod2.CreateOptionGroup( ExtractedDirectory, group, groupPriority++, description.ToString(), options ); - } - } - Mod2.CreateDefaultFiles( ExtractedDirectory ); - return ExtractedDirectory; - } - - private void ImportMetaModPack( FileInfo file ) - { - throw new NotImplementedException(); - } - - private void ExtractSimpleModList( DirectoryInfo outDirectory, IEnumerable< SimpleMod > mods, PenumbraSqPackStream dataStream ) - { - State = ImporterState.ExtractingModFiles; - - // haha allocation go brr - var wtf = mods.ToList(); - - TotalProgress += wtf.LongCount(); - - // Extract each SimpleMod into the new mod folder - foreach( var simpleMod in wtf.Where( m => m != null ) ) - { - ExtractMod( outDirectory, simpleMod, dataStream ); - CurrentProgress++; - } - } - - private void ExtractMod( DirectoryInfo outDirectory, SimpleMod mod, PenumbraSqPackStream dataStream ) - { - PluginLog.Log( " -> Extracting {0} at {1}", mod.FullPath!, mod.ModOffset.ToString( "X" ) ); - - try - { - var data = dataStream.ReadFile< PenumbraSqPackStream.PenumbraFileResource >( mod.ModOffset ); - - var extractedFile = new FileInfo( Path.Combine( outDirectory.FullName, mod.FullPath! ) ); - extractedFile.Directory?.Create(); - - if( extractedFile.FullName.EndsWith( "mdl" ) ) - { - ProcessMdl( data.Data ); - } - - File.WriteAllBytes( extractedFile.FullName, data.Data ); - } - catch( Exception ex ) - { - PluginLog.LogError( ex, "Could not extract mod." ); - } - } - - private void ProcessMdl( byte[] mdl ) - { - // Model file header LOD num - mdl[ 64 ] = 1; - - // Model header LOD num - var stackSize = BitConverter.ToUInt32( mdl, 4 ); - var runtimeBegin = stackSize + 0x44; - var stringsLengthOffset = runtimeBegin + 4; - var stringsLength = BitConverter.ToUInt32( mdl, ( int )stringsLengthOffset ); - var modelHeaderStart = stringsLengthOffset + stringsLength + 4; - var modelHeaderLodOffset = 22; - mdl[ modelHeaderStart + modelHeaderLodOffset ] = 1; - } - - private static Stream GetStreamFromZipEntry( ZipFile file, ZipEntry entry ) - => file.GetInputStream( entry ); - - private static string GetStringFromZipEntry( ZipFile file, ZipEntry entry, Encoding encoding ) - { - using var ms = new MemoryStream(); - using var s = GetStreamFromZipEntry( file, entry ); - s.CopyTo( ms ); - return encoding.GetString( ms.ToArray() ); - } -} \ No newline at end of file diff --git a/Penumbra/Importer/TexToolsMeta.cs b/Penumbra/Importer/TexToolsMeta.cs deleted file mode 100644 index 1afd8f0f..00000000 --- a/Penumbra/Importer/TexToolsMeta.cs +++ /dev/null @@ -1,427 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using Dalamud.Logging; -using Penumbra.GameData.Enums; -using Penumbra.GameData.Structs; -using Penumbra.GameData.Util; -using Penumbra.Meta.Files; -using Penumbra.Meta.Manipulations; -using Penumbra.Util; -using ImcFile = Penumbra.Meta.Files.ImcFile; - -namespace Penumbra.Importer; - -// TexTools provices custom generated *.meta files for its modpacks, that contain changes to -// - imc files -// - eqp files -// - gmp files -// - est files -// - eqdp files -// made by the mod. The filename determines to what the changes are applied, and the binary file itself contains changes. -// We parse every *.meta file in a mod and combine all actual changes that do not keep data on default values and that can be applied to the game in a .json. -// TexTools may also generate files that contain non-existing changes, e.g. *.imc files for weapon offhands, which will be ignored. -// TexTools also provides .rgsp files, that contain changes to the racial scaling parameters in the human.cmp file. -public class TexToolsMeta -{ - // The info class determines the files or table locations the changes need to apply to from the filename. - public class Info - { - private const string Pt = @"(?'PrimaryType'[a-z]*)"; // language=regex - private const string Pp = @"(?'PrimaryPrefix'[a-z])"; // language=regex - private const string Pi = @"(?'PrimaryId'\d{4})"; // language=regex - private const string Pir = @"\k'PrimaryId'"; // language=regex - private const string St = @"(?'SecondaryType'[a-z]*)"; // language=regex - private const string Sp = @"(?'SecondaryPrefix'[a-z])"; // language=regex - private const string Si = @"(?'SecondaryId'\d{4})"; // language=regex - private const string File = @"\k'PrimaryPrefix'\k'PrimaryId'(\k'SecondaryPrefix'\k'SecondaryId')?"; // language=regex - private const string Slot = @"(_(?'Slot'[a-z]{3}))?"; // language=regex - private const string Ext = @"\.meta"; - - // These are the valid regexes for .meta files that we are able to support at the moment. - private static readonly Regex HousingMeta = new($"bgcommon/hou/{Pt}/general/{Pi}/{Pir}{Ext}", RegexOptions.Compiled); - private static readonly Regex CharaMeta = new($"chara/{Pt}/{Pp}{Pi}(/obj/{St}/{Sp}{Si})?/{File}{Slot}{Ext}", RegexOptions.Compiled); - - public readonly ObjectType PrimaryType; - public readonly BodySlot SecondaryType; - public readonly ushort PrimaryId; - public readonly ushort SecondaryId; - public readonly EquipSlot EquipSlot = EquipSlot.Unknown; - public readonly CustomizationType CustomizationType = CustomizationType.Unknown; - - private static bool ValidType( ObjectType type ) - { - return type switch - { - ObjectType.Accessory => true, - ObjectType.Character => true, - ObjectType.Equipment => true, - ObjectType.DemiHuman => true, - ObjectType.Housing => true, - ObjectType.Monster => true, - ObjectType.Weapon => true, - ObjectType.Icon => false, - ObjectType.Font => false, - ObjectType.Interface => false, - ObjectType.LoadingScreen => false, - ObjectType.Map => false, - ObjectType.Vfx => false, - ObjectType.Unknown => false, - ObjectType.World => false, - _ => false, - }; - } - - public Info( string fileName ) - : this( new GamePath( fileName ) ) - { } - - public Info( GamePath fileName ) - { - PrimaryType = GameData.GameData.GetGamePathParser().PathToObjectType( fileName ); - PrimaryId = 0; - SecondaryType = BodySlot.Unknown; - SecondaryId = 0; - if( !ValidType( PrimaryType ) ) - { - PrimaryType = ObjectType.Unknown; - return; - } - - if( PrimaryType == ObjectType.Housing ) - { - var housingMatch = HousingMeta.Match( fileName ); - if( housingMatch.Success ) - { - PrimaryId = ushort.Parse( housingMatch.Groups[ "PrimaryId" ].Value ); - } - - return; - } - - var match = CharaMeta.Match( fileName ); - if( !match.Success ) - { - return; - } - - PrimaryId = ushort.Parse( match.Groups[ "PrimaryId" ].Value ); - if( match.Groups[ "Slot" ].Success ) - { - switch( PrimaryType ) - { - case ObjectType.Equipment: - case ObjectType.Accessory: - if( Names.SuffixToEquipSlot.TryGetValue( match.Groups[ "Slot" ].Value, out var tmpSlot ) ) - { - EquipSlot = tmpSlot; - } - - break; - case ObjectType.Character: - if( Names.SuffixToCustomizationType.TryGetValue( match.Groups[ "Slot" ].Value, out var tmpCustom ) ) - { - CustomizationType = tmpCustom; - } - - break; - } - } - - if( match.Groups[ "SecondaryType" ].Success - && Names.StringToBodySlot.TryGetValue( match.Groups[ "SecondaryType" ].Value, out SecondaryType ) ) - { - SecondaryId = ushort.Parse( match.Groups[ "SecondaryId" ].Value ); - } - } - } - - public readonly uint Version; - public readonly string FilePath; - public readonly List< EqpManipulation > EqpManipulations = new(); - public readonly List< GmpManipulation > GmpManipulations = new(); - public readonly List< EqdpManipulation > EqdpManipulations = new(); - public readonly List< EstManipulation > EstManipulations = new(); - public readonly List< RspManipulation > RspManipulations = new(); - public readonly List< ImcManipulation > ImcManipulations = new(); - - private void DeserializeEqpEntry( Info info, byte[]? data ) - { - if( data == null || !info.EquipSlot.IsEquipment() ) - { - return; - } - - var value = Eqp.FromSlotAndBytes( info.EquipSlot, data ); - var def = new EqpManipulation( ExpandedEqpFile.GetDefault( info.PrimaryId ), info.EquipSlot, info.PrimaryId ); - var manip = new EqpManipulation( value, info.EquipSlot, info.PrimaryId ); - if( def.Entry != manip.Entry ) - { - EqpManipulations.Add( manip ); - } - } - - private void DeserializeEqdpEntries( Info info, byte[]? data ) - { - if( data == null ) - { - return; - } - - var num = data.Length / 5; - using var reader = new BinaryReader( new MemoryStream( data ) ); - for( var i = 0; i < num; ++i ) - { - var gr = ( GenderRace )reader.ReadUInt32(); - var byteValue = reader.ReadByte(); - if( !gr.IsValid() || !info.EquipSlot.IsEquipment() && !info.EquipSlot.IsAccessory() ) - { - continue; - } - - var value = Eqdp.FromSlotAndBits( info.EquipSlot, ( byteValue & 1 ) == 1, ( byteValue & 2 ) == 2 ); - var def = new EqdpManipulation( ExpandedEqdpFile.GetDefault( gr, info.EquipSlot.IsAccessory(), info.PrimaryId ), info.EquipSlot, - gr.Split().Item1, gr.Split().Item2, info.PrimaryId ); - var manip = new EqdpManipulation( value, info.EquipSlot, gr.Split().Item1, gr.Split().Item2, info.PrimaryId ); - if( def.Entry != manip.Entry ) - { - EqdpManipulations.Add( manip ); - } - } - } - - private void DeserializeGmpEntry( Info info, byte[]? data ) - { - if( data == null ) - { - return; - } - - using var reader = new BinaryReader( new MemoryStream( data ) ); - var value = ( GmpEntry )reader.ReadUInt32(); - value.UnknownTotal = reader.ReadByte(); - var def = ExpandedGmpFile.GetDefault( info.PrimaryId ); - if( value != def ) - { - GmpManipulations.Add( new GmpManipulation( value, info.PrimaryId ) ); - } - } - - private void DeserializeEstEntries( Info info, byte[]? data ) - { - if( data == null ) - { - return; - } - - var num = data.Length / 6; - using var reader = new BinaryReader( new MemoryStream( data ) ); - for( var i = 0; i < num; ++i ) - { - var gr = ( GenderRace )reader.ReadUInt16(); - var id = reader.ReadUInt16(); - var value = reader.ReadUInt16(); - var type = ( info.SecondaryType, info.EquipSlot ) switch - { - (BodySlot.Face, _) => EstManipulation.EstType.Face, - (BodySlot.Hair, _) => EstManipulation.EstType.Hair, - (_, EquipSlot.Head) => EstManipulation.EstType.Head, - (_, EquipSlot.Body) => EstManipulation.EstType.Body, - _ => ( EstManipulation.EstType )0, - }; - if( !gr.IsValid() || type == 0 ) - { - continue; - } - - var def = EstFile.GetDefault( type, gr, id ); - if( def != value ) - { - EstManipulations.Add( new EstManipulation( gr.Split().Item1, gr.Split().Item2, type, id, value ) ); - } - } - } - - private void DeserializeImcEntries( Info info, byte[]? data ) - { - if( data == null ) - { - return; - } - - var num = data.Length / 6; - using var reader = new BinaryReader( new MemoryStream( data ) ); - var values = reader.ReadStructures< ImcEntry >( num ); - ushort i = 0; - try - { - if( info.PrimaryType is ObjectType.Equipment or ObjectType.Accessory ) - { - var def = new ImcFile( new ImcManipulation( info.EquipSlot, i, info.PrimaryId, new ImcEntry() ).GamePath() ); - var partIdx = ImcFile.PartIndex( info.EquipSlot ); - foreach( var value in values ) - { - if( !value.Equals( def.GetEntry( partIdx, i ) ) ) - { - ImcManipulations.Add( new ImcManipulation( info.EquipSlot, i, info.PrimaryId, value ) ); - } - - ++i; - } - } - else - { - var def = new ImcFile( new ImcManipulation( info.PrimaryType, info.SecondaryType, info.PrimaryId, info.SecondaryId, i, - new ImcEntry() ).GamePath() ); - foreach( var value in values ) - { - if( !value.Equals( def.GetEntry( 0, i ) ) ) - { - ImcManipulations.Add( new ImcManipulation( info.PrimaryType, info.SecondaryType, info.PrimaryId, info.SecondaryId, i, - value ) ); - } - - ++i; - } - } - } - catch( Exception e ) - { - PluginLog.Warning( $"Could not compute IMC manipulation for {info.PrimaryType} {info.PrimaryId}. This is in all likelihood due to TexTools corrupting your index files.\n" - + $"If the following error looks like Lumina is having trouble to read an IMC file, please do a do-over in TexTools:\n{e}" ); - } - } - - private static string ReadNullTerminated( BinaryReader reader ) - { - var builder = new System.Text.StringBuilder(); - for( var c = reader.ReadChar(); c != 0; c = reader.ReadChar() ) - { - builder.Append( c ); - } - - return builder.ToString(); - } - - public TexToolsMeta( byte[] data ) - { - try - { - using var reader = new BinaryReader( new MemoryStream( data ) ); - Version = reader.ReadUInt32(); - FilePath = ReadNullTerminated( reader ); - var metaInfo = new Info( FilePath ); - var numHeaders = reader.ReadUInt32(); - var headerSize = reader.ReadUInt32(); - var headerStart = reader.ReadUInt32(); - reader.BaseStream.Seek( headerStart, SeekOrigin.Begin ); - - List< (MetaManipulation.Type type, uint offset, int size) > entries = new(); - for( var i = 0; i < numHeaders; ++i ) - { - var currentOffset = reader.BaseStream.Position; - var type = ( MetaManipulation.Type )reader.ReadUInt32(); - var offset = reader.ReadUInt32(); - var size = reader.ReadInt32(); - entries.Add( ( type, offset, size ) ); - reader.BaseStream.Seek( currentOffset + headerSize, SeekOrigin.Begin ); - } - - byte[]? ReadEntry( MetaManipulation.Type type ) - { - var idx = entries.FindIndex( t => t.type == type ); - if( idx < 0 ) - { - return null; - } - - reader.BaseStream.Seek( entries[ idx ].offset, SeekOrigin.Begin ); - return reader.ReadBytes( entries[ idx ].size ); - } - - DeserializeEqpEntry( metaInfo, ReadEntry( MetaManipulation.Type.Eqp ) ); - DeserializeGmpEntry( metaInfo, ReadEntry( MetaManipulation.Type.Gmp ) ); - DeserializeEqdpEntries( metaInfo, ReadEntry( MetaManipulation.Type.Eqdp ) ); - DeserializeEstEntries( metaInfo, ReadEntry( MetaManipulation.Type.Est ) ); - DeserializeImcEntries( metaInfo, ReadEntry( MetaManipulation.Type.Imc ) ); - } - catch( Exception e ) - { - FilePath = ""; - PluginLog.Error( $"Error while parsing .meta file:\n{e}" ); - } - } - - private TexToolsMeta( string filePath, uint version ) - { - FilePath = filePath; - Version = version; - } - - public static TexToolsMeta Invalid = new(string.Empty, 0); - - public static TexToolsMeta FromRgspFile( string filePath, byte[] data ) - { - if( data.Length != 45 && data.Length != 42 ) - { - PluginLog.Error( "Error while parsing .rgsp file:\n\tInvalid number of bytes." ); - return Invalid; - } - - using var s = new MemoryStream( data ); - using var br = new BinaryReader( s ); - var flag = br.ReadByte(); - var version = flag != 255 ? ( uint )1 : br.ReadUInt16(); - - var ret = new TexToolsMeta( filePath, version ); - - var subRace = ( SubRace )( version == 1 ? flag + 1 : br.ReadByte() + 1 ); - if( !Enum.IsDefined( typeof( SubRace ), subRace ) || subRace == SubRace.Unknown ) - { - PluginLog.Error( $"Error while parsing .rgsp file:\n\t{subRace} is not a valid SubRace." ); - return Invalid; - } - - var gender = br.ReadByte(); - if( gender != 1 && gender != 0 ) - { - PluginLog.Error( $"Error while parsing .rgsp file:\n\t{gender} is neither Male nor Female." ); - return Invalid; - } - - void Add( RspAttribute attribute, float value ) - { - var def = CmpFile.GetDefault( subRace, attribute ); - if( value != def ) - { - ret!.RspManipulations.Add( new RspManipulation( subRace, attribute, value ) ); - } - } - - if( gender == 1 ) - { - Add( RspAttribute.FemaleMinSize, br.ReadSingle() ); - Add( RspAttribute.FemaleMaxSize, br.ReadSingle() ); - Add( RspAttribute.FemaleMinTail, br.ReadSingle() ); - Add( RspAttribute.FemaleMaxTail, br.ReadSingle() ); - - Add( RspAttribute.BustMinX, br.ReadSingle() ); - Add( RspAttribute.BustMinY, br.ReadSingle() ); - Add( RspAttribute.BustMinZ, br.ReadSingle() ); - Add( RspAttribute.BustMaxX, br.ReadSingle() ); - Add( RspAttribute.BustMaxY, br.ReadSingle() ); - Add( RspAttribute.BustMaxZ, br.ReadSingle() ); - } - else - { - Add( RspAttribute.MaleMinSize, br.ReadSingle() ); - Add( RspAttribute.MaleMaxSize, br.ReadSingle() ); - Add( RspAttribute.MaleMinTail, br.ReadSingle() ); - Add( RspAttribute.MaleMaxTail, br.ReadSingle() ); - } - - return ret; - } -} \ No newline at end of file diff --git a/Penumbra/Interop/Loader/ResourceLoader.Replacement.cs b/Penumbra/Interop/Loader/ResourceLoader.Replacement.cs index dd7eb89e..6aaccde5 100644 --- a/Penumbra/Interop/Loader/ResourceLoader.Replacement.cs +++ b/Penumbra/Interop/Loader/ResourceLoader.Replacement.cs @@ -8,7 +8,6 @@ using FFXIVClientStructs.FFXIV.Client.System.Resource; using Penumbra.GameData.ByteString; using Penumbra.GameData.Enums; using Penumbra.Interop.Structs; -using Penumbra.Mods; using FileMode = Penumbra.Interop.Structs.FileMode; using ResourceHandle = FFXIVClientStructs.FFXIV.Client.System.Resource.Handle.ResourceHandle; diff --git a/Penumbra/Meta/Manager/MetaManager.Imc.cs b/Penumbra/Meta/Manager/MetaManager.Imc.cs index 23c83487..161307fa 100644 --- a/Penumbra/Meta/Manager/MetaManager.Imc.cs +++ b/Penumbra/Meta/Manager/MetaManager.Imc.cs @@ -22,7 +22,6 @@ public partial class MetaManager private readonly ModCollection _collection; private static int _imcManagerCount; - public MetaManagerImc( ModCollection collection ) { _collection = collection; diff --git a/Penumbra/Meta/Manipulations/MetaManipulation.cs b/Penumbra/Meta/Manipulations/MetaManipulation.cs index 421642a0..4f2439a9 100644 --- a/Penumbra/Meta/Manipulations/MetaManipulation.cs +++ b/Penumbra/Meta/Manipulations/MetaManipulation.cs @@ -12,91 +12,10 @@ public interface IMetaManipulation public int FileIndex(); } -public interface IMetaManipulation< T > : IMetaManipulation, IComparable< T >, IEquatable< T > where T : struct +public interface IMetaManipulation< T > + : IMetaManipulation, IComparable< T >, IEquatable< T > where T : struct { } -public struct ManipulationSet< T > where T : struct, IMetaManipulation< T > -{ - private List< T >? _data = null; - - public IReadOnlyList< T > Data - => ( IReadOnlyList< T >? )_data ?? Array.Empty< T >(); - - public int Count - => _data?.Count ?? 0; - - public ManipulationSet( int count = 0 ) - { - if( count > 0 ) - { - _data = new List< T >( count ); - } - } - - public bool TryAdd( T manip ) - { - if( _data == null ) - { - _data = new List< T > { manip }; - return true; - } - - var idx = _data.BinarySearch( manip ); - if( idx >= 0 ) - { - return false; - } - - _data.Insert( ~idx, manip ); - return true; - } - - public int Set( T manip ) - { - if( _data == null ) - { - _data = new List< T > { manip }; - return 0; - } - - var idx = _data.BinarySearch( manip ); - if( idx >= 0 ) - { - _data[ idx ] = manip; - return idx; - } - - idx = ~idx; - _data.Insert( idx, manip ); - return idx; - } - - public bool TryGet( T manip, out T value ) - { - var idx = _data?.BinarySearch( manip ) ?? -1; - if( idx < 0 ) - { - value = default; - return false; - } - - value = _data![ idx ]; - return true; - } - - public bool Remove( T manip ) - { - var idx = _data?.BinarySearch( manip ) ?? -1; - if( idx < 0 ) - { - return false; - } - - _data!.RemoveAt( idx ); - return true; - } -} - [StructLayout( LayoutKind.Explicit, Pack = 1, Size = 16 )] public readonly struct MetaManipulation : IEquatable< MetaManipulation >, IComparable< MetaManipulation > { diff --git a/Penumbra/MigrateConfiguration.cs b/Penumbra/MigrateConfiguration.cs index 1344bf27..deff2d29 100644 --- a/Penumbra/MigrateConfiguration.cs +++ b/Penumbra/MigrateConfiguration.cs @@ -103,7 +103,7 @@ public partial class Configuration private void ResettleSortOrder() { ModSortOrder = _data[ nameof( ModSortOrder ) ]?.ToObject< Dictionary< string, string > >() ?? ModSortOrder; - var file = Mod2.Manager.ModFileSystemFile; + var file = ModFileSystem.ModFileSystemFile; using var stream = File.Open( file, File.Exists( file ) ? FileMode.Truncate : FileMode.CreateNew ); using var writer = new StreamWriter( stream ); using var j = new JsonTextWriter( writer ); @@ -169,7 +169,7 @@ public partial class Configuration var data = JArray.Parse( text ); var maxPriority = 0; - var dict = new Dictionary< string, ModSettings2.SavedSettings >(); + var dict = new Dictionary< string, ModSettings.SavedSettings >(); foreach( var setting in data.Cast< JObject >() ) { var modName = ( string )setting[ "FolderName" ]!; @@ -178,7 +178,7 @@ public partial class Configuration var settings = setting[ "Settings" ]!.ToObject< Dictionary< string, uint > >() ?? setting[ "Conf" ]!.ToObject< Dictionary< string, uint > >(); - dict[ modName ] = new ModSettings2.SavedSettings() + dict[ modName ] = new ModSettings.SavedSettings() { Enabled = enabled, Priority = priority, diff --git a/Penumbra/Mods/Manager/Mod.Manager.BasePath.cs b/Penumbra/Mods/Manager/Mod.Manager.BasePath.cs new file mode 100644 index 00000000..ce095609 --- /dev/null +++ b/Penumbra/Mods/Manager/Mod.Manager.BasePath.cs @@ -0,0 +1,65 @@ +using System; +using System.IO; +using System.Linq; +using Dalamud.Logging; + +namespace Penumbra.Mods; + +public partial class Mod +{ + public partial class Manager + { + public delegate void ModPathChangeDelegate( ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, + DirectoryInfo? newDirectory ); + + public event ModPathChangeDelegate? ModPathChanged; + + public void MoveModDirectory( Index idx, DirectoryInfo newDirectory ) + { + var mod = this[ idx ]; + // TODO + } + + public void DeleteMod( int idx ) + { + var mod = this[ idx ]; + if( Directory.Exists( mod.BasePath.FullName ) ) + { + try + { + Directory.Delete( mod.BasePath.FullName, true ); + } + catch( Exception e ) + { + PluginLog.Error( $"Could not delete the mod {mod.BasePath.Name}:\n{e}" ); + } + } + + _mods.RemoveAt( idx ); + foreach( var remainingMod in _mods.Skip( idx ) ) + { + --remainingMod.Index; + } + + ModPathChanged?.Invoke( ModPathChangeType.Deleted, mod, mod.BasePath, null ); + } + + public void AddMod( DirectoryInfo modFolder ) + { + if( _mods.Any( m => m.BasePath.Name == modFolder.Name ) ) + { + return; + } + + var mod = LoadMod( modFolder ); + if( mod == null ) + { + return; + } + + mod.Index = _mods.Count; + _mods.Add( mod ); + ModPathChanged?.Invoke( ModPathChangeType.Added, mod, null, mod.BasePath ); + } + } +} \ No newline at end of file diff --git a/Penumbra/Mods/Manager/Mod2.Manager.Meta.cs b/Penumbra/Mods/Manager/Mod.Manager.Meta.cs similarity index 91% rename from Penumbra/Mods/Manager/Mod2.Manager.Meta.cs rename to Penumbra/Mods/Manager/Mod.Manager.Meta.cs index 5fa8c6f8..96fb8fa0 100644 --- a/Penumbra/Mods/Manager/Mod2.Manager.Meta.cs +++ b/Penumbra/Mods/Manager/Mod.Manager.Meta.cs @@ -2,11 +2,11 @@ using System; namespace Penumbra.Mods; -public sealed partial class Mod2 +public sealed partial class Mod { public partial class Manager { - public delegate void ModMetaChangeDelegate( MetaChangeType type, Mod2 mod ); + public delegate void ModMetaChangeDelegate( MetaChangeType type, Mod mod, string? oldName ); public event ModMetaChangeDelegate? ModMetaChanged; public void ChangeModName( Index idx, string newName ) @@ -14,9 +14,10 @@ public sealed partial class Mod2 var mod = this[ idx ]; if( mod.Name != newName ) { + var oldName = mod.Name; mod.Name = newName; mod.SaveMeta(); - ModMetaChanged?.Invoke( MetaChangeType.Name, mod ); + ModMetaChanged?.Invoke( MetaChangeType.Name, mod, oldName.Text ); } } @@ -27,7 +28,7 @@ public sealed partial class Mod2 { mod.Author = newAuthor; mod.SaveMeta(); - ModMetaChanged?.Invoke( MetaChangeType.Author, mod ); + ModMetaChanged?.Invoke( MetaChangeType.Author, mod, null ); } } @@ -38,7 +39,7 @@ public sealed partial class Mod2 { mod.Description = newDescription; mod.SaveMeta(); - ModMetaChanged?.Invoke( MetaChangeType.Description, mod ); + ModMetaChanged?.Invoke( MetaChangeType.Description, mod, null ); } } @@ -49,7 +50,7 @@ public sealed partial class Mod2 { mod.Version = newVersion; mod.SaveMeta(); - ModMetaChanged?.Invoke( MetaChangeType.Version, mod ); + ModMetaChanged?.Invoke( MetaChangeType.Version, mod, null ); } } @@ -60,7 +61,7 @@ public sealed partial class Mod2 { mod.Website = newWebsite; mod.SaveMeta(); - ModMetaChanged?.Invoke( MetaChangeType.Website, mod ); + ModMetaChanged?.Invoke( MetaChangeType.Website, mod, null ); } } } diff --git a/Penumbra/Mods/Manager/Mod2.Manager.Options.cs b/Penumbra/Mods/Manager/Mod.Manager.Options.cs similarity index 53% rename from Penumbra/Mods/Manager/Mod2.Manager.Options.cs rename to Penumbra/Mods/Manager/Mod.Manager.Options.cs index 248745ee..78f3d58c 100644 --- a/Penumbra/Mods/Manager/Mod2.Manager.Options.cs +++ b/Penumbra/Mods/Manager/Mod.Manager.Options.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using Dalamud.Logging; +using OtterGui.Filesystem; using Penumbra.GameData.ByteString; using Penumbra.Meta.Manipulations; using Penumbra.Util; @@ -13,25 +14,43 @@ public enum ModOptionChangeType GroupRenamed, GroupAdded, GroupDeleted, + GroupMoved, + GroupTypeChanged, PriorityChanged, OptionAdded, OptionDeleted, - OptionChanged, + OptionMoved, + OptionFilesChanged, + OptionSwapsChanged, + OptionMetaChanged, + OptionUpdated, DisplayChange, } -public sealed partial class Mod2 +public sealed partial class Mod { public sealed partial class Manager { - public delegate void ModOptionChangeDelegate( ModOptionChangeType type, Mod2 mod, int groupIdx, int optionIdx ); + public delegate void ModOptionChangeDelegate( ModOptionChangeType type, Mod mod, int groupIdx, int optionIdx, int movedToIdx ); public event ModOptionChangeDelegate ModOptionChanged; - public void RenameModGroup( Mod2 mod, int groupIdx, string newName ) + public void ChangeModGroupType( Mod mod, int groupIdx, SelectType type ) + { + var group = mod._groups[ groupIdx ]; + if( group.Type == type ) + { + return; + } + + mod._groups[ groupIdx ] = group.Convert( type ); + ModOptionChanged.Invoke( ModOptionChangeType.GroupTypeChanged, mod, groupIdx, -1, -1 ); + } + + public void RenameModGroup( Mod mod, int groupIdx, string newName ) { var group = mod._groups[ groupIdx ]; var oldName = group.Name; - if( oldName == newName || !VerifyFileName( mod, group, newName ) ) + if( oldName == newName || !VerifyFileName( mod, group, newName, true ) ) { return; } @@ -43,33 +62,41 @@ public sealed partial class Mod2 _ => newName, }; - ModOptionChanged.Invoke( ModOptionChangeType.GroupRenamed, mod, groupIdx, 0 ); + ModOptionChanged.Invoke( ModOptionChangeType.GroupRenamed, mod, groupIdx, -1, -1 ); } - public void AddModGroup( Mod2 mod, SelectType type, string newName ) + public void AddModGroup( Mod mod, SelectType type, string newName ) { - if( !VerifyFileName( mod, null, newName ) ) + if( !VerifyFileName( mod, null, newName, true ) ) { return; } - var maxPriority = mod._groups.Max( o => o.Priority ) + 1; + var maxPriority = mod._groups.Count == 0 ? 0 : mod._groups.Max( o => o.Priority ) + 1; mod._groups.Add( type == SelectType.Multi ? new MultiModGroup { Name = newName, Priority = maxPriority } : new SingleModGroup { Name = newName, Priority = maxPriority } ); - ModOptionChanged.Invoke( ModOptionChangeType.GroupAdded, mod, mod._groups.Count - 1, 0 ); + ModOptionChanged.Invoke( ModOptionChangeType.GroupAdded, mod, mod._groups.Count - 1, -1, -1 ); } - public void DeleteModGroup( Mod2 mod, int groupIdx ) + public void DeleteModGroup( Mod mod, int groupIdx ) { var group = mod._groups[ groupIdx ]; mod._groups.RemoveAt( groupIdx ); - group.DeleteFile( BasePath ); - ModOptionChanged.Invoke( ModOptionChangeType.GroupDeleted, mod, groupIdx, 0 ); + group.DeleteFile( mod.BasePath ); + ModOptionChanged.Invoke( ModOptionChangeType.GroupDeleted, mod, groupIdx, -1, -1 ); } - public void ChangeGroupDescription( Mod2 mod, int groupIdx, string newDescription ) + public void MoveModGroup( Mod mod, int groupIdxFrom, int groupIdxTo ) + { + if( mod._groups.Move( groupIdxFrom, groupIdxTo ) ) + { + ModOptionChanged.Invoke( ModOptionChangeType.GroupMoved, mod, groupIdxFrom, -1, groupIdxTo ); + } + } + + public void ChangeGroupDescription( Mod mod, int groupIdx, string newDescription ) { var group = mod._groups[ groupIdx ]; if( group.Description == newDescription ) @@ -83,10 +110,10 @@ public sealed partial class Mod2 MultiModGroup m => m.Description = newDescription, _ => newDescription, }; - ModOptionChanged.Invoke( ModOptionChangeType.DisplayChange, mod, groupIdx, 0 ); + ModOptionChanged.Invoke( ModOptionChangeType.DisplayChange, mod, groupIdx, -1, -1 ); } - public void ChangeGroupPriority( Mod2 mod, int groupIdx, int newPriority ) + public void ChangeGroupPriority( Mod mod, int groupIdx, int newPriority ) { var group = mod._groups[ groupIdx ]; if( group.Priority == newPriority ) @@ -100,14 +127,14 @@ public sealed partial class Mod2 MultiModGroup m => m.Priority = newPriority, _ => newPriority, }; - ModOptionChanged.Invoke( ModOptionChangeType.PriorityChanged, mod, groupIdx, -1 ); + ModOptionChanged.Invoke( ModOptionChangeType.PriorityChanged, mod, groupIdx, -1, -1 ); } - public void ChangeOptionPriority( Mod2 mod, int groupIdx, int optionIdx, int newPriority ) + public void ChangeOptionPriority( Mod mod, int groupIdx, int optionIdx, int newPriority ) { switch( mod._groups[ groupIdx ] ) { - case SingleModGroup s: + case SingleModGroup: ChangeGroupPriority( mod, groupIdx, newPriority ); break; case MultiModGroup m: @@ -117,12 +144,12 @@ public sealed partial class Mod2 } m.PrioritizedOptions[ optionIdx ] = ( m.PrioritizedOptions[ optionIdx ].Mod, newPriority ); - ModOptionChanged.Invoke( ModOptionChangeType.PriorityChanged, mod, groupIdx, optionIdx ); + ModOptionChanged.Invoke( ModOptionChangeType.PriorityChanged, mod, groupIdx, optionIdx, -1 ); return; } } - public void RenameOption( Mod2 mod, int groupIdx, int optionIdx, string newName ) + public void RenameOption( Mod mod, int groupIdx, int optionIdx, string newName ) { switch( mod._groups[ groupIdx ] ) { @@ -145,10 +172,10 @@ public sealed partial class Mod2 return; } - ModOptionChanged.Invoke( ModOptionChangeType.DisplayChange, mod, groupIdx, optionIdx ); + ModOptionChanged.Invoke( ModOptionChangeType.DisplayChange, mod, groupIdx, optionIdx, -1 ); } - public void AddOption( Mod2 mod, int groupIdx, string newName ) + public void AddOption( Mod mod, int groupIdx, string newName ) { switch( mod._groups[ groupIdx ] ) { @@ -160,10 +187,30 @@ public sealed partial class Mod2 break; } - ModOptionChanged.Invoke( ModOptionChangeType.OptionAdded, mod, groupIdx, mod._groups[ groupIdx ].Count - 1 ); + ModOptionChanged.Invoke( ModOptionChangeType.OptionAdded, mod, groupIdx, mod._groups[ groupIdx ].Count - 1, -1 ); } - public void DeleteOption( Mod2 mod, int groupIdx, int optionIdx ) + public void AddOption( Mod mod, int groupIdx, ISubMod option, int priority = 0 ) + { + if( option is not SubMod o ) + { + return; + } + + switch( mod._groups[ groupIdx ] ) + { + case SingleModGroup s: + s.OptionData.Add( o ); + break; + case MultiModGroup m: + m.PrioritizedOptions.Add( ( o, priority ) ); + break; + } + + ModOptionChanged.Invoke( ModOptionChangeType.OptionAdded, mod, groupIdx, mod._groups[ groupIdx ].Count - 1, -1 ); + } + + public void DeleteOption( Mod mod, int groupIdx, int optionIdx ) { switch( mod._groups[ groupIdx ] ) { @@ -175,10 +222,19 @@ public sealed partial class Mod2 break; } - ModOptionChanged.Invoke( ModOptionChangeType.OptionDeleted, mod, groupIdx, optionIdx ); + ModOptionChanged.Invoke( ModOptionChangeType.OptionDeleted, mod, groupIdx, optionIdx, -1 ); } - public void OptionSetManipulation( Mod2 mod, int groupIdx, int optionIdx, MetaManipulation manip, bool delete = false ) + public void MoveOption( Mod mod, int groupIdx, int optionIdxFrom, int optionIdxTo ) + { + var group = mod._groups[ groupIdx ]; + if( group.MoveOption( optionIdxFrom, optionIdxTo ) ) + { + ModOptionChanged.Invoke( ModOptionChangeType.OptionMoved, mod, groupIdx, optionIdxFrom, optionIdxTo ); + } + } + + public void OptionSetManipulation( Mod mod, int groupIdx, int optionIdx, MetaManipulation manip, bool delete = false ) { var subMod = GetSubMod( mod, groupIdx, optionIdx ); if( delete ) @@ -206,41 +262,94 @@ public sealed partial class Mod2 } } - ModOptionChanged.Invoke( ModOptionChangeType.OptionChanged, mod, groupIdx, optionIdx ); + ModOptionChanged.Invoke( ModOptionChangeType.OptionMetaChanged, mod, groupIdx, optionIdx, -1 ); } - public void OptionSetFile( Mod2 mod, int groupIdx, int optionIdx, Utf8GamePath gamePath, FullPath? newPath ) + public void OptionSetManipulations( Mod mod, int groupIdx, int optionIdx, HashSet< MetaManipulation > manipulations ) + { + var subMod = GetSubMod( mod, groupIdx, optionIdx ); + if( subMod.Manipulations.SetEquals( manipulations ) ) + { + return; + } + + subMod.ManipulationData.Clear(); + subMod.ManipulationData.UnionWith( manipulations ); + ModOptionChanged.Invoke( ModOptionChangeType.OptionMetaChanged, mod, groupIdx, optionIdx, -1 ); + } + + public void OptionSetFile( Mod mod, int groupIdx, int optionIdx, Utf8GamePath gamePath, FullPath? newPath ) { var subMod = GetSubMod( mod, groupIdx, optionIdx ); if( OptionSetFile( subMod.FileData, gamePath, newPath ) ) { - ModOptionChanged.Invoke( ModOptionChangeType.OptionChanged, mod, groupIdx, optionIdx ); + ModOptionChanged.Invoke( ModOptionChangeType.OptionFilesChanged, mod, groupIdx, optionIdx, -1 ); } } - public void OptionSetFileSwap( Mod2 mod, int groupIdx, int optionIdx, Utf8GamePath gamePath, FullPath? newPath ) + public void OptionSetFiles( Mod mod, int groupIdx, int optionIdx, Dictionary< Utf8GamePath, FullPath > replacements ) + { + var subMod = GetSubMod( mod, groupIdx, optionIdx ); + if( subMod.FileData.Equals( replacements ) ) + { + return; + } + + subMod.FileData.SetTo( replacements ); + ModOptionChanged.Invoke( ModOptionChangeType.OptionFilesChanged, mod, groupIdx, optionIdx, -1 ); + } + + public void OptionSetFileSwap( Mod mod, int groupIdx, int optionIdx, Utf8GamePath gamePath, FullPath? newPath ) { var subMod = GetSubMod( mod, groupIdx, optionIdx ); if( OptionSetFile( subMod.FileSwapData, gamePath, newPath ) ) { - ModOptionChanged.Invoke( ModOptionChangeType.OptionChanged, mod, groupIdx, optionIdx ); + ModOptionChanged.Invoke( ModOptionChangeType.OptionSwapsChanged, mod, groupIdx, optionIdx, -1 ); } } - private bool VerifyFileName( Mod2 mod, IModGroup? group, string newName ) + public void OptionSetFileSwaps( Mod mod, int groupIdx, int optionIdx, Dictionary< Utf8GamePath, FullPath > swaps ) + { + var subMod = GetSubMod( mod, groupIdx, optionIdx ); + if( subMod.FileSwapData.Equals( swaps ) ) + { + return; + } + + subMod.FileSwapData.SetTo( swaps ); + ModOptionChanged.Invoke( ModOptionChangeType.OptionSwapsChanged, mod, groupIdx, optionIdx, -1 ); + } + + public void OptionUpdate( Mod mod, int groupIdx, int optionIdx, Dictionary< Utf8GamePath, FullPath > replacements, + HashSet< MetaManipulation > manipulations, Dictionary< Utf8GamePath, FullPath > swaps ) + { + var subMod = GetSubMod( mod, groupIdx, optionIdx ); + subMod.FileData.SetTo( replacements ); + subMod.ManipulationData.Clear(); + subMod.ManipulationData.UnionWith( manipulations ); + subMod.FileSwapData.SetTo( swaps ); + ModOptionChanged.Invoke( ModOptionChangeType.OptionUpdated, mod, groupIdx, optionIdx, -1 ); + } + + public static bool VerifyFileName( Mod mod, IModGroup? group, string newName, bool message ) { var path = newName.RemoveInvalidPathSymbols(); - if( mod.Groups.Any( o => !ReferenceEquals( o, group ) + if( path.Length == 0 + || mod.Groups.Any( o => !ReferenceEquals( o, group ) && string.Equals( o.Name.RemoveInvalidPathSymbols(), path, StringComparison.InvariantCultureIgnoreCase ) ) ) { - PluginLog.Warning( $"Could not name option {newName} because option with same filename {path} already exists." ); + if( message ) + { + PluginLog.Warning( $"Could not name option {newName} because option with same filename {path} already exists." ); + } + return false; } return true; } - private static SubMod GetSubMod( Mod2 mod, int groupIdx, int optionIdx ) + private static SubMod GetSubMod( Mod mod, int groupIdx, int optionIdx ) { return mod._groups[ groupIdx ] switch { @@ -278,7 +387,7 @@ public sealed partial class Mod2 return true; } - private static void OnModOptionChange( ModOptionChangeType type, Mod2 mod, int groupIdx, int _ ) + private static void OnModOptionChange( ModOptionChangeType type, Mod mod, int groupIdx, int _, int _2 ) { // File deletion is handled in the actual function. if( type != ModOptionChangeType.GroupDeleted ) @@ -289,10 +398,11 @@ public sealed partial class Mod2 // State can not change on adding groups, as they have no immediate options. mod.HasOptions = type switch { - ModOptionChangeType.GroupDeleted => mod.HasOptions = mod.Groups.Any( o => o.IsOption ), - ModOptionChangeType.OptionAdded => mod.HasOptions |= mod._groups[ groupIdx ].IsOption, - ModOptionChangeType.OptionDeleted => mod.HasOptions = mod.Groups.Any( o => o.IsOption ), - _ => mod.HasOptions, + ModOptionChangeType.GroupDeleted => mod.HasOptions = mod.Groups.Any( o => o.IsOption ), + ModOptionChangeType.GroupTypeChanged => mod.HasOptions = mod.Groups.Any( o => o.IsOption ), + ModOptionChangeType.OptionAdded => mod.HasOptions |= mod._groups[ groupIdx ].IsOption, + ModOptionChangeType.OptionDeleted => mod.HasOptions = mod.Groups.Any( o => o.IsOption ), + _ => mod.HasOptions, }; } } diff --git a/Penumbra/Mods/Manager/Mod2.Manager.Root.cs b/Penumbra/Mods/Manager/Mod.Manager.Root.cs similarity index 96% rename from Penumbra/Mods/Manager/Mod2.Manager.Root.cs rename to Penumbra/Mods/Manager/Mod.Manager.Root.cs index 59f61e6c..6967f0bc 100644 --- a/Penumbra/Mods/Manager/Mod2.Manager.Root.cs +++ b/Penumbra/Mods/Manager/Mod.Manager.Root.cs @@ -4,7 +4,7 @@ using Dalamud.Logging; namespace Penumbra.Mods; -public sealed partial class Mod2 +public sealed partial class Mod { public sealed partial class Manager { @@ -50,7 +50,7 @@ public sealed partial class Mod2 } BasePath = newDir; - Valid = true; + Valid = Directory.Exists( newDir.FullName ); if( Penumbra.Config.ModDirectory != BasePath.FullName ) { Penumbra.Config.ModDirectory = BasePath.FullName; @@ -74,7 +74,7 @@ public sealed partial class Mod2 { continue; } - + mod.Index = _mods.Count; _mods.Add( mod ); } diff --git a/Penumbra/Mods/Manager/Mod2.Manager.cs b/Penumbra/Mods/Manager/Mod.Manager.cs similarity index 64% rename from Penumbra/Mods/Manager/Mod2.Manager.cs rename to Penumbra/Mods/Manager/Mod.Manager.cs index c2250d3a..e467c733 100644 --- a/Penumbra/Mods/Manager/Mod2.Manager.cs +++ b/Penumbra/Mods/Manager/Mod.Manager.cs @@ -4,22 +4,22 @@ using System.Collections.Generic; namespace Penumbra.Mods; -public sealed partial class Mod2 +public sealed partial class Mod { - public sealed partial class Manager : IEnumerable< Mod2 > + public sealed partial class Manager : IEnumerable< Mod > { - private readonly List< Mod2 > _mods = new(); + private readonly List< Mod > _mods = new(); - public Mod2 this[ Index idx ] + public Mod this[ Index idx ] => _mods[ idx ]; - public IReadOnlyList< Mod2 > Mods + public IReadOnlyList< Mod > Mods => _mods; public int Count => _mods.Count; - public IEnumerator< Mod2 > GetEnumerator() + public IEnumerator< Mod > GetEnumerator() => _mods.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() diff --git a/Penumbra/Mods/Manager/Mod2.Manager.BasePath.cs b/Penumbra/Mods/Manager/Mod2.Manager.BasePath.cs deleted file mode 100644 index a0c9b8ca..00000000 --- a/Penumbra/Mods/Manager/Mod2.Manager.BasePath.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.IO; -using Dalamud.Logging; - -namespace Penumbra.Mods; - -public partial class Mod2 -{ - public partial class Manager - { - public delegate void ModPathChangeDelegate( ModPathChangeType type, Mod2 mod, DirectoryInfo? oldDirectory, - DirectoryInfo? newDirectory ); - - public event ModPathChangeDelegate? ModPathChanged; - - public void MoveModDirectory( Index idx, DirectoryInfo newDirectory ) - { - var mod = this[ idx ]; - // TODO - } - - public void DeleteMod( Index idx ) - { - var mod = this[ idx ]; - if( Directory.Exists( mod.BasePath.FullName ) ) - { - try - { - Directory.Delete( mod.BasePath.FullName, true ); - } - catch( Exception e ) - { - PluginLog.Error( $"Could not delete the mod {mod.BasePath.Name}:\n{e}" ); - } - } - - // TODO - // mod.Order.ParentFolder.RemoveMod( mod ); - // _mods.RemoveAt( idx ); - //for( var i = idx; i < _mods.Count; ++i ) - //{ - // --_mods[i].Index; - //} - - ModPathChanged?.Invoke( ModPathChangeType.Deleted, mod, mod.BasePath, null ); - } - - public Mod2 AddMod( DirectoryInfo modFolder ) - { - // TODO - - //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 this[^1]; - } - } -} \ No newline at end of file diff --git a/Penumbra/Mods/Manager/Mod2.Manager.FileSystem.cs b/Penumbra/Mods/Manager/Mod2.Manager.FileSystem.cs deleted file mode 100644 index b8466c50..00000000 --- a/Penumbra/Mods/Manager/Mod2.Manager.FileSystem.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.IO; - -namespace Penumbra.Mods; - -public sealed partial class Mod2 -{ - public sealed partial class Manager - { - public static string ModFileSystemFile - => Path.Combine( Dalamud.PluginInterface.GetPluginConfigDirectory(), "sort_order.json" ); - } -} \ No newline at end of file diff --git a/Penumbra/Mods/Mod2.BasePath.cs b/Penumbra/Mods/Mod.BasePath.cs similarity index 82% rename from Penumbra/Mods/Mod2.BasePath.cs rename to Penumbra/Mods/Mod.BasePath.cs index 765b6106..c925a4f0 100644 --- a/Penumbra/Mods/Mod2.BasePath.cs +++ b/Penumbra/Mods/Mod.BasePath.cs @@ -10,15 +10,15 @@ public enum ModPathChangeType Moved, } -public partial class Mod2 +public partial class Mod { public DirectoryInfo BasePath { get; private set; } public int Index { get; private set; } = -1; - private Mod2( DirectoryInfo basePath ) + private Mod( DirectoryInfo basePath ) => BasePath = basePath; - public static Mod2? LoadMod( DirectoryInfo basePath ) + public static Mod? LoadMod( DirectoryInfo basePath ) { basePath.Refresh(); if( !basePath.Exists ) @@ -27,7 +27,7 @@ public partial class Mod2 return null; } - var mod = new Mod2( basePath ); + var mod = new Mod( basePath ); mod.LoadMeta(); if( mod.Name.Length == 0 ) { diff --git a/Penumbra/Mods/Mod2.ChangedItems.cs b/Penumbra/Mods/Mod.ChangedItems.cs similarity index 95% rename from Penumbra/Mods/Mod2.ChangedItems.cs rename to Penumbra/Mods/Mod.ChangedItems.cs index bcd7fc4b..9e68225c 100644 --- a/Penumbra/Mods/Mod2.ChangedItems.cs +++ b/Penumbra/Mods/Mod.ChangedItems.cs @@ -3,7 +3,7 @@ using System.Linq; namespace Penumbra.Mods; -public sealed partial class Mod2 +public sealed partial class Mod { public SortedList< string, object? > ChangedItems { get; } = new(); public string LowerChangedItemsString { get; private set; } = string.Empty; diff --git a/Penumbra/Mods/Mod.Creation.cs b/Penumbra/Mods/Mod.Creation.cs new file mode 100644 index 00000000..a8015c49 --- /dev/null +++ b/Penumbra/Mods/Mod.Creation.cs @@ -0,0 +1,154 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Dalamud.Utility; +using OtterGui.Classes; +using OtterGui.Filesystem; +using Penumbra.GameData.ByteString; +using Penumbra.Import; + +namespace Penumbra.Mods; + +public partial class Mod +{ + // Create and return a new directory based on the given directory and name, that is + // - Not Empty + // - Unique, by appending (digit) for duplicates. + // - Containing no symbols invalid for FFXIV or windows paths. + internal static DirectoryInfo CreateModFolder( DirectoryInfo outDirectory, string modListName ) + { + var name = Path.GetFileNameWithoutExtension( modListName ); + if( name.Length == 0 ) + { + name = "_"; + } + + var newModFolderBase = NewOptionDirectory( outDirectory, name ); + var newModFolder = newModFolderBase.FullName.ObtainUniqueFile(); + if( newModFolder.Length == 0 ) + { + throw new IOException( "Could not create mod folder: too many folders of the same name exist." ); + } + + Directory.CreateDirectory( newModFolder ); + return new DirectoryInfo( newModFolder ); + } + + // Create the name for a group or option subfolder based on its parent folder and given name. + // subFolderName should never be empty, and the result is unique and contains no invalid symbols. + internal static DirectoryInfo? NewSubFolderName( DirectoryInfo parentFolder, string subFolderName ) + { + var newModFolderBase = NewOptionDirectory( parentFolder, subFolderName ); + var newModFolder = newModFolderBase.FullName.ObtainUniqueFile(); + return newModFolder.Length == 0 ? null : new DirectoryInfo( newModFolder ); + } + + // Create the file containing the meta information about a mod from scratch. + internal static void CreateMeta( DirectoryInfo directory, string? name, string? author, string? description, string? version, + string? website ) + { + var mod = new Mod( directory ); + mod.Name = name.IsNullOrEmpty() ? mod.Name : new LowerString( name! ); + mod.Author = author != null ? new LowerString( author ) : mod.Author; + mod.Description = description ?? mod.Description; + mod.Version = version ?? mod.Version; + mod.Website = website ?? mod.Website; + mod.SaveMeta(); + } + + // Create a file for an option group from given data. + internal static void CreateOptionGroup( DirectoryInfo baseFolder, ModGroup groupData, + int priority, string desc, IEnumerable< ISubMod > subMods ) + { + switch( groupData.SelectionType ) + { + case SelectType.Multi: + { + var group = new MultiModGroup() + { + Name = groupData.GroupName!, + Description = desc, + Priority = priority, + }; + group.PrioritizedOptions.AddRange( subMods.OfType< SubMod >().Select( ( s, idx ) => ( s, idx ) ) ); + IModGroup.SaveModGroup( group, baseFolder ); + break; + } + case SelectType.Single: + { + var group = new SingleModGroup() + { + Name = groupData.GroupName!, + Description = desc, + Priority = priority, + }; + group.OptionData.AddRange( subMods.OfType< SubMod >() ); + IModGroup.SaveModGroup( group, baseFolder ); + break; + } + } + } + + // Create the data for a given sub mod from its data and the folder it is based on. + internal static ISubMod CreateSubMod( DirectoryInfo baseFolder, DirectoryInfo optionFolder, OptionList option ) + { + var list = optionFolder.EnumerateFiles( "*.*", SearchOption.AllDirectories ) + .Select( f => ( Utf8GamePath.FromFile( f, optionFolder, out var gamePath, true ), gamePath, new FullPath( f ) ) ) + .Where( t => t.Item1 ); + + var mod = new SubMod() + { + Name = option.Name!, + }; + foreach( var (_, gamePath, file) in list ) + { + mod.FileData.TryAdd( gamePath, file ); + } + + mod.IncorporateMetaChanges( baseFolder, true ); + return mod; + } + + // Create the default data file from all unused files that were not handled before + // and are used in sub mods. + internal static void CreateDefaultFiles( DirectoryInfo directory ) + { + var mod = new Mod( directory ); + foreach( var file in mod.FindUnusedFiles() ) + { + if( Utf8GamePath.FromFile( new FileInfo( file.FullName ), directory, out var gamePath, true ) ) + { + mod._default.FileData.TryAdd( gamePath, file ); + } + } + + mod._default.IncorporateMetaChanges( directory, true ); + mod.SaveDefaultMod(); + } + + // Return the name of a new valid directory based on the base directory and the given name. + private static DirectoryInfo NewOptionDirectory( DirectoryInfo baseDir, string optionName ) + => new(Path.Combine( baseDir.FullName, ReplaceBadXivSymbols( optionName ) )); + + + // XIV can not deal with non-ascii symbols in a path, + // and the path must obviously be valid itself. + private static string ReplaceBadXivSymbols( string s, string replacement = "_" ) + { + StringBuilder sb = new(s.Length); + foreach( var c in s ) + { + if( c.IsInvalidAscii() || c.IsInvalidInPath() ) + { + sb.Append( replacement ); + } + else + { + sb.Append( c ); + } + } + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/Penumbra/Mods/Mod2.Files.cs b/Penumbra/Mods/Mod.Files.cs similarity index 99% rename from Penumbra/Mods/Mod2.Files.cs rename to Penumbra/Mods/Mod.Files.cs index a9353bc1..4ff1bbd9 100644 --- a/Penumbra/Mods/Mod2.Files.cs +++ b/Penumbra/Mods/Mod.Files.cs @@ -9,7 +9,7 @@ using Penumbra.Meta.Manipulations; namespace Penumbra.Mods; -public partial class Mod2 +public partial class Mod { public ISubMod Default => _default; diff --git a/Penumbra/Mods/Mod2.Meta.Migration.cs b/Penumbra/Mods/Mod.Meta.Migration.cs similarity index 61% rename from Penumbra/Mods/Mod2.Meta.Migration.cs rename to Penumbra/Mods/Mod.Meta.Migration.cs index 5b13121f..6b2ae97b 100644 --- a/Penumbra/Mods/Mod2.Meta.Migration.cs +++ b/Penumbra/Mods/Mod.Meta.Migration.cs @@ -6,19 +6,18 @@ using Dalamud.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Penumbra.GameData.ByteString; -using Penumbra.Importer; using Penumbra.Util; namespace Penumbra.Mods; -public sealed partial class Mod2 +public sealed partial class Mod { private static class Migration { - public static bool Migrate( Mod2 mod, JObject json ) + public static bool Migrate( Mod mod, JObject json ) => MigrateV0ToV1( mod, json ); - private static bool MigrateV0ToV1( Mod2 mod, JObject json ) + private static bool MigrateV0ToV1( Mod mod, JObject json ) { if( mod.FileVersion > 0 ) { @@ -27,14 +26,15 @@ public sealed partial class Mod2 var swaps = json[ "FileSwaps" ]?.ToObject< Dictionary< Utf8GamePath, FullPath > >() ?? new Dictionary< Utf8GamePath, FullPath >(); - var groups = json[ "Groups" ]?.ToObject< Dictionary< string, OptionGroupV0 > >() ?? new Dictionary< string, OptionGroupV0 >(); + var groups = json[ "Groups" ]?.ToObject< Dictionary< string, OptionGroupV0 > >() ?? new Dictionary< string, OptionGroupV0 >(); var priority = 1; + var seenMetaFiles = new HashSet< FullPath >(); foreach( var group in groups.Values ) { - ConvertGroup( mod, group, ref priority ); + ConvertGroup( mod, group, ref priority, seenMetaFiles ); } - foreach( var unusedFile in mod.FindUnusedFiles() ) + foreach( var unusedFile in mod.FindUnusedFiles().Where( f => !seenMetaFiles.Contains( f ) ) ) { if( unusedFile.ToGamePath( mod.BasePath, out var gamePath ) && !mod._default.FileData.TryAdd( gamePath, unusedFile ) ) @@ -61,7 +61,7 @@ public sealed partial class Mod2 return true; } - private static void ConvertGroup( Mod2 mod, OptionGroupV0 group, ref int priority ) + private static void ConvertGroup( Mod mod, OptionGroupV0 group, ref int priority, HashSet< FullPath > seenMetaFiles ) { if( group.Options.Count == 0 ) { @@ -82,14 +82,14 @@ public sealed partial class Mod2 mod._groups.Add( newMultiGroup ); foreach( var option in group.Options ) { - newMultiGroup.PrioritizedOptions.Add( ( SubModFromOption( mod.BasePath, option ), optionPriority++ ) ); + newMultiGroup.PrioritizedOptions.Add( ( SubModFromOption( mod.BasePath, option, seenMetaFiles ), optionPriority++ ) ); } break; case SelectType.Single: if( group.Options.Count == 1 ) { - AddFilesToSubMod( mod._default, mod.BasePath, group.Options[ 0 ] ); + AddFilesToSubMod( mod._default, mod.BasePath, group.Options[ 0 ], seenMetaFiles ); return; } @@ -102,28 +102,34 @@ public sealed partial class Mod2 mod._groups.Add( newSingleGroup ); foreach( var option in group.Options ) { - newSingleGroup.OptionData.Add( SubModFromOption( mod.BasePath, option ) ); + newSingleGroup.OptionData.Add( SubModFromOption( mod.BasePath, option, seenMetaFiles ) ); } break; } } - private static void AddFilesToSubMod( SubMod mod, DirectoryInfo basePath, OptionV0 option ) + private static void AddFilesToSubMod( SubMod mod, DirectoryInfo basePath, OptionV0 option, HashSet< FullPath > seenMetaFiles ) { foreach( var (relPath, gamePaths) in option.OptionFiles ) { + var fullPath = new FullPath( basePath, relPath ); foreach( var gamePath in gamePaths ) { - mod.FileData.TryAdd( gamePath, new FullPath( basePath, relPath ) ); + mod.FileData.TryAdd( gamePath, fullPath ); + } + + if( fullPath.Extension is ".meta" or ".rgsp" ) + { + seenMetaFiles.Add( fullPath ); } } } - private static SubMod SubModFromOption( DirectoryInfo basePath, OptionV0 option ) + private static SubMod SubModFromOption( DirectoryInfo basePath, OptionV0 option, HashSet< FullPath > seenMetaFiles ) { - var subMod = new SubMod() { Name = option.OptionName }; - AddFilesToSubMod( subMod, basePath, option ); + var subMod = new SubMod { Name = option.OptionName }; + AddFilesToSubMod( subMod, basePath, option, seenMetaFiles ); subMod.IncorporateMetaChanges( basePath, false ); return subMod; } @@ -152,5 +158,45 @@ public sealed partial class Mod2 public OptionGroupV0() { } } + + // Not used anymore, but required for migration. + private class SingleOrArrayConverter< T > : JsonConverter + { + public override bool CanConvert( Type objectType ) + => objectType == typeof( HashSet< T > ); + + public override object ReadJson( JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer ) + { + var token = JToken.Load( reader ); + + if( token.Type == JTokenType.Array ) + { + return token.ToObject< HashSet< T > >() ?? new HashSet< T >(); + } + + var tmp = token.ToObject< T >(); + return tmp != null + ? new HashSet< T > { tmp } + : new HashSet< T >(); + } + + public override bool CanWrite + => true; + + public override void WriteJson( JsonWriter writer, object? value, JsonSerializer serializer ) + { + writer.WriteStartArray(); + if( value != null ) + { + var v = ( HashSet< T > )value; + foreach( var val in v ) + { + serializer.Serialize( writer, val?.ToString() ); + } + } + + writer.WriteEndArray(); + } + } } } \ No newline at end of file diff --git a/Penumbra/Mods/Mod2.Meta.cs b/Penumbra/Mods/Mod.Meta.cs similarity index 98% rename from Penumbra/Mods/Mod2.Meta.cs rename to Penumbra/Mods/Mod.Meta.cs index bcc05cf8..33760dfe 100644 --- a/Penumbra/Mods/Mod2.Meta.cs +++ b/Penumbra/Mods/Mod.Meta.cs @@ -4,6 +4,7 @@ using Dalamud.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using OtterGui; +using OtterGui.Classes; namespace Penumbra.Mods; @@ -20,7 +21,7 @@ public enum MetaChangeType : byte Migration = 0x40, } -public sealed partial class Mod2 +public sealed partial class Mod { public const uint CurrentFileVersion = 1; public uint FileVersion { get; private set; } = CurrentFileVersion; diff --git a/Penumbra/Mods/Mod2.Creation.cs b/Penumbra/Mods/Mod2.Creation.cs deleted file mode 100644 index 915ace3f..00000000 --- a/Penumbra/Mods/Mod2.Creation.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Penumbra.GameData.ByteString; -using Penumbra.Importer.Models; - -namespace Penumbra.Mods; - -public partial class Mod2 -{ - internal static void CreateMeta( DirectoryInfo directory, string? name, string? author, string? description, string? version, - string? website ) - { - var mod = new Mod2( directory ); - if( name is { Length: 0 } ) - { - mod.Name = name; - } - - if( author != null ) - { - mod.Author = author; - } - - if( description != null ) - { - mod.Description = description; - } - - if( version != null ) - { - mod.Version = version; - } - - if( website != null ) - { - mod.Website = website; - } - - mod.SaveMeta(); - } - - internal static void CreateOptionGroup( DirectoryInfo baseFolder, ModGroup groupData, - int priority, string desc, List< ISubMod > subMods ) - { - switch( groupData.SelectionType ) - { - case SelectType.Multi: - { - var group = new MultiModGroup() - { - Name = groupData.GroupName!, - Description = desc, - Priority = priority, - }; - group.PrioritizedOptions.AddRange( subMods.OfType< SubMod >().Select( ( s, idx ) => ( s, idx ) ) ); - IModGroup.SaveModGroup( group, baseFolder ); - break; - } - case SelectType.Single: - { - var group = new SingleModGroup() - { - Name = groupData.GroupName!, - Description = desc, - Priority = priority, - }; - group.OptionData.AddRange( subMods.OfType< SubMod >() ); - IModGroup.SaveModGroup( group, baseFolder ); - break; - } - } - } - - internal static ISubMod CreateSubMod( DirectoryInfo baseFolder, DirectoryInfo optionFolder, OptionList option ) - { - var list = optionFolder.EnumerateFiles( "*.*", SearchOption.AllDirectories ) - .Select( f => ( Utf8GamePath.FromFile( f, optionFolder, out var gamePath, true ), gamePath, new FullPath( f ) ) ) - .Where( t => t.Item1 ); - - var mod = new SubMod() - { - Name = option.Name!, - }; - foreach( var (_, gamePath, file) in list ) - { - mod.FileData.TryAdd( gamePath, file ); - } - - mod.IncorporateMetaChanges( baseFolder, true ); - return mod; - } - - internal static void CreateDefaultFiles( DirectoryInfo directory ) - { - var mod = new Mod2( directory ); - foreach( var file in mod.FindUnusedFiles() ) - { - if( Utf8GamePath.FromFile( new FileInfo( file.FullName ), directory, out var gamePath, true ) ) - { - mod._default.FileData.TryAdd( gamePath, file ); - } - } - - mod._default.IncorporateMetaChanges( directory, true ); - mod.SaveDefaultMod(); - } -} \ No newline at end of file diff --git a/Penumbra/Mods/ModCleanup.cs b/Penumbra/Mods/ModCleanup.cs index 80e7632b..9497c9c6 100644 --- a/Penumbra/Mods/ModCleanup.cs +++ b/Penumbra/Mods/ModCleanup.cs @@ -7,7 +7,7 @@ using System.Linq; using System.Security.Cryptography; using Dalamud.Logging; using Penumbra.GameData.ByteString; -using Penumbra.Importer; +using Penumbra.Import; using Penumbra.Util; namespace Penumbra.Mods; diff --git a/Penumbra/Mods/ModFileSystem.cs b/Penumbra/Mods/ModFileSystem.cs index 3a65011b..d310250b 100644 --- a/Penumbra/Mods/ModFileSystem.cs +++ b/Penumbra/Mods/ModFileSystem.cs @@ -1,16 +1,21 @@ using System; using System.IO; +using System.Linq; +using System.Text.RegularExpressions; using OtterGui.Filesystem; namespace Penumbra.Mods; -public sealed class ModFileSystem : FileSystem< Mod2 >, IDisposable +public sealed class ModFileSystem : FileSystem< Mod >, IDisposable { + public static string ModFileSystemFile + => Path.Combine( Dalamud.PluginInterface.GetPluginConfigDirectory(), "sort_order.json" ); + // Save the current sort order. // Does not save or copy the backup in the current mod directory, // as this is done on mod directory changes only. public void Save() - => SaveToFile( new FileInfo( Mod2.Manager.ModFileSystemFile ), SaveMod, true ); + => SaveToFile( new FileInfo( ModFileSystemFile ), SaveMod, true ); // Create a new ModFileSystem from the currently loaded mods and the current sort order file. public static ModFileSystem Load() @@ -20,18 +25,24 @@ public sealed class ModFileSystem : FileSystem< Mod2 >, IDisposable ret.Changed += ret.OnChange; Penumbra.ModManager.ModDiscoveryFinished += ret.Reload; + Penumbra.ModManager.ModMetaChanged += ret.OnMetaChange; + Penumbra.ModManager.ModPathChanged += ret.OnModPathChange; return ret; } public void Dispose() - => Penumbra.ModManager.ModDiscoveryFinished -= Reload; + { + Penumbra.ModManager.ModPathChanged -= OnModPathChange; + Penumbra.ModManager.ModDiscoveryFinished -= Reload; + Penumbra.ModManager.ModMetaChanged -= OnMetaChange; + } // Reload the whole filesystem from currently loaded mods and the current sort order file. // Used on construction and on mod rediscoveries. private void Reload() { - if( Load( new FileInfo( Mod2.Manager.ModFileSystemFile ), Penumbra.ModManager.Mods, ModToIdentifier, ModToName ) ) + if( Load( new FileInfo( ModFileSystemFile ), Penumbra.ModManager.Mods, ModToIdentifier, ModToName ) ) { Save(); } @@ -46,17 +57,61 @@ public sealed class ModFileSystem : FileSystem< Mod2 >, IDisposable } } + // Update sort order when defaulted mod names change. + private void OnMetaChange( MetaChangeType type, Mod mod, string? oldName ) + { + if( type.HasFlag( MetaChangeType.Name ) && oldName != null ) + { + var old = oldName.FixName(); + if( Find( old, out var child ) ) + { + Rename( child, mod.Name.Text ); + } + } + } + + // Update the filesystem if a mod has been added or removed. + // Save it, if the mod directory has been moved, since this will change the save format. + private void OnModPathChange( ModPathChangeType type, Mod mod, DirectoryInfo? oldPath, DirectoryInfo? newPath ) + { + switch( type ) + { + case ModPathChangeType.Added: + var originalName = mod.Name.Text.FixName(); + var name = originalName; + var counter = 1; + while( Find( name, out _ ) ) + { + name = $"{originalName} ({++counter})"; + } + + CreateLeaf( Root, name, mod ); + break; + case ModPathChangeType.Deleted: + var leaf = Root.GetAllDescendants( SortMode.Lexicographical ).OfType< Leaf >().FirstOrDefault( l => l.Value == mod ); + if( leaf != null ) + { + Delete( leaf ); + } + break; + case ModPathChangeType.Moved: + Save(); + break; + } + } + // Used for saving and loading. - private static string ModToIdentifier( Mod2 mod ) + private static string ModToIdentifier( Mod mod ) => mod.BasePath.Name; - private static string ModToName( Mod2 mod ) - => mod.Name.Text; + private static string ModToName( Mod mod ) + => mod.Name.Text.FixName(); - private static (string, bool) SaveMod( Mod2 mod, string fullPath ) + private static (string, bool) SaveMod( Mod mod, string fullPath ) { + var regex = new Regex( $@"^{Regex.Escape( ModToName( mod ) )}( \(\d+\))?" ); // Only save pairs with non-default paths. - if( fullPath == ModToName( mod ) ) + if( regex.IsMatch( fullPath ) ) { return ( string.Empty, false ); } diff --git a/Penumbra/Mods/Subclasses/IModGroup.cs b/Penumbra/Mods/Subclasses/IModGroup.cs index ac643ecb..924e294e 100644 --- a/Penumbra/Mods/Subclasses/IModGroup.cs +++ b/Penumbra/Mods/Subclasses/IModGroup.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using Dalamud.Logging; using Newtonsoft.Json; +using OtterGui.Filesystem; using Penumbra.Util; namespace Penumbra.Mods; @@ -76,4 +77,7 @@ public interface IModGroup : IEnumerable< ISubMod > j.WriteEndArray(); j.WriteEndObject(); } + + public IModGroup Convert( SelectType type ); + public bool MoveOption( int optionIdxFrom, int optionIdxTo ); } \ No newline at end of file diff --git a/Penumbra/Mods/Subclasses/Mod2.Files.MultiModGroup.cs b/Penumbra/Mods/Subclasses/Mod.Files.MultiModGroup.cs similarity index 70% rename from Penumbra/Mods/Subclasses/Mod2.Files.MultiModGroup.cs rename to Penumbra/Mods/Subclasses/Mod.Files.MultiModGroup.cs index cd05a844..88350364 100644 --- a/Penumbra/Mods/Subclasses/Mod2.Files.MultiModGroup.cs +++ b/Penumbra/Mods/Subclasses/Mod.Files.MultiModGroup.cs @@ -5,10 +5,11 @@ using System.IO; using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using OtterGui.Filesystem; namespace Penumbra.Mods; -public partial class Mod2 +public partial class Mod { private sealed class MultiModGroup : IModGroup { @@ -63,5 +64,26 @@ public partial class Mod2 return ret; } + + public IModGroup Convert( SelectType type ) + { + switch( type ) + { + case SelectType.Multi: return this; + case SelectType.Single: + var multi = new SingleModGroup() + { + Name = Name, + Description = Description, + Priority = Priority, + }; + multi.OptionData.AddRange( PrioritizedOptions.Select( p => p.Mod ) ); + return multi; + default: throw new ArgumentOutOfRangeException( nameof( type ), type, null ); + } + } + + public bool MoveOption( int optionIdxFrom, int optionIdxTo ) + => PrioritizedOptions.Move( optionIdxFrom, optionIdxTo ); } } \ No newline at end of file diff --git a/Penumbra/Mods/Subclasses/Mod2.Files.SingleModGroup.cs b/Penumbra/Mods/Subclasses/Mod.Files.SingleModGroup.cs similarity index 68% rename from Penumbra/Mods/Subclasses/Mod2.Files.SingleModGroup.cs rename to Penumbra/Mods/Subclasses/Mod.Files.SingleModGroup.cs index 40ebbcce..8c0b4103 100644 --- a/Penumbra/Mods/Subclasses/Mod2.Files.SingleModGroup.cs +++ b/Penumbra/Mods/Subclasses/Mod.Files.SingleModGroup.cs @@ -2,12 +2,14 @@ using System; using System.Collections; using System.Collections.Generic; using System.IO; +using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using OtterGui.Filesystem; namespace Penumbra.Mods; -public partial class Mod2 +public partial class Mod { private sealed class SingleModGroup : IModGroup { @@ -62,5 +64,26 @@ public partial class Mod2 return ret; } + + public IModGroup Convert( SelectType type ) + { + switch( type ) + { + case SelectType.Single: return this; + case SelectType.Multi: + var multi = new MultiModGroup() + { + Name = Name, + Description = Description, + Priority = Priority, + }; + multi.PrioritizedOptions.AddRange( OptionData.Select( ( o, i ) => ( o, i ) ) ); + return multi; + default: throw new ArgumentOutOfRangeException( nameof( type ), type, null ); + } + } + + public bool MoveOption( int optionIdxFrom, int optionIdxTo ) + => OptionData.Move( optionIdxFrom, optionIdxTo ); } } \ No newline at end of file diff --git a/Penumbra/Mods/Subclasses/Mod2.Files.SubMod.cs b/Penumbra/Mods/Subclasses/Mod.Files.SubMod.cs similarity index 81% rename from Penumbra/Mods/Subclasses/Mod2.Files.SubMod.cs rename to Penumbra/Mods/Subclasses/Mod.Files.SubMod.cs index ccbf2df8..e9be472d 100644 --- a/Penumbra/Mods/Subclasses/Mod2.Files.SubMod.cs +++ b/Penumbra/Mods/Subclasses/Mod.Files.SubMod.cs @@ -6,12 +6,12 @@ using Dalamud.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Penumbra.GameData.ByteString; -using Penumbra.Importer; +using Penumbra.Import; using Penumbra.Meta.Manipulations; namespace Penumbra.Mods; -public partial class Mod2 +public partial class Mod { private string DefaultFile => Path.Combine( BasePath.FullName, "default_mod.json" ); @@ -135,31 +135,7 @@ public partial class Mod2 { File.Delete( file.FullName ); } - - foreach( var manip in meta.EqpManipulations ) - { - ManipulationData.Add( manip ); - } - - foreach( var manip in meta.EqdpManipulations ) - { - ManipulationData.Add( manip ); - } - - foreach( var manip in meta.EstManipulations ) - { - ManipulationData.Add( manip ); - } - - foreach( var manip in meta.GmpManipulations ) - { - ManipulationData.Add( manip ); - } - - foreach( var manip in meta.ImcManipulations ) - { - ManipulationData.Add( manip ); - } + ManipulationData.UnionWith( meta.MetaManipulations ); break; case ".rgsp": @@ -174,11 +150,7 @@ public partial class Mod2 { File.Delete( file.FullName ); } - - foreach( var manip in rgsp.RspManipulations ) - { - ManipulationData.Add( manip ); - } + ManipulationData.UnionWith( rgsp.MetaManipulations ); break; default: continue; diff --git a/Penumbra/Mods/Subclasses/ModSettings.cs b/Penumbra/Mods/Subclasses/ModSettings.cs index d40f578b..982776f8 100644 --- a/Penumbra/Mods/Subclasses/ModSettings.cs +++ b/Penumbra/Mods/Subclasses/ModSettings.cs @@ -1,19 +1,20 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Numerics; +using OtterGui.Filesystem; namespace Penumbra.Mods; - // Contains the settings for a given mod. -public class ModSettings2 +public class ModSettings { - public static readonly ModSettings2 Empty = new(); + public static readonly ModSettings Empty = new(); public List< uint > Settings { get; init; } = new(); public int Priority { get; set; } public bool Enabled { get; set; } - public ModSettings2 DeepCopy() + public ModSettings DeepCopy() => new() { Enabled = Enabled, @@ -21,7 +22,7 @@ public class ModSettings2 Settings = Settings.ToList(), }; - public static ModSettings2 DefaultSettings( Mod2 mod ) + public static ModSettings DefaultSettings( Mod mod ) => new() { Enabled = false, @@ -29,19 +30,31 @@ public class ModSettings2 Settings = Enumerable.Repeat( 0u, mod.Groups.Count ).ToList(), }; - - - public void HandleChanges( ModOptionChangeType type, Mod2 mod, int groupIdx, int optionIdx ) + public bool HandleChanges( ModOptionChangeType type, Mod mod, int groupIdx, int optionIdx, int movedToIdx ) { switch( type ) { + case ModOptionChangeType.GroupRenamed: return true; case ModOptionChangeType.GroupAdded: Settings.Insert( groupIdx, 0 ); - break; + return true; case ModOptionChangeType.GroupDeleted: Settings.RemoveAt( groupIdx ); - break; + return true; + case ModOptionChangeType.GroupTypeChanged: + { + var group = mod.Groups[ groupIdx ]; + var config = Settings[ groupIdx ]; + Settings[ groupIdx ] = group.Type switch + { + SelectType.Single => ( uint )Math.Min( group.Count - 1, BitOperations.TrailingZeroCount( config ) ), + SelectType.Multi => 1u << ( int )config, + _ => config, + }; + return config != Settings[ groupIdx ]; + } case ModOptionChangeType.OptionDeleted: + { var group = mod.Groups[ groupIdx ]; var config = Settings[ groupIdx ]; Settings[ groupIdx ] = group.Type switch @@ -50,20 +63,38 @@ public class ModSettings2 SelectType.Multi => RemoveBit( config, optionIdx ), _ => config, }; - break; + return config != Settings[ groupIdx ]; + } + case ModOptionChangeType.GroupMoved: return Settings.Move( groupIdx, movedToIdx ); + case ModOptionChangeType.OptionMoved: + { + var group = mod.Groups[ groupIdx ]; + var config = Settings[ groupIdx ]; + Settings[ groupIdx ] = group.Type switch + { + SelectType.Single => config == optionIdx ? ( uint )movedToIdx : config, + SelectType.Multi => MoveBit( config, optionIdx, movedToIdx ), + _ => config, + }; + return config != Settings[ groupIdx ]; + } + default: return false; } } - public void SetValue( Mod2 mod, int groupIdx, uint newValue ) + private static uint FixSetting( IModGroup group, uint value ) + => group.Type switch + { + SelectType.Single => ( uint )Math.Min( value, group.Count - 1 ), + SelectType.Multi => ( uint )( value & ( ( 1 << group.Count ) - 1 ) ), + _ => value, + }; + + public void SetValue( Mod mod, int groupIdx, uint newValue ) { AddMissingSettings( groupIdx + 1 ); var group = mod.Groups[ groupIdx ]; - Settings[ groupIdx ] = group.Type switch - { - SelectType.Single => ( uint )Math.Max( newValue, group.Count ), - SelectType.Multi => ( ( 1u << group.Count ) - 1 ) & newValue, - _ => newValue, - }; + Settings[ groupIdx ] = FixSetting( group, newValue ); } private static uint RemoveBit( uint config, int bit ) @@ -75,6 +106,16 @@ public class ModSettings2 return low | high; } + private static uint MoveBit( uint config, int bit1, int bit2 ) + { + var enabled = ( config & ( 1 << bit1 ) ) != 0 ? 1u << bit2 : 0u; + config = RemoveBit( config, bit1 ); + var lowMask = ( 1u << bit2 ) - 1u; + var low = config & lowMask; + var high = ( config & ~lowMask ) << 1; + return low | enabled | high; + } + internal bool AddMissingSettings( int totalCount ) { if( totalCount <= Settings.Count ) @@ -100,7 +141,7 @@ public class ModSettings2 Settings = Settings.ToDictionary( kvp => kvp.Key, kvp => kvp.Value ), }; - public SavedSettings( ModSettings2 settings, Mod2 mod ) + public SavedSettings( ModSettings settings, Mod mod ) { Priority = settings.Priority; Enabled = settings.Enabled; @@ -113,7 +154,7 @@ public class ModSettings2 } } - public bool ToSettings( Mod2 mod, out ModSettings2 settings ) + public bool ToSettings( Mod mod, out ModSettings settings ) { var list = new List< uint >( mod.Groups.Count ); var changes = Settings.Count != mod.Groups.Count; @@ -121,7 +162,12 @@ public class ModSettings2 { if( Settings.TryGetValue( group.Name, out var config ) ) { - list.Add( config ); + var actualConfig = FixSetting( group, config ); + list.Add( actualConfig ); + if( actualConfig != config ) + { + changes = true; + } } else { @@ -130,7 +176,7 @@ public class ModSettings2 } } - settings = new ModSettings2 + settings = new ModSettings { Enabled = Enabled, Priority = Priority, diff --git a/Penumbra/Penumbra.cs b/Penumbra/Penumbra.cs index f4a931fa..50ce0d07 100644 --- a/Penumbra/Penumbra.cs +++ b/Penumbra/Penumbra.cs @@ -40,7 +40,7 @@ public class Penumbra : IDalamudPlugin public static ResidentResourceManager ResidentResources { get; private set; } = null!; public static CharacterUtility CharacterUtility { get; private set; } = null!; public static MetaFileManager MetaFileManager { get; private set; } = null!; - public static Mod2.Manager ModManager { get; private set; } = null!; + public static Mod.Manager ModManager { get; private set; } = null!; public static ModCollection.Manager CollectionManager { get; private set; } = null!; public static SimpleRedirectManager Redirects { get; private set; } = null!; public static ResourceLoader ResourceLoader { get; private set; } = null!; @@ -78,7 +78,7 @@ public class Penumbra : IDalamudPlugin MetaFileManager = new MetaFileManager(); ResourceLoader = new ResourceLoader( this ); ResourceLogger = new ResourceLogger( ResourceLoader ); - ModManager = new Mod2.Manager( Config.ModDirectory ); + ModManager = new Mod.Manager( Config.ModDirectory ); ModManager.DiscoverMods(); CollectionManager = new ModCollection.Manager( ModManager ); ModFileSystem = ModFileSystem.Load(); @@ -138,6 +138,7 @@ public class Penumbra : IDalamudPlugin btn = new LaunchButton( _configWindow ); system = new WindowSystem( Name ); system.AddWindow( _configWindow ); + system.AddWindow( cfg.SubModPopup ); Dalamud.PluginInterface.UiBuilder.Draw += system.Draw; Dalamud.PluginInterface.UiBuilder.OpenConfigUi += cfg.Toggle; } @@ -294,8 +295,7 @@ public class Penumbra : IDalamudPlugin case "reload": { ModManager.DiscoverMods(); - Dalamud.Chat.Print( - $"Reloaded Penumbra mods. You have {ModManager.Mods.Count} mods." + Dalamud.Chat.Print( $"Reloaded Penumbra mods. You have {ModManager.Mods.Count} mods." ); break; } @@ -314,7 +314,8 @@ public class Penumbra : IDalamudPlugin } case "debug": { - // TODO + Config.DebugMode = true; + Config.Save(); break; } case "enable": @@ -370,7 +371,7 @@ public class Penumbra : IDalamudPlugin { var list = new DirectoryInfo( ModCollection.CollectionDirectory ).EnumerateFiles( "*.json" ).ToList(); list.Add( Dalamud.PluginInterface.ConfigFile ); - list.Add( new FileInfo( Mod2.Manager.ModFileSystemFile ) ); + list.Add( new FileInfo( ModFileSystem.ModFileSystemFile ) ); list.Add( new FileInfo( ModCollection.Manager.ActiveCollectionFile ) ); return list; } diff --git a/Penumbra/UI/Classes/ModFileSystemSelector.Filters.cs b/Penumbra/UI/Classes/ModFileSystemSelector.Filters.cs index 66473453..de9cf7b7 100644 --- a/Penumbra/UI/Classes/ModFileSystemSelector.Filters.cs +++ b/Penumbra/UI/Classes/ModFileSystemSelector.Filters.cs @@ -5,6 +5,7 @@ using System.Numerics; using System.Runtime.InteropServices; using ImGuiNET; using OtterGui; +using OtterGui.Classes; using OtterGui.Filesystem; using OtterGui.Raii; using Penumbra.Collections; @@ -21,7 +22,7 @@ public partial class ModFileSystemSelector } private const StringComparison IgnoreCase = StringComparison.InvariantCultureIgnoreCase; - private readonly IReadOnlySet< Mod2 > _newMods = new HashSet< Mod2 >(); + private readonly IReadOnlySet< Mod > _newMods = new HashSet< Mod >(); private LowerString _modFilter = LowerString.Empty; private int _filterType = -1; private ModFilter _stateFilter = ModFilterExtensions.UnfilteredStateMods; @@ -75,7 +76,7 @@ public partial class ModFileSystemSelector // Folders have default state and are filtered out on the direct string instead of the other options. // If any filter is set, they should be hidden by default unless their children are visible, // or they contain the path search string. - protected override bool ApplyFiltersAndState( FileSystem< Mod2 >.IPath path, out ModState state ) + protected override bool ApplyFiltersAndState( FileSystem< Mod >.IPath path, out ModState state ) { if( path is ModFileSystem.Folder f ) { @@ -88,7 +89,7 @@ public partial class ModFileSystemSelector } // Apply the string filters. - private bool ApplyStringFilters( ModFileSystem.Leaf leaf, Mod2 mod ) + private bool ApplyStringFilters( ModFileSystem.Leaf leaf, Mod mod ) { return _filterType switch { @@ -102,7 +103,7 @@ public partial class ModFileSystemSelector } // Only get the text color for a mod if no filters are set. - private uint GetTextColor( Mod2 mod, ModSettings2? settings, ModCollection collection ) + private uint GetTextColor( Mod mod, ModSettings? settings, ModCollection collection ) { if( _newMods.Contains( mod ) ) { @@ -119,7 +120,7 @@ public partial class ModFileSystemSelector return collection != Penumbra.CollectionManager.Current ? ColorId.InheritedDisabledMod.Value() : ColorId.DisabledMod.Value(); } - var conflicts = Penumbra.CollectionManager.Current.ModConflicts( mod.Index ).ToList(); + var conflicts = Penumbra.CollectionManager.Current.ModConflicts( mod.Index ); if( conflicts.Count == 0 ) { return collection != Penumbra.CollectionManager.Current ? ColorId.InheritedMod.Value() : ColorId.EnabledMod.Value(); @@ -130,7 +131,7 @@ public partial class ModFileSystemSelector : ColorId.HandledConflictMod.Value(); } - private bool CheckStateFilters( Mod2 mod, ModSettings2? settings, ModCollection collection, ref ModState state ) + private bool CheckStateFilters( Mod mod, ModSettings? settings, ModCollection collection, ref ModState state ) { var isNew = _newMods.Contains( mod ); // Handle mod details. @@ -188,7 +189,7 @@ public partial class ModFileSystemSelector } // Conflicts can only be relevant if the mod is enabled. - var conflicts = Penumbra.CollectionManager.Current.ModConflicts( mod.Index ).ToList(); + var conflicts = Penumbra.CollectionManager.Current.ModConflicts( mod.Index ); if( conflicts.Count > 0 ) { if( conflicts.Any( c => !c.Solved ) ) diff --git a/Penumbra/UI/Classes/ModFileSystemSelector.cs b/Penumbra/UI/Classes/ModFileSystemSelector.cs index ca0fbaf0..4d7a236e 100644 --- a/Penumbra/UI/Classes/ModFileSystemSelector.cs +++ b/Penumbra/UI/Classes/ModFileSystemSelector.cs @@ -1,23 +1,30 @@ +using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Numerics; using Dalamud.Interface; +using Dalamud.Interface.ImGuiFileDialog; +using Dalamud.Logging; using ImGuiNET; using OtterGui; using OtterGui.Filesystem; using OtterGui.FileSystem.Selector; using OtterGui.Raii; using Penumbra.Collections; +using Penumbra.Import; using Penumbra.Mods; namespace Penumbra.UI.Classes; -public sealed partial class ModFileSystemSelector : FileSystemSelector< Mod2, ModFileSystemSelector.ModState > +public sealed partial class ModFileSystemSelector : FileSystemSelector< Mod, ModFileSystemSelector.ModState > { - public ModSettings2 SelectedSettings { get; private set; } = ModSettings2.Empty; + private readonly FileDialogManager _fileManager = new(); + private TexToolsImporter? _import; + public ModSettings SelectedSettings { get; private set; } = ModSettings.Empty; public ModCollection SelectedSettingCollection { get; private set; } = ModCollection.Empty; - public ModFileSystemSelector( ModFileSystem fileSystem, IReadOnlySet< Mod2 > newMods ) + public ModFileSystemSelector( ModFileSystem fileSystem, IReadOnlySet< Mod > newMods ) : base( fileSystem ) { _newMods = newMods; @@ -26,6 +33,7 @@ public sealed partial class ModFileSystemSelector : FileSystemSelector< Mod2, Mo SubscribeRightClickFolder( InheritDescendants, 15 ); SubscribeRightClickFolder( OwnDescendants, 15 ); AddButton( AddNewModButton, 0 ); + AddButton( AddImportModButton, 1 ); AddButton( DeleteModButton, 1000 ); SetFilterTooltip(); @@ -33,6 +41,7 @@ public sealed partial class ModFileSystemSelector : FileSystemSelector< Mod2, Mo Penumbra.CollectionManager.CollectionChanged += OnCollectionChange; Penumbra.CollectionManager.Current.ModSettingChanged += OnSettingChange; Penumbra.CollectionManager.Current.InheritanceChanged += OnInheritanceChange; + Penumbra.ModManager.ModMetaChanged += OnModMetaChange; Penumbra.ModManager.ModDiscoveryStarted += StoreCurrentSelection; Penumbra.ModManager.ModDiscoveryFinished += RestoreLastSelection; OnCollectionChange( ModCollection.Type.Current, null, Penumbra.CollectionManager.Current, null ); @@ -43,6 +52,7 @@ public sealed partial class ModFileSystemSelector : FileSystemSelector< Mod2, Mo base.Dispose(); Penumbra.ModManager.ModDiscoveryStarted -= StoreCurrentSelection; Penumbra.ModManager.ModDiscoveryFinished -= RestoreLastSelection; + Penumbra.ModManager.ModMetaChanged -= OnModMetaChange; Penumbra.CollectionManager.Current.ModSettingChanged -= OnSettingChange; Penumbra.CollectionManager.Current.InheritanceChanged -= OnInheritanceChange; Penumbra.CollectionManager.CollectionChanged -= OnCollectionChange; @@ -64,10 +74,11 @@ public sealed partial class ModFileSystemSelector : FileSystemSelector< Mod2, Mo protected override uint FolderLineColor => ColorId.FolderLine.Value(); - protected override void DrawLeafName( FileSystem< Mod2 >.Leaf leaf, in ModState state, bool selected ) + protected override void DrawLeafName( FileSystem< Mod >.Leaf leaf, in ModState state, bool selected ) { var flags = selected ? ImGuiTreeNodeFlags.Selected | LeafFlags : LeafFlags; using var c = ImRaii.PushColor( ImGuiCol.Text, state.Color ); + using var id = ImRaii.PushId( leaf.Value.Index ); using var _ = ImRaii.TreeNode( leaf.Value.Name, flags ); } @@ -107,17 +118,90 @@ public sealed partial class ModFileSystemSelector : FileSystemSelector< Mod2, Mo // Add custom buttons. - private static void AddNewModButton( Vector2 size ) + private string _newModName = string.Empty; + + private void AddNewModButton( Vector2 size ) { - if( ImGuiUtil.DrawDisabledButton( FontAwesomeIcon.Plus.ToIconString(), size, "Create a new, empty mod of a given name.", false, true ) ) - { } + if( ImGuiUtil.DrawDisabledButton( FontAwesomeIcon.Plus.ToIconString(), size, "Create a new, empty mod of a given name.", !Penumbra.ModManager.Valid, true ) ) + { + ImGui.OpenPopup( "Create New Mod" ); + } + + if( ImGuiUtil.OpenNameField( "Create New Mod", ref _newModName ) ) + { + try + { + var newDir = Mod.CreateModFolder( Penumbra.ModManager.BasePath, _newModName ); + Mod.CreateMeta( newDir, _newModName, string.Empty, string.Empty, "1.0", string.Empty ); + Penumbra.ModManager.AddMod( newDir ); + _newModName = string.Empty; + } + catch( Exception e ) + { + PluginLog.Error( $"Could not create directory for new Mod {_newModName}:\n{e}" ); + } + } + } + + // Add an import mods button that opens a file selector. + private void AddImportModButton( Vector2 size ) + { + if( ImGuiUtil.DrawDisabledButton( FontAwesomeIcon.FileImport.ToIconString(), size, + "Import one or multiple mods from Tex Tools Mod Pack Files.", !Penumbra.ModManager.Valid, true ) ) + { + _fileManager.OpenFileDialog( "Import Mod Pack", "TexTools Mod Packs{.ttmp,.ttmp2}", ( s, f ) => + { + if( s ) + { + _import = new TexToolsImporter( Penumbra.ModManager.BasePath, f.Count, f.Select( file => new FileInfo( file ) ) ); + ImGui.OpenPopup( "Import Status" ); + } + }, 0, Penumbra.Config.ModDirectory ); + } + + _fileManager.Draw(); + DrawInfoPopup(); + } + + // Draw the progress information for import. + private void DrawInfoPopup() + { + var display = ImGui.GetIO().DisplaySize; + ImGui.SetNextWindowSize( display / 4 ); + ImGui.SetNextWindowPos( 3 * display / 8 ); + using var popup = ImRaii.Popup( "Import Status", ImGuiWindowFlags.Modal ); + if( _import != null && popup.Success ) + { + _import.DrawProgressInfo( ImGuiHelpers.ScaledVector2( -1, ImGui.GetFrameHeight() ) ); + if( _import.State == ImporterState.Done ) + { + ImGui.SetCursorPosY( ImGui.GetWindowHeight() - ImGui.GetFrameHeight() * 2 ); + if( ImGui.Button( "Close", -Vector2.UnitX ) ) + { + _import = null; + ImGui.CloseCurrentPopup(); + } + } + } } private void DeleteModButton( Vector2 size ) { - if( ImGuiUtil.DrawDisabledButton( FontAwesomeIcon.Trash.ToIconString(), size, - "Delete the currently selected mod entirely from your drive.", SelectedLeaf == null, true ) ) - { } + var keys = ImGui.GetIO().KeyCtrl && ImGui.GetIO().KeyShift; + var tt = SelectedLeaf == null + ? "No mod selected." + : "Delete the currently selected mod entirely from your drive.\n" + + "This can not be undone."; + if( !keys ) + { + tt += "\nHold Control and Shift while clicking to delete the mod."; + } + + if( ImGuiUtil.DrawDisabledButton( FontAwesomeIcon.Trash.ToIconString(), size, tt, SelectedLeaf == null || !keys, true ) + && Selected != null ) + { + Penumbra.ModManager.DeleteMod( Selected.Index ); + } } @@ -146,6 +230,17 @@ public sealed partial class ModFileSystemSelector : FileSystemSelector< Mod2, Mo } } + private void OnModMetaChange( MetaChangeType type, Mod mod, string? oldName ) + { + switch( type ) + { + case MetaChangeType.Name: + case MetaChangeType.Author: + SetFilterDirty(); + break; + } + } + private void OnInheritanceChange( bool _ ) { SetFilterDirty(); @@ -175,17 +270,17 @@ public sealed partial class ModFileSystemSelector : FileSystemSelector< Mod2, Mo OnSelectionChange( Selected, Selected, default ); } - private void OnSelectionChange( Mod2? _1, Mod2? newSelection, in ModState _2 ) + private void OnSelectionChange( Mod? _1, Mod? newSelection, in ModState _2 ) { if( newSelection == null ) { - SelectedSettings = ModSettings2.Empty; + SelectedSettings = ModSettings.Empty; SelectedSettingCollection = ModCollection.Empty; } else { ( var settings, SelectedSettingCollection ) = Penumbra.CollectionManager.Current[ newSelection.Index ]; - SelectedSettings = settings ?? ModSettings2.Empty; + SelectedSettings = settings ?? ModSettings.Empty; } } diff --git a/Penumbra/UI/Classes/SubModEditWindow.cs b/Penumbra/UI/Classes/SubModEditWindow.cs new file mode 100644 index 00000000..8b562959 --- /dev/null +++ b/Penumbra/UI/Classes/SubModEditWindow.cs @@ -0,0 +1,225 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Dalamud.Interface.Windowing; +using ImGuiNET; +using OtterGui.Raii; +using Penumbra.GameData.ByteString; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods; +using Penumbra.Util; + +namespace Penumbra.UI.Classes; + +public class SubModEditWindow : Window +{ + private const string WindowBaseLabel = "###SubModEdit"; + private Mod? _mod; + private int _groupIdx = -1; + private int _optionIdx = -1; + private IModGroup? _group; + private ISubMod? _subMod; + private readonly List< FilePathInfo > _availableFiles = new(); + + private readonly struct FilePathInfo + { + public readonly FullPath File; + public readonly Utf8RelPath RelFile; + public readonly long Size; + public readonly List< (int, int, Utf8GamePath) > SubMods; + + public FilePathInfo( FileInfo file, Mod mod ) + { + File = new FullPath( file ); + RelFile = Utf8RelPath.FromFile( File, mod.BasePath, out var f ) ? f : Utf8RelPath.Empty; + Size = file.Length; + SubMods = new List< (int, int, Utf8GamePath) >(); + var path = File; + foreach( var (group, groupIdx) in mod.Groups.WithIndex() ) + { + foreach( var (subMod, optionIdx) in group.WithIndex() ) + { + SubMods.AddRange( subMod.Files.Where( kvp => kvp.Value.Equals( path ) ).Select( kvp => ( groupIdx, optionIdx, kvp.Key ) ) ); + } + } + SubMods.AddRange( mod.Default.Files.Where( kvp => kvp.Value.Equals( path ) ).Select( kvp => (-1, 0, kvp.Key) ) ); + } + } + + private readonly HashSet< MetaManipulation > _manipulations = new(); + private readonly Dictionary< Utf8GamePath, FullPath > _files = new(); + private readonly Dictionary< Utf8GamePath, FullPath > _fileSwaps = new(); + + public void Activate( Mod mod, int groupIdx, int optionIdx ) + { + IsOpen = true; + _mod = mod; + _groupIdx = groupIdx; + _group = groupIdx >= 0 ? mod.Groups[ groupIdx ] : null; + _optionIdx = optionIdx; + _subMod = groupIdx >= 0 ? _group![ optionIdx ] : _mod.Default; + _availableFiles.Clear(); + _availableFiles.AddRange( mod.BasePath.EnumerateDirectories() + .SelectMany( d => d.EnumerateFiles( "*.*", SearchOption.AllDirectories ) ) + .Select( f => new FilePathInfo( f, _mod ) ) ); + + _manipulations.Clear(); + _manipulations.UnionWith( _subMod.Manipulations ); + _files.SetTo( _subMod.Files ); + _fileSwaps.SetTo( _subMod.FileSwaps ); + + WindowName = $"{_mod.Name}: {(_group != null ? $"{_group.Name} - " : string.Empty)}{_subMod.Name}"; + } + + public override bool DrawConditions() + => _subMod != null; + + public override void Draw() + { + using var tabBar = ImRaii.TabBar( "##tabs" ); + if( !tabBar ) + { + return; + } + + DrawFileTab(); + DrawMetaTab(); + DrawSwapTab(); + } + + private void Save() + { + if( _mod != null ) + { + Penumbra.ModManager.OptionUpdate( _mod, _groupIdx, _optionIdx, _files, _manipulations, _fileSwaps ); + } + } + + public override void OnClose() + { + _subMod = null; + } + + private void DrawFileTab() + { + using var tab = ImRaii.TabItem( "File Redirections" ); + if( !tab ) + { + return; + } + + using var list = ImRaii.Table( "##files", 3 ); + if( !list ) + { + return; + } + + foreach( var file in _availableFiles ) + { + ImGui.TableNextColumn(); + ConfigWindow.Text( file.RelFile.Path ); + ImGui.TableNextColumn(); + ImGui.Text( file.Size.ToString() ); + ImGui.TableNextColumn(); + if( file.SubMods.Count == 0 ) + { + ImGui.Text( "Unused" ); + } + + foreach( var (groupIdx, optionIdx, gamePath) in file.SubMods ) + { + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + var group = groupIdx >= 0 ? _mod!.Groups[ groupIdx ] : null; + var option = groupIdx >= 0 ? group![ optionIdx ] : _mod!.Default; + var text = groupIdx >= 0 + ? $"{group!.Name} - {option.Name}" + : option.Name; + ImGui.Text( text ); + ImGui.TableNextColumn(); + ConfigWindow.Text( gamePath.Path ); + } + } + + ImGui.TableNextRow(); + foreach( var (gamePath, fullPath) in _files ) + { + ImGui.TableNextColumn(); + ConfigWindow.Text( gamePath.Path ); + ImGui.TableNextColumn(); + ImGui.Text( fullPath.FullName ); + ImGui.TableNextColumn(); + } + } + + private void DrawMetaTab() + { + using var tab = ImRaii.TabItem( "Meta Manipulations" ); + if( !tab ) + { + return; + } + + using var list = ImRaii.Table( "##meta", 3 ); + if( !list ) + { + return; + } + + foreach( var manip in _manipulations ) + { + ImGui.TableNextColumn(); + ImGui.Text( manip.ManipulationType.ToString() ); + ImGui.TableNextColumn(); + ImGui.Text( manip.ManipulationType switch + { + MetaManipulation.Type.Imc => manip.Imc.ToString(), + MetaManipulation.Type.Eqdp => manip.Eqdp.ToString(), + MetaManipulation.Type.Eqp => manip.Eqp.ToString(), + MetaManipulation.Type.Est => manip.Est.ToString(), + MetaManipulation.Type.Gmp => manip.Gmp.ToString(), + MetaManipulation.Type.Rsp => manip.Rsp.ToString(), + _ => string.Empty, + } ); + ImGui.TableNextColumn(); + ImGui.Text( manip.ManipulationType switch + { + MetaManipulation.Type.Imc => manip.Imc.Entry.ToString(), + MetaManipulation.Type.Eqdp => manip.Eqdp.Entry.ToString(), + MetaManipulation.Type.Eqp => manip.Eqp.Entry.ToString(), + MetaManipulation.Type.Est => manip.Est.Entry.ToString(), + MetaManipulation.Type.Gmp => manip.Gmp.Entry.ToString(), + MetaManipulation.Type.Rsp => manip.Rsp.Entry.ToString(), + _ => string.Empty, + } ); + } + } + + private void DrawSwapTab() + { + using var tab = ImRaii.TabItem( "File Swaps" ); + if( !tab ) + { + return; + } + + using var list = ImRaii.Table( "##swaps", 3 ); + if( !list ) + { + return; + } + + foreach( var (from, to) in _fileSwaps ) + { + ImGui.TableNextColumn(); + ConfigWindow.Text( from.Path ); + ImGui.TableNextColumn(); + ImGui.Text( to.FullName ); + ImGui.TableNextColumn(); + } + } + + public SubModEditWindow() + : base( WindowBaseLabel ) + { } +} \ No newline at end of file diff --git a/Penumbra/UI/ConfigWindow.ChangedItemsTab.cs b/Penumbra/UI/ConfigWindow.ChangedItemsTab.cs index f7aedac3..c99cb3d4 100644 --- a/Penumbra/UI/ConfigWindow.ChangedItemsTab.cs +++ b/Penumbra/UI/ConfigWindow.ChangedItemsTab.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Numerics; using ImGuiNET; using OtterGui; +using OtterGui.Classes; using OtterGui.Raii; namespace Penumbra.UI; diff --git a/Penumbra/UI/ConfigWindow.EffectiveTab.cs b/Penumbra/UI/ConfigWindow.EffectiveTab.cs index fc1cc3d2..3e843231 100644 --- a/Penumbra/UI/ConfigWindow.EffectiveTab.cs +++ b/Penumbra/UI/ConfigWindow.EffectiveTab.cs @@ -4,6 +4,7 @@ using System.Numerics; using Dalamud.Interface; using ImGuiNET; using OtterGui; +using OtterGui.Classes; using OtterGui.Raii; using Penumbra.Collections; using Penumbra.GameData.ByteString; diff --git a/Penumbra/UI/ConfigWindow.ModPanel.Edit.cs b/Penumbra/UI/ConfigWindow.ModPanel.Edit.cs new file mode 100644 index 00000000..0ca5dcd4 --- /dev/null +++ b/Penumbra/UI/ConfigWindow.ModPanel.Edit.cs @@ -0,0 +1,444 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Numerics; +using Dalamud.Interface; +using ImGuiNET; +using OtterGui; +using OtterGui.Raii; +using Penumbra.Mods; + +namespace Penumbra.UI; + +public partial class ConfigWindow +{ + private partial class ModPanel + { + public readonly Queue< Action > _delayedActions = new(); + + private void DrawAddOptionGroupInput() + { + ImGui.SetNextItemWidth( _window._inputTextWidth.X ); + ImGui.InputTextWithHint( "##newGroup", "Add new option group...", ref _newGroupName, 256 ); + ImGui.SameLine(); + + var nameValid = Mod.Manager.VerifyFileName( _mod, null, _newGroupName, false ); + var tt = nameValid ? "Add new option group to the mod." : "Can not add a group of this name."; + if( ImGuiUtil.DrawDisabledButton( FontAwesomeIcon.Plus.ToIconString(), ImGui.GetFrameHeight() * Vector2.One, + tt, !nameValid, true ) ) + { + Penumbra.ModManager.AddModGroup( _mod, SelectType.Single, _newGroupName ); + _newGroupName = string.Empty; + } + } + + private Vector2 _cellPadding = Vector2.Zero; + private Vector2 _itemSpacing = Vector2.Zero; + + private void DrawEditModTab() + { + using var tab = DrawTab( EditModTabHeader, Tabs.Edit ); + if( !tab ) + { + return; + } + + using var child = ImRaii.Child( "##editChild", -Vector2.One ); + if( !child ) + { + return; + } + + _cellPadding = ImGui.GetStyle().CellPadding with { X = 2 * ImGuiHelpers.GlobalScale }; + _itemSpacing = ImGui.GetStyle().CellPadding with { X = 4 * ImGuiHelpers.GlobalScale }; + + EditRegularMeta(); + ImGui.Dummy( _window._defaultSpace ); + + if( TextInput( "Mod Path", PathFieldIdx, NoFieldIdx, _leaf.FullName(), out var newPath, 256, _window._inputTextWidth.X ) ) + { + _window._penumbra.ModFileSystem.RenameAndMove( _leaf, newPath ); + } + + ImGui.Dummy( _window._defaultSpace ); + DrawAddOptionGroupInput(); + ImGui.Dummy( _window._defaultSpace ); + + for( var groupIdx = 0; groupIdx < _mod.Groups.Count; ++groupIdx ) + { + EditGroup( groupIdx ); + } + + EndActions(); + EditDescriptionPopup(); + } + + + // Special field indices to reuse the same string buffer. + private const int NoFieldIdx = -1; + private const int NameFieldIdx = -2; + private const int AuthorFieldIdx = -3; + private const int VersionFieldIdx = -4; + private const int WebsiteFieldIdx = -5; + private const int PathFieldIdx = -6; + private const int DescriptionFieldIdx = -7; + + private void EditRegularMeta() + { + if( TextInput( "Name", NameFieldIdx, NoFieldIdx, _mod.Name, out var newName, 256, _window._inputTextWidth.X ) ) + { + Penumbra.ModManager.ChangeModName( _mod.Index, newName ); + } + + if( TextInput( "Author", AuthorFieldIdx, NoFieldIdx, _mod.Author, out var newAuthor, 256, _window._inputTextWidth.X ) ) + { + Penumbra.ModManager.ChangeModAuthor( _mod.Index, newAuthor ); + } + + if( TextInput( "Version", VersionFieldIdx, NoFieldIdx, _mod.Version, out var newVersion, 32, _window._inputTextWidth.X ) ) + { + Penumbra.ModManager.ChangeModVersion( _mod.Index, newVersion ); + } + + if( TextInput( "Website", WebsiteFieldIdx, NoFieldIdx, _mod.Website, out var newWebsite, 256, _window._inputTextWidth.X ) ) + { + Penumbra.ModManager.ChangeModWebsite( _mod.Index, newWebsite ); + } + + if( ImGui.Button( "Edit Description", _window._inputTextWidth ) ) + { + _delayedActions.Enqueue( () => OpenEditDescriptionPopup( DescriptionFieldIdx ) ); + } + + if( ImGui.Button( "Edit Default Mod", _window._inputTextWidth ) ) + { + _window.SubModPopup.Activate( _mod, -1, 0 ); + } + } + + + // Temporary strings + private string? _currentEdit; + private int? _currentGroupPriority; + private int _currentField = -1; + private int _optionIndex = -1; + + private string _newGroupName = string.Empty; + private string _newOptionName = string.Empty; + private string _newDescription = string.Empty; + private int _newDescriptionIdx = -1; + + private void EditGroup( int groupIdx ) + { + var group = _mod.Groups[ groupIdx ]; + using var id = ImRaii.PushId( groupIdx ); + using var frame = ImRaii.FramedGroup( $"Group #{groupIdx + 1}" ); + + using var style = ImRaii.PushStyle( ImGuiStyleVar.CellPadding, _cellPadding ) + .Push( ImGuiStyleVar.ItemSpacing, _itemSpacing ); + + if( TextInput( "##Name", groupIdx, NoFieldIdx, group.Name, out var newGroupName, 256, _window._inputTextWidth.X ) ) + { + Penumbra.ModManager.RenameModGroup( _mod, groupIdx, newGroupName ); + } + + ImGuiUtil.HoverTooltip( "Group Name" ); + ImGui.SameLine(); + if( ImGuiUtil.DrawDisabledButton( FontAwesomeIcon.Trash.ToIconString(), ImGui.GetFrameHeight() * Vector2.One, + "Delete this option group.\nHold Control while clicking to delete.", !ImGui.GetIO().KeyCtrl, true ) ) + { + _delayedActions.Enqueue( () => Penumbra.ModManager.DeleteModGroup( _mod, groupIdx ) ); + } + + ImGui.SameLine(); + + if( ImGuiUtil.DrawDisabledButton( FontAwesomeIcon.Edit.ToIconString(), ImGui.GetFrameHeight() * Vector2.One, + "Edit group description.", false, true ) ) + { + _delayedActions.Enqueue( () => OpenEditDescriptionPopup( groupIdx ) ); + } + + ImGui.SameLine(); + + if( PriorityInput( "##Priority", groupIdx, NoFieldIdx, group.Priority, out var priority, 50 * ImGuiHelpers.GlobalScale ) ) + { + Penumbra.ModManager.ChangeGroupPriority( _mod, groupIdx, priority ); + } + + ImGuiUtil.HoverTooltip( "Group Priority" ); + + ImGui.SetNextItemWidth( _window._inputTextWidth.X - 2 * ImGui.GetFrameHeight() - 8 * ImGuiHelpers.GlobalScale ); + using( var combo = ImRaii.Combo( "##GroupType", GroupTypeName( group.Type ) ) ) + { + if( combo ) + { + foreach( var type in new[] { SelectType.Single, SelectType.Multi } ) + { + if( ImGui.Selectable( GroupTypeName( type ), group.Type == type ) ) + { + Penumbra.ModManager.ChangeModGroupType( _mod, groupIdx, type ); + } + } + } + } + + ImGui.SameLine(); + + var tt = groupIdx == 0 ? "Can not move this group further upwards." : $"Move this group up to group {groupIdx}."; + if( ImGuiUtil.DrawDisabledButton( FontAwesomeIcon.ArrowUp.ToIconString(), ImGui.GetFrameHeight() * Vector2.One, + tt, groupIdx == 0, true ) ) + { + _delayedActions.Enqueue( () => Penumbra.ModManager.MoveModGroup( _mod, groupIdx, groupIdx - 1 ) ); + } + + ImGui.SameLine(); + tt = groupIdx == _mod.Groups.Count - 1 + ? "Can not move this group further downwards." + : $"Move this group down to group {groupIdx + 2}."; + if( ImGuiUtil.DrawDisabledButton( FontAwesomeIcon.ArrowDown.ToIconString(), ImGui.GetFrameHeight() * Vector2.One, + tt, groupIdx == _mod.Groups.Count - 1, true ) ) + { + _delayedActions.Enqueue( () => Penumbra.ModManager.MoveModGroup( _mod, groupIdx, groupIdx + 1 ) ); + } + + ImGui.Dummy( _window._defaultSpace ); + + using var table = ImRaii.Table( string.Empty, 5, ImGuiTableFlags.SizingFixedFit ); + ImGui.TableSetupColumn( "idx", ImGuiTableColumnFlags.WidthFixed, 60 * ImGuiHelpers.GlobalScale ); + ImGui.TableSetupColumn( "name", ImGuiTableColumnFlags.WidthFixed, _window._inputTextWidth.X - 62 * ImGuiHelpers.GlobalScale ); + ImGui.TableSetupColumn( "delete", ImGuiTableColumnFlags.WidthFixed, ImGui.GetFrameHeight() ); + ImGui.TableSetupColumn( "edit", ImGuiTableColumnFlags.WidthFixed, ImGui.GetFrameHeight() ); + ImGui.TableSetupColumn( "priority", ImGuiTableColumnFlags.WidthFixed, 50 * ImGuiHelpers.GlobalScale ); + if( table ) + { + for( var optionIdx = 0; optionIdx < group.Count; ++optionIdx ) + { + EditOption( group, groupIdx, optionIdx ); + } + + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth( -1 ); + ImGui.InputTextWithHint( "##newOption", "Add new option...", ref _newOptionName, 256 ); + ImGui.TableNextColumn(); + if( ImGuiUtil.DrawDisabledButton( FontAwesomeIcon.Plus.ToIconString(), ImGui.GetFrameHeight() * Vector2.One, + "Add a new option to this group.", _newOptionName.Length == 0, true ) ) + { + Penumbra.ModManager.AddOption( _mod, groupIdx, _newOptionName ); + _newOptionName = string.Empty; + } + } + } + + private static string GroupTypeName( SelectType type ) + => type switch + { + SelectType.Single => "Single Group", + SelectType.Multi => "Multi Group", + _ => "Unknown", + }; + + private int _dragDropGroupIdx = -1; + private int _dragDropOptionIdx = -1; + + private void OptionDragDrop( IModGroup group, int groupIdx, int optionIdx ) + { + const string label = "##DragOption"; + using( var source = ImRaii.DragDropSource() ) + { + if( source ) + { + if( ImGui.SetDragDropPayload( label, IntPtr.Zero, 0 ) ) + { + _dragDropGroupIdx = groupIdx; + _dragDropOptionIdx = optionIdx; + } + + ImGui.Text( $"Dragging option {group[ optionIdx ].Name} from group {group.Name}..." ); + } + } + + using( var target = ImRaii.DragDropTarget() ) + { + if( target.Success && ImGuiUtil.IsDropping( label ) ) + { + if( _dragDropGroupIdx >= 0 && _dragDropOptionIdx >= 0 ) + { + if( _dragDropGroupIdx == groupIdx ) + { + // TODO + Dalamud.Chat.Print( + $"Dropped {_mod.Groups[ _dragDropGroupIdx ][ _dragDropOptionIdx ].Name} onto {_mod.Groups[ groupIdx ][ optionIdx ].Name}" ); + } + else + { + Dalamud.Chat.Print( + $"Dropped {_mod.Groups[ _dragDropGroupIdx ][ _dragDropOptionIdx ].Name} onto {_mod.Groups[ groupIdx ][ optionIdx ].Name}" ); + } + } + + _dragDropGroupIdx = -1; + _dragDropOptionIdx = -1; + } + } + } + + private void EditOption( IModGroup group, int groupIdx, int optionIdx ) + { + var option = group[ optionIdx ]; + using var id = ImRaii.PushId( optionIdx ); + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.Selectable( $"Option #{optionIdx + 1}" ); + OptionDragDrop( group, groupIdx, optionIdx ); + + ImGui.TableNextColumn(); + if( TextInput( "##Name", groupIdx, optionIdx, option.Name, out var newOptionName, 256, -1 ) ) + { + Penumbra.ModManager.RenameOption( _mod, groupIdx, optionIdx, newOptionName ); + } + + ImGui.TableNextColumn(); + if( ImGuiUtil.DrawDisabledButton( FontAwesomeIcon.Trash.ToIconString(), ImGui.GetFrameHeight() * Vector2.One, + "Delete this option.\nHold Control while clicking to delete.", !ImGui.GetIO().KeyCtrl, true ) ) + { + _delayedActions.Enqueue( () => Penumbra.ModManager.DeleteOption( _mod, groupIdx, optionIdx ) ); + } + + ImGui.TableNextColumn(); + if( ImGuiUtil.DrawDisabledButton( FontAwesomeIcon.Edit.ToIconString(), ImGui.GetFrameHeight() * Vector2.One, + "Edit this option.", false, true ) ) + { + _window.SubModPopup.Activate( _mod, groupIdx, optionIdx ); + } + + ImGui.TableNextColumn(); + if( group.Type == SelectType.Multi ) + { + if( PriorityInput( "##Priority", groupIdx, optionIdx, group.OptionPriority( optionIdx ), out var priority, + 50 * ImGuiHelpers.GlobalScale ) ) + { + Penumbra.ModManager.ChangeOptionPriority( _mod, groupIdx, optionIdx, priority ); + } + + ImGuiUtil.HoverTooltip( "Option priority." ); + } + } + + private bool TextInput( string label, int field, int option, string oldValue, out string value, uint maxLength, float width ) + { + var tmp = field == _currentField && option == _optionIndex ? _currentEdit ?? oldValue : oldValue; + ImGui.SetNextItemWidth( width ); + if( ImGui.InputText( label, ref tmp, maxLength ) ) + { + _currentEdit = tmp; + _optionIndex = option; + _currentField = field; + } + + if( ImGui.IsItemDeactivatedAfterEdit() && _currentEdit != null ) + { + var ret = _currentEdit != oldValue; + value = _currentEdit; + _currentEdit = null; + _currentField = NoFieldIdx; + _optionIndex = NoFieldIdx; + return ret; + } + + value = string.Empty; + return false; + } + + private bool PriorityInput( string label, int field, int option, int oldValue, out int value, float width ) + { + var tmp = field == _currentField && option == _optionIndex ? _currentGroupPriority ?? oldValue : oldValue; + ImGui.SetNextItemWidth( width ); + if( ImGui.InputInt( label, ref tmp, 0, 0 ) ) + { + _currentGroupPriority = tmp; + _optionIndex = option; + _currentField = field; + } + + if( ImGui.IsItemDeactivatedAfterEdit() && _currentGroupPriority != null ) + { + var ret = _currentGroupPriority != oldValue; + value = _currentGroupPriority.Value; + _currentGroupPriority = null; + _currentField = NoFieldIdx; + _optionIndex = NoFieldIdx; + return ret; + } + + value = 0; + return false; + } + + // Delete a marked group or option outside of iteration. + private void EndActions() + { + while( _delayedActions.TryDequeue( out var action ) ) + { + action.Invoke(); + } + } + + private void OpenEditDescriptionPopup( int groupIdx ) + { + _newDescriptionIdx = groupIdx; + _newDescription = groupIdx < 0 ? _mod.Description : _mod.Groups[ groupIdx ].Description; + ImGui.OpenPopup( "Edit Description" ); + } + + private void EditDescriptionPopup() + { + using var popup = ImRaii.Popup( "Edit Description" ); + if( popup ) + { + if( ImGui.IsWindowAppearing() ) + { + ImGui.SetKeyboardFocusHere(); + } + + ImGui.InputTextMultiline( "##editDescription", ref _newDescription, 4096, ImGuiHelpers.ScaledVector2( 800, 800 ) ); + ImGui.Dummy( _window._defaultSpace ); + + var buttonSize = ImGuiHelpers.ScaledVector2( 100, 0 ); + var width = 2 * buttonSize.X + + 4 * ImGui.GetStyle().FramePadding.X + + ImGui.GetStyle().ItemSpacing.X; + ImGui.SetCursorPosX( ( 800 * ImGuiHelpers.GlobalScale - width ) / 2 ); + + var oldDescription = _newDescriptionIdx == DescriptionFieldIdx + ? _mod.Description + : _mod.Groups[ _newDescriptionIdx ].Description; + + var tooltip = _newDescription != oldDescription ? string.Empty : "No changes made yet."; + + if( ImGuiUtil.DrawDisabledButton( "Save", buttonSize, tooltip, tooltip.Length > 0 ) ) + { + if( _newDescriptionIdx == DescriptionFieldIdx ) + { + Penumbra.ModManager.ChangeModDescription( _mod.Index, _newDescription ); + } + else if( _newDescriptionIdx >= 0 ) + { + Penumbra.ModManager.ChangeGroupDescription( _mod, _newDescriptionIdx, _newDescription ); + } + + ImGui.CloseCurrentPopup(); + } + + ImGui.SameLine(); + if( ImGui.Button( "Cancel", buttonSize ) + || ImGui.IsKeyPressed( ImGui.GetKeyIndex( ImGuiKey.Escape ) ) ) + { + _newDescriptionIdx = NoFieldIdx; + _newDescription = string.Empty; + ImGui.CloseCurrentPopup(); + } + } + } + } +} \ No newline at end of file diff --git a/Penumbra/UI/ConfigWindow.ModPanel.Header.cs b/Penumbra/UI/ConfigWindow.ModPanel.Header.cs new file mode 100644 index 00000000..6b91c1b0 --- /dev/null +++ b/Penumbra/UI/ConfigWindow.ModPanel.Header.cs @@ -0,0 +1,214 @@ +using System; +using System.Diagnostics; +using System.Numerics; +using Dalamud.Interface; +using Dalamud.Interface.GameFonts; +using ImGuiNET; +using OtterGui; +using OtterGui.Raii; +using Penumbra.UI.Classes; + +namespace Penumbra.UI; + +public partial class ConfigWindow +{ + private partial class ModPanel : IDisposable + { + // We use a big, nice game font for the title. + private readonly GameFontHandle _nameFont = + Dalamud.PluginInterface.UiBuilder.GetGameFontHandle( new GameFontStyle( GameFontFamilyAndSize.Jupiter23 ) ); + + public void Dispose() + { + _nameFont.Dispose(); + } + + // Header data. + private string _modName = string.Empty; + private string _modAuthor = string.Empty; + private string _modVersion = string.Empty; + private string _modWebsite = string.Empty; + private string _modWebsiteButton = string.Empty; + private bool _websiteValid; + + private float _modNameWidth; + private float _modAuthorWidth; + private float _modVersionWidth; + private float _modWebsiteButtonWidth; + private float _secondRowWidth; + + // Draw the header for the current mod, + // consisting of its name, version, author and website, if they exist. + private void DrawModHeader() + { + var offset = DrawModName(); + DrawVersion( offset ); + DrawSecondRow( offset ); + } + + // Draw the mod name in the game font with a 2px border, centered, + // with at least the width of the version space to each side. + private float DrawModName() + { + var decidingWidth = Math.Max( _secondRowWidth, ImGui.GetWindowWidth() ); + var offsetWidth = ( decidingWidth - _modNameWidth ) / 2; + var offsetVersion = _modVersion.Length > 0 + ? _modVersionWidth + ImGui.GetStyle().ItemSpacing.X + ImGui.GetStyle().WindowPadding.X + : 0; + var offset = Math.Max( offsetWidth, offsetVersion ); + if( offset > 0 ) + { + ImGui.SetCursorPosX( offset ); + } + + using var color = ImRaii.PushColor( ImGuiCol.Border, Colors.MetaInfoText ); + using var style = ImRaii.PushStyle( ImGuiStyleVar.FrameBorderSize, 2 * ImGuiHelpers.GlobalScale ); + using var font = ImRaii.PushFont( _nameFont.ImFont, _nameFont.Available ); + ImGuiUtil.DrawTextButton( _modName, Vector2.Zero, 0 ); + return offset; + } + + // Draw the version in the top-right corner. + private void DrawVersion( float offset ) + { + var oldPos = ImGui.GetCursorPos(); + ImGui.SetCursorPos( new Vector2( 2 * offset + _modNameWidth - _modVersionWidth - ImGui.GetStyle().WindowPadding.X, + ImGui.GetStyle().FramePadding.Y ) ); + ImGuiUtil.TextColored( Colors.MetaInfoText, _modVersion ); + ImGui.SetCursorPos( oldPos ); + } + + // Draw author and website if they exist. The website is a button if it is valid. + // Usually, author begins at the left boundary of the name, + // and website ends at the right boundary of the name. + // If their combined width is larger than the name, they are combined-centered. + private void DrawSecondRow( float offset ) + { + if( _modAuthor.Length == 0 ) + { + if( _modWebsiteButton.Length == 0 ) + { + ImGui.NewLine(); + return; + } + + offset += ( _modNameWidth - _modWebsiteButtonWidth ) / 2; + ImGui.SetCursorPosX( offset ); + DrawWebsite(); + } + else if( _modWebsiteButton.Length == 0 ) + { + offset += ( _modNameWidth - _modAuthorWidth ) / 2; + ImGui.SetCursorPosX( offset ); + DrawAuthor(); + } + else if( _secondRowWidth < _modNameWidth ) + { + ImGui.SetCursorPosX( offset ); + DrawAuthor(); + ImGui.SameLine( offset + _modNameWidth - _modWebsiteButtonWidth ); + DrawWebsite(); + } + else + { + offset -= ( _secondRowWidth - _modNameWidth ) / 2; + if( offset > 0 ) + { + ImGui.SetCursorPosX( offset ); + } + + DrawAuthor(); + ImGui.SameLine(); + DrawWebsite(); + } + } + + // Draw the author text. + private void DrawAuthor() + { + using var style = ImRaii.PushStyle( ImGuiStyleVar.ItemSpacing, Vector2.Zero ); + ImGuiUtil.TextColored( Colors.MetaInfoText, "by " ); + ImGui.SameLine(); + style.Pop(); + ImGui.Text( _mod.Author ); + } + + // Draw either a website button if the source is a valid website address, + // or a source text if it is not. + private void DrawWebsite() + { + if( _websiteValid ) + { + if( ImGui.SmallButton( _modWebsiteButton ) ) + { + try + { + var process = new ProcessStartInfo( _modWebsite ) + { + UseShellExecute = true, + }; + Process.Start( process ); + } + catch + { + // ignored + } + } + + ImGuiUtil.HoverTooltip( _modWebsite ); + } + else + { + using var style = ImRaii.PushStyle( ImGuiStyleVar.ItemSpacing, Vector2.Zero ); + ImGuiUtil.TextColored( Colors.MetaInfoText, "from " ); + ImGui.SameLine(); + style.Pop(); + ImGui.Text( _mod.Website ); + } + } + + // Update all mod header data. Should someone change frame padding or item spacing, + // or his default font, this will break, but he will just have to select a different mod to restore. + private void UpdateModData() + { + // Name + var name = $" {_mod.Name} "; + if( name != _modName ) + { + using var font = ImRaii.PushFont( _nameFont.ImFont, _nameFont.Available ); + _modName = name; + _modNameWidth = ImGui.CalcTextSize( name ).X + 2 * ( ImGui.GetStyle().FramePadding.X + 2 * ImGuiHelpers.GlobalScale ); + } + + // Author + var author = _mod.Author.IsEmpty ? string.Empty : $"by {_mod.Author}"; + if( author != _modAuthor ) + { + _modAuthor = author; + _modAuthorWidth = ImGui.CalcTextSize( author ).X; + _secondRowWidth = _modAuthorWidth + _modWebsiteButtonWidth + ImGui.GetStyle().ItemSpacing.X; + } + + // Version + var version = _mod.Version.Length > 0 ? $"({_mod.Version})" : string.Empty; + if( version != _modVersion ) + { + _modVersion = version; + _modVersionWidth = ImGui.CalcTextSize( version ).X; + } + + // Website + if( _modWebsite != _mod.Website ) + { + _modWebsite = _mod.Website; + _websiteValid = Uri.TryCreate( _modWebsite, UriKind.Absolute, out var uriResult ) + && ( uriResult.Scheme == Uri.UriSchemeHttps || uriResult.Scheme == Uri.UriSchemeHttp ); + _modWebsiteButton = _websiteValid ? "Open Website" : _modWebsite.Length == 0 ? string.Empty : $"from {_modWebsite}"; + _modWebsiteButtonWidth = _websiteValid + ? ImGui.CalcTextSize( _modWebsiteButton ).X + 2 * ImGui.GetStyle().FramePadding.X + : ImGui.CalcTextSize( _modWebsiteButton ).X; + _secondRowWidth = _modAuthorWidth + _modWebsiteButtonWidth + ImGui.GetStyle().ItemSpacing.X; + } + } + } +} \ No newline at end of file diff --git a/Penumbra/UI/ConfigWindow.ModPanel.Settings.cs b/Penumbra/UI/ConfigWindow.ModPanel.Settings.cs new file mode 100644 index 00000000..7ab3b496 --- /dev/null +++ b/Penumbra/UI/ConfigWindow.ModPanel.Settings.cs @@ -0,0 +1,207 @@ +using System.Linq; +using System.Numerics; +using Dalamud.Interface; +using ImGuiNET; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Raii; +using OtterGui.Widgets; +using Penumbra.Collections; +using Penumbra.Mods; +using Penumbra.UI.Classes; + +namespace Penumbra.UI; + +public partial class ConfigWindow +{ + private partial class ModPanel + { + private ModSettings _settings = null!; + private ModCollection _collection = null!; + private bool _emptySetting; + private bool _inherited; + private SubList< ConflictCache.Conflict > _conflicts = SubList< ConflictCache.Conflict >.Empty; + + private int? _currentPriority; + + private void UpdateSettingsData( ModFileSystemSelector selector ) + { + _settings = selector.SelectedSettings; + _collection = selector.SelectedSettingCollection; + _emptySetting = _settings == ModSettings.Empty; + _inherited = _collection != Penumbra.CollectionManager.Current; + _conflicts = Penumbra.CollectionManager.Current.ModConflicts( _mod.Index ); + } + + // Draw the whole settings tab as well as its contents. + private void DrawSettingsTab() + { + using var tab = DrawTab( SettingsTabHeader, Tabs.Settings ); + if( !tab ) + { + return; + } + + using var child = ImRaii.Child( "##settings" ); + if( !child ) + { + return; + } + + DrawInheritedWarning(); + ImGui.Dummy( _window._defaultSpace ); + DrawEnabledInput(); + ImGui.SameLine(); + DrawPriorityInput(); + DrawRemoveSettings(); + ImGui.Dummy( _window._defaultSpace ); + for( var idx = 0; idx < _mod.Groups.Count; ++idx ) + { + DrawSingleGroup( _mod.Groups[ idx ], idx ); + } + + for( var idx = 0; idx < _mod.Groups.Count; ++idx ) + { + DrawMultiGroup( _mod.Groups[ idx ], idx ); + } + } + + + // Draw a big red bar if the current setting is inherited. + private void DrawInheritedWarning() + { + if( !_inherited ) + { + return; + } + + using var color = ImRaii.PushColor( ImGuiCol.Button, Colors.PressEnterWarningBg ); + var width = new Vector2( ImGui.GetContentRegionAvail().X, 0 ); + if( ImGui.Button( $"These settings are inherited from {_collection.Name}.", width ) ) + { + Penumbra.CollectionManager.Current.SetModInheritance( _mod.Index, false ); + } + + ImGuiUtil.HoverTooltip( "You can click this button to copy the current settings to the current selection.\n" + + "You can also just change any setting, which will copy the settings with the single setting changed to the current selection." ); + } + + // Draw a checkbox for the enabled status of the mod. + private void DrawEnabledInput() + { + var enabled = _settings.Enabled; + if( ImGui.Checkbox( "Enabled", ref enabled ) ) + { + Penumbra.CollectionManager.Current.SetModState( _mod.Index, enabled ); + } + } + + // Draw a priority input. + // Priority is changed on deactivation of the input box. + private void DrawPriorityInput() + { + var priority = _currentPriority ?? _settings.Priority; + ImGui.SetNextItemWidth( 50 * ImGuiHelpers.GlobalScale ); + if( ImGui.InputInt( "##Priority", ref priority, 0, 0 ) ) + { + _currentPriority = priority; + } + + if( ImGui.IsItemDeactivatedAfterEdit() && _currentPriority.HasValue ) + { + if( _currentPriority != _settings.Priority ) + { + Penumbra.CollectionManager.Current.SetModPriority( _mod.Index, _currentPriority.Value ); + } + + _currentPriority = null; + } + + ImGuiUtil.LabeledHelpMarker( "Priority", "Mods with higher priority take precedence before Mods with lower priority.\n" + + "That means, if Mod A should overwrite changes from Mod B, Mod A should have higher priority than Mod B." ); + } + + // Draw a button to remove the current settings and inherit them instead + // on the top-right corner of the window/tab. + private void DrawRemoveSettings() + { + const string text = "Remove Settings"; + if( _inherited || _emptySetting ) + { + return; + } + + var scroll = ImGui.GetScrollMaxY() > 0 ? ImGui.GetStyle().ScrollbarSize : 0; + ImGui.SameLine( ImGui.GetWindowWidth() - ImGui.CalcTextSize( text ).X - ImGui.GetStyle().FramePadding.X * 2 - scroll); + if( ImGui.Button( text ) ) + { + Penumbra.CollectionManager.Current.SetModInheritance( _mod.Index, true ); + } + + ImGuiUtil.HoverTooltip( "Remove current settings from this collection so that it can inherit them.\n" + + "If no inherited collection has settings for this mod, it will be disabled." ); + } + + // Draw a single group selector as a combo box. + // If a description is provided, add a help marker besides it. + private void DrawSingleGroup( IModGroup group, int groupIdx ) + { + if( group.Type != SelectType.Single || !group.IsOption ) + { + return; + } + + using var id = ImRaii.PushId( groupIdx ); + var selectedOption = _emptySetting ? 0 : ( int )_settings.Settings[ groupIdx ]; + ImGui.SetNextItemWidth( _window._inputTextWidth.X * 3 / 4 ); + using var combo = ImRaii.Combo( string.Empty, group[ selectedOption ].Name ); + if( combo ) + { + for( var idx2 = 0; idx2 < group.Count; ++idx2 ) + { + if( ImGui.Selectable( group[ idx2 ].Name, idx2 == selectedOption ) ) + { + Penumbra.CollectionManager.Current.SetModSetting( _mod.Index, groupIdx, ( uint )idx2 ); + } + } + } + + combo.Dispose(); + ImGui.SameLine(); + if( group.Description.Length > 0 ) + { + ImGuiUtil.LabeledHelpMarker( group.Name, group.Description ); + } + else + { + ImGui.Text( group.Name ); + } + } + + // Draw a multi group selector as a bordered set of checkboxes. + // If a description is provided, add a help marker in the title. + private void DrawMultiGroup( IModGroup group, int groupIdx ) + { + if( group.Type != SelectType.Multi || !group.IsOption ) + { + return; + } + + using var id = ImRaii.PushId( groupIdx ); + var flags = _emptySetting ? 0u : _settings.Settings[ groupIdx ]; + Widget.BeginFramedGroup( group.Name, group.Description ); + for( var idx2 = 0; idx2 < group.Count; ++idx2 ) + { + var flag = 1u << idx2; + var setting = ( flags & flag ) != 0; + if( ImGui.Checkbox( group[ idx2 ].Name, ref setting ) ) + { + flags = setting ? flags | flag : flags & ~flag; + Penumbra.CollectionManager.Current.SetModSetting( _mod.Index, groupIdx, flags ); + } + } + + Widget.EndFramedGroup(); + } + } +} \ No newline at end of file diff --git a/Penumbra/UI/ConfigWindow.ModPanel.Tabs.cs b/Penumbra/UI/ConfigWindow.ModPanel.Tabs.cs new file mode 100644 index 00000000..556be8dc --- /dev/null +++ b/Penumbra/UI/ConfigWindow.ModPanel.Tabs.cs @@ -0,0 +1,178 @@ +using System; +using System.ComponentModel.Design; +using System.Linq; +using System.Numerics; +using ImGuiNET; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Raii; +using Penumbra.GameData.ByteString; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods; +using Penumbra.UI.Classes; + +namespace Penumbra.UI; + +public partial class ConfigWindow +{ + private partial class ModPanel + { + [Flags] + private enum Tabs + { + Description = 0x01, + Settings = 0x02, + ChangedItems = 0x04, + Conflicts = 0x08, + Edit = 0x10, + }; + + // We want to keep the preferred tab selected even if switching through mods. + private Tabs _preferredTab = Tabs.Settings; + private Tabs _availableTabs = 0; + + // Required to use tabs that can not be closed but have a flag to set them open. + private static readonly Utf8String ConflictTabHeader = Utf8String.FromStringUnsafe( "Conflicts", false ); + private static readonly Utf8String DescriptionTabHeader = Utf8String.FromStringUnsafe( "Description", false ); + private static readonly Utf8String SettingsTabHeader = Utf8String.FromStringUnsafe( "Settings", false ); + private static readonly Utf8String ChangedItemsTabHeader = Utf8String.FromStringUnsafe( "Changed Items", false ); + private static readonly Utf8String EditModTabHeader = Utf8String.FromStringUnsafe( "Edit Mod", false ); + + private void DrawTabBar() + { + ImGui.Dummy( _window._defaultSpace ); + using var tabBar = ImRaii.TabBar( "##ModTabs" ); + if( !tabBar ) + { + return; + } + + _availableTabs = Tabs.Settings + | ( _mod.ChangedItems.Count > 0 ? Tabs.ChangedItems : 0 ) + | ( _mod.Description.Length > 0 ? Tabs.Description : 0 ) + | ( _conflicts.Count > 0 ? Tabs.Conflicts : 0 ) + | ( Penumbra.Config.ShowAdvanced ? Tabs.Edit : 0 ); + + DrawSettingsTab(); + DrawDescriptionTab(); + DrawChangedItemsTab(); + DrawConflictsTab(); + DrawEditModTab(); + } + + // Just a simple text box with the wrapped description, if it exists. + private void DrawDescriptionTab() + { + using var tab = DrawTab( DescriptionTabHeader, Tabs.Description ); + if( !tab ) + { + return; + } + + using var child = ImRaii.Child( "##description" ); + if( !child ) + { + return; + } + + ImGui.TextWrapped( _mod.Description ); + } + + // A simple clipped list of changed items. + private void DrawChangedItemsTab() + { + using var tab = DrawTab( ChangedItemsTabHeader, Tabs.ChangedItems ); + if( !tab ) + { + return; + } + + using var list = ImRaii.ListBox( "##changedItems", -Vector2.One ); + if( !list ) + { + return; + } + + var zipList = ZipList.FromSortedList( _mod.ChangedItems ); + var height = ImGui.GetTextLineHeight(); + ImGuiClip.ClippedDraw( zipList, kvp => _window.DrawChangedItem( kvp.Item1, kvp.Item2 ), height ); + } + + // If any conflicts exist, show them in this tab. + private void DrawConflictsTab() + { + using var tab = DrawTab( ConflictTabHeader, Tabs.Conflicts ); + if( !tab ) + { + return; + } + + using var box = ImRaii.ListBox( "##conflicts" ); + if( !box ) + { + return; + } + var conflicts = Penumbra.CollectionManager.Current.ModConflicts( _mod.Index ); + Mod? oldBadMod = null; + using var indent = ImRaii.PushIndent( 0f ); + foreach( var conflict in conflicts ) + { + var badMod = Penumbra.ModManager[ conflict.Mod2 ]; + if( badMod != oldBadMod ) + { + if( oldBadMod != null ) + { + indent.Pop( 30f ); + } + + if( ImGui.Selectable( badMod.Name ) ) + { + _window._selector.SelectByValue( badMod ); + } + + ImGui.SameLine(); + using var color = ImRaii.PushColor( ImGuiCol.Text, conflict.Mod1Priority ? ColorId.HandledConflictMod.Value() : ColorId.ConflictingMod.Value() ); + ImGui.Text( $"(Priority {Penumbra.CollectionManager.Current[ conflict.Mod2 ].Settings!.Priority})" ); + + indent.Push( 30f ); + } + + if( conflict.Data is Utf8GamePath p ) + { + unsafe + { + ImGuiNative.igSelectable_Bool( p.Path.Path, 0, ImGuiSelectableFlags.None, Vector2.Zero ); + } + } + else if( conflict.Data is MetaManipulation m ) + { + ImGui.Selectable( m.Manipulation?.ToString() ?? string.Empty ); + } + + oldBadMod = badMod; + } + } + + + // Draw a tab by given name if it is available, and deal with changing the preferred tab. + private ImRaii.IEndObject DrawTab( Utf8String name, Tabs flag ) + { + if( !_availableTabs.HasFlag( flag ) ) + { + return ImRaii.IEndObject.Empty; + } + + var flags = _preferredTab == flag ? ImGuiTabItemFlags.SetSelected : ImGuiTabItemFlags.None; + unsafe + { + var tab = ImRaii.TabItem( name.Path, flags ); + if( ImGui.IsItemClicked() ) + { + _preferredTab = flag; + } + + return tab; + } + } + } +} \ No newline at end of file diff --git a/Penumbra/UI/ConfigWindow.ModsTab.cs b/Penumbra/UI/ConfigWindow.ModsTab.cs index b0d1145e..dd5dddc7 100644 --- a/Penumbra/UI/ConfigWindow.ModsTab.cs +++ b/Penumbra/UI/ConfigWindow.ModsTab.cs @@ -1,386 +1,15 @@ using System; -using System.Diagnostics; using System.Numerics; using Dalamud.Interface; using ImGuiNET; using OtterGui; using OtterGui.Raii; -using OtterGui.Widgets; using Penumbra.Collections; using Penumbra.Mods; using Penumbra.UI.Classes; namespace Penumbra.UI; -public partial class ConfigWindow -{ - private class ModPanel - { - private readonly ConfigWindow _window; - private bool _valid; - private bool _emptySetting; - private bool _inherited; - private ModFileSystem.Leaf _leaf = null!; - private Mod2 _mod = null!; - private ModSettings2 _settings = null!; - private ModCollection _collection = null!; - private string _lastWebsite = string.Empty; - private bool _websiteValid; - - private string? _currentSortOrderPath; - private int? _currentPriority; - - public ModPanel( ConfigWindow window ) - => _window = window; - - private void Init( ModFileSystemSelector selector ) - { - _valid = selector.Selected != null; - if( !_valid ) - { - return; - } - - _leaf = selector.SelectedLeaf!; - _mod = selector.Selected!; - _settings = selector.SelectedSettings; - _collection = selector.SelectedSettingCollection; - _emptySetting = _settings == ModSettings2.Empty; - _inherited = _collection != Penumbra.CollectionManager.Current; - } - - public void Draw( ModFileSystemSelector selector ) - { - Init( selector ); - if( !_valid ) - { - return; - } - - DrawInheritedWarning(); - DrawHeaderLine(); - DrawFilesystemPath(); - DrawEnabledInput(); - ImGui.SameLine(); - DrawPriorityInput(); - DrawRemoveSettings(); - DrawTabBar(); - } - - private void DrawDescriptionTab() - { - if( _mod.Description.Length == 0 ) - { - return; - } - - using var tab = ImRaii.TabItem( "Description" ); - if( !tab ) - { - return; - } - - using var child = ImRaii.Child( "##tab" ); - if( !child ) - { - return; - } - - ImGui.TextWrapped( _mod.Description ); - } - - private void DrawSettingsTab() - { - if( !_mod.HasOptions ) - { - return; - } - - using var tab = ImRaii.TabItem( "Settings" ); - if( !tab ) - { - return; - } - - using var child = ImRaii.Child( "##tab" ); - if( !child ) - { - return; - } - - for( var idx = 0; idx < _mod.Groups.Count; ++idx ) - { - var group = _mod.Groups[ idx ]; - if( group.Type == SelectType.Single && group.IsOption ) - { - using var id = ImRaii.PushId( idx ); - var selectedOption = _emptySetting ? 0 : ( int )_settings.Settings[ idx ]; - ImGui.SetNextItemWidth( _window._inputTextWidth.X ); - using var combo = ImRaii.Combo( string.Empty, group[ selectedOption ].Name ); - if( combo ) - { - for( var idx2 = 0; idx2 < group.Count; ++idx2 ) - { - if( ImGui.Selectable( group[ idx2 ].Name, idx2 == selectedOption ) ) - { - Penumbra.CollectionManager.Current.SetModSetting( _mod.Index, idx, ( uint )idx2 ); - } - } - } - - combo.Dispose(); - ImGui.SameLine(); - if( group.Description.Length > 0 ) - { - ImGuiUtil.LabeledHelpMarker( group.Name, group.Description ); - } - else - { - ImGui.Text( group.Name ); - } - } - } - - // TODO add description - for( var idx = 0; idx < _mod.Groups.Count; ++idx ) - { - var group = _mod.Groups[ idx ]; - if( group.Type == SelectType.Multi && group.IsOption ) - { - using var id = ImRaii.PushId( idx ); - var flags = _emptySetting ? 0u : _settings.Settings[ idx ]; - Widget.BeginFramedGroup( group.Name ); - for( var idx2 = 0; idx2 < group.Count; ++idx2 ) - { - var flag = 1u << idx2; - var setting = ( flags & flag ) != 0; - if( ImGui.Checkbox( group[ idx2 ].Name, ref setting ) ) - { - flags = setting ? flags | flag : flags & ~flag; - Penumbra.CollectionManager.Current.SetModSetting( _mod.Index, idx, flags ); - } - } - - Widget.EndFramedGroup(); - } - } - } - - private void DrawChangedItemsTab() - { - if( _mod.ChangedItems.Count == 0 ) - { - return; - } - - using var tab = ImRaii.TabItem( "Changed Items" ); - if( !tab ) - { - return; - } - - using var list = ImRaii.ListBox( "##changedItems", -Vector2.One ); - if( !list ) - { - return; - } - - foreach( var (name, data) in _mod.ChangedItems ) - { - _window.DrawChangedItem( name, data ); - } - } - - private void DrawTabBar() - { - using var tabBar = ImRaii.TabBar( "##ModTabs" ); - if( !tabBar ) - { - return; - } - - DrawDescriptionTab(); - DrawSettingsTab(); - DrawChangedItemsTab(); - } - - private void DrawInheritedWarning() - { - if( _inherited ) - { - using var color = ImRaii.PushColor( ImGuiCol.Button, Colors.PressEnterWarningBg ); - var w = new Vector2( ImGui.GetContentRegionAvail().X, 0 ); - if( ImGui.Button( $"These settings are inherited from {_collection.Name}.", w ) ) - { - Penumbra.CollectionManager.Current.SetModInheritance( _mod.Index, false ); - } - } - } - - private void DrawPriorityInput() - { - var priority = _currentPriority ?? _settings.Priority; - ImGui.SetNextItemWidth( 50 * ImGuiHelpers.GlobalScale ); - if( ImGui.InputInt( "Priority", ref priority, 0, 0 ) ) - { - _currentPriority = priority; - } - - if( ImGui.IsItemDeactivatedAfterEdit() && _currentPriority.HasValue ) - { - if( _currentPriority != _settings.Priority ) - { - Penumbra.CollectionManager.Current.SetModPriority( _mod.Index, _currentPriority.Value ); - } - - _currentPriority = null; - } - } - - private void DrawRemoveSettings() - { - if( _inherited ) - { - return; - } - - ImGui.SameLine(); - if( ImGui.Button( "Remove Settings" ) ) - { - Penumbra.CollectionManager.Current.SetModInheritance( _mod.Index, true ); - } - - ImGuiUtil.HoverTooltip( "Remove current settings from this collection so that it can inherit them.\n" - + "If no inherited collection has settings for this mod, it will be disabled." ); - } - - private void DrawEnabledInput() - { - var enabled = _settings.Enabled; - if( ImGui.Checkbox( "Enabled", ref enabled ) ) - { - Penumbra.CollectionManager.Current.SetModState( _mod.Index, enabled ); - } - } - - private void DrawFilesystemPath() - { - var fullName = _leaf.FullName(); - var path = _currentSortOrderPath ?? fullName; - ImGui.SetNextItemWidth( 300 * ImGuiHelpers.GlobalScale ); - if( ImGui.InputText( "Sort Order", ref path, 256 ) ) - { - _currentSortOrderPath = path; - } - - if( ImGui.IsItemDeactivatedAfterEdit() && _currentSortOrderPath != null ) - { - if( _currentSortOrderPath != fullName ) - { - _window._penumbra.ModFileSystem.RenameAndMove( _leaf, _currentSortOrderPath ); - } - - _currentSortOrderPath = null; - } - } - - - // Draw the first info line for the mod panel, - // containing all basic meta information. - private void DrawHeaderLine() - { - DrawName(); - ImGui.SameLine(); - DrawVersion(); - ImGui.SameLine(); - DrawAuthor(); - ImGui.SameLine(); - DrawWebsite(); - } - - // Draw the mod name. - private void DrawName() - { - ImGui.Text( _mod.Name.Text ); - } - - // Draw the author of the mod, if any. - private void DrawAuthor() - { - using var group = ImRaii.Group(); - ImGuiUtil.TextColored( Colors.MetaInfoText, "by" ); - ImGui.SameLine(); - ImGui.Text( _mod.Author.IsEmpty ? "Unknown" : _mod.Author.Text ); - } - - // Draw the mod version, if any. - private void DrawVersion() - { - if( _mod.Version.Length > 0 ) - { - ImGui.Text( $"(Version {_mod.Version})" ); - } - else - { - ImGui.Dummy( Vector2.Zero ); - } - } - - // Update the last seen website and check for validity. - private void UpdateWebsite( string newWebsite ) - { - if( _lastWebsite == newWebsite ) - { - return; - } - - _lastWebsite = newWebsite; - _websiteValid = Uri.TryCreate( _lastWebsite, UriKind.Absolute, out var uriResult ) - && ( uriResult.Scheme == Uri.UriSchemeHttps || uriResult.Scheme == Uri.UriSchemeHttp ); - } - - // Draw the website source either as a button to open the site, - // if it is a valid http website, or as pure text. - private void DrawWebsite() - { - UpdateWebsite( _mod.Website ); - if( _lastWebsite.Length == 0 ) - { - ImGui.Dummy( Vector2.Zero ); - return; - } - - using var group = ImRaii.Group(); - if( _websiteValid ) - { - if( ImGui.Button( "Open Website" ) ) - { - try - { - var process = new ProcessStartInfo( _lastWebsite ) - { - UseShellExecute = true, - }; - Process.Start( process ); - } - catch - { - // ignored - } - } - - ImGuiUtil.HoverTooltip( _lastWebsite ); - } - else - { - ImGuiUtil.TextColored( Colors.MetaInfoText, "from" ); - ImGui.SameLine(); - ImGui.Text( _lastWebsite ); - } - } - } -} - public partial class ConfigWindow { public void DrawModsTab() @@ -401,14 +30,13 @@ public partial class ConfigWindow using var group = ImRaii.Group(); DrawHeaderLine(); - using var child = ImRaii.Child( "##ModsTabMod", -Vector2.One, true ); + using var child = ImRaii.Child( "##ModsTabMod", -Vector2.One, true, ImGuiWindowFlags.HorizontalScrollbar ); if( child ) { _modPanel.Draw( _selector ); } } - // Draw the header line that can quick switch between collections. private void DrawHeaderLine() { @@ -466,4 +94,46 @@ public partial class ConfigWindow ? absoluteSize : Math.Max( absoluteSize, relativeSize * ImGui.GetContentRegionAvail().X / 100 ); } + + // The basic setup for the mod panel. + // Details are in other files. + private partial class ModPanel + { + private readonly ConfigWindow _window; + + private bool _valid; + private ModFileSystem.Leaf _leaf = null!; + private Mod _mod = null!; + + public ModPanel( ConfigWindow window ) + { + _window = window; + } + + public void Draw( ModFileSystemSelector selector ) + { + Init( selector ); + if( !_valid ) + { + return; + } + + DrawModHeader(); + DrawTabBar(); + } + + private void Init( ModFileSystemSelector selector ) + { + _valid = selector.Selected != null; + if( !_valid ) + { + return; + } + + _leaf = selector.SelectedLeaf!; + _mod = selector.Selected!; + UpdateSettingsData( selector ); + UpdateModData(); + } + } } \ No newline at end of file diff --git a/Penumbra/UI/ConfigWindow.SettingsTab.cs b/Penumbra/UI/ConfigWindow.SettingsTab.cs index 0739bd5b..839809e0 100644 --- a/Penumbra/UI/ConfigWindow.SettingsTab.cs +++ b/Penumbra/UI/ConfigWindow.SettingsTab.cs @@ -47,7 +47,7 @@ public partial class ConfigWindow DrawAdvancedSettings(); } - private string? _settingsNewModDirectory; + private string? _newModDirectory; private readonly FileDialogManager _dialogManager = new(); private bool _dialogOpen; @@ -70,12 +70,18 @@ public partial class ConfigWindow } else { - // TODO - //_dialogManager.OpenFolderDialog( "Choose Mod Directory", ( b, s ) => - //{ - // _newModDirectory = b ? s : _newModDirectory; - // _dialogOpen = false; - //}, _newModDirectory, false); + _newModDirectory ??= Penumbra.Config.ModDirectory; + var startDir = Directory.Exists( _newModDirectory ) + ? _newModDirectory + : Directory.Exists( Penumbra.Config.ModDirectory ) + ? Penumbra.Config.ModDirectory + : "."; + + _dialogManager.OpenFolderDialog( "Choose Mod Directory", ( b, s ) => + { + _newModDirectory = b ? s : _newModDirectory; + _dialogOpen = false; + }, startDir ); _dialogOpen = true; } } @@ -99,12 +105,12 @@ public partial class ConfigWindow private void DrawRootFolder() { - _settingsNewModDirectory ??= Penumbra.Config.ModDirectory; + _newModDirectory ??= Penumbra.Config.ModDirectory; var spacing = 3 * ImGuiHelpers.GlobalScale; using var group = ImRaii.Group(); ImGui.SetNextItemWidth( _window._inputTextWidth.X - spacing - ImGui.GetFrameHeight() ); - var save = ImGui.InputText( "##rootDirectory", ref _settingsNewModDirectory, 255, ImGuiInputTextFlags.EnterReturnsTrue ); + var save = ImGui.InputText( "##rootDirectory", ref _newModDirectory, 255, ImGuiInputTextFlags.EnterReturnsTrue ); using var style = ImRaii.PushStyle( ImGuiStyleVar.ItemSpacing, new Vector2( spacing, 0 ) ); ImGui.SameLine(); DrawDirectoryPickerButton(); @@ -121,14 +127,14 @@ public partial class ConfigWindow var pos = ImGui.GetCursorPosX(); ImGui.NewLine(); - if( Penumbra.Config.ModDirectory == _settingsNewModDirectory || _settingsNewModDirectory.Length == 0 ) + if( Penumbra.Config.ModDirectory == _newModDirectory || _newModDirectory.Length == 0 ) { return; } if( save || DrawPressEnterWarning( Penumbra.Config.ModDirectory, pos ) ) { - Penumbra.ModManager.DiscoverMods( _settingsNewModDirectory ); + Penumbra.ModManager.DiscoverMods( _newModDirectory ); } } @@ -137,12 +143,13 @@ public partial class ConfigWindow { DrawOpenDirectoryButton( 0, Penumbra.ModManager.BasePath, Penumbra.ModManager.Valid ); ImGui.SameLine(); - if( ImGui.Button( "Rediscover Mods" ) ) + var tt = Penumbra.ModManager.Valid + ? "Force Penumbra to completely re-scan your root directory as if it was restarted." + : "The currently selected folder is not valid. Please select a different folder."; + if( ImGuiUtil.DrawDisabledButton( "Rediscover Mods", Vector2.Zero, tt, !Penumbra.ModManager.Valid ) ) { Penumbra.ModManager.DiscoverMods(); } - - ImGuiUtil.HoverTooltip( "Force Penumbra to completely re-scan your root directory as if it was restarted." ); } private void DrawEnabledBox() diff --git a/Penumbra/UI/ConfigWindow.cs b/Penumbra/UI/ConfigWindow.cs index 59b03525..f98ddb0a 100644 --- a/Penumbra/UI/ConfigWindow.cs +++ b/Penumbra/UI/ConfigWindow.cs @@ -21,13 +21,14 @@ public sealed partial class ConfigWindow : Window, IDisposable private readonly EffectiveTab _effectiveTab; private readonly DebugTab _debugTab; private readonly ResourceTab _resourceTab; + public readonly SubModEditWindow SubModPopup = new(); public ConfigWindow( Penumbra penumbra ) : base( GetLabel() ) { _penumbra = penumbra; _settingsTab = new SettingsTab( this ); - _selector = new ModFileSystemSelector( _penumbra.ModFileSystem, new HashSet< Mod2 >() ); // TODO + _selector = new ModFileSystemSelector( _penumbra.ModFileSystem, new HashSet< Mod >() ); // TODO _modPanel = new ModPanel( this ); _collectionsTab = new CollectionsTab( this ); _effectiveTab = new EffectiveTab(); @@ -61,6 +62,7 @@ public sealed partial class ConfigWindow : Window, IDisposable public void Dispose() { _selector.Dispose(); + _modPanel.Dispose(); } private static string GetLabel() @@ -70,10 +72,12 @@ public sealed partial class ConfigWindow : Window, IDisposable private Vector2 _defaultSpace; private Vector2 _inputTextWidth; + private Vector2 _iconButtonSize; private void SetupSizes() { _defaultSpace = new Vector2( 0, 10 * ImGuiHelpers.GlobalScale ); _inputTextWidth = new Vector2( 350f * ImGuiHelpers.GlobalScale, 0 ); + _iconButtonSize = new Vector2( ImGui.GetFrameHeight() ); } } \ No newline at end of file diff --git a/Penumbra/UI/LaunchButton.cs b/Penumbra/UI/LaunchButton.cs index 425f7e3e..1bcdda4d 100644 --- a/Penumbra/UI/LaunchButton.cs +++ b/Penumbra/UI/LaunchButton.cs @@ -9,7 +9,7 @@ namespace Penumbra.UI; // using the Dalamud-provided collapsible submenu. public class LaunchButton : IDisposable { - private readonly ConfigWindow _configWindow; + private readonly ConfigWindow _configWindow; private readonly TextureWrap? _icon; private readonly TitleScreenMenu.TitleScreenMenuEntry? _entry; diff --git a/Penumbra/Util/ArrayExtensions.cs b/Penumbra/Util/ArrayExtensions.cs index a5a8bb82..8cce7065 100644 --- a/Penumbra/Util/ArrayExtensions.cs +++ b/Penumbra/Util/ArrayExtensions.cs @@ -1,11 +1,15 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; namespace Penumbra.Util; public static class ArrayExtensions { + public static IEnumerable< (T, int) > WithIndex< T >( this IEnumerable< T > list ) + => list.Select( ( x, i ) => ( x, i ) ); + public static int IndexOf< T >( this IReadOnlyList< T > array, Predicate< T > predicate ) { for( var i = 0; i < array.Count; ++i ) @@ -61,35 +65,4 @@ public static class ArrayExtensions result = default; return false; } - - public static bool Move< T >( this IList< T > list, int idx1, int idx2 ) - { - idx1 = Math.Clamp( idx1, 0, list.Count - 1 ); - idx2 = Math.Clamp( idx2, 0, list.Count - 1 ); - if( idx1 == idx2 ) - { - return false; - } - - var tmp = list[ idx1 ]; - // move element down and shift other elements up - if( idx1 < idx2 ) - { - for( var i = idx1; i < idx2; i++ ) - { - list[ i ] = list[ i + 1 ]; - } - } - // move element up and shift other elements down - else - { - for( var i = idx1; i > idx2; i-- ) - { - list[ i ] = list[ i - 1 ]; - } - } - - list[ idx2 ] = tmp; - return true; - } } \ No newline at end of file diff --git a/Penumbra/Util/Backup.cs b/Penumbra/Util/Backup.cs index a096e238..a9d4b489 100644 --- a/Penumbra/Util/Backup.cs +++ b/Penumbra/Util/Backup.cs @@ -66,12 +66,12 @@ public static class Backup { ++count; var time = file.CreationTimeUtc; - if( ( oldest?.CreationTimeUtc ?? DateTime.MinValue ) < time ) + if( ( oldest?.CreationTimeUtc ?? DateTime.MaxValue ) > time ) { oldest = file; } - if( ( newest?.CreationTimeUtc ?? DateTime.MaxValue ) > time ) + if( ( newest?.CreationTimeUtc ?? DateTime.MinValue ) < time ) { newest = file; } diff --git a/Penumbra/Util/DialogExtensions.cs b/Penumbra/Util/DialogExtensions.cs deleted file mode 100644 index 33df7bfc..00000000 --- a/Penumbra/Util/DialogExtensions.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System; -using System.Diagnostics; -using System.Drawing; -using System.Threading; -using System.Threading.Tasks; -using System.Windows.Forms; - -namespace Penumbra.Util; - -public static class DialogExtensions -{ - public static Task< DialogResult > ShowDialogAsync( this CommonDialog form ) - { - using var process = Process.GetCurrentProcess(); - return form.ShowDialogAsync( new DialogHandle( process.MainWindowHandle ) ); - } - - public static Task< DialogResult > ShowDialogAsync( this CommonDialog form, IWin32Window owner ) - { - var taskSource = new TaskCompletionSource< DialogResult >(); - var th = new Thread( () => DialogThread( form, owner, taskSource ) ); - th.Start(); - return taskSource.Task; - } - - [STAThread] - private static void DialogThread( CommonDialog form, IWin32Window owner, - TaskCompletionSource< DialogResult > taskSource ) - { - Application.SetCompatibleTextRenderingDefault( false ); - Application.EnableVisualStyles(); - using var hiddenForm = new HiddenForm( form, owner, taskSource ); - Application.Run( hiddenForm ); - Application.ExitThread(); - } - - public class DialogHandle : IWin32Window - { - public IntPtr Handle { get; set; } - - public DialogHandle( IntPtr handle ) - => Handle = handle; - } - - public class HiddenForm : Form - { - private readonly CommonDialog _form; - private readonly IWin32Window _owner; - private readonly TaskCompletionSource< DialogResult > _taskSource; - - public HiddenForm( CommonDialog form, IWin32Window owner, TaskCompletionSource< DialogResult > taskSource ) - { - _form = form; - _owner = owner; - _taskSource = taskSource; - - Opacity = 0; - FormBorderStyle = FormBorderStyle.None; - ShowInTaskbar = false; - Size = new Size( 0, 0 ); - - Shown += HiddenForm_Shown; - } - - private void HiddenForm_Shown( object? sender, EventArgs _ ) - { - Hide(); - try - { - var result = _form.ShowDialog( _owner ); - _taskSource.SetResult( result ); - } - catch( Exception e ) - { - _taskSource.SetException( e ); - } - - Close(); - } - } -} \ No newline at end of file diff --git a/Penumbra/Util/DictionaryExtensions.cs b/Penumbra/Util/DictionaryExtensions.cs new file mode 100644 index 00000000..97e2638a --- /dev/null +++ b/Penumbra/Util/DictionaryExtensions.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; + +namespace Penumbra.Util; + +public static class DictionaryExtensions +{ + // Returns whether two dictionaries contain equal keys and values. + public static bool SetEquals< TKey, TValue >( this IReadOnlyDictionary< TKey, TValue > lhs, IReadOnlyDictionary< TKey, TValue > rhs ) + { + if( lhs.Count != rhs.Count ) + { + return false; + } + + foreach( var (key, value) in lhs ) + { + if( !rhs.TryGetValue( key, out var rhsValue ) ) + { + return false; + } + + if( value == null ) + { + if( rhsValue != null ) + { + return false; + } + + continue; + } + + if( !value.Equals( rhsValue ) ) + { + return false; + } + } + + return true; + } + + // Set one dictionary to the other, deleting previous entries and ensuring capacity beforehand. + public static void SetTo< TKey, TValue >( this Dictionary< TKey, TValue > lhs, IReadOnlyDictionary< TKey, TValue > rhs ) + where TKey : notnull + { + lhs.Clear(); + lhs.EnsureCapacity( rhs.Count ); + foreach( var (key, value) in rhs ) + { + lhs.Add( key, value ); + } + } +} \ No newline at end of file diff --git a/Penumbra/Util/Functions.cs b/Penumbra/Util/Functions.cs deleted file mode 100644 index a3c50e4a..00000000 --- a/Penumbra/Util/Functions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Runtime.CompilerServices; - -namespace Penumbra.Util; - -public static class Functions -{ - [MethodImpl( MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization )] - public static bool SetDifferent< T >( T oldValue, T newValue, Action< T > set ) where T : IEquatable< T > - { - if( oldValue.Equals( newValue ) ) - { - return false; - } - - set( newValue ); - return true; - } -} \ No newline at end of file diff --git a/Penumbra/Util/ModelChanger.cs b/Penumbra/Util/ModelChanger.cs index 2ff2b37d..25cc6df8 100644 --- a/Penumbra/Util/ModelChanger.cs +++ b/Penumbra/Util/ModelChanger.cs @@ -74,7 +74,7 @@ public static class ModelChanger } } - public static bool ChangeModMaterials( Mod2 mod, string from, string to ) + public static bool ChangeModMaterials( Mod mod, string from, string to ) { if( ValidStrings( from, to ) ) { diff --git a/Penumbra/Util/SingleOrArrayConverter.cs b/Penumbra/Util/SingleOrArrayConverter.cs deleted file mode 100644 index 29da6249..00000000 --- a/Penumbra/Util/SingleOrArrayConverter.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Collections.Generic; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace Penumbra.Util; - -public class SingleOrArrayConverter< T > : JsonConverter -{ - public override bool CanConvert( Type objectType ) - => objectType == typeof( HashSet< T > ); - - public override object ReadJson( JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer ) - { - var token = JToken.Load( reader ); - - if( token.Type == JTokenType.Array ) - { - return token.ToObject< HashSet< T > >() ?? new HashSet< T >(); - } - - var tmp = token.ToObject< T >(); - return tmp != null - ? new HashSet< T > { tmp } - : new HashSet< T >(); - } - - public override bool CanWrite - => true; - - public override void WriteJson( JsonWriter writer, object? value, JsonSerializer serializer ) - { - writer.WriteStartArray(); - if( value != null ) - { - var v = ( HashSet< T > )value; - foreach( var val in v ) - { - serializer.Serialize( writer, val?.ToString() ); - } - } - - writer.WriteEndArray(); - } -} \ No newline at end of file diff --git a/Penumbra/Util/StringPathExtensions.cs b/Penumbra/Util/StringPathExtensions.cs deleted file mode 100644 index bcce2a88..00000000 --- a/Penumbra/Util/StringPathExtensions.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Text; - -namespace Penumbra.Util; - -public static class StringPathExtensions -{ - private static readonly HashSet< char > Invalid = new(Path.GetInvalidFileNameChars()); - - public static string ReplaceInvalidPathSymbols( this string s, string replacement = "_" ) - { - StringBuilder sb = new(s.Length); - foreach( var c in s ) - { - if( Invalid.Contains( c ) ) - { - sb.Append( replacement ); - } - else - { - sb.Append( c ); - } - } - - return sb.ToString(); - } - - public static string RemoveInvalidPathSymbols( this string s ) - => string.Concat( s.Split( Path.GetInvalidFileNameChars() ) ); - - public static string ReplaceNonAsciiSymbols( this string s, string replacement = "_" ) - { - StringBuilder sb = new(s.Length); - foreach( var c in s ) - { - if( c >= 128 ) - { - sb.Append( replacement ); - } - else - { - sb.Append( c ); - } - } - - return sb.ToString(); - } - - public static string ReplaceBadXivSymbols( this string s, string replacement = "_" ) - { - StringBuilder sb = new(s.Length); - foreach( var c in s ) - { - if( c >= 128 || Invalid.Contains( c ) ) - { - sb.Append( replacement ); - } - else - { - sb.Append( c ); - } - } - - return sb.ToString(); - } -} \ No newline at end of file diff --git a/Penumbra/Util/TempFile.cs b/Penumbra/Util/TempFile.cs deleted file mode 100644 index 76fad9e2..00000000 --- a/Penumbra/Util/TempFile.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.IO; - -namespace Penumbra.Util; - -public static class TempFile -{ - public static FileInfo TempFileName( DirectoryInfo baseDir, string suffix = "" ) - { - const uint maxTries = 15; - for( var i = 0; i < maxTries; ++i ) - { - var name = Path.GetRandomFileName(); - var path = new FileInfo( Path.Combine( baseDir.FullName, - suffix.Length > 0 ? name[ ..name.LastIndexOf( '.' ) ] + suffix : name ) ); - if( !path.Exists ) - { - return path; - } - } - - throw new IOException(); - } -} \ No newline at end of file