diff --git a/Penumbra/Mods/Editor/ModBackup.cs b/Penumbra/Mods/Editor/ModBackup.cs index 083fb803..b675ca6b 100644 --- a/Penumbra/Mods/Editor/ModBackup.cs +++ b/Penumbra/Mods/Editor/ModBackup.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Threading.Tasks; @@ -7,7 +8,9 @@ namespace Penumbra.Mods; /// Utility to create and apply a zipped backup of a mod. public class ModBackup -{ +{ + /// Set when reading Config and migrating from v4 to v5. + public static bool MigrateModBackups = false; public static bool CreatingBackup { get; private set; } private readonly Mod _mod; @@ -22,9 +25,9 @@ public class ModBackup } /// Migrate file extensions. - public static void MigrateZipToPmp(ModManager modManager) + public static void MigrateZipToPmp(IEnumerable modStorage) { - foreach (var mod in modManager) + foreach (var mod in modStorage) { var pmpName = mod.ModPath + ".pmp"; var zipName = mod.ModPath + ".zip"; diff --git a/Penumbra/Mods/Manager/Mod.Manager.cs b/Penumbra/Mods/Manager/Mod.Manager.cs index 95fbe2ac..febe8209 100644 --- a/Penumbra/Mods/Manager/Mod.Manager.cs +++ b/Penumbra/Mods/Manager/Mod.Manager.cs @@ -1,74 +1,325 @@ using System; using System.Collections; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.IO; using System.Linq; +using System.Threading.Tasks; using Penumbra.Services; -using Penumbra.Util; - +using Penumbra.Util; namespace Penumbra.Mods; - -public sealed class ModManager2 : IReadOnlyList, IDisposable + +/// Describes the state of a potential move-target for a mod. +public enum NewDirectoryState { + NonExisting, + ExistsEmpty, + ExistsNonEmpty, + ExistsAsFile, + ContainsInvalidSymbols, + Identical, + Empty, +} + +public sealed class ModManager2 : ModStorage +{ + private readonly Configuration _config; + private readonly CommunicatorService _communicator; + public readonly ModDataEditor DataEditor; public readonly ModOptionEditor OptionEditor; - /// - /// An easily accessible set of new mods. - /// Mods are added when they are created or imported. - /// Mods are removed when they are deleted or when they are toggled in any collection. - /// Also gets cleared on mod rediscovery. - /// - public readonly HashSet NewMods = new(); + public DirectoryInfo BasePath { get; private set; } = null!; + public bool Valid { get; private set; } - public Mod this[int idx] - => _mods[idx]; - - public Mod this[Index idx] - => _mods[idx]; - - public int Count - => _mods.Count; - - public IEnumerator GetEnumerator() - => _mods.GetEnumerator(); - - IEnumerator IEnumerable.GetEnumerator() - => GetEnumerator(); - - /// - /// Try to obtain a mod by its directory name (unique identifier, preferred), - /// or the first mod of the given name if no directory fits. - /// - public bool TryGetMod(string identifier, string modName, [NotNullWhen(true)] out Mod? mod) + public ModManager2(Configuration config, CommunicatorService communicator, ModDataEditor dataEditor, ModOptionEditor optionEditor) { - mod = null; - foreach (var m in _mods) - { - if (string.Equals(m.Identifier, identifier, StringComparison.OrdinalIgnoreCase)) + _config = config; + _communicator = communicator; + DataEditor = dataEditor; + OptionEditor = optionEditor; + } + + /// Change the mod base directory and discover available mods. + public void DiscoverMods(string newDir) + { + SetBaseDirectory(newDir, false); + DiscoverMods(); + } + + /// + /// Discover mods without changing the root directory. + /// + public void DiscoverMods() + { + _communicator.ModDiscoveryStarted.Invoke(); + NewMods.Clear(); + Mods.Clear(); + BasePath.Refresh(); + + if (Valid && BasePath.Exists) + ScanMods(); + + _communicator.ModDiscoveryFinished.Invoke(); + Penumbra.Log.Information("Rediscovered mods."); + + if (ModBackup.MigrateModBackups) + ModBackup.MigrateZipToPmp(this); + } + + /// Load a new mod and add it to the manager if successful. + public void AddMod(DirectoryInfo modFolder) + { + if (this.Any(m => m.ModPath.Name == modFolder.Name)) + return; + + Mod.Creator.SplitMultiGroups(modFolder); + var mod = Mod.LoadMod(Penumbra.ModManager, modFolder, true); + if (mod == null) + return; + + mod.Index = Count; + Mods.Add(mod); + _communicator.ModPathChanged.Invoke(ModPathChangeType.Added, mod, null, mod.ModPath); + Penumbra.Log.Debug($"Added new mod {mod.Name} from {modFolder.FullName}."); + } + + /// + /// Delete a mod. The event is invoked before the mod is removed from the list. + /// Deletes from filesystem as well as from internal data. + /// Updates indices of later mods. + /// + public void DeleteMod(Mod mod) + { + if (Directory.Exists(mod.ModPath.FullName)) + try { - mod = m; - return true; + Directory.Delete(mod.ModPath.FullName, true); + Penumbra.Log.Debug($"Deleted directory {mod.ModPath.FullName} for {mod.Name}."); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not delete the mod {mod.ModPath.Name}:\n{e}"); } - if (m.Name == modName) - mod ??= m; + _communicator.ModPathChanged.Invoke(ModPathChangeType.Deleted, mod, mod.ModPath, null); + Mods.RemoveAt(mod.Index); + foreach (var remainingMod in this.Skip(mod.Index)) + --remainingMod.Index; + + Penumbra.Log.Debug($"Deleted mod {mod.Name}."); + } + + /// + /// Reload a mod without changing its base directory. + /// If the base directory does not exist anymore, the mod will be deleted. + /// + public void ReloadMod(Mod mod) + { + var oldName = mod.Name; + + _communicator.ModPathChanged.Invoke(ModPathChangeType.StartingReload, mod, mod.ModPath, mod.ModPath); + if (!mod.Reload(Penumbra.ModManager, true, out var metaChange)) + { + Penumbra.Log.Warning(mod.Name.Length == 0 + ? $"Reloading mod {oldName} has failed, new name is empty. Deleting instead." + : $"Reloading mod {oldName} failed, {mod.ModPath.FullName} does not exist anymore or it ha. Deleting instead."); + + DeleteMod(mod); + return; } - return mod != null; - } - - /// The actual list of mods. - private readonly List _mods = new(); - - public ModManager2(ModDataEditor dataEditor, ModOptionEditor optionEditor) + _communicator.ModPathChanged.Invoke(ModPathChangeType.Reloaded, mod, mod.ModPath, mod.ModPath); + if (metaChange != ModDataChangeType.None) + _communicator.ModDataChanged.Invoke(metaChange, mod, oldName); + } + + + /// + /// Rename/Move a mod directory. + /// Updates all collection settings and sort order settings. + /// + public void MoveModDirectory(Mod mod, string newName) { - DataEditor = dataEditor; - OptionEditor = optionEditor; + var oldName = mod.Name; + var oldDirectory = mod.ModPath; + + switch (NewDirectoryValid(oldDirectory.Name, newName, out var dir)) + { + case NewDirectoryState.NonExisting: + // Nothing to do + break; + case NewDirectoryState.ExistsEmpty: + try + { + Directory.Delete(dir!.FullName); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not delete empty directory {dir!.FullName} to move {mod.Name} to it:\n{e}"); + return; + } + + break; + // Should be caught beforehand. + case NewDirectoryState.ExistsNonEmpty: + case NewDirectoryState.ExistsAsFile: + case NewDirectoryState.ContainsInvalidSymbols: + // Nothing to do at all. + case NewDirectoryState.Identical: + default: + return; + } + + try + { + Directory.Move(oldDirectory.FullName, dir!.FullName); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not move {mod.Name} from {oldDirectory.Name} to {dir!.Name}:\n{e}"); + return; + } + + DataEditor.MoveDataFile(oldDirectory, dir); + + dir.Refresh(); + mod.ModPath = dir; + if (!mod.Reload(Penumbra.ModManager, false, out var metaChange)) + { + Penumbra.Log.Error($"Error reloading moved mod {mod.Name}."); + return; + } + + _communicator.ModPathChanged.Invoke(ModPathChangeType.Moved, mod, oldDirectory, dir); + if (metaChange != ModDataChangeType.None) + _communicator.ModDataChanged.Invoke(metaChange, mod, oldName); + } + + /// Return the state of the new potential name of a directory. + public NewDirectoryState NewDirectoryValid(string oldName, string newName, out DirectoryInfo? directory) + { + directory = null; + if (newName.Length == 0) + return NewDirectoryState.Empty; + + if (oldName == newName) + return NewDirectoryState.Identical; + + var fixedNewName = Mod.Creator.ReplaceBadXivSymbols(newName); + if (fixedNewName != newName) + return NewDirectoryState.ContainsInvalidSymbols; + + directory = new DirectoryInfo(Path.Combine(BasePath.FullName, fixedNewName)); + if (File.Exists(directory.FullName)) + return NewDirectoryState.ExistsAsFile; + + if (!Directory.Exists(directory.FullName)) + return NewDirectoryState.NonExisting; + + if (directory.EnumerateFileSystemInfos().Any()) + return NewDirectoryState.ExistsNonEmpty; + + return NewDirectoryState.ExistsEmpty; } + + /// Add new mods to NewMods and remove deleted mods from NewMods. + private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, + DirectoryInfo? newDirectory) + { + switch (type) + { + case ModPathChangeType.Added: + NewMods.Add(mod); + break; + case ModPathChangeType.Deleted: + NewMods.Remove(mod); + break; + case ModPathChangeType.Moved: + if (oldDirectory != null && newDirectory != null) + DataEditor.MoveDataFile(oldDirectory, newDirectory); + + break; + } + } + public void Dispose() { } + + /// + /// Set the mod base directory. + /// If its not the first time, check if it is the same directory as before. + /// Also checks if the directory is available and tries to create it if it is not. + /// + private void SetBaseDirectory(string newPath, bool firstTime) + { + if (!firstTime && string.Equals(newPath, _config.ModDirectory, StringComparison.OrdinalIgnoreCase)) + return; + + if (newPath.Length == 0) + { + Valid = false; + BasePath = new DirectoryInfo("."); + if (_config.ModDirectory != BasePath.FullName) + TriggerModDirectoryChange(string.Empty, false); + } + else + { + var newDir = new DirectoryInfo(newPath); + if (!newDir.Exists) + try + { + Directory.CreateDirectory(newDir.FullName); + newDir.Refresh(); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not create specified mod directory {newDir.FullName}:\n{e}"); + } + + BasePath = newDir; + Valid = Directory.Exists(newDir.FullName); + if (_config.ModDirectory != BasePath.FullName) + TriggerModDirectoryChange(BasePath.FullName, Valid); + } + } + + private void TriggerModDirectoryChange(string newPath, bool valid) + { + _config.ModDirectory = newPath; + _config.Save(); + Penumbra.Log.Information($"Set new mod base directory from {_config.ModDirectory} to {newPath}."); + _communicator.ModDirectoryChanged.Invoke(newPath, valid); + } + + + + /// + /// Iterate through available mods with multiple threads and queue their loads, + /// then add the mods from the queue. + /// + private void ScanMods() + { + var options = new ParallelOptions() + { + MaxDegreeOfParallelism = Environment.ProcessorCount / 2, + }; + var queue = new ConcurrentQueue(); + Parallel.ForEach(BasePath.EnumerateDirectories(), options, dir => + { + var mod = Mod.LoadMod(Penumbra.ModManager, dir, false); + if (mod != null) + queue.Enqueue(mod); + }); + + foreach (var mod in queue) + { + mod.Index = Count; + Mods.Add(mod); + } + } } public sealed partial class ModManager : IReadOnlyList, IDisposable diff --git a/Penumbra/Mods/Manager/ModOptionEditor.cs b/Penumbra/Mods/Manager/ModOptionEditor.cs index 8e96b3e2..88cc9e75 100644 --- a/Penumbra/Mods/Manager/ModOptionEditor.cs +++ b/Penumbra/Mods/Manager/ModOptionEditor.cs @@ -32,6 +32,7 @@ public class ModOptionEditor mod._groups[groupIdx] = group.Convert(type); _saveService.QueueSave(new ModSaveGroup(mod, groupIdx)); + mod.HasOptions = mod.Groups.Any(o => o.IsOption); _communicator.ModOptionChanged.Invoke(ModOptionChangeType.GroupTypeChanged, mod, groupIdx, -1, -1); } diff --git a/Penumbra/Mods/Manager/ModStorage.cs b/Penumbra/Mods/Manager/ModStorage.cs new file mode 100644 index 00000000..dbf5c46a --- /dev/null +++ b/Penumbra/Mods/Manager/ModStorage.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Penumbra.Mods; + +public class ModStorage : IReadOnlyList +{ + /// The actual list of mods. + protected readonly List Mods = new(); + + public int Count + => Mods.Count; + + public Mod this[int idx] + => Mods[idx]; + + public Mod this[Index idx] + => Mods[idx]; + + public IEnumerator GetEnumerator() + => Mods.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + /// + /// Try to obtain a mod by its directory name (unique identifier, preferred), + /// or the first mod of the given name if no directory fits. + /// + public bool TryGetMod(string identifier, string modName, [NotNullWhen(true)] out Mod? mod) + { + mod = null; + foreach (var m in Mods) + { + if (string.Equals(m.Identifier, identifier, StringComparison.OrdinalIgnoreCase)) + { + mod = m; + return true; + } + + if (m.Name == modName) + mod ??= m; + } + + return mod != null; + } + + /// + /// An easily accessible set of new mods. + /// Mods are added when they are created or imported. + /// Mods are removed when they are deleted or when they are toggled in any collection. + /// Also gets cleared on mod rediscovery. + /// + protected readonly HashSet NewMods = new(); + + public bool IsNew(Mod mod) + => NewMods.Contains(mod); + + public void SetNew(Mod mod) + => NewMods.Add(mod); + + public void SetKnown(Mod mod) + => NewMods.Remove(mod); +} diff --git a/Penumbra/Mods/Mod.Files.cs b/Penumbra/Mods/Mod.Files.cs index 97910191..bd11d477 100644 --- a/Penumbra/Mods/Mod.Files.cs +++ b/Penumbra/Mods/Mod.Files.cs @@ -14,80 +14,78 @@ public partial class Mod public ISubMod Default => _default; - public IReadOnlyList< IModGroup > Groups + public IReadOnlyList Groups => _groups; - internal readonly SubMod _default; - internal readonly List< IModGroup > _groups = new(); + internal readonly SubMod _default; + internal readonly List _groups = new(); - public int TotalFileCount { get; internal set; } - public int TotalSwapCount { get; internal set; } - public int TotalManipulations { get; internal set; } - public bool HasOptions { get; internal set; } + public int TotalFileCount { get; internal set; } + public int TotalSwapCount { get; internal set; } + public int TotalManipulations { get; internal set; } + public bool HasOptions { get; internal set; } internal bool SetCounts() { TotalFileCount = 0; TotalSwapCount = 0; TotalManipulations = 0; - foreach( var s in AllSubMods ) + foreach (var s in AllSubMods) { TotalFileCount += s.Files.Count; TotalSwapCount += s.FileSwaps.Count; TotalManipulations += s.Manipulations.Count; } - HasOptions = _groups.Any( o + HasOptions = _groups.Any(o => o is MultiModGroup m && m.PrioritizedOptions.Count > 0 - || o is SingleModGroup s && s.OptionData.Count > 1 ); + || o is SingleModGroup s && s.OptionData.Count > 1); return true; } - public IEnumerable< ISubMod > AllSubMods - => _groups.SelectMany( o => o ).Prepend( _default ); + public IEnumerable AllSubMods + => _groups.SelectMany(o => o).Prepend(_default); - public IEnumerable< MetaManipulation > AllManipulations - => AllSubMods.SelectMany( s => s.Manipulations ); + public IEnumerable AllManipulations + => AllSubMods.SelectMany(s => s.Manipulations); - public IEnumerable< Utf8GamePath > AllRedirects - => AllSubMods.SelectMany( s => s.Files.Keys.Concat( s.FileSwaps.Keys ) ); + public IEnumerable AllRedirects + => AllSubMods.SelectMany(s => s.Files.Keys.Concat(s.FileSwaps.Keys)); - public IEnumerable< FullPath > AllFiles - => AllSubMods.SelectMany( o => o.Files ) - .Select( p => p.Value ); + public IEnumerable AllFiles + => AllSubMods.SelectMany(o => o.Files) + .Select(p => p.Value); - public IEnumerable< FileInfo > GroupFiles - => ModPath.EnumerateFiles( "group_*.json" ); + public IEnumerable GroupFiles + => ModPath.EnumerateFiles("group_*.json"); - public List< FullPath > FindUnusedFiles() + public List FindUnusedFiles() { var modFiles = AllFiles.ToHashSet(); return ModPath.EnumerateDirectories() - .SelectMany( f => f.EnumerateFiles( "*", SearchOption.AllDirectories ) ) - .Select( f => new FullPath( f ) ) - .Where( f => !modFiles.Contains( f ) ) - .ToList(); + .SelectMany(f => f.EnumerateFiles("*", SearchOption.AllDirectories)) + .Select(f => new FullPath(f)) + .Where(f => !modFiles.Contains(f)) + .ToList(); } - private static IModGroup? LoadModGroup( Mod mod, FileInfo file, int groupIdx ) + private static IModGroup? LoadModGroup(Mod mod, FileInfo file, int groupIdx) { - if( !File.Exists( file.FullName ) ) - { + if (!File.Exists(file.FullName)) return null; - } try { - var json = JObject.Parse( File.ReadAllText( file.FullName ) ); - switch( json[ nameof( Type ) ]?.ToObject< GroupType >() ?? GroupType.Single ) + var json = JObject.Parse(File.ReadAllText(file.FullName)); + switch (json[nameof(Type)]?.ToObject() ?? GroupType.Single) { - case GroupType.Multi: return MultiModGroup.Load( mod, json, groupIdx ); - case GroupType.Single: return SingleModGroup.Load( mod, json, groupIdx ); + case GroupType.Multi: return MultiModGroup.Load(mod, json, groupIdx); + case GroupType.Single: return SingleModGroup.Load(mod, json, groupIdx); } } - catch( Exception e ) + catch (Exception e) { - Penumbra.Log.Error( $"Could not read mod group from {file.FullName}:\n{e}" ); + Penumbra.Log.Error($"Could not read mod group from {file.FullName}:\n{e}"); } return null; @@ -97,13 +95,13 @@ public partial class Mod { _groups.Clear(); var changes = false; - foreach( var file in GroupFiles ) + foreach (var file in GroupFiles) { - var group = LoadModGroup( this, file, _groups.Count ); - if( group != null && _groups.All( g => g.Name != group.Name ) ) + var group = LoadModGroup(this, file, _groups.Count); + if (group != null && _groups.All(g => g.Name != group.Name)) { changes = changes || Penumbra.Filenames.OptionGroupFile(ModPath.FullName, Groups.Count, group.Name) != file.FullName; - _groups.Add( group ); + _groups.Add(group); } else { @@ -111,7 +109,7 @@ public partial class Mod } } - if( changes ) + if (changes) Penumbra.SaveService.SaveAllOptionGroups(this); } @@ -122,13 +120,9 @@ public partial class Mod try { if (!File.Exists(defaultFile)) - { _default.Load(ModPath, new JObject(), out _); - } else - { _default.Load(ModPath, JObject.Parse(File.ReadAllText(defaultFile)), out _); - } } catch (Exception e) { @@ -145,17 +139,13 @@ public partial class Mod { var dir = Creator.NewOptionDirectory(ModPath, group.Name); if (!dir.Exists) - { dir.Create(); - } foreach (var option in group.OfType()) { var optionDir = Creator.NewOptionDirectory(dir, option.Name); if (!optionDir.Exists) - { optionDir.Create(); - } option.WriteTexToolsMeta(optionDir); } @@ -166,5 +156,4 @@ public partial class Mod Penumbra.Log.Error($"Error writing TexToolsMeta:\n{e}"); } } - -} \ No newline at end of file +} diff --git a/Penumbra/Mods/ModCache.cs b/Penumbra/Mods/ModCache.cs new file mode 100644 index 00000000..3fb6d3f0 --- /dev/null +++ b/Penumbra/Mods/ModCache.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace Penumbra.Mods; + +public class ModCache +{ + public int TotalFileCount; + public int TotalSwapCount; + public int TotalManipulations; + public bool HasOptions; + + public SortedList ChangedItems = new(); + public string LowerChangedItemsString = string.Empty; + public string AllTagsLower = string.Empty; + + public void Reset() + { + TotalFileCount = 0; + TotalSwapCount = 0; + TotalManipulations = 0; + HasOptions = false; + ChangedItems.Clear(); + LowerChangedItemsString = string.Empty; + AllTagsLower = string.Empty; + } +} diff --git a/Penumbra/Mods/ModCacheManager.cs b/Penumbra/Mods/ModCacheManager.cs new file mode 100644 index 00000000..69787644 --- /dev/null +++ b/Penumbra/Mods/ModCacheManager.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Penumbra.GameData; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.Meta.Manipulations; +using Penumbra.Services; + +namespace Penumbra.Mods; + +public class ModCacheManager : IDisposable, IReadOnlyList +{ + private readonly CommunicatorService _communicator; + private readonly IdentifierService _identifier; + private readonly IReadOnlyList _modManager; + + private readonly List _cache = new(); + + // TODO ModManager2 + public ModCacheManager(CommunicatorService communicator, IdentifierService identifier, ModManager modManager) + { + _communicator = communicator; + _identifier = identifier; + _modManager = modManager; + + _communicator.ModOptionChanged.Event += OnModOptionChange; + _communicator.ModPathChanged.Event += OnModPathChange; + _communicator.ModDataChanged.Event += OnModDataChange; + _communicator.ModDiscoveryFinished.Event += OnModDiscoveryFinished; + if (!identifier.Valid) + identifier.FinishedCreation += OnIdentifierCreation; + OnModDiscoveryFinished(); + } + + public IEnumerator GetEnumerator() + => _cache.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + public int Count { get; private set; } + + public ModCache this[int index] + => _cache[index]; + + public ModCache this[Mod mod] + => _cache[mod.Index]; + + public void Dispose() + { + _communicator.ModOptionChanged.Event -= OnModOptionChange; + _communicator.ModPathChanged.Event -= OnModPathChange; + _communicator.ModDataChanged.Event -= OnModDataChange; + _communicator.ModDiscoveryFinished.Event -= OnModDiscoveryFinished; + } + + /// Compute the items changed by a given meta manipulation and put them into the changedItems dictionary. + public static void ComputeChangedItems(IObjectIdentifier identifier, IDictionary changedItems, MetaManipulation manip) + { + switch (manip.ManipulationType) + { + case MetaManipulation.Type.Imc: + switch (manip.Imc.ObjectType) + { + case ObjectType.Equipment: + case ObjectType.Accessory: + identifier.Identify(changedItems, + GamePaths.Equipment.Mtrl.Path(manip.Imc.PrimaryId, GenderRace.MidlanderMale, manip.Imc.EquipSlot, manip.Imc.Variant, + "a")); + break; + case ObjectType.Weapon: + identifier.Identify(changedItems, + GamePaths.Weapon.Mtrl.Path(manip.Imc.PrimaryId, manip.Imc.SecondaryId, manip.Imc.Variant, "a")); + break; + case ObjectType.DemiHuman: + identifier.Identify(changedItems, + GamePaths.DemiHuman.Mtrl.Path(manip.Imc.PrimaryId, manip.Imc.SecondaryId, manip.Imc.EquipSlot, manip.Imc.Variant, + "a")); + break; + case ObjectType.Monster: + identifier.Identify(changedItems, + GamePaths.Monster.Mtrl.Path(manip.Imc.PrimaryId, manip.Imc.SecondaryId, manip.Imc.Variant, "a")); + break; + } + + break; + case MetaManipulation.Type.Eqdp: + identifier.Identify(changedItems, + GamePaths.Equipment.Mdl.Path(manip.Eqdp.SetId, Names.CombinedRace(manip.Eqdp.Gender, manip.Eqdp.Race), manip.Eqdp.Slot)); + break; + case MetaManipulation.Type.Eqp: + identifier.Identify(changedItems, GamePaths.Equipment.Mdl.Path(manip.Eqp.SetId, GenderRace.MidlanderMale, manip.Eqp.Slot)); + break; + case MetaManipulation.Type.Est: + switch (manip.Est.Slot) + { + case EstManipulation.EstType.Hair: + changedItems.TryAdd($"Customization: {manip.Est.Race} {manip.Est.Gender} Hair (Hair) {manip.Est.SetId}", null); + break; + case EstManipulation.EstType.Face: + changedItems.TryAdd($"Customization: {manip.Est.Race} {manip.Est.Gender} Face (Face) {manip.Est.SetId}", null); + break; + case EstManipulation.EstType.Body: + identifier.Identify(changedItems, + GamePaths.Equipment.Mdl.Path(manip.Est.SetId, Names.CombinedRace(manip.Est.Gender, manip.Est.Race), + EquipSlot.Body)); + break; + case EstManipulation.EstType.Head: + identifier.Identify(changedItems, + GamePaths.Equipment.Mdl.Path(manip.Est.SetId, Names.CombinedRace(manip.Est.Gender, manip.Est.Race), + EquipSlot.Head)); + break; + } + + break; + case MetaManipulation.Type.Gmp: + identifier.Identify(changedItems, GamePaths.Equipment.Mdl.Path(manip.Gmp.SetId, GenderRace.MidlanderMale, EquipSlot.Head)); + break; + case MetaManipulation.Type.Rsp: + changedItems.TryAdd($"{manip.Rsp.SubRace.ToName()} {manip.Rsp.Attribute.ToFullString()}", null); + break; + } + } + + private void OnModOptionChange(ModOptionChangeType type, Mod mod, int groupIdx, int _, int _2) + { + ModCache cache; + switch (type) + { + case ModOptionChangeType.GroupAdded: + case ModOptionChangeType.GroupDeleted: + case ModOptionChangeType.OptionAdded: + case ModOptionChangeType.OptionDeleted: + cache = EnsureCount(mod); + UpdateChangedItems(cache, mod); + UpdateCounts(cache, mod); + break; + case ModOptionChangeType.GroupTypeChanged: + UpdateHasOptions(EnsureCount(mod), mod); + break; + case ModOptionChangeType.OptionFilesChanged: + case ModOptionChangeType.OptionFilesAdded: + cache = EnsureCount(mod); + UpdateChangedItems(cache, mod); + UpdateFileCount(cache, mod); + break; + case ModOptionChangeType.OptionSwapsChanged: + cache = EnsureCount(mod); + UpdateChangedItems(cache, mod); + UpdateSwapCount(cache, mod); + break; + case ModOptionChangeType.OptionMetaChanged: + cache = EnsureCount(mod); + UpdateChangedItems(cache, mod); + UpdateMetaCount(cache, mod); + break; + } + } + + private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? old, DirectoryInfo? @new) + { + switch (type) + { + case ModPathChangeType.Added: + case ModPathChangeType.Reloaded: + Refresh(EnsureCount(mod), mod); + break; + case ModPathChangeType.Deleted: + --Count; + var oldCache = _cache[mod.Index]; + oldCache.Reset(); + for (var i = mod.Index; i < Count; ++i) + _cache[i] = _cache[i + 1]; + _cache[Count] = oldCache; + break; + } + } + + private void OnModDataChange(ModDataChangeType type, Mod mod, string? _) + { + if ((type & (ModDataChangeType.LocalTags | ModDataChangeType.ModTags)) != 0) + UpdateTags(EnsureCount(mod), mod); + } + + private void OnModDiscoveryFinished() + { + if (_modManager.Count > _cache.Count) + _cache.AddRange(Enumerable.Range(0, _modManager.Count - _cache.Count).Select(_ => new ModCache())); + + Parallel.ForEach(Enumerable.Range(0, _modManager.Count), idx => { Refresh(_cache[idx], _modManager[idx]); }); + } + + private void OnIdentifierCreation() + { + Parallel.ForEach(Enumerable.Range(0, _modManager.Count), idx => { UpdateChangedItems(_cache[idx], _modManager[idx]); }); + _identifier.FinishedCreation -= OnIdentifierCreation; + } + + private static void UpdateFileCount(ModCache cache, Mod mod) + => cache.TotalFileCount = mod.AllSubMods.Sum(s => s.Files.Count); + + private static void UpdateSwapCount(ModCache cache, Mod mod) + => cache.TotalFileCount = mod.AllSubMods.Sum(s => s.FileSwaps.Count); + + private static void UpdateMetaCount(ModCache cache, Mod mod) + => cache.TotalFileCount = mod.AllSubMods.Sum(s => s.Manipulations.Count); + + private static void UpdateHasOptions(ModCache cache, Mod mod) + => cache.HasOptions = mod.Groups.Any(o => o.IsOption); + + private static void UpdateTags(ModCache cache, Mod mod) + => cache.AllTagsLower = string.Join('\0', mod.ModTags.Concat(mod.LocalTags).Select(s => s.ToLowerInvariant())); + + private void UpdateChangedItems(ModCache cache, Mod mod) + { + cache.ChangedItems.Clear(); + if (!_identifier.Valid) + return; + + foreach (var gamePath in mod.AllRedirects) + _identifier.AwaitedService.Identify(cache.ChangedItems, gamePath.ToString()); + + foreach (var manip in mod.AllManipulations) + ComputeChangedItems(_identifier.AwaitedService, cache.ChangedItems, manip); + + cache.LowerChangedItemsString = string.Join("\0", cache.ChangedItems.Keys.Select(k => k.ToLowerInvariant())); + } + + private static void UpdateCounts(ModCache cache, Mod mod) + { + cache.TotalFileCount = mod.Default.Files.Count; + cache.TotalSwapCount = mod.Default.FileSwaps.Count; + cache.TotalManipulations = mod.Default.Manipulations.Count; + cache.HasOptions = false; + foreach (var group in mod.Groups) + { + cache.HasOptions |= group.IsOption; + foreach (var s in group) + { + cache.TotalFileCount += s.Files.Count; + cache.TotalSwapCount += s.FileSwaps.Count; + cache.TotalManipulations += s.Manipulations.Count; + } + } + } + + private void Refresh(ModCache cache, Mod mod) + { + UpdateTags(cache, mod); + UpdateCounts(cache, mod); + UpdateChangedItems(cache, mod); + } + + private ModCache EnsureCount(Mod mod) + { + if (mod.Index < Count) + return _cache[mod.Index]; + + + if (mod.Index >= _cache.Count) + _cache.AddRange(Enumerable.Range(0, mod.Index - _cache.Count).Select(_ => new ModCache())); + else if (mod.Index >= Count) + for (var i = Count; i <= mod.Index; ++i) + _cache[i].Reset(); + + return _cache[mod.Index]; + } +} diff --git a/Penumbra/Penumbra.cs b/Penumbra/Penumbra.cs index 78931cf0..283de2bb 100644 --- a/Penumbra/Penumbra.cs +++ b/Penumbra/Penumbra.cs @@ -105,6 +105,7 @@ public class Penumbra : IDalamudPlugin RedrawService = _tmp.Services.GetRequiredService(); _tmp.Services.GetRequiredService(); ResourceLoader = _tmp.Services.GetRequiredService(); + _tmp.Services.GetRequiredService(); using (var t = _tmp.Services.GetRequiredService().Measure(StartTimeType.PathResolver)) { PathResolver = _tmp.Services.GetRequiredService(); diff --git a/Penumbra/PenumbraNew.cs b/Penumbra/PenumbraNew.cs index afba2adf..73c642ac 100644 --- a/Penumbra/PenumbraNew.cs +++ b/Penumbra/PenumbraNew.cs @@ -149,8 +149,9 @@ public class PenumbraNew .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton() + .AddSingleton(); // Add API services.AddSingleton() diff --git a/Penumbra/Services/CommunicatorService.cs b/Penumbra/Services/CommunicatorService.cs index 18c64eac..55f057a3 100644 --- a/Penumbra/Services/CommunicatorService.cs +++ b/Penumbra/Services/CommunicatorService.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using Penumbra.Collections; using Penumbra.Mods; using Penumbra.Util; @@ -7,7 +8,9 @@ namespace Penumbra.Services; public class CommunicatorService : IDisposable { - /// + /// + /// Triggered whenever collection setup is changed. + /// /// Parameter is the type of the changed collection. (Inactive or Temporary for additions or deletions) /// Parameter is the old collection, or null on additions. /// Parameter is the new collection, or null on deletions. @@ -15,21 +18,18 @@ public class CommunicatorService : IDisposable /// public readonly EventWrapper CollectionChange = new(nameof(CollectionChange)); - /// + /// + /// Triggered whenever a temporary mod for all collections is changed. + /// /// Parameter added, deleted or edited temporary mod. /// Parameter is whether the mod was newly created. /// Parameter is whether the mod was deleted. /// public readonly EventWrapper TemporaryGlobalModChange = new(nameof(TemporaryGlobalModChange)); - /// - /// Parameter is the type of change. - /// Parameter is the affected mod. - /// Parameter is either null or the old name of the mod. - /// - public readonly EventWrapper ModMetaChange = new(nameof(ModMetaChange)); - - /// + /// + /// Triggered whenever a character base draw object is being created by the game. + /// /// Parameter is the game object for which a draw object is created. /// Parameter is the name of the applied collection. /// Parameter is a pointer to the model id (an uint). @@ -45,14 +45,18 @@ public class CommunicatorService : IDisposable /// public readonly EventWrapper CreatedCharacterBase = new(nameof(CreatedCharacterBase)); - /// + /// + /// Triggered whenever mod meta data or local data is changed. + /// /// Parameter is the type of data change for the mod, which can be multiple flags. /// Parameter is the changed mod. /// Parameter is the old name of the mod in case of a name change, and null otherwise. /// public readonly EventWrapper ModDataChanged = new(nameof(ModDataChanged)); - /// + /// + /// Triggered whenever an option of a mod is changed inside the mod. + /// /// Parameter is the type option change. /// Parameter is the changed mod. /// Parameter is the index of the changed group inside the mod. @@ -61,14 +65,44 @@ public class CommunicatorService : IDisposable /// public readonly EventWrapper ModOptionChanged = new(nameof(ModOptionChanged)); + + /// Triggered whenever mods are prepared to be rediscovered. + public readonly EventWrapper ModDiscoveryStarted = new(nameof(ModDiscoveryStarted)); + + /// Triggered whenever a new mod discovery has finished. + public readonly EventWrapper ModDiscoveryFinished = new(nameof(ModDiscoveryFinished)); + + /// + /// Triggered whenever the mod root directory changes. + /// + /// Parameter is the full path of the new directory. + /// Parameter is whether the new directory is valid. + /// + /// + public readonly EventWrapper ModDirectoryChanged = new(nameof(ModDirectoryChanged)); + + /// + /// Triggered whenever a mod is added, deleted, moved or reloaded. + /// + /// Parameter is the type of change. + /// Parameter is the changed mod. + /// Parameter is the old directory on deletion, move or reload and null on addition. + /// Parameter is the new directory on addition, move or reload and null on deletion. + /// + /// + public EventWrapper ModPathChanged = new(nameof(ModPathChanged)); + public void Dispose() { CollectionChange.Dispose(); TemporaryGlobalModChange.Dispose(); - ModMetaChange.Dispose(); CreatingCharacterBase.Dispose(); CreatedCharacterBase.Dispose(); ModDataChanged.Dispose(); ModOptionChanged.Dispose(); + ModDiscoveryStarted.Dispose(); + ModDiscoveryFinished.Dispose(); + ModDirectoryChanged.Dispose(); + ModPathChanged.Dispose(); } } diff --git a/Penumbra/Util/EventWrapper.cs b/Penumbra/Util/EventWrapper.cs index e25cc99c..2472e74d 100644 --- a/Penumbra/Util/EventWrapper.cs +++ b/Penumbra/Util/EventWrapper.cs @@ -58,6 +58,60 @@ public readonly struct EventWrapper : IDisposable } } +public readonly struct EventWrapper : IDisposable +{ + private readonly string _name; + private readonly List> _event = new(); + + public EventWrapper(string name) + => _name = name; + + public void Invoke(T1 arg1) + { + lock (_event) + { + foreach (var action in _event) + { + try + { + action.Invoke(arg1); + } + catch (Exception ex) + { + Penumbra.Log.Error($"[{_name}] Exception thrown during invocation:\n{ex}"); + } + } + } + } + + public void Dispose() + { + lock (_event) + { + _event.Clear(); + } + } + + public event Action Event + { + add + { + lock (_event) + { + if (_event.All(a => a != value)) + _event.Add(value); + } + } + remove + { + lock (_event) + { + _event.Remove(value); + } + } + } +} + public readonly struct EventWrapper : IDisposable { private readonly string _name;