diff --git a/Penumbra/Mods/Editor/DuplicateManager.cs b/Penumbra/Mods/Editor/DuplicateManager.cs
index 03321cc3..98eda9e4 100644
--- a/Penumbra/Mods/Editor/DuplicateManager.cs
+++ b/Penumbra/Mods/Editor/DuplicateManager.cs
@@ -15,7 +15,7 @@ public class DuplicateManager
{
private readonly SaveService _saveService;
private readonly ModManager _modManager;
- private readonly SHA256 _hasher = SHA256.Create();
+ private readonly SHA256 _hasher = SHA256.Create();
private readonly List<(FullPath[] Paths, long Size, byte[] Hash)> _duplicates = new();
public DuplicateManager(ModManager modManager, SaveService saveService)
@@ -175,7 +175,8 @@ public class DuplicateManager
}
}
- private static unsafe bool CompareFilesDirectly(FullPath f1, FullPath f2)
+ /// Check if two files are identical on a binary level. Returns true if they are identical.
+ public static unsafe bool CompareFilesDirectly(FullPath f1, FullPath f2)
{
if (!f1.Exists || !f2.Exists)
return false;
diff --git a/Penumbra/Mods/Editor/ModMerger.cs b/Penumbra/Mods/Editor/ModMerger.cs
index 29ed210e..2d90a6dd 100644
--- a/Penumbra/Mods/Editor/ModMerger.cs
+++ b/Penumbra/Mods/Editor/ModMerger.cs
@@ -2,12 +2,12 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
-using System.Runtime.CompilerServices;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Utility;
using OtterGui;
using Penumbra.Api.Enums;
using Penumbra.Communication;
+using Penumbra.Meta.Manipulations;
using Penumbra.Mods.Manager;
using Penumbra.Services;
using Penumbra.String.Classes;
@@ -24,7 +24,9 @@ public class ModMerger : IDisposable
private readonly ModManager _mods;
private readonly ModCreator _creator;
- public Mod? MergeFromMod { get; private set; }
+ public Mod? MergeFromMod
+ => _selector.Selected;
+
public Mod? MergeToMod;
public string OptionGroupName = "Merges";
public string OptionName = string.Empty;
@@ -32,11 +34,13 @@ public class ModMerger : IDisposable
private readonly Dictionary _fileToFile = new();
private readonly HashSet _createdDirectories = new();
- public readonly HashSet SelectedOptions = new();
+ private readonly HashSet _createdGroups = new();
+ private readonly HashSet _createdOptions = new();
- private int _createdGroup = -1;
- private SubMod? _createdOption;
- public Exception? Error { get; private set; }
+ public readonly HashSet SelectedOptions = new();
+
+ public readonly IReadOnlyList Warnings = new List();
+ public Exception? Error { get; private set; }
public ModMerger(ModManager mods, ModOptionEditor editor, ModFileSystemSelector selector, DuplicateManager duplicates,
CommunicatorService communicator, ModCreator creator)
@@ -48,7 +52,7 @@ public class ModMerger : IDisposable
_creator = creator;
_mods = mods;
_selector.SelectionChanged += OnSelectionChange;
- _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.Api);
+ _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModMerger);
}
public void Dispose()
@@ -61,7 +65,7 @@ public class ModMerger : IDisposable
=> _mods.Where(m => m != MergeFromMod);
public bool CanMerge
- => MergeToMod != null && MergeToMod != MergeFromMod && !MergeFromMod!.HasOptions;
+ => MergeToMod != null && MergeToMod != MergeFromMod;
public void Merge()
{
@@ -91,7 +95,34 @@ public class ModMerger : IDisposable
private void MergeWithOptions()
{
- // Not supported
+ MergeIntoOption(Enumerable.Repeat(MergeFromMod!.Default, 1), MergeToMod!.Default, false);
+
+ foreach (var originalGroup in MergeFromMod!.Groups)
+ {
+ var (group, groupIdx, groupCreated) = _editor.FindOrAddModGroup(MergeToMod!, originalGroup.Type, originalGroup.Name);
+ if (groupCreated)
+ _createdGroups.Add(groupIdx);
+ if (group.Type != originalGroup.Type)
+ ((List)Warnings).Add(
+ $"The merged group {group.Name} already existed, but has a different type {group.Type} than the original group of type {originalGroup.Type}.");
+
+ foreach (var originalOption in originalGroup)
+ {
+ var (option, optionCreated) = _editor.FindOrAddOption(MergeToMod!, groupIdx, originalOption.Name);
+ if (optionCreated)
+ {
+ _createdOptions.Add(option);
+ MergeIntoOption(Enumerable.Repeat(originalOption, 1), option, false);
+ }
+ else
+ {
+ throw new Exception(
+ $"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}: The option {option.FullName} already existed.");
+ }
+ }
+ }
+
+ CopyFiles(MergeToMod!.ModPath);
}
private void MergeIntoOption(string groupName, string optionName)
@@ -99,7 +130,7 @@ public class ModMerger : IDisposable
if (groupName.Length == 0 && optionName.Length == 0)
{
CopyFiles(MergeToMod!.ModPath);
- MergeIntoOption(MergeFromMod!.AllSubMods.Reverse(), MergeToMod!.Default);
+ MergeIntoOption(MergeFromMod!.AllSubMods.Reverse(), MergeToMod!.Default, true);
}
else if (groupName.Length * optionName.Length == 0)
{
@@ -108,10 +139,10 @@ public class ModMerger : IDisposable
var (group, groupIdx, groupCreated) = _editor.FindOrAddModGroup(MergeToMod!, GroupType.Multi, groupName);
if (groupCreated)
- _createdGroup = groupIdx;
+ _createdGroups.Add(groupIdx);
var (option, optionCreated) = _editor.FindOrAddOption(MergeToMod!, groupIdx, optionName);
if (optionCreated)
- _createdOption = option;
+ _createdOptions.Add(option);
var dir = ModCreator.NewOptionDirectory(MergeToMod!.ModPath, groupName);
if (!dir.Exists)
_createdDirectories.Add(dir.FullName);
@@ -119,14 +150,36 @@ public class ModMerger : IDisposable
if (!dir.Exists)
_createdDirectories.Add(dir.FullName);
CopyFiles(dir);
- MergeIntoOption(MergeFromMod!.AllSubMods.Reverse(), option);
+ MergeIntoOption(MergeFromMod!.AllSubMods.Reverse(), option, true);
}
- private void MergeIntoOption(IEnumerable mergeOptions, SubMod option)
+ private void MergeIntoOption(IEnumerable mergeOptions, SubMod option, bool fromFileToFile)
{
var redirections = option.FileData.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
var swaps = option.FileSwapData.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
var manips = option.ManipulationData.ToHashSet();
+
+ bool GetFullPath(FullPath input, out FullPath ret)
+ {
+ if (fromFileToFile)
+ {
+ if (!_fileToFile.TryGetValue(input.FullName, out var s))
+ {
+ ret = input;
+ return false;
+ }
+
+ ret = new FullPath(s);
+ return true;
+ }
+
+ if (!Utf8RelPath.FromFile(input, MergeFromMod!.ModPath, out var relPath))
+ throw new Exception($"Could not create relative path from {input} and {MergeFromMod!.ModPath}.");
+
+ ret = new FullPath(MergeToMod!.ModPath, relPath);
+ return true;
+ }
+
foreach (var originalOption in mergeOptions)
{
foreach (var manip in originalOption.Manipulations)
@@ -143,14 +196,14 @@ public class ModMerger : IDisposable
$"Could not add file swap {swapB} -> {swapA} from {originalOption.FullName} to {option.FullName} because another swap of the key already exists.");
}
- foreach (var (gamePath, relPath) in originalOption.Files)
+ foreach (var (gamePath, path) in originalOption.Files)
{
- if (!_fileToFile.TryGetValue(relPath.FullName, out var newFile))
+ if (!GetFullPath(path, out var newFile))
throw new Exception(
- $"Could not add file redirection {relPath} -> {gamePath} from {originalOption.FullName} to {option.FullName} because the file does not exist in the new mod.");
- if (!redirections.TryAdd(gamePath, new FullPath(newFile)))
+ $"Could not add file redirection {path} -> {gamePath} from {originalOption.FullName} to {option.FullName} because the file does not exist in the new mod.");
+ if (!redirections.TryAdd(gamePath, newFile))
throw new Exception(
- $"Could not add file redirection {relPath} -> {gamePath} from {originalOption.FullName} to {option.FullName} because a redirection for the game path already exists.");
+ $"Could not add file redirection {path} -> {gamePath} from {originalOption.FullName} to {option.FullName} because a redirection for the game path already exists.");
}
}
@@ -193,6 +246,7 @@ public class ModMerger : IDisposable
if (mods.Count == 0)
return;
+ ((List)Warnings).Clear();
Error = null;
DirectoryInfo? dir = null;
Mod? result = null;
@@ -261,10 +315,14 @@ public class ModMerger : IDisposable
{
var target = Path.GetRelativePath(parentPath, file.FullName);
target = Path.Combine(newMod.FullName, target);
+ var targetPath = new FullPath(target);
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
- File.Copy(file.FullName, target);
+ // Copy throws if the file exists, which we want.
+ // This copies if the target does not exist, throws if it exists and is different, or does nothing if it exists and is identical.
+ if (!File.Exists(target) || !DuplicateManager.CompareFilesDirectly(targetPath, file))
+ File.Copy(file.FullName, target);
Penumbra.Log.Verbose($"[Splitter] Copied file {file.FullName} to {target}.");
- ret.Add(path, new FullPath(target));
+ ret.Add(path, targetPath);
}
return ret;
@@ -274,16 +332,24 @@ public class ModMerger : IDisposable
{
_fileToFile.Clear();
_createdDirectories.Clear();
- _createdOption = null;
- _createdGroup = -1;
+ _createdGroups.Clear();
+ _createdOptions.Clear();
}
private void FailureCleanup()
{
- if (_createdGroup >= 0 && _createdGroup < MergeToMod!.Groups.Count)
- _editor.DeleteModGroup(MergeToMod!, _createdGroup);
- else if (_createdOption != null)
- _editor.DeleteOption(MergeToMod!, _createdOption.GroupIdx, _createdOption.OptionIdx);
+ foreach (var option in _createdOptions)
+ {
+ _editor.DeleteOption(MergeToMod!, option.GroupIdx, option.OptionIdx);
+ Penumbra.Log.Verbose($"[Merger] Removed option {option.FullName}.");
+ }
+
+ foreach (var group in _createdGroups)
+ {
+ var groupName = MergeToMod!.Groups[group];
+ _editor.DeleteModGroup(MergeToMod!, group);
+ Penumbra.Log.Verbose($"[Merger] Removed option group {groupName}.");
+ }
foreach (var dir in _createdDirectories)
{
@@ -329,7 +395,6 @@ public class ModMerger : IDisposable
MergeToMod = null;
SelectedOptions.Clear();
- MergeFromMod = newSelection;
}
private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? _1, DirectoryInfo? _2)
@@ -339,10 +404,7 @@ public class ModMerger : IDisposable
case ModPathChangeType.Deleted:
{
if (mod == MergeFromMod)
- {
SelectedOptions.Clear();
- MergeFromMod = null;
- }
if (mod == MergeToMod)
MergeToMod = null;
@@ -350,11 +412,7 @@ public class ModMerger : IDisposable
}
case ModPathChangeType.StartingReload:
SelectedOptions.Clear();
- MergeFromMod = null;
- MergeToMod = null;
- break;
- case ModPathChangeType.Reloaded:
- MergeFromMod = _selector.Selected;
+ MergeToMod = null;
break;
}
}
diff --git a/Penumbra/Mods/Manager/ModOptionEditor.cs b/Penumbra/Mods/Manager/ModOptionEditor.cs
index a5e77c37..b4306877 100644
--- a/Penumbra/Mods/Manager/ModOptionEditor.cs
+++ b/Penumbra/Mods/Manager/ModOptionEditor.cs
@@ -349,7 +349,7 @@ public class ModOptionEditor
}
/// Set the file redirections for a given option. Replaces existing redirections.
- public void OptionSetFiles(Mod mod, int groupIdx, int optionIdx, Dictionary replacements)
+ public void OptionSetFiles(Mod mod, int groupIdx, int optionIdx, IReadOnlyDictionary replacements)
{
var subMod = GetSubMod(mod, groupIdx, optionIdx);
if (subMod.FileData.SetEquals(replacements))
@@ -362,7 +362,7 @@ public class ModOptionEditor
}
/// Add additional file redirections to a given option, keeping already existing ones. Only fires an event if anything is actually added.
- public void OptionAddFiles(Mod mod, int groupIdx, int optionIdx, Dictionary additions)
+ public void OptionAddFiles(Mod mod, int groupIdx, int optionIdx, IReadOnlyDictionary additions)
{
var subMod = GetSubMod(mod, groupIdx, optionIdx);
var oldCount = subMod.FileData.Count;
@@ -375,7 +375,7 @@ public class ModOptionEditor
}
/// Set the file swaps for a given option. Replaces existing swaps.
- public void OptionSetFileSwaps(Mod mod, int groupIdx, int optionIdx, Dictionary swaps)
+ public void OptionSetFileSwaps(Mod mod, int groupIdx, int optionIdx, IReadOnlyDictionary swaps)
{
var subMod = GetSubMod(mod, groupIdx, optionIdx);
if (subMod.FileSwapData.SetEquals(swaps))
diff --git a/Penumbra/UI/AdvancedWindow/ModMergeTab.cs b/Penumbra/UI/AdvancedWindow/ModMergeTab.cs
index c4cf1fb4..d1a5ec95 100644
--- a/Penumbra/UI/AdvancedWindow/ModMergeTab.cs
+++ b/Penumbra/UI/AdvancedWindow/ModMergeTab.cs
@@ -38,11 +38,14 @@ public class ModMergeTab
ImGui.SameLine();
DrawCombo();
- ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale);
- ImGui.InputTextWithHint("##optionGroupInput", "Target Option Group", ref _modMerger.OptionGroupName, 64);
- ImGui.SameLine();
- ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale);
- ImGui.InputTextWithHint("##optionInput", "Target Option Name", ref _modMerger.OptionName, 64);
+ using (var disabled = ImRaii.Disabled(_modMerger.MergeFromMod.HasOptions))
+ {
+ ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale);
+ ImGui.InputTextWithHint("##optionGroupInput", "Target Option Group", ref _modMerger.OptionGroupName, 64);
+ ImGui.SameLine();
+ ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale);
+ ImGui.InputTextWithHint("##optionInput", "Target Option Name", ref _modMerger.OptionName, 64);
+ }
if (ImGuiUtil.DrawDisabledButton("Merge", Vector2.Zero, string.Empty, !_modMerger.CanMerge))
_modMerger.Merge();
@@ -50,7 +53,8 @@ public class ModMergeTab
ImGui.Dummy(Vector2.One);
ImGui.Separator();
ImGui.Dummy(Vector2.One);
- using (var table = ImRaii.Table("##options", 6, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.ScrollY, ImGui.GetContentRegionAvail() with { Y = 6 * ImGui.GetFrameHeightWithSpacing()}))
+ using (var table = ImRaii.Table("##options", 6, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.ScrollY,
+ ImGui.GetContentRegionAvail() with { Y = 6 * ImGui.GetFrameHeightWithSpacing() }))
{
foreach (var (option, idx) in _modMerger.MergeFromMod.AllSubMods.WithIndex())
{
@@ -79,13 +83,27 @@ public class ModMergeTab
ImGuiUtil.DrawTableColumn(option.FileData.Count.ToString());
ImGuiUtil.DrawTableColumn(option.FileSwapData.Count.ToString());
ImGuiUtil.DrawTableColumn(option.Manipulations.Count.ToString());
-
}
}
+
ImGui.InputTextWithHint("##newModInput", "New Mod Name...", ref _newModName, 64);
- if (ImGuiUtil.DrawDisabledButton("Split Off", Vector2.Zero, string.Empty, _newModName.Length == 0 || _modMerger.SelectedOptions.Count == 0))
+ if (ImGuiUtil.DrawDisabledButton("Split Off", Vector2.Zero, string.Empty,
+ _newModName.Length == 0 || _modMerger.SelectedOptions.Count == 0))
_modMerger.SplitIntoMod(_newModName);
+ if (_modMerger.Warnings.Count > 0)
+ {
+ ImGui.Separator();
+ ImGui.Dummy(Vector2.One);
+ using var color = ImRaii.PushColor(ImGuiCol.Text, Colors.TutorialBorder);
+ foreach (var warning in _modMerger.Warnings.SkipLast(1))
+ {
+ ImGuiUtil.TextWrapped(warning);
+ ImGui.Separator();
+ }
+ ImGuiUtil.TextWrapped(_modMerger.Warnings[^1]);
+ }
+
if (_modMerger.Error != null)
{
ImGui.Separator();