A lot of interface stuff, some more cleanup and fixes. Main functionality should be mostly fine, importing works. Missing a lot of mod edit options.

This commit is contained in:
Ottermandias 2022-04-26 21:35:09 +02:00
parent 8dd681bdda
commit dbb9931189
77 changed files with 3332 additions and 2066 deletions

@ -1 +1 @@
Subproject commit a832fb6ca5e7c6cb4e35a51a08d30d1800f405da
Subproject commit 1a3cd1f881f3b6c2c4d9d4b20f054d1ab5ccc014

View file

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

View file

@ -48,7 +48,7 @@ public class SimpleRedirectManager
return RedirectResult.NoPermission;
}
if( Mod2.FilterFile( path ) )
if( Mod.FilterFile( path ) )
{
return RedirectResult.FilteredGamePath;
}

View file

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

View file

@ -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 );
}
}
}
}

View file

@ -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()

View file

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

View file

@ -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:

View file

@ -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;
}

View file

@ -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 );

View file

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

View file

@ -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);
}

View file

@ -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();

View file

@ -0,0 +1,9 @@
namespace Penumbra.Import;
public enum ImporterState
{
None,
WritingPackToDisk,
ExtractingModFiles,
Done,
}

View file

@ -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 );
}
}
}

View file

@ -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 );
}
}

View file

@ -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 );
}
}

View file

@ -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() );
}
}
}
}

View file

@ -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;
}
}

View file

@ -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}" );
}
}
}

View file

@ -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;
}
}

View file

@ -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();
}
}

View file

@ -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 >();
}

View file

@ -1,10 +0,0 @@
namespace Penumbra.Importer
{
public enum ImporterState
{
None,
WritingPackToDisk,
ExtractingModFiles,
Done,
}
}

View file

@ -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 );
}
}
}

View file

@ -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; }
}
}

View file

@ -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; }
}
}

View file

@ -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() );
}
}

View file

@ -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;
}
}

View file

@ -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;

View file

@ -22,7 +22,6 @@ public partial class MetaManager
private readonly ModCollection _collection;
private static int _imcManagerCount;
public MetaManagerImc( ModCollection collection )
{
_collection = collection;

View file

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

View file

@ -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,

View file

@ -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 );
}
}
}

View file

@ -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 );
}
}
}

View file

@ -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,
};
}
}

View file

@ -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;

View file

@ -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()

View file

@ -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];
}
}
}

View file

@ -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" );
}
}

View file

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

View file

@ -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;

View file

@ -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();
}
}

View file

@ -9,7 +9,7 @@ using Penumbra.Meta.Manipulations;
namespace Penumbra.Mods;
public partial class Mod2
public partial class Mod
{
public ISubMod Default
=> _default;

View file

@ -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();
}
}
}
}

View file

@ -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;

View file

@ -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();
}
}

View file

@ -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;

View file

@ -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 );
}

View file

@ -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 );
}

View file

@ -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 );
}
}

View file

@ -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 );
}
}

View file

@ -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;

View file

@ -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,

View file

@ -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;
}

View file

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

View file

@ -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;
}
}

View file

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

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Numerics;
using ImGuiNET;
using OtterGui;
using OtterGui.Classes;
using OtterGui.Raii;
namespace Penumbra.UI;

View file

@ -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;

View file

@ -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();
}
}
}
}
}

View file

@ -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;
}
}
}
}

View file

@ -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();
}
}
}

View file

@ -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;
}
}
}
}

View file

@ -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();
}
}
}

View file

@ -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()

View file

@ -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() );
}
}

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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();
}
}
}

View file

@ -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 );
}
}
}

View file

@ -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;
}
}

View file

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

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}