From 74ef63c6a5438d4c04f5f9da79a27dbd9cddd557 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Mon, 29 Dec 2025 17:15:07 -0600 Subject: [PATCH 01/14] Initial working profiles --- .../Attributes/DescriptionAttribute.cs | 6 + .../Exceptions/ModListFileLoadException.cs | 9 + .../Extensions/ProfileExtensions.cs | 22 ++ .../ModLoadOrder/Generator.cs | 12 +- ShinRyuModManager-CE/ModLoadOrder/Mods/Mod.cs | 2 + .../ModLoadOrder/Mods/ModInfo.cs | 26 ++- .../ModLoadOrder/Mods/Profile.cs | 22 ++ .../ModLoadOrder/Mods/ProfileMask.cs | 14 ++ .../Serialization/ModListSerializer.V0.cs | 29 +++ .../Serialization/ModListSerializer.V1.cs | 35 +++ .../Serialization/ModListSerializer.V2.cs | 37 ++++ .../Mods/Serialization/ModListSerializer.cs | 78 +++++++ ShinRyuModManager-CE/Program.cs | 201 +++++------------- .../ViewModels/MainWindowViewModel.cs | 20 +- .../UserInterface/Views/MainWindow.axaml | 18 ++ .../UserInterface/Views/MainWindow.axaml.cs | 26 ++- Utils/Constants.cs | 1 + doc/ML Format.md | 34 +++ 18 files changed, 424 insertions(+), 168 deletions(-) create mode 100644 ShinRyuModManager-CE/Attributes/DescriptionAttribute.cs create mode 100644 ShinRyuModManager-CE/Exceptions/ModListFileLoadException.cs create mode 100644 ShinRyuModManager-CE/Extensions/ProfileExtensions.cs create mode 100644 ShinRyuModManager-CE/ModLoadOrder/Mods/Profile.cs create mode 100644 ShinRyuModManager-CE/ModLoadOrder/Mods/ProfileMask.cs create mode 100644 ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V0.cs create mode 100644 ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V1.cs create mode 100644 ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V2.cs create mode 100644 ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.cs create mode 100644 doc/ML Format.md diff --git a/ShinRyuModManager-CE/Attributes/DescriptionAttribute.cs b/ShinRyuModManager-CE/Attributes/DescriptionAttribute.cs new file mode 100644 index 0000000..3f5db38 --- /dev/null +++ b/ShinRyuModManager-CE/Attributes/DescriptionAttribute.cs @@ -0,0 +1,6 @@ +namespace ShinRyuModManager.Attributes; + +[AttributeUsage(AttributeTargets.Field)] +public class DescriptionAttribute(string name) : Attribute { + public string Name { get; } = name; +} diff --git a/ShinRyuModManager-CE/Exceptions/ModListFileLoadException.cs b/ShinRyuModManager-CE/Exceptions/ModListFileLoadException.cs new file mode 100644 index 0000000..dde9f85 --- /dev/null +++ b/ShinRyuModManager-CE/Exceptions/ModListFileLoadException.cs @@ -0,0 +1,9 @@ +namespace ShinRyuModManager.Exceptions; + +public class ModListFileLoadException : Exception { + public ModListFileLoadException() { } + + public ModListFileLoadException(string message) : base(message) { } + + public ModListFileLoadException(string message, Exception inner) : base(message, inner) { } +} diff --git a/ShinRyuModManager-CE/Extensions/ProfileExtensions.cs b/ShinRyuModManager-CE/Extensions/ProfileExtensions.cs new file mode 100644 index 0000000..d8c4ba0 --- /dev/null +++ b/ShinRyuModManager-CE/Extensions/ProfileExtensions.cs @@ -0,0 +1,22 @@ +using System.Reflection; +using ShinRyuModManager.Attributes; +using ShinRyuModManager.ModLoadOrder.Mods; + +namespace ShinRyuModManager.Extensions; + +public static class ProfileExtensions { + public static ProfileMask ToMask(this Profile profile) { + return (ProfileMask)(1 << (int)profile); + } + + public static bool AppliesTo(this ProfileMask mask, Profile profile) { + return mask.HasFlag(profile.ToMask()); + } + + public static string GetDescription(this Profile profile) { + var field = profile.GetType().GetField(profile.ToString()); + var attr = field?.GetCustomAttribute(); + + return attr?.Name ?? profile.ToString(); + } +} diff --git a/ShinRyuModManager-CE/ModLoadOrder/Generator.cs b/ShinRyuModManager-CE/ModLoadOrder/Generator.cs index 846b9ac..1039ea9 100644 --- a/ShinRyuModManager-CE/ModLoadOrder/Generator.cs +++ b/ShinRyuModManager-CE/ModLoadOrder/Generator.cs @@ -6,7 +6,7 @@ namespace ShinRyuModManager.ModLoadOrder; public static class Generator { - public static async Task GenerateModeLoadOrder(List mods, bool looseFilesEnabled, bool cpkRepackingEnabled) { + public static async Task GenerateModeLoadOrder(List mods, bool looseFilesEnabled, bool cpkRepackingEnabled) { List modIndices = [0]; var files = new OrderedSet(); var modsWithFoldersNotFound = new Dictionary>(); // Dict of Mod, ListOfFolders @@ -42,7 +42,7 @@ public static async Task GenerateModeLoadOrder(List mods, bool loos // Use a reverse loop to be able to remove items from the list when necessary for (var i = mods.Count - 1; i >= 0; i--) { var mod = new Mod(mods[i]); - var modPath = GamePath.GetModDirectory(mods[i]); + var modPath = GamePath.GetModDirectory(mod.Name); mod.AddFiles(modPath, ""); @@ -104,9 +104,11 @@ public static async Task GenerateModeLoadOrder(List mods, bool loos mods.Reverse(); Log.Information($"Generating {Constants.MLO} file..."); + + var modNames = mods.Select(x => x.Name).ToList(); // Generate MLO - var mlo = new MLO(modIndices, mods, files, loose.ParlessFolders, cpkDictionary); + var mlo = new MLO(modIndices, modNames, files, loose.ParlessFolders, cpkDictionary); mlo.WriteMLO(Path.Combine(GamePath.FullGamePath, Constants.MLO)); @@ -114,7 +116,7 @@ public static async Task GenerateModeLoadOrder(List mods, bool loos // Check if a mod has a par that will override the repacked par, and skip repacking it in that case foreach (var key in parDictionary.Keys.ToList()) { - var value = parDictionary[key]; + var value = new ModInfo(parDictionary[key][0], 0); // Faster lookup by checking in the OrderedSet if (!files.Contains($"{key}.par")) @@ -124,7 +126,7 @@ public static async Task GenerateModeLoadOrder(List mods, bool loos var matchIndex = mlo.Files.Find(f => f.Name == Utils.NormalizeToNodePath($"{key}.par")).Index; // Avoid repacking pars which exist as a file in mods that have a higher priority that the first mod in the par to be repacked - if (mods.IndexOf(value[0]) > matchIndex) { + if (mods.IndexOf(value) > matchIndex) { parDictionary.Remove(key); } } diff --git a/ShinRyuModManager-CE/ModLoadOrder/Mods/Mod.cs b/ShinRyuModManager-CE/ModLoadOrder/Mods/Mod.cs index dbf9c82..5982bf7 100644 --- a/ShinRyuModManager-CE/ModLoadOrder/Mods/Mod.cs +++ b/ShinRyuModManager-CE/ModLoadOrder/Mods/Mod.cs @@ -26,6 +26,8 @@ public class Mod { /// Folders that need to be repacked. /// public List RepackCpKs { get; } + + public Mod(ModInfo modInfo) : this(modInfo.Name) { } public Mod(string name) { Name = name; diff --git a/ShinRyuModManager-CE/ModLoadOrder/Mods/ModInfo.cs b/ShinRyuModManager-CE/ModLoadOrder/Mods/ModInfo.cs index 8b728ba..07ae7e6 100644 --- a/ShinRyuModManager-CE/ModLoadOrder/Mods/ModInfo.cs +++ b/ShinRyuModManager-CE/ModLoadOrder/Mods/ModInfo.cs @@ -1,24 +1,32 @@ using CommunityToolkit.Mvvm.ComponentModel; -using Utils; +using ShinRyuModManager.Extensions; namespace ShinRyuModManager.ModLoadOrder.Mods; public sealed partial class ModInfo : ObservableObject, IEquatable { [ObservableProperty] private string _name; - [ObservableProperty] private bool _enabled; - public ModInfo(string name, bool enabled = true) { + public ProfileMask EnabledProfiles { get; set; } + + public bool Enabled { + get => EnabledProfiles.AppliesTo(Program.ActiveProfile); + set { + if (value) { + EnabledProfiles |= Program.ActiveProfile.ToMask(); + } else { + EnabledProfiles &= ~Program.ActiveProfile.ToMask(); + } + } + } + + public ModInfo(string name, ProfileMask enabledMask = ProfileMask.All) { Name = name; - Enabled = enabled; + EnabledProfiles = enabledMask; } public void ToggleEnabled() { Enabled = !Enabled; } - - public static bool IsValid(ModInfo info) { - return (info != null) && Directory.Exists(Path.Combine(GamePath.MODS, info.Name)); - } public bool Equals(ModInfo other) { if (other is null) @@ -44,6 +52,6 @@ public override bool Equals(object obj) { } public override int GetHashCode() { - return HashCode.Combine(Name, Enabled); + return Name.GetHashCode(); } } diff --git a/ShinRyuModManager-CE/ModLoadOrder/Mods/Profile.cs b/ShinRyuModManager-CE/ModLoadOrder/Mods/Profile.cs new file mode 100644 index 0000000..079b8ec --- /dev/null +++ b/ShinRyuModManager-CE/ModLoadOrder/Mods/Profile.cs @@ -0,0 +1,22 @@ +using ShinRyuModManager.Attributes; + +namespace ShinRyuModManager.ModLoadOrder.Mods; + +public enum Profile : byte { + [Description("Profile 1")] + Profile1 = 0, + [Description("Profile 2")] + Profile2 = 1, + [Description("Profile 3")] + Profile3 = 2, + [Description("Profile 4")] + Profile4 = 3, + [Description("Profile 5")] + Profile5 = 4, + [Description("Profile 6")] + Profile6 = 5, + [Description("Profile 7")] + Profile7 = 6, + [Description("Profile 8")] + Profile8 = 7 +} diff --git a/ShinRyuModManager-CE/ModLoadOrder/Mods/ProfileMask.cs b/ShinRyuModManager-CE/ModLoadOrder/Mods/ProfileMask.cs new file mode 100644 index 0000000..b302701 --- /dev/null +++ b/ShinRyuModManager-CE/ModLoadOrder/Mods/ProfileMask.cs @@ -0,0 +1,14 @@ +namespace ShinRyuModManager.ModLoadOrder.Mods; + +[Flags] +public enum ProfileMask : byte { + Profile1 = 1 << 0, + Profile2 = 1 << 1, + Profile3 = 1 << 2, + Profile4 = 1 << 3, + Profile5 = 1 << 4, + Profile6 = 1 << 5, + Profile7 = 1 << 6, + Profile8 = 1 << 7, + All = Profile1 | Profile2 | Profile3 | Profile4 | Profile5 | Profile6 | Profile7 | Profile8 +} diff --git a/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V0.cs b/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V0.cs new file mode 100644 index 0000000..5529836 --- /dev/null +++ b/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V0.cs @@ -0,0 +1,29 @@ +using Utils; + +namespace ShinRyuModManager.ModLoadOrder.Mods.Serialization; + +// Old mod list format (ModLoadOrder.txt) +public static partial class ModListSerializer { + private static List ReadV0(string path) { + var mods = new List(); + + foreach (var line in File.ReadLines(path)) { + if (line.StartsWith(';')) + continue; + + var sanitizedLine = line.Split(';', 1, StringSplitOptions.TrimEntries)[0]; + + if (string.IsNullOrEmpty(sanitizedLine) || !Directory.Exists(GamePath.GetModDirectory(sanitizedLine))) + continue; + + var entry = new ModInfo(sanitizedLine); + + if (mods.Contains(entry)) + continue; + + mods.Add(entry); + } + + return mods; + } +} diff --git a/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V1.cs b/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V1.cs new file mode 100644 index 0000000..1f00fcd --- /dev/null +++ b/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V1.cs @@ -0,0 +1,35 @@ +using Utils; + +namespace ShinRyuModManager.ModLoadOrder.Mods.Serialization; + +// Original SRMM mod list format (ModList.txt) +public static partial class ModListSerializer { + private static List ReadV1(string path) { + var mods = new List(); + + var modContent = File.ReadAllText(path); + + if (string.IsNullOrWhiteSpace(modContent)) + return mods; + + foreach (var mod in modContent.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { + if (!mod.StartsWith('<') && !mod.StartsWith('>')) + continue; + + var enabledMask = mod[0] == '<' ? ProfileMask.All : 0; + var modName = mod[1..]; + + if (!Directory.Exists(GamePath.GetModDirectory(modName))) + continue; + + var entry = new ModInfo(modName, enabledMask); + + if (mods.Contains(entry)) + continue; + + mods.Add(entry); + } + + return mods; + } +} diff --git a/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V2.cs b/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V2.cs new file mode 100644 index 0000000..7e7e397 --- /dev/null +++ b/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V2.cs @@ -0,0 +1,37 @@ +using System.Text; + +namespace ShinRyuModManager.ModLoadOrder.Mods.Serialization; + +// Current binary mod list format (ModList.ml) +public static partial class ModListSerializer { + private static List ReadV2(BinaryReader reader, Profile? profile = null) { + var readProfile = (Profile)reader.ReadByte(); + + if (profile == null) { + Program.ActiveProfile = readProfile; + } + + var entryCount = reader.ReadUInt16(); + + var mods = new List(); + + for (var i = 0; i < entryCount; i++) { + var entry = ReadEntryV2(reader); + + mods.Add(entry); + } + + return mods; + } + + private static ModInfo ReadEntryV2(BinaryReader reader) { + var mask = (ProfileMask)reader.ReadByte(); + var nameLength = reader.ReadUInt16(); + + ReadOnlySpan nameBytes = reader.ReadBytes(nameLength); + + var name = Encoding.UTF8.GetString(nameBytes); + + return new ModInfo(name, mask); + } +} diff --git a/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.cs b/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.cs new file mode 100644 index 0000000..24ea12c --- /dev/null +++ b/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.cs @@ -0,0 +1,78 @@ +using System.Text; +using ShinRyuModManager.Exceptions; +using Utils; + +namespace ShinRyuModManager.ModLoadOrder.Mods.Serialization; + +public static partial class ModListSerializer { + private const string SIGNATURE = "SRMM_ML"; + + private static readonly byte[] SignatureBytes = Encoding.UTF8.GetBytes(SIGNATURE); + + public static List Read(string path, Profile? profile = null) { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + if (!File.Exists(path)) + throw new FileNotFoundException("ModList file not found!", path); + + switch (path) { + case Constants.TXT_OLD: + return ReadV0(path); + case Constants.TXT: + return ReadV1(path); + } + + using var fs = File.OpenRead(path); + using var reader = new BinaryReader(fs, Encoding.UTF8, true); + + ValidateModList(reader); + + var version = reader.ReadByte(); + + // No V0 or V1 as that's handled before + return version switch { + 2 => ReadV2(reader, profile), + _ => throw new NotSupportedException() + }; + } + + // Write methods ALWAYS use the newest format. Never need to be in partial classes + public static void Write(string path, List mods) { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + var fileExists = File.Exists(path); + var mode = fileExists ? FileMode.Truncate : FileMode.CreateNew; + + using var fs = new FileStream(path, mode, FileAccess.Write, FileShare.None); + using var writer = new BinaryWriter(fs, Encoding.UTF8, true); + + writer.Write(SignatureBytes); + writer.Write(Program.CurrentModListVersion); + writer.Write((byte)Program.ActiveProfile); + writer.Write((ushort)mods.Count); + + foreach (var modInfo in mods) { + WriteEntry(writer, modInfo); + } + } + + private static void WriteEntry(BinaryWriter writer, ModInfo modInfo) { + ReadOnlySpan nameBytes = Encoding.UTF8.GetBytes(modInfo.Name); + + writer.Write((byte)modInfo.EnabledProfiles); + writer.Write((ushort)nameBytes.Length); + writer.Write(nameBytes); + } + + private static void ValidateModList(BinaryReader reader) { + ArgumentNullException.ThrowIfNull(reader); + + reader.BaseStream.Seek(0, SeekOrigin.Begin); + + var sigBytes = reader.ReadBytes(SIGNATURE.Length); + + if (!sigBytes.SequenceEqual(SignatureBytes)) { + throw new ModListFileLoadException("File signature doesn't match!"); + } + } +} diff --git a/ShinRyuModManager-CE/Program.cs b/ShinRyuModManager-CE/Program.cs index 6651da1..9631ecf 100644 --- a/ShinRyuModManager-CE/Program.cs +++ b/ShinRyuModManager-CE/Program.cs @@ -18,6 +18,7 @@ using ShinRyuModManager.UserInterface; using Utils; using Constants = Utils.Constants; +using ModListSerializer = ShinRyuModManager.ModLoadOrder.Mods.Serialization.ModListSerializer; namespace ShinRyuModManager; @@ -38,6 +39,10 @@ public static class Program { } }; + // Mod List file information + public static byte CurrentModListVersion { get => 2; } + public static Profile ActiveProfile { get; set; } = Profile.Profile1; + public static bool RebuildMlo { get; private set; } = true; public static bool IsRebuildMloSupported { get; private set; } = true; public static LogEventLevel LogLevel { get; private set; } = LogEventLevel.Information; @@ -75,10 +80,6 @@ private static void Main(string[] args) { // Check if there are any args, if so, run in CLI mode // Unfortunately, no one way to detect left Ctrl while being cross-platform if (args.Length == 0) { - if (_checkForUpdates) { - // TODO: Implement updates - } - Log.Information("Shin Ryu Mod Manager GUI Application Start"); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); @@ -121,8 +122,9 @@ private static async Task MainCLI(string[] args) { if (list.Contains("-s") || list.Contains("--silent")) { _isSilent = true; } + + await RunGeneration(PreRun()); - await RunGeneration(ConvertNewToOldModList(PreRun())); PostRun(); await Log.CloseAndFlushAsync(); @@ -187,7 +189,7 @@ private static void LoadConfig() { } } - internal static List PreRun() { + internal static List PreRun(Profile? profile = null) { if (GamePath.CurrentGame != Game.Unsupported) { Directory.CreateDirectory(GamePath.MODS); Directory.CreateDirectory(GamePath.LIBRARIES); @@ -240,39 +242,37 @@ internal static List PreRun() { IniParser.WriteFile(Constants.INI, _iniData); RebuildMlo = false; } - + var mods = new List(); if (ShouldBeExternalOnly()) { // Only load the files inside the external mods path, and ignore the load order in the txt - mods.Add(new ModInfo(Constants.EXTERNAL_MODS)); + mods.Add(new ModInfo(Constants.EXTERNAL_MODS, 0)); } else { - var defaultEnabled = true; - - if (File.Exists(Constants.TXT_OLD) && _iniData.GetKey("SavedSettings.ModListImported") == null) { - // Scanned mods should be disabled, because that's how they were with the old txt format - defaultEnabled = false; - - // Set a flag so we can delete the old file after we actually save the mod list - _migrated = true; - - // Migrate old format to new - Log.Information("Old format load order file ({TxtOld}) was found. Importing to the new format...", Constants.TXT_OLD); + if (File.Exists(Constants.TXT_OLD)) { + mods.AddRange(ModListSerializer.Read(Constants.TXT_OLD, profile)); - mods.AddRange(ConvertOldToNewModList(ReadModLoadOrderTxt(Constants.TXT_OLD)) - .Where(n => !mods.Any(m => EqualModNames(m.Name, n.Name)))); + File.Move(Constants.TXT_OLD, $"{Constants.TXT_OLD}.bak"); } else if (File.Exists(Constants.TXT)) { - mods.AddRange(ReadModListTxt(Constants.TXT).Where(n => !mods.Any(m => EqualModNames(m.Name, n.Name)))); + mods.AddRange(ModListSerializer.Read(Constants.TXT, profile)); + + File.Move(Constants.TXT, $"{Constants.TXT}.bak"); + } else if (File.Exists(Constants.MOD_LIST)) { + mods.AddRange(ModListSerializer.Read(Constants.MOD_LIST, profile)); } else { - Log.Information($"{Constants.TXT} was not found. Will load all existing mods.\n"); + Log.Information($"{Constants.MOD_LIST} was not found. Will load all existing mods.\n"); } - - if (Directory.Exists(GamePath.MODS)) { + + if (Directory.Exists(GamePath.ModsPath)) { // Add all scanned mods that have not been added to the load order yet Log.Information("Scanning for mods..."); - - mods.AddRange(ScanMods().Where(n => !mods.Any(m => EqualModNames(m.Name, n))) - .Select(m => new ModInfo(m, defaultEnabled))); + + foreach (var newMod in ScanMods()) { + if (mods.Contains(newMod)) + continue; + + mods.Add(newMod); + } Log.Information("Found {ModsCount} mods.", mods.Count); } @@ -286,11 +286,11 @@ internal static List PreRun() { _iniData.Sections["Overrides"]["RebuildMLO"] = "0"; IniParser.WriteFile(Constants.INI, _iniData); RebuildMlo = false; - + return mods; } - internal static async Task RunGeneration(List mods) { + internal static async Task RunGeneration(List mods) { if (File.Exists(Constants.MLO)) { Log.Information("Removing old MLO..."); @@ -309,8 +309,14 @@ internal static async Task RunGeneration(List mods) { if (!mods.IsNullOrEmpty() || _looseFilesEnabled) { // Create Parless mod as highest priority - mods.Remove("Parless"); - mods.Insert(0, "Parless"); + var parlessEntry = mods.FirstOrDefault(x => string.Equals(x.Name, "Parless", StringComparison.InvariantCultureIgnoreCase)); + + if (parlessEntry != null) { + mods.Remove(parlessEntry); + mods.Insert(0, parlessEntry); + } else { + mods.Insert(0, new ModInfo("Parless", 0)); + } Directory.CreateDirectory(Constants.PARLESS_MODS_PATH); @@ -400,127 +406,40 @@ private static void PostRun() { } } - private static List ReadModLoadOrderTxt(string txt) { - if (!File.Exists(txt)) { - return []; - } - - var mods = new HashSet(); - - foreach (var line in File.ReadLines(txt)) { - if (line.StartsWith(';')) - continue; - - var sanitizedLine = line.Split(';', 1)[0].Trim(); - - // Add only valid and unique mods - if (!string.IsNullOrEmpty(sanitizedLine) && - Directory.Exists(Path.Combine(GamePath.MODS, sanitizedLine))) { - mods.Add(sanitizedLine); - } - } - - return mods.ToList(); - } - - private static List ConvertOldToNewModList(List mods) { - return mods.Select(m => new ModInfo(m)).ToList(); - } - - internal static List ConvertNewToOldModList(List mods) { - return mods.Where(m => m.Enabled).Select(m => m.Name).ToList(); - } - internal static bool ShouldBeExternalOnly() { return _externalModsOnly && Directory.Exists(GamePath.ExternalModsPath); } - - private static List ScanMods() { - return Directory.GetDirectories(GamePath.ModsPath) - .Select(d => Path.GetFileName(d.TrimEnd(Path.DirectorySeparatorChar))) - .Where(m => !string.Equals(m, "Parless") && !string.Equals(m, Constants.EXTERNAL_MODS)) - .ToList(); - } - - private static bool EqualModNames(string m, string n) { - return string.Compare(m, n, StringComparison.InvariantCultureIgnoreCase) == 0; - } - - internal static bool MissingDll() { - return !(File.Exists(Constants.DINPUT8DLL) || File.Exists(Constants.VERSIONDLL) || File.Exists(Constants.WINMMDLL)); - } - internal static bool MissingAsi() { - return !File.Exists(Constants.ASI); - } - - public static List ReadModListTxt(string text) { + private static List ScanMods(ProfileMask activeProfiles = ProfileMask.All) { var mods = new List(); - - if (!File.Exists(text)) { - return mods; - } - - using var file = new StreamReader(new FileInfo(text).FullName); - var line = file.ReadLine(); - - if (line == null) - return mods; - - foreach (var mod in line.Split('|', StringSplitOptions.RemoveEmptyEntries)) { - if (!mod.StartsWith('<') && !mod.StartsWith('>')) + + foreach (var dir in Directory.EnumerateDirectories(GamePath.ModsPath)) { + var modPath = Path.GetFileName(dir.TrimEnd(Path.DirectorySeparatorChar)); + + if (string.Equals(modPath, "Parless") || string.Equals(modPath, Constants.EXTERNAL_MODS)) continue; - var info = new ModInfo(mod[1..], mod[0] == '<'); - - if (ModInfo.IsValid(info) && !mods.Contains(info)) { - mods.Add(info); - } + var entry = new ModInfo(modPath, activeProfiles); + + if (mods.Contains(entry)) + continue; + + mods.Add(entry); } return mods; } - internal static async Task SaveModListAsync(List mods) { - var result = await WriteModListTextAsync(mods); - - if (!_migrated) - return result; - - try { - File.Delete(Constants.TXT_OLD); - - var iniParser = new FileIniDataParser(); - iniParser.Parser.Configuration.AssigmentSpacer = string.Empty; - - var ini = iniParser.ReadFile(Constants.INI); - - ini.Sections.AddSection("SavedSettings"); - ini["SavedSettings"].AddKey("ModListImported", "true"); - iniParser.WriteFile(Constants.INI, ini); - } catch { - Log.Warning($"Could not delete {Constants.TXT_OLD}. This file should be deleted manually."); - } - - return result; + internal static bool MissingDll() { + return !(File.Exists(Constants.DINPUT8DLL) || File.Exists(Constants.VERSIONDLL) || File.Exists(Constants.WINMMDLL)); } - private static async Task WriteModListTextAsync(List mods) { - if (mods.IsNullOrEmpty()) - return false; - - var sb = new StringBuilder(); - - foreach (var mod in mods) { - sb.Append($"{(mod.Enabled ? '<' : '>')}{mod.Name}|"); - } - - // Remove leftover pipe - sb.Length -= 1; - - await File.WriteAllTextAsync(Constants.TXT, sb.ToString()); + internal static bool MissingAsi() { + return !File.Exists(Constants.ASI); + } - return true; + internal static void SaveModList(List mods) { + ModListSerializer.Write(Constants.MOD_LIST, mods); } public static string[] GetModDependencies(string mod) { @@ -620,16 +539,14 @@ private static async Task DownloadLibraryPackageAsync(string fileName, L } } - public static async Task InstallAllModDependenciesAsync() { + public static async Task InstallAllModDependenciesAsync(List mods) { try { await LibMeta.FetchAsync(); } catch { // ignored } - var modList = ReadModListTxt(Constants.TXT); - - foreach (var mod in modList.Where(x => x.Enabled)) { + foreach (var mod in mods.Where(x => x.Enabled)) { await InstallModDependenciesAsync(mod.Name); } } diff --git a/ShinRyuModManager-CE/UserInterface/ViewModels/MainWindowViewModel.cs b/ShinRyuModManager-CE/UserInterface/ViewModels/MainWindowViewModel.cs index 4c4e689..d6f3033 100644 --- a/ShinRyuModManager-CE/UserInterface/ViewModels/MainWindowViewModel.cs +++ b/ShinRyuModManager-CE/UserInterface/ViewModels/MainWindowViewModel.cs @@ -1,5 +1,9 @@ using System.Collections.ObjectModel; +using Avalonia.Input; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Serilog; +using ShinRyuModManager.Extensions; using ShinRyuModManager.ModLoadOrder.Mods; using Utils; @@ -26,9 +30,17 @@ public void SelectMod(ModMeta mod) { ModAuthor = mod.Author; ModVersion = mod.Version; } + + [RelayCommand] + public void UpdateProfile(Profile profile) { + Program.ActiveProfile = profile; + + Log.Information("Setting Profile to "); + + LoadModList(profile); + } private void Initialize() { - TitleText = $"Shin Ryu Mod Manager [{GamePath.GetGameFriendlyName(GamePath.CurrentGame)}]"; AppVersionText = $"v{AssemblyVersion.GetVersion()}"; // Prefer launching through Steam, but if Windows, allow launching via exe @@ -42,7 +54,9 @@ private void Initialize() { Directory.CreateDirectory(GamePath.LIBRARIES); } - internal void LoadModList() { - ModList = new ObservableCollection(Program.PreRun()); + internal void LoadModList(Profile? profile = null) { + ModList = new ObservableCollection(Program.PreRun(profile)); + + TitleText = $"Shin Ryu Mod Manager [{GamePath.GetGameFriendlyName(GamePath.CurrentGame)}] [{Program.ActiveProfile.GetDescription()}]"; } } \ No newline at end of file diff --git a/ShinRyuModManager-CE/UserInterface/Views/MainWindow.axaml b/ShinRyuModManager-CE/UserInterface/Views/MainWindow.axaml index 552466a..6e095f8 100644 --- a/ShinRyuModManager-CE/UserInterface/Views/MainWindow.axaml +++ b/ShinRyuModManager-CE/UserInterface/Views/MainWindow.axaml @@ -116,6 +116,24 @@ Click="ChangeLog_OnClick" /> + + + + + + + + + + diff --git a/ShinRyuModManager-CE/UserInterface/Views/MainWindow.axaml.cs b/ShinRyuModManager-CE/UserInterface/Views/MainWindow.axaml.cs index 82b8791..0c7a994 100644 --- a/ShinRyuModManager-CE/UserInterface/Views/MainWindow.axaml.cs +++ b/ShinRyuModManager-CE/UserInterface/Views/MainWindow.axaml.cs @@ -53,8 +53,11 @@ private void Window_OnLoaded(object sender, RoutedEventArgs e) { private async void FileSystemWatcher_Created(object _, FileSystemEventArgs e) { try { + if (DataContext is not MainWindowViewModel viewModel) + return; + await Dispatcher.UIThread.InvokeAsync(RefreshModList); - await Program.InstallAllModDependenciesAsync(); + await Program.InstallAllModDependenciesAsync(viewModel.ModList.ToList()); } catch { // ignored // Prevents application crashing @@ -157,8 +160,12 @@ private async void ModSave_OnClick(object sender, RoutedEventArgs e) { if (DataContext is not MainWindowViewModel viewModel) return; - if (await Program.SaveModListAsync(viewModel.ModList.ToList())) { - await CheckModDependenciesAsync(); + if (viewModel.ModList.Count > 0) { + var mods = viewModel.ModList.ToList(); + + Program.SaveModList(mods); + + await CheckModDependenciesAsync(mods); // Run generation only if it will not be run on game launch (i.e. if RebuildMlo is disabled or unsupported) if (Program.RebuildMlo && Program.IsRebuildMloSupported) { @@ -173,7 +180,7 @@ private async void ModSave_OnClick(object sender, RoutedEventArgs e) { await Task.Run(async () => { try { - await Program.RunGeneration(Program.ConvertNewToOldModList(viewModel.ModList.ToList())); + await Program.RunGeneration(mods.Where(x => x.Enabled).ToList()); } catch (Exception ex) { Log.Error(ex, "Could not generate MLO!"); @@ -197,14 +204,12 @@ await Task.Run(async () => { } } - private async Task CheckModDependenciesAsync() { - var modList = Program.ReadModListTxt(Constants.TXT); - + private async Task CheckModDependenciesAsync(List mods) { var modsWithDependencyProblems = new List(); var disabledLibraries = new List(); var missingLibraries = new List(); - foreach (var enabledMod in modList.Where(x => x.Enabled)) { + foreach (var enabledMod in mods.Where(x => x.Enabled)) { foreach (var dependencyGuid in Program.GetModDependencies(enabledMod.Name)) { if (!Program.DoesLibraryExist(dependencyGuid)) { if(!modsWithDependencyProblems.Contains(enabledMod.Name)) @@ -255,6 +260,9 @@ private async Task CheckModDependenciesAsync() { private async void ModInstall_OnClick(object sender, RoutedEventArgs e) { try { + if (DataContext is not MainWindowViewModel viewModel) + return; + Directory.CreateDirectory(GamePath.MODS); var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { @@ -281,7 +289,7 @@ await Task.Run(async () => { await Utils.TryInstallModZipAsync(localPath); } - await Program.InstallAllModDependenciesAsync(); + await Program.InstallAllModDependenciesAsync(viewModel.ModList.ToList()); }); RefreshModList(); diff --git a/Utils/Constants.cs b/Utils/Constants.cs index 52718cc..251677e 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -5,6 +5,7 @@ public static class Constants { public const string INI = "YakuzaParless.ini"; public const string TXT = "ModList.txt"; public const string TXT_OLD = "ModLoadOrder.txt"; + public const string MOD_LIST = "ModList.ml"; public const string MLO = "YakuzaParless.mlo"; public const string ASI = "YakuzaParless.asi"; public const string DINPUT8DLL = "dinput8.dll"; diff --git a/doc/ML Format.md b/doc/ML Format.md new file mode 100644 index 0000000..9f313e2 --- /dev/null +++ b/doc/ML Format.md @@ -0,0 +1,34 @@ +> All byte values are little-endian + +### ModList Format + +| Segment | Value / Range | Length | Type | Encoding / Details | +|:---------------|:--------------|:---------|:----------|:-----------------------------------------------------------| +| Signature | "SRMM_ML" | 7 bytes | char[] | ASCII text, hex: `53 52 4D 4D 5F 4D 4C` | +| Version | 0 - 255 | 1 byte | uint8 | File format version | +| Active Profile | 0 - 7 | 1 byte | uint8 | Active profile index (value + 1 = profile, 0 -> Profile 1) | +| Mod Count | 0 - 65,535 | 2 bytes | uint16 | Number of mod entries | +| Mod Entries | Variable | Variable | `ModInfo` | Repeats `Mod Count` times | + + +### ModInfo Format + +| Segment | Value / Range | Length | Type | Encoding / Details | +|:-----------------|:-------------------|:---------|:-------|:-------------------------------------------------------| +| Enabled Profiles | `Enabled Profiles` | 1 byte | uint8 | Bitmask representing profiles where the mod is enabled | +| Name Length | 0 - 65,535 | 2 bytes | utin16 | Length of mod name in bytes | +| Mod Name | Variable | Variable | char[] | UTF8 encoded text, length = `Name Length` | + + +### Enabled Profiles Format + +| Bit | Mask | Profile | +|:----|:-----|:----------| +| 0 | 0x01 | Profile 1 | +| 1 | 0x02 | Profile 2 | +| 2 | 0x04 | Profile 3 | +| 3 | 0x08 | Profile 4 | +| 4 | 0x10 | Profile 5 | +| 5 | 0x20 | Profile 6 | +| 6 | 0x40 | Profile 7 | +| 7 | 0x80 | Profile 8 | \ No newline at end of file From 1518ef88b7be577359943c042b11db63cdb45291 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Mon, 29 Dec 2025 17:19:49 -0600 Subject: [PATCH 02/14] Updated list of builds to ignore auto updates --- ShinRyuModManager-CE/UserInterface/Updater/AutoUpdating.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ShinRyuModManager-CE/UserInterface/Updater/AutoUpdating.cs b/ShinRyuModManager-CE/UserInterface/Updater/AutoUpdating.cs index 0793815..ae343a1 100644 --- a/ShinRyuModManager-CE/UserInterface/Updater/AutoUpdating.cs +++ b/ShinRyuModManager-CE/UserInterface/Updater/AutoUpdating.cs @@ -18,6 +18,8 @@ public static class AutoUpdating { private static string _tempDir; private static SparkleUpdater _updater; + private static readonly string[] IgnoredBuilds = ["debug", "test"]; + public static void Init() { _tempDir = Path.Combine(Environment.CurrentDirectory, "srmm_temp"); @@ -31,7 +33,7 @@ public static void Init() { var suffix = AssemblyVersion.GetBuildSuffix(); - if (string.Equals(suffix, "debug", StringComparison.OrdinalIgnoreCase)) { + if (IgnoredBuilds.Contains(suffix, StringComparer.OrdinalIgnoreCase)) { return; // Don't need to be annoyed with "Update Now" when debugging } From 58d3f3bdf68173f025f66fc2f98f81326154de71 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Mon, 29 Dec 2025 17:49:34 -0600 Subject: [PATCH 03/14] Bump version and add preview indicator + new action --- .github/workflows/create-draft-preview.yml | 75 +++++++++++++++++++ Scripts/build.sh | 23 +++++- .../ShinRyuModManager-CE.csproj | 2 +- .../UserInterface/Assets/changelog.md | 10 ++- .../UserInterface/Views/MainWindow.axaml | 12 +++ 5 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/create-draft-preview.yml diff --git a/.github/workflows/create-draft-preview.yml b/.github/workflows/create-draft-preview.yml new file mode 100644 index 0000000..f085727 --- /dev/null +++ b/.github/workflows/create-draft-preview.yml @@ -0,0 +1,75 @@ +name: Prepare for Release +on: + workflow_dispatch: + inputs: + name: + description: "Release name" + required: true + default: "ShinRyuModManager-CE 1.0.0" + tag: + description: "Tag for the release (example: 1.2.3)" + required: true + default: "1.0.0" + updater_version: + description: "Version for RyuUpdater (example: 1.2.3)" + required: true + default: "1.0.0" + +jobs: + release: + runs-on: ubuntu-latest + permissions: write-all + + steps: + - name: Checkout Repo + uses: actions/checkout@v6 + + - name: Checkout AppCast Repo + uses: actions/checkout@v6 + with: + repository: 'TheTrueColonel/SRMM-AppCast' + token: '${{ secrets.SRMM_APPCAST_TOKEN }}' + path: AppcastRepo + + - name: Install .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Add .NET tools to PATH + run: echo "$HOME/.dotnet/tools" >> $GITHUB_PATH + + - name: Create Draft Release + id: create_release + uses: ncipollo/release-action@v1 + with: + tag: "v${{ github.event.inputs.tag }}" + name: ${{ github.event.inputs.name }} + draft: true + generateReleaseNotesPreviousTag: true + + - name: Run Scripts + run: | + ./Scripts/build.sh -p + ./Scripts/package.sh -s ${{ github.event.inputs.tag }} -u ${{ github.event.inputs.updater_version }} + env: + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + SPARKLE_PUBLIC_KEY: ${{ secrets.SPARKLE_PUBLIC_KEY }} + + - name: Upload SRMM Release Assets + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + UPLOAD_URL: ${{ steps.create_release.outputs.upload_url }} + run: | + for file in ${{ github.workspace }}/dist/srmm/out/*; do + name=$(basename "$file") + mime=$(file --brief --mime-type "$file") + echo "Uploading $name ($mime)" + curl \ + -X POST \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Content-Type: $mime" \ + --data-binary @"$file" \ + --no-progress-meter \ + "${UPLOAD_URL%%\{*}?name=$name" > /dev/null + done diff --git a/Scripts/build.sh b/Scripts/build.sh index 7b483c4..8617a75 100755 --- a/Scripts/build.sh +++ b/Scripts/build.sh @@ -2,6 +2,14 @@ set -euo pipefail +### Check arguments +while getopts p: flag; do + case "${flag}" in + p) IS_PREVIEW=true;; + *) echo "Usage: $0 -s -u "; exit 1;; + esac +done + ### Variables SRMM_PROJECT="$GITHUB_WORKSPACE/ShinRyuModManager-CE/ShinRyuModManager-CE.csproj" UPDATER_PROJECT="$GITHUB_WORKSPACE/RyuUpdater/RyuUpdater.csproj" @@ -21,12 +29,25 @@ FILES_TO_COPY=( ) # Declares runtime and known build params -declare -A TARGET_ARGS=( +declare -A TARGET_ARGS_PROD=( ["linux"]="linux-x64;--self-contained -p:BuildSuffix=linux" ["linux-slim"]="linux-x64;--no-self-contained -p:BuildSuffix=linux-slim" ["windows"]="win-x64;--self-contained -p:BuildSuffix=windows" ["windows-slim"]="win-x64;--no-self-contained -p:BuildSuffix=windows-slim" ) + +declare -A TARGET_ARGS_PREVIEW=( + ["linux"]="linux-x64;--self-contained -p:BuildSuffix=linux-preview" + ["linux-slim"]="linux-x64;--no-self-contained -p:BuildSuffix=linux-slim-preview" + ["windows"]="win-x64;--self-contained -p:BuildSuffix=windows-preview" + ["windows-slim"]="win-x64;--no-self-contained -p:BuildSuffix=windows-slim-preview" +) + +if [ "$IS_PREVIEW" = true ]; then + declare -n TARGET_ARGS=TARGET_ARGS_PREVIEW; +else + declare -n TARGET_ARGS=TARGET_ARGS_PROD; +fi declare -A UPDATER_TARGET_ARGS=( ["linux"]="linux-x64;--self-contained" diff --git a/ShinRyuModManager-CE/ShinRyuModManager-CE.csproj b/ShinRyuModManager-CE/ShinRyuModManager-CE.csproj index 40180bf..6985825 100644 --- a/ShinRyuModManager-CE/ShinRyuModManager-CE.csproj +++ b/ShinRyuModManager-CE/ShinRyuModManager-CE.csproj @@ -14,7 +14,7 @@ true - 1.3.8 + 1.4.0 $(AssemblyVersion) ShinRyuModManager-CE SRMM Studio diff --git a/ShinRyuModManager-CE/UserInterface/Assets/changelog.md b/ShinRyuModManager-CE/UserInterface/Assets/changelog.md index 7979de4..6cc1bdf 100644 --- a/ShinRyuModManager-CE/UserInterface/Assets/changelog.md +++ b/ShinRyuModManager-CE/UserInterface/Assets/changelog.md @@ -1,4 +1,12 @@ -> ### **%{color:gold} Version 1.3.8 %** ### +> ### **%{color:gold} Version 1.4.0 %** ### +* Added multiple profiles +* Updated mod list save file to version 2 + * New file name is `ModList.ml` + * Old mod list will be appended `.bak` in case it needs to be recovered + +--- + +> ### **%{color:orange} Version 1.3.8 %** ### * Fixed MLO generation --- diff --git a/ShinRyuModManager-CE/UserInterface/Views/MainWindow.axaml b/ShinRyuModManager-CE/UserInterface/Views/MainWindow.axaml index 6e095f8..b56c858 100644 --- a/ShinRyuModManager-CE/UserInterface/Views/MainWindow.axaml +++ b/ShinRyuModManager-CE/UserInterface/Views/MainWindow.axaml @@ -63,6 +63,13 @@ + +